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.
This commit is contained in:
Ehab Younes
2026-02-04 22:30:44 +03:00
committed by GitHub
parent ac18b2995b
commit d5ae72d5e2
4 changed files with 337 additions and 26 deletions
@@ -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<typeof TaskActionButton> = {
title: "pages/TasksPage/TaskActionButton",
component: TaskActionButton,
args: {
onClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof TaskActionButton>;
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);
},
};
@@ -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<TaskActionButtonProps> = ({
action,
disabled,
loading,
onClick,
}) => {
const config = actionConfig[action];
const Icon = config.icon;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-lg"
variant="outline"
disabled={disabled || loading}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Spinner loading={loading}>
<Icon aria-hidden="true" />
</Spinner>
<span className="sr-only">{config.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{config.tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -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"],
+96 -26
View File
@@ -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<TaskRowProps> = ({
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<TaskRowProps> = ({
/>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Show task actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end gap-1">
{showPause && (
<TaskActionButton
action="pause"
disabled={pauseDisabled}
loading={pauseMutation.isPending}
onClick={pauseMutation.mutate}
/>
)}
{showResume && (
<TaskActionButton
action="resume"
loading={resumeMutation.isPending}
onClick={resumeMutation.mutate}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical aria-hidden="true" />
<span className="sr-only">Show task actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
}}
>
<TrashIcon />
Delete&hellip;
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>