From e218ddecfd2860d965bf2f02d459d63330609e84 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 24 Nov 2025 15:24:21 +0000 Subject: [PATCH 01/11] feat(site): add bulk delete for tasks Fixes coder/internal#1088 --- .../BatchDeleteConfirmation.stories.tsx | 95 ++++++ .../TasksPage/BatchDeleteConfirmation.tsx | 316 ++++++++++++++++++ .../src/pages/TasksPage/TasksPage.stories.tsx | 8 +- site/src/pages/TasksPage/TasksPage.tsx | 124 ++++++- site/src/pages/TasksPage/TasksTable.tsx | 146 ++++++-- site/src/pages/TasksPage/batchActions.ts | 36 ++ 6 files changed, 696 insertions(+), 29 deletions(-) create mode 100644 site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx create mode 100644 site/src/pages/TasksPage/BatchDeleteConfirmation.tsx create mode 100644 site/src/pages/TasksPage/batchActions.ts diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx new file mode 100644 index 0000000000000..5d93fb7cda0c7 --- /dev/null +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -0,0 +1,95 @@ +import { chromatic } from "testHelpers/chromatic"; +import { MockTask, MockWorkspace } from "testHelpers/entities"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { action } from "storybook/actions"; +import { userEvent, within } from "storybook/test"; +import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; + +const meta: Meta = { + title: "pages/TasksPage/BatchDeleteConfirmation", + parameters: { chromatic }, + component: BatchDeleteConfirmation, + args: { + onClose: action("onClose"), + onConfirm: action("onConfirm"), + open: true, + isLoading: false, + checkedTasks: [ + MockTask, + { + ...MockTask, + id: "task-2", + name: "task-test-456", + initial_prompt: "Add comprehensive tests for the API endpoints", + owner_name: "bob", + created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + updated_at: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), + }, + { + ...MockTask, + id: "task-3", + name: "task-docs-789", + initial_prompt: "Update documentation for the new features", + workspace_id: null, + created_at: new Date( + Date.now() - 3 * 24 * 60 * 60 * 1000, + ).toISOString(), + updated_at: new Date( + Date.now() - 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + ], + workspaces: [ + MockWorkspace, + { + ...MockWorkspace, + id: "workspace-2", + name: "bob-workspace", + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +const Stage1_Consequences: Story = {}; + +const Stage2_ReviewTasks: Story = { + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body); + + await step("Advance to stage 2: Review tasks", async () => { + const confirmButton = await body.findByRole("button", { + name: /review selected tasks/i, + }); + await userEvent.click(confirmButton); + }); + }, +}; + +const Stage3_ReviewResources: Story = { + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body); + + await step("Advance to stage 2: Review tasks", async () => { + const confirmButton = await body.findByRole("button", { + name: /review selected tasks/i, + }); + await userEvent.click(confirmButton); + }); + + await step("Advance to stage 3: Review resources", async () => { + const confirmButton = await body.findByRole("button", { + name: /confirm.*tasks/i, + }); + await userEvent.click(confirmButton); + }); + }, +}; + +export { + Stage1_Consequences as Consequences, + Stage2_ReviewTasks as ReviewTasks, + Stage3_ReviewResources as ReviewResources, +}; diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx new file mode 100644 index 0000000000000..848b732739b08 --- /dev/null +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx @@ -0,0 +1,316 @@ +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import { visuallyHidden } from "@mui/utils"; +import type { Task, Workspace } from "api/typesGenerated"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Stack } from "components/Stack/Stack"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { ClockIcon, ServerIcon, UserIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { getResourceIconPath } from "utils/workspace"; + +dayjs.extend(relativeTime); + +type BatchDeleteConfirmationProps = { + checkedTasks: readonly Task[]; + workspaces: readonly Workspace[]; + open: boolean; + isLoading: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export const BatchDeleteConfirmation: FC = ({ + checkedTasks, + workspaces, + open, + onClose, + onConfirm, + isLoading, +}) => { + const [stage, setStage] = useState<"consequences" | "tasks" | "resources">( + "consequences", + ); + + const onProceed = () => { + switch (stage) { + case "resources": + onConfirm(); + break; + case "tasks": + setStage("resources"); + break; + case "consequences": + setStage("tasks"); + break; + } + }; + + const taskCount = `${checkedTasks.length} ${ + checkedTasks.length === 1 ? "task" : "tasks" + }`; + + let confirmText: ReactNode = <>Review selected tasks…; + if (stage === "tasks") { + confirmText = <>Confirm {taskCount}…; + } + if (stage === "resources") { + const workspaceCount = workspaces.length; + const resources = workspaces + .map((workspace) => workspace.latest_build.resources.length) + .reduce((a, b) => a + b, 0); + const resourceCount = `${resources} ${ + resources === 1 ? "resource" : "resources" + }`; + const workspaceCountText = `${workspaceCount} ${ + workspaceCount === 1 ? "workspace" : "workspaces" + }`; + confirmText = ( + <> + Delete {taskCount}, {workspaceCountText} and {resourceCount} + + ); + } + + // The flicker of these icons is quite noticeable if they aren't + // loaded in advance, so we insert them into the document without + // actually displaying them yet. + const resourceIconPreloads = [ + ...new Set( + workspaces.flatMap((workspace) => + workspace.latest_build.resources.map( + (resource) => resource.icon || getResourceIconPath(resource.type), + ), + ), + ), + ].map((url) => ( + + )); + + return ( + { + setStage("consequences"); + onClose(); + }} + title={`Delete ${taskCount}`} + hideCancel + confirmLoading={isLoading} + confirmText={confirmText} + onConfirm={onProceed} + description={ + <> + {stage === "consequences" && } + {stage === "tasks" && } + {stage === "resources" && ( + + )} + {resourceIconPreloads} + + } + /> + ); +}; + +interface TasksStageProps { + tasks: readonly Task[]; +} + +interface ResourcesStageProps { + tasks: readonly Task[]; + workspaces: readonly Workspace[]; +} + +const Consequences: FC = () => { + return ( + <> +

Deleting tasks is irreversible!

+
    +
  • + Tasks with associated workspaces will have those workspaces deleted. +
  • +
  • Terraform resources in task workspaces will be destroyed.
  • +
  • Any data stored in task workspaces will be permanently deleted.
  • +
+ + ); +}; + +const Tasks: FC = ({ tasks }) => { + const theme = useTheme(); + + const mostRecent = tasks.reduce( + (latestSoFar, against) => { + if (!latestSoFar) { + return against; + } + + return new Date(against.created_at).getTime() > + new Date(latestSoFar.created_at).getTime() + ? against + : latestSoFar; + }, + undefined as Task | undefined, + ); + + const owners = new Set(tasks.map((it) => it.owner_name)).size; + const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`; + + return ( + <> +
    + {tasks.map((task) => ( +
  • + + + {task.initial_prompt} + + + + + {task.owner_name} + + + + + {dayjs(task.created_at).fromNow()} + + + + + +
  • + ))} +
+ + + + {ownersCount} + + {mostRecent && ( + + + Last created {dayjs(mostRecent.created_at).fromNow()} + + )} + + + ); +}; + +const Resources: FC = ({ tasks, workspaces }) => { + const resources: Record = {}; + for (const workspace of workspaces) { + for (const resource of workspace.latest_build.resources) { + if (!resources[resource.type]) { + resources[resource.type] = { + count: 0, + icon: resource.icon || getResourceIconPath(resource.type), + }; + } + + resources[resource.type].count++; + } + } + + return ( + +

+ Deleting {tasks.length === 1 ? "this task" : "these tasks"} will also + permanently destroy… +

+ + + + + {workspaces.length}{" "} + {workspaces.length === 1 ? "workspace" : "workspaces"} + + + {Object.entries(resources).map(([type, summary]) => ( + + + + {summary.count} {type} + + + ))} + +
+ ); +}; + +const PersonIcon: FC = () => { + return ; +}; + +const styles = { + summaryIcon: { width: 16, height: 16 }, + + consequences: { + display: "flex", + flexDirection: "column", + gap: 8, + paddingLeft: 16, + marginBottom: 0, + }, + + tasksList: (theme) => ({ + listStyleType: "none", + padding: 0, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 8, + overflow: "hidden auto", + maxHeight: 184, + }), + + task: (theme) => ({ + padding: "8px 16px", + borderBottom: `1px solid ${theme.palette.divider}`, + + "&:last-child": { + border: "none", + }, + }), +} satisfies Record>; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 2e12d96983196..62e8dc03441c5 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -6,7 +6,11 @@ import { MockUserOwner, mockApiError, } from "testHelpers/entities"; -import { withAuthProvider, withProxyProvider } from "testHelpers/storybook"; +import { + withAuthProvider, + withDashboardProvider, + withProxyProvider, +} from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { MockUsers } from "pages/UsersPage/storybookData/users"; @@ -17,7 +21,7 @@ import TasksPage from "./TasksPage"; const meta: Meta = { title: "pages/TasksPage", component: TasksPage, - decorators: [withAuthProvider, withProxyProvider()], + decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], parameters: { user: MockUserOwner, permissions: { diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 7a19ee601bb26..9724cdabcc874 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,21 +1,33 @@ import { API } from "api/api"; import { templates } from "api/queries/templates"; +import { workspaces } from "api/queries/workspaces"; import type { TasksFilter } from "api/typesGenerated"; import { Badge } from "components/Badge/Badge"; import { Button, type ButtonProps } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { Margins } from "components/Margins/Margins"; import { PageHeader, PageHeaderSubtitle, PageHeaderTitle, } from "components/PageHeader/PageHeader"; +import { Spinner } from "components/Spinner/Spinner"; +import { TableToolbar } from "components/TableToolbar/TableToolbar"; import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import { ChevronDownIcon, TrashIcon } from "lucide-react"; import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt"; -import type { FC } from "react"; +import { type FC, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; +import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; +import { useBatchTaskActions } from "./batchActions"; import { TasksTable } from "./TasksTable"; import { UsersCombobox } from "./UsersCombobox"; @@ -49,6 +61,52 @@ const TasksPage: FC = () => { const displayedTasks = tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data; + const [checkedTaskIds, setCheckedTaskIds] = useState>(new Set()); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const checkedTasks = + displayedTasks?.filter((t) => checkedTaskIds.has(t.id)) ?? []; + + const batchActions = useBatchTaskActions({ + onSuccess: async () => { + await tasksQuery.refetch(); + setCheckedTaskIds(new Set()); + setIsDeleteDialogOpen(false); + }, + }); + + const handleCheckChange = (newIds: Set) => { + setCheckedTaskIds(newIds); + }; + + const handleBatchDelete = () => { + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + await batchActions.delete(checkedTasks); + }; + + const canCheckTasks = permissions.viewDeploymentConfig; + + // Workspaces are fetched lazily only when dialog opens because tasks + // don't always have associated workspaces and we need full resource data + // for the confirmation dialog. + const workspaceIds = checkedTasks + .map((t) => t.workspace_id) + .filter((id): id is string => id !== null); + const workspacesQuery = useQuery({ + ...workspaces({ q: `id:${workspaceIds.join(",")}` }), + enabled: workspaceIds.length > 0 && isDeleteDialogOpen, + }); + + // Clear selections when switching tabs/filters to avoid confusing UX + // where selected tasks might no longer be visible. + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on tab/filter changes. + useEffect(() => { + setCheckedTaskIds(new Set()); + }, [tab.value, ownerFilter.value]); + return ( <> {pageTitle("AI Tasks")} @@ -105,14 +163,78 @@ const TasksPage: FC = () => { )} +
+ + {checkedTasks.length > 0 ? ( + <> +
+ Selected {checkedTasks.length} of{" "} + {displayedTasks?.length}{" "} + {displayedTasks?.length === 1 ? "task" : "tasks"} +
+ + + + + + + + Delete… + + + + + ) : ( +
+ Showing{" "} + {displayedTasks && displayedTasks.length > 0 ? ( + <> + 1 to{" "} + {displayedTasks.length} of{" "} + {displayedTasks.length} + + ) : ( + 0 + )}{" "} + {displayedTasks?.length === 1 ? "task" : "tasks"} +
+ )} +
+
+ )} + + setIsDeleteDialogOpen(false)} + onConfirm={handleConfirmDelete} + /> ); diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 5ea40bea710eb..4c0826e4d3d8a 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -1,3 +1,4 @@ +import Checkbox from "@mui/material/Checkbox"; import { getErrorDetail, getErrorMessage } from "api/errors"; import type { Task } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; @@ -29,38 +30,97 @@ import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialo import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus"; import { type FC, type ReactNode, useState } from "react"; import { useNavigate } from "react-router"; +import { cn } from "utils/cn"; import { relativeTime } from "utils/time"; type TasksTableProps = { tasks: readonly Task[] | undefined; error: unknown; onRetry: () => void; + checkedTaskIds?: Set; + onCheckChange?: (checkedTaskIds: Set) => void; + canCheckTasks?: boolean; }; -export const TasksTable: FC = ({ tasks, error, onRetry }) => { +export const TasksTable: FC = ({ + tasks, + error, + onRetry, + checkedTaskIds = new Set(), + onCheckChange, + canCheckTasks = false, +}) => { let body: ReactNode = null; if (error) { body = ; } else if (!tasks) { - body = ; + body = ; } else if (tasks.length === 0) { body = ; } else { - body = tasks.map((task) => ); + body = tasks.map((task) => { + const checked = checkedTaskIds.has(task.id); + return ( + { + if (!onCheckChange) return; + const newIds = new Set(checkedTaskIds); + if (checked) { + newIds.add(taskId); + } else { + newIds.delete(taskId); + } + onCheckChange(newIds); + }} + canCheck={canCheckTasks} + /> + ); + }); } return ( - +
- Task + +
+ {canCheckTasks && ( + 0 && + checkedTaskIds.size === tasks.length + } + size="xsmall" + onChange={(_, checked) => { + if (!tasks || !onCheckChange) { + return; + } + + if (!checked) { + onCheckChange(new Set()); + } else { + onCheckChange(new Set(tasks.map((t) => t.id))); + } + }} + aria-label="Select all tasks" + /> + )} + Task +
+
Status Created by
- {body} + {body}
); }; @@ -96,7 +156,7 @@ const TasksErrorBody: FC = ({ error, onRetry }) => { const TasksEmpty: FC = () => { return ( - +

@@ -112,9 +172,19 @@ const TasksEmpty: FC = () => { ); }; -type TaskRowProps = { task: Task }; +type TaskRowProps = { + task: Task; + checked: boolean; + onCheckChange: (taskId: string, checked: boolean) => void; + canCheck: boolean; +}; -const TaskRow: FC = ({ task }) => { +const TaskRow: FC = ({ + task, + checked, + onCheckChange, + canCheck, +}) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const templateDisplayName = task.template_display_name ?? task.template_name; const navigate = useNavigate(); @@ -131,24 +201,41 @@ const TaskRow: FC = ({ task }) => { key={task.id} data-testid={`task-${task.id}`} {...clickableRowProps} + className={cn(checked && "bg-surface-secondary")} > - - {task.display_name} - - } - subtitle={templateDisplayName} - avatar={ - + {canCheck && ( + { + e.stopPropagation(); + }} + onChange={(e) => { + onCheckChange(task.id, e.currentTarget.checked); + }} + aria-label={`Select task ${task.initial_prompt}`} /> - } - /> + )} + + {task.display_name} + + } + subtitle={templateDisplayName} + avatar={ + + } + /> +

= ({ task }) => { ); }; -const TasksSkeleton: FC = () => { +type TasksSkeletonProps = { + canCheckTasks: boolean; +}; + +const TasksSkeleton: FC = ({ canCheckTasks }) => { return ( - +
+ {canCheckTasks && } + +
diff --git a/site/src/pages/TasksPage/batchActions.ts b/site/src/pages/TasksPage/batchActions.ts new file mode 100644 index 0000000000000..dfd06b903f094 --- /dev/null +++ b/site/src/pages/TasksPage/batchActions.ts @@ -0,0 +1,36 @@ +import { API } from "api/api"; +import type { Task } from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { useMutation } from "react-query"; + +interface UseBatchTaskActionsOptions { + onSuccess: () => Promise; +} + +type UseBatchTaskActionsResult = Readonly<{ + isProcessing: boolean; + delete: (tasks: readonly Task[]) => Promise; +}>; + +export function useBatchTaskActions( + options: UseBatchTaskActionsOptions, +): UseBatchTaskActionsResult { + const { onSuccess } = options; + + const deleteAllMutation = useMutation({ + mutationFn: async (tasks: readonly Task[]): Promise => { + await Promise.all( + tasks.map((task) => API.deleteTask(task.owner_name, task.id)), + ); + }, + onSuccess, + onError: () => { + displayError("Failed to delete some tasks"); + }, + }); + + return { + delete: deleteAllMutation.mutateAsync, + isProcessing: deleteAllMutation.isPending, + }; +} From 7e1a09c01e6eccc0e2c5c7590198e19d0e210fea Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 24 Nov 2025 19:09:09 +0000 Subject: [PATCH 02/11] add feature flag --- codersdk/deployment.go | 3 +++ site/src/api/typesGenerated.ts | 2 ++ site/src/pages/TasksPage/TasksPage.tsx | 4 +++- site/src/testHelpers/entities.ts | 4 ++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index a51e3e9247e58..29081b4bf6076 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -80,6 +80,7 @@ const ( FeatureWorkspaceProxy FeatureName = "workspace_proxy" FeatureExternalTokenEncryption FeatureName = "external_token_encryption" FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" + FeatureTaskBatchActions FeatureName = "task_batch_actions" FeatureAccessControl FeatureName = "access_control" FeatureControlSharedPorts FeatureName = "control_shared_ports" FeatureCustomRoles FeatureName = "custom_roles" @@ -111,6 +112,7 @@ var ( FeatureUserRoleManagement, FeatureExternalTokenEncryption, FeatureWorkspaceBatchActions, + FeatureTaskBatchActions, FeatureAccessControl, FeatureControlSharedPorts, FeatureCustomRoles, @@ -157,6 +159,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureExternalProvisionerDaemons: true, FeatureAppearance: true, FeatureWorkspaceBatchActions: true, + FeatureTaskBatchActions: true, FeatureHighAvailability: true, FeatureCustomRoles: true, FeatureMultipleOrganizations: true, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5597abae9eacc..d31b069dce921 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2103,6 +2103,7 @@ export type FeatureName = | "multiple_external_auth" | "multiple_organizations" | "scim" + | "task_batch_actions" | "template_rbac" | "user_limit" | "user_role_management" @@ -2128,6 +2129,7 @@ export const FeatureNames: FeatureName[] = [ "multiple_external_auth", "multiple_organizations", "scim", + "task_batch_actions", "template_rbac", "user_limit", "user_role_management", diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 9724cdabcc874..392d9286ba55b 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -21,6 +21,7 @@ import { TableToolbar } from "components/TableToolbar/TableToolbar"; import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { ChevronDownIcon, TrashIcon } from "lucide-react"; +import { useDashboard } from "modules/dashboard/useDashboard"; import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt"; import { type FC, useEffect, useState } from "react"; import { useQuery } from "react-query"; @@ -87,7 +88,8 @@ const TasksPage: FC = () => { await batchActions.delete(checkedTasks); }; - const canCheckTasks = permissions.viewDeploymentConfig; + const { entitlements } = useDashboard(); + const canCheckTasks = entitlements.features.task_batch_actions.enabled; // Workspaces are fetched lazily only when dialog opens because tasks // don't always have associated workspaces and we need full resource data diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6c89deb873023..4b8657c593ee8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2461,6 +2461,10 @@ export const MockEntitlements: TypesGen.Entitlements = { enabled: true, entitlement: "entitled", }, + task_batch_actions: { + enabled: true, + entitlement: "entitled", + }, }), require_telemetry: false, trial: false, From 41ea0dea8c048b27d54bf6ec498bced0ef578e2e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 09:31:28 +0000 Subject: [PATCH 03/11] fix: change emotion to Tailwind --- .../BatchDeleteConfirmation.stories.tsx | 23 +- .../TasksPage/BatchDeleteConfirmation.tsx | 207 ++++-------------- site/src/pages/TasksPage/TasksPage.tsx | 18 +- 3 files changed, 55 insertions(+), 193 deletions(-) diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx index 5d93fb7cda0c7..d53d548a144a4 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -1,5 +1,5 @@ import { chromatic } from "testHelpers/chromatic"; -import { MockTask, MockWorkspace } from "testHelpers/entities"; +import { MockTask } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { userEvent, within } from "storybook/test"; @@ -39,23 +39,16 @@ const meta: Meta = { ).toISOString(), }, ], - workspaces: [ - MockWorkspace, - { - ...MockWorkspace, - id: "workspace-2", - name: "bob-workspace", - }, - ], + workspaceCount: 2, }, }; export default meta; type Story = StoryObj; -const Stage1_Consequences: Story = {}; +export const Consequences: Story = {}; -const Stage2_ReviewTasks: Story = { +export const ReviewTasks: Story = { play: async ({ canvasElement, step }) => { const body = within(canvasElement.ownerDocument.body); @@ -68,7 +61,7 @@ const Stage2_ReviewTasks: Story = { }, }; -const Stage3_ReviewResources: Story = { +export const ReviewResources: Story = { play: async ({ canvasElement, step }) => { const body = within(canvasElement.ownerDocument.body); @@ -87,9 +80,3 @@ const Stage3_ReviewResources: Story = { }); }, }; - -export { - Stage1_Consequences as Consequences, - Stage2_ReviewTasks as ReviewTasks, - Stage3_ReviewResources as ReviewResources, -}; diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx index 848b732739b08..2182a0fae8be6 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx @@ -1,20 +1,15 @@ -import { type Interpolation, type Theme, useTheme } from "@emotion/react"; -import { visuallyHidden } from "@mui/utils"; -import type { Task, Workspace } from "api/typesGenerated"; +import type { Task } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { ExternalImage } from "components/ExternalImage/ExternalImage"; -import { Stack } from "components/Stack/Stack"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { ClockIcon, ServerIcon, UserIcon } from "lucide-react"; import { type FC, type ReactNode, useState } from "react"; -import { getResourceIconPath } from "utils/workspace"; dayjs.extend(relativeTime); type BatchDeleteConfirmationProps = { checkedTasks: readonly Task[]; - workspaces: readonly Workspace[]; + workspaceCount: number; open: boolean; isLoading: boolean; onClose: () => void; @@ -23,7 +18,7 @@ type BatchDeleteConfirmationProps = { export const BatchDeleteConfirmation: FC = ({ checkedTasks, - workspaces, + workspaceCount, open, onClose, onConfirm, @@ -56,38 +51,16 @@ export const BatchDeleteConfirmation: FC = ({ confirmText = <>Confirm {taskCount}…; } if (stage === "resources") { - const workspaceCount = workspaces.length; - const resources = workspaces - .map((workspace) => workspace.latest_build.resources.length) - .reduce((a, b) => a + b, 0); - const resourceCount = `${resources} ${ - resources === 1 ? "resource" : "resources" - }`; const workspaceCountText = `${workspaceCount} ${ workspaceCount === 1 ? "workspace" : "workspaces" }`; confirmText = ( <> - Delete {taskCount}, {workspaceCountText} and {resourceCount} + Delete {taskCount} and {workspaceCountText} ); } - // The flicker of these icons is quite noticeable if they aren't - // loaded in advance, so we insert them into the document without - // actually displaying them yet. - const resourceIconPreloads = [ - ...new Set( - workspaces.flatMap((workspace) => - workspace.latest_build.resources.map( - (resource) => resource.icon || getResourceIconPath(resource.type), - ), - ), - ), - ].map((url) => ( - - )); - return ( = ({ {stage === "consequences" && } {stage === "tasks" && } {stage === "resources" && ( - + )} - {resourceIconPreloads} + {/* Preload ServerIcon to prevent flicker on stage 3 */} + } /> @@ -121,18 +95,17 @@ interface TasksStageProps { interface ResourcesStageProps { tasks: readonly Task[]; - workspaces: readonly Workspace[]; + workspaceCount: number; } const Consequences: FC = () => { return ( <>

Deleting tasks is irreversible!

-
    +
    • Tasks with associated workspaces will have those workspaces deleted.
    • -
    • Terraform resources in task workspaces will be destroyed.
    • Any data stored in task workspaces will be permanently deleted.
    @@ -140,8 +113,6 @@ const Consequences: FC = () => { }; const Tasks: FC = ({ tasks }) => { - const theme = useTheme(); - const mostRecent = tasks.reduce( (latestSoFar, against) => { if (!latestSoFar) { @@ -161,156 +132,66 @@ const Tasks: FC = ({ tasks }) => { return ( <> -
      +
        {tasks.map((task) => ( -
      • - - - {task.initial_prompt} +
      • +
        + + {task.display_name} - - - {task.owner_name} - - - - +
        +
        + {task.owner_name} + +
        +
        + {dayjs(task.created_at).fromNow()} - - - +
        +
        +
      • ))}
      - - - +
      +
      + {ownersCount} - +
      {mostRecent && ( - +
      Last created {dayjs(mostRecent.created_at).fromNow()} - +
      )} -
      +
      ); }; -const Resources: FC = ({ tasks, workspaces }) => { - const resources: Record = {}; - for (const workspace of workspaces) { - for (const resource of workspace.latest_build.resources) { - if (!resources[resource.type]) { - resources[resource.type] = { - count: 0, - icon: resource.icon || getResourceIconPath(resource.type), - }; - } - - resources[resource.type].count++; - } - } +const Resources: FC = ({ tasks, workspaceCount }) => { + const taskCount = tasks.length; return ( - +

      - Deleting {tasks.length === 1 ? "this task" : "these tasks"} will also + Deleting {taskCount === 1 ? "this task" : "these tasks"} will also permanently destroy…

      - - +
      +
      - {workspaces.length}{" "} - {workspaces.length === 1 ? "workspace" : "workspaces"} + {workspaceCount} {workspaceCount === 1 ? "workspace" : "workspaces"} - - {Object.entries(resources).map(([type, summary]) => ( - - - - {summary.count} {type} - - - ))} - - +
      +
      +
      ); }; - -const PersonIcon: FC = () => { - return ; -}; - -const styles = { - summaryIcon: { width: 16, height: 16 }, - - consequences: { - display: "flex", - flexDirection: "column", - gap: 8, - paddingLeft: 16, - marginBottom: 0, - }, - - tasksList: (theme) => ({ - listStyleType: "none", - padding: 0, - border: `1px solid ${theme.palette.divider}`, - borderRadius: 8, - overflow: "hidden auto", - maxHeight: 184, - }), - - task: (theme) => ({ - padding: "8px 16px", - borderBottom: `1px solid ${theme.palette.divider}`, - - "&:last-child": { - border: "none", - }, - }), -} satisfies Record>; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 392d9286ba55b..b5b8a9e3d9667 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,6 +1,6 @@ import { API } from "api/api"; import { templates } from "api/queries/templates"; -import { workspaces } from "api/queries/workspaces"; + import type { TasksFilter } from "api/typesGenerated"; import { Badge } from "components/Badge/Badge"; import { Button, type ButtonProps } from "components/Button/Button"; @@ -91,16 +91,10 @@ const TasksPage: FC = () => { const { entitlements } = useDashboard(); const canCheckTasks = entitlements.features.task_batch_actions.enabled; - // Workspaces are fetched lazily only when dialog opens because tasks - // don't always have associated workspaces and we need full resource data - // for the confirmation dialog. - const workspaceIds = checkedTasks - .map((t) => t.workspace_id) - .filter((id): id is string => id !== null); - const workspacesQuery = useQuery({ - ...workspaces({ q: `id:${workspaceIds.join(",")}` }), - enabled: workspaceIds.length > 0 && isDeleteDialogOpen, - }); + // Count workspaces that will be deleted with the selected tasks. + const workspaceCount = checkedTasks.filter( + (t) => t.workspace_id !== null, + ).length; // Clear selections when switching tabs/filters to avoid confusing UX // where selected tasks might no longer be visible. @@ -232,7 +226,7 @@ const TasksPage: FC = () => { setIsDeleteDialogOpen(false)} onConfirm={handleConfirmDelete} From 89dd770f43e032b45012726991e7e75b66ce10dc Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 10:39:42 +0000 Subject: [PATCH 04/11] last created -> last updated --- site/src/pages/TasksPage/BatchDeleteConfirmation.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx index 2182a0fae8be6..ede6939dbdf11 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx @@ -119,8 +119,8 @@ const Tasks: FC = ({ tasks }) => { return against; } - return new Date(against.created_at).getTime() > - new Date(latestSoFar.created_at).getTime() + return new Date(against.updated_at).getTime() > + new Date(latestSoFar.updated_at).getTime() ? against : latestSoFar; }, @@ -150,7 +150,7 @@ const Tasks: FC = ({ tasks }) => {
- {dayjs(task.created_at).fromNow()} + {dayjs(task.updated_at).fromNow()}
@@ -167,7 +167,7 @@ const Tasks: FC = ({ tasks }) => { {mostRecent && (
- Last created {dayjs(mostRecent.created_at).fromNow()} + Last updated {dayjs(mostRecent.updated_at).fromNow()}
)} From dff41013bfb682aab844695045116e84d54df851 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 10:46:48 +0000 Subject: [PATCH 05/11] fix button --- site/src/pages/TasksPage/TasksPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index b5b8a9e3d9667..a49a0d3d6bc9f 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -175,7 +175,7 @@ const TasksPage: FC = () => { disabled={batchActions.isProcessing} variant="outline" size="sm" - css={{ borderRadius: 9999, marginLeft: "auto" }} + className="ml-auto" > Bulk actions From be2457747a09f60d8e5783981427752ecd51efea Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 10:56:48 +0000 Subject: [PATCH 06/11] update testdata --- site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx index d53d548a144a4..7ae7c2a5f8740 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { ...MockTask, id: "task-2", name: "task-test-456", + display_name: "Add API Tests", initial_prompt: "Add comprehensive tests for the API endpoints", owner_name: "bob", created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), @@ -29,6 +30,7 @@ const meta: Meta = { ...MockTask, id: "task-3", name: "task-docs-789", + display_name: "Update Documentation", initial_prompt: "Update documentation for the new features", workspace_id: null, created_at: new Date( From 4c830ecd9f7f1c06eed80c0fb8c70504586de322 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 11:00:02 +0000 Subject: [PATCH 07/11] add table batch actions test --- .../src/pages/TasksPage/TasksPage.stories.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 62e8dc03441c5..61e85e47670e7 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -219,3 +219,105 @@ export const InitializingTasks: Story = { ], }, }; + +export const BatchActionsEnabled: Story = { + parameters: { + features: ["task_batch_actions"], + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, +}; + +export const BatchActionsSomeSelected: Story = { + parameters: { + features: ["task_batch_actions"], + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Select first two tasks", async () => { + await canvas.findByRole("table"); + const checkboxes = await canvas.findAllByRole("checkbox"); + // Skip the "select all" checkbox (first one) and select the next two + await userEvent.click(checkboxes[1]); + await userEvent.click(checkboxes[2]); + }); + }, +}; + +export const BatchActionsAllSelected: Story = { + parameters: { + features: ["task_batch_actions"], + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Select all tasks using header checkbox", async () => { + await canvas.findByRole("table"); + const checkboxes = await canvas.findAllByRole("checkbox"); + // Click the first checkbox (select all) + await userEvent.click(checkboxes[0]); + }); + }, +}; + +export const BatchActionsDropdownOpen: Story = { + parameters: { + features: ["task_batch_actions"], + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: MockTasks, + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Select some tasks", async () => { + await canvas.findByRole("table"); + const checkboxes = await canvas.findAllByRole("checkbox"); + await userEvent.click(checkboxes[1]); + await userEvent.click(checkboxes[2]); + }); + + await step("Open bulk actions dropdown", async () => { + const bulkActionsButton = await canvas.findByRole("button", { + name: /bulk actions/i, + }); + await userEvent.click(bulkActionsButton); + }); + }, +}; From af188b1357297304c18051fbd41e28a08a1cc5d6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 11:13:10 +0000 Subject: [PATCH 08/11] remove row highlight on select --- site/src/pages/TasksPage/TasksTable.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 4c0826e4d3d8a..392c0b7ed4146 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -30,7 +30,7 @@ import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialo import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus"; import { type FC, type ReactNode, useState } from "react"; import { useNavigate } from "react-router"; -import { cn } from "utils/cn"; + import { relativeTime } from "utils/time"; type TasksTableProps = { @@ -201,7 +201,6 @@ const TaskRow: FC = ({ key={task.id} data-testid={`task-${task.id}`} {...clickableRowProps} - className={cn(checked && "bg-surface-secondary")} >
From 294312bc7ff8b842d580140194936d18e87c0ed2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 13:39:40 +0000 Subject: [PATCH 09/11] pr feedback --- .../src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx | 1 + site/src/pages/TasksPage/BatchDeleteConfirmation.tsx | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx index 7ae7c2a5f8740..d7b1e9ac6c8b7 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -32,6 +32,7 @@ const meta: Meta = { name: "task-docs-789", display_name: "Update Documentation", initial_prompt: "Update documentation for the new features", + // Intentionally null to test that only 2 workspaces are shown in review resources stage workspace_id: null, created_at: new Date( Date.now() - 3 * 24 * 60 * 60 * 1000, diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx index ede6939dbdf11..9e903f898571a 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.tsx @@ -127,8 +127,8 @@ const Tasks: FC = ({ tasks }) => { undefined as Task | undefined, ); - const owners = new Set(tasks.map((it) => it.owner_name)).size; - const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`; + const ownersCount = new Set(tasks.map((it) => it.owner_name)).size; + const ownersCountDisplay = `${ownersCount} ${ownersCount === 1 ? "owner" : "owners"}`; return ( <> @@ -162,7 +162,7 @@ const Tasks: FC = ({ tasks }) => {
- {ownersCount} + {ownersCountDisplay}
{mostRecent && (
From 7ec75ae84081b30bb59a965227759c0b13680c36 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 26 Nov 2025 13:59:01 +0000 Subject: [PATCH 10/11] pr feedback --- site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx index d7b1e9ac6c8b7..bea8e1796e5be 100644 --- a/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx +++ b/site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx @@ -22,6 +22,7 @@ const meta: Meta = { name: "task-test-456", display_name: "Add API Tests", initial_prompt: "Add comprehensive tests for the API endpoints", + // Different owner to test admin bulk delete of other users' tasks owner_name: "bob", created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), updated_at: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), From c4827db3cc755a8aa38f4dcba254b63cc114cdbc Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 27 Nov 2025 10:34:59 +0000 Subject: [PATCH 11/11] remove useEffect per feedback --- site/src/pages/TasksPage/TasksPage.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index a49a0d3d6bc9f..15c0fe5ea9b9e 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -23,7 +23,7 @@ import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { ChevronDownIcon, TrashIcon } from "lucide-react"; import { useDashboard } from "modules/dashboard/useDashboard"; import { TaskPrompt } from "modules/tasks/TaskPrompt/TaskPrompt"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useState } from "react"; import { useQuery } from "react-query"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; @@ -96,13 +96,6 @@ const TasksPage: FC = () => { (t) => t.workspace_id !== null, ).length; - // Clear selections when switching tabs/filters to avoid confusing UX - // where selected tasks might no longer be visible. - // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on tab/filter changes. - useEffect(() => { - setCheckedTaskIds(new Set()); - }, [tab.value, ownerFilter.value]); - return ( <> {pageTitle("AI Tasks")} @@ -130,14 +123,20 @@ const TasksPage: FC = () => {
tab.setValue("all")} + onClick={() => { + tab.setValue("all"); + setCheckedTaskIds(new Set()); + }} > All tasks tab.setValue("waiting-for-input")} + onClick={() => { + tab.setValue("waiting-for-input"); + setCheckedTaskIds(new Set()); + }} > Waiting for input {idleTasks && idleTasks.length > 0 && ( @@ -154,6 +153,7 @@ const TasksPage: FC = () => { ownerFilter.setValue( username === ownerFilter.value ? "" : username, ); + setCheckedTaskIds(new Set()); }} />