From d5ae72d5e28a0a5de889cd59dbb138a573a6c573 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 4 Feb 2026 22:30:44 +0300 Subject: [PATCH] feat(site): add pause/resume action buttons to tasks table (#21728) Add the ability to pause and resume tasks directly from the Tasks table, allowing users to manage workspace resources without navigating to individual task pages. --- .../TasksPage/TaskActionButton.stories.tsx | 52 ++++++++ site/src/pages/TasksPage/TaskActionButton.tsx | 64 +++++++++ .../src/pages/TasksPage/TasksPage.stories.tsx | 125 ++++++++++++++++++ site/src/pages/TasksPage/TasksTable.tsx | 122 +++++++++++++---- 4 files changed, 337 insertions(+), 26 deletions(-) create mode 100644 site/src/pages/TasksPage/TaskActionButton.stories.tsx create mode 100644 site/src/pages/TasksPage/TaskActionButton.tsx diff --git a/site/src/pages/TasksPage/TaskActionButton.stories.tsx b/site/src/pages/TasksPage/TaskActionButton.stories.tsx new file mode 100644 index 0000000000..2a102755bb --- /dev/null +++ b/site/src/pages/TasksPage/TaskActionButton.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { TaskActionButton } from "./TaskActionButton"; + +const meta: Meta = { + title: "pages/TasksPage/TaskActionButton", + component: TaskActionButton, + args: { + onClick: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Pause: Story = { + args: { + action: "pause", + }, +}; + +export const Resume: Story = { + args: { + action: "resume", + }, +}; + +export const Loading: Story = { + args: { + action: "pause", + loading: true, + }, +}; + +export const Disabled: Story = { + args: { + action: "pause", + disabled: true, + }, +}; + +export const ClickHandler: Story = { + args: { + action: "pause", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /pause task/i }); + await userEvent.click(button); + expect(args.onClick).toHaveBeenCalledTimes(1); + }, +}; diff --git a/site/src/pages/TasksPage/TaskActionButton.tsx b/site/src/pages/TasksPage/TaskActionButton.tsx new file mode 100644 index 0000000000..f3edc664e4 --- /dev/null +++ b/site/src/pages/TasksPage/TaskActionButton.tsx @@ -0,0 +1,64 @@ +import { Button } from "components/Button/Button"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { PauseIcon, PlayIcon } from "lucide-react"; +import type { FC } from "react"; + +type TaskActionButtonProps = { + action: "pause" | "resume"; + disabled?: boolean; + loading?: boolean; + onClick: () => void; +}; + +const actionConfig = { + pause: { + icon: PauseIcon, + label: "Pause task", + tooltip: "Pause the task to save resources. You can resume later.", + }, + resume: { + icon: PlayIcon, + label: "Resume task", + tooltip: "Resuming takes time while the workspace starts.", + }, +} as const; + +export const TaskActionButton: FC = ({ + action, + disabled, + loading, + onClick, +}) => { + const config = actionConfig[action]; + const Icon = config.icon; + + return ( + + + + + + {config.tooltip} + + + ); +}; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 07a9eacf60..5d1767ef08 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -2,9 +2,11 @@ import { MockDisplayNameTasks, MockInitializingTasks, MockSystemNotificationTemplates, + MockTask, MockTasks, MockTemplate, MockUserOwner, + MockWorkspaceBuildStop, mockApiError, } from "testHelpers/entities"; import { @@ -288,6 +290,129 @@ export const InitializingTasks: Story = { }, }; +export const AllTaskStatuses: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: [ + { + ...MockTask, + id: "active-task", + display_name: "Active Task", + status: "active", + }, + { + ...MockTask, + id: "initializing-task", + display_name: "Initializing Task", + status: "initializing", + }, + { + ...MockTask, + id: "pending-task", + display_name: "Pending Task", + status: "pending", + }, + { + ...MockTask, + id: "paused-task", + display_name: "Paused Task", + status: "paused", + }, + { + ...MockTask, + id: "error-task", + display_name: "Error Task", + status: "error", + }, + { + ...MockTask, + id: "unknown-task", + display_name: "Unknown Task", + status: "unknown", + }, + ], + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, +}; + +export const PauseTask: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: [{ ...MockTask, status: "active" }], + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, + beforeEach: () => { + // Mock APIs for refetch after mutation invalidates queries + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API, "getTasks").mockResolvedValue([ + { ...MockTask, status: "active" }, + ]); + spyOn(API, "stopWorkspace").mockResolvedValue(MockWorkspaceBuildStop); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const pauseButton = await canvas.findByRole("button", { + name: /pause task/i, + }); + await userEvent.click(pauseButton); + await waitFor(() => { + expect(API.stopWorkspace).toHaveBeenCalledWith(MockTask.workspace_id); + }); + }, +}; + +export const ResumeTask: Story = { + parameters: { + queries: [ + { + key: ["tasks", { owner: MockUserOwner.username }], + data: [{ ...MockTask, status: "paused" }], + }, + { + key: getTemplatesQueryKey({ q: "has-ai-task:true" }), + data: [MockTemplate], + }, + ], + }, + beforeEach: () => { + // Mock APIs for refetch after mutation invalidates queries + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API, "getTasks").mockResolvedValue([ + { ...MockTask, status: "paused" }, + ]); + spyOn(API, "startWorkspace").mockResolvedValue(MockWorkspaceBuildStop); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const resumeButton = await canvas.findByRole("button", { + name: /resume task/i, + }); + await userEvent.click(resumeButton); + await waitFor(() => { + expect(API.startWorkspace).toHaveBeenCalledWith( + MockTask.workspace_id, + MockTask.template_version_id, + undefined, + undefined, + ); + }); + }, +}; + export const BatchActionsEnabled: Story = { parameters: { features: ["task_batch_actions"], diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index a7a5056952..c2ada09947 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -1,5 +1,6 @@ +import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; -import type { Task } from "api/typesGenerated"; +import type { Task, TaskStatus as TaskStatusType } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; @@ -11,6 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; +import { displayError } from "components/GlobalSnackbar/utils"; import { Skeleton } from "components/Skeleton/Skeleton"; import { Table, @@ -29,9 +31,10 @@ import { EllipsisVertical, RotateCcwIcon, TrashIcon } from "lucide-react"; import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog"; import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus"; import { type FC, type ReactNode, useState } from "react"; +import { useMutation, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; - import { relativeTime } from "utils/time"; +import { TaskActionButton } from "./TaskActionButton"; type TasksTableProps = { tasks: readonly Task[] | undefined; @@ -170,6 +173,16 @@ const TasksEmpty: FC = () => { ); }; +const pauseStatuses: TaskStatusType[] = [ + "active", + "initializing", + "pending", + "error", + "unknown", +]; +const pauseDisabledStatuses: TaskStatusType[] = ["pending", "initializing"]; +const resumeStatuses: TaskStatusType[] = ["paused", "error", "unknown"]; + type TaskRowProps = { task: Task; checked: boolean; @@ -186,6 +199,46 @@ const TaskRow: FC = ({ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const templateDisplayName = task.template_display_name ?? task.template_name; const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const showPause = pauseStatuses.includes(task.status); + const pauseDisabled = pauseDisabledStatuses.includes(task.status); + const showResume = resumeStatuses.includes(task.status); + + const pauseMutation = useMutation({ + mutationFn: async () => { + if (!task.workspace_id) { + throw new Error("Task has no workspace"); + } + return API.stopWorkspace(task.workspace_id); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (error: unknown) => { + displayError(getErrorMessage(error, "Failed to pause task.")); + }, + }); + + const resumeMutation = useMutation({ + mutationFn: async () => { + if (!task.workspace_id) { + throw new Error("Task has no workspace"); + } + return API.startWorkspace( + task.workspace_id, + task.template_version_id, + undefined, + undefined, + ); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (error: unknown) => { + displayError(getErrorMessage(error, "Failed to resume task.")); + }, + }); const taskPageLink = `/tasks/${task.owner_name}/${task.id}`; // Discard role, breaks Chromatic. @@ -252,30 +305,47 @@ const TaskRow: FC = ({ /> - - - - - - { - e.stopPropagation(); - setIsDeleteDialogOpen(true); - }} - > - - Delete… - - - +
+ {showPause && ( + + )} + {showResume && ( + + )} + + + + + + { + e.stopPropagation(); + setIsDeleteDialogOpen(true); + }} + > + + Delete… + + + +