From 8990a107a0ca847cc8aec58c4cdb7fbbcf82e859 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 9 Feb 2026 23:37:44 +0300 Subject: [PATCH] feat(site): add pause/resume actions to task page (#21952) Add the ability to pause a running task and resume a paused task directly from the TaskPage. This includes showing contextual messages when a task is paused (manual vs timeout) and proper error handling with dialogs for API errors. - Extract task action logic into reusable mutations (api/queries/tasks.ts) - Move TaskActionButton to modules/tasks for better organization - Add pause button to TaskStartingAgent component - Show appropriate state messages for transitioning states (pausing, canceling, deleting) --- site/src/api/queries/tasks.ts | 36 ++ .../tasks}/TaskActionButton.stories.tsx | 2 +- .../tasks}/TaskActionButton.tsx | 0 .../TasksSidebar/TasksSidebar.stories.tsx | 67 ++++ .../tasks/TasksSidebar/TasksSidebar.tsx | 62 +++- site/src/modules/tasks/taskActions.ts | 43 +++ site/src/pages/TaskPage/TaskPage.stories.tsx | 112 ++++++- site/src/pages/TaskPage/TaskPage.tsx | 311 ++++++++++++------ site/src/pages/TasksPage/TasksTable.tsx | 57 +--- 9 files changed, 533 insertions(+), 157 deletions(-) create mode 100644 site/src/api/queries/tasks.ts rename site/src/{pages/TasksPage => modules/tasks}/TaskActionButton.stories.tsx (95%) rename site/src/{pages/TasksPage => modules/tasks}/TaskActionButton.tsx (100%) create mode 100644 site/src/modules/tasks/taskActions.ts diff --git a/site/src/api/queries/tasks.ts b/site/src/api/queries/tasks.ts new file mode 100644 index 0000000000..97b51dd878 --- /dev/null +++ b/site/src/api/queries/tasks.ts @@ -0,0 +1,36 @@ +import { API } from "api/api"; +import type { Task } from "api/typesGenerated"; +import type { QueryClient } from "react-query"; + +export const pauseTask = (task: Task, queryClient: QueryClient) => { + return { + 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"] }); + }, + }; +}; + +export const resumeTask = (task: Task, queryClient: QueryClient) => { + return { + 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"] }); + }, + }; +}; diff --git a/site/src/pages/TasksPage/TaskActionButton.stories.tsx b/site/src/modules/tasks/TaskActionButton.stories.tsx similarity index 95% rename from site/src/pages/TasksPage/TaskActionButton.stories.tsx rename to site/src/modules/tasks/TaskActionButton.stories.tsx index 2a102755bb..4b68589762 100644 --- a/site/src/pages/TasksPage/TaskActionButton.stories.tsx +++ b/site/src/modules/tasks/TaskActionButton.stories.tsx @@ -3,7 +3,7 @@ import { expect, fn, userEvent, within } from "storybook/test"; import { TaskActionButton } from "./TaskActionButton"; const meta: Meta = { - title: "pages/TasksPage/TaskActionButton", + title: "modules/tasks/TaskActionButton", component: TaskActionButton, args: { onClick: fn(), diff --git a/site/src/pages/TasksPage/TaskActionButton.tsx b/site/src/modules/tasks/TaskActionButton.tsx similarity index 100% rename from site/src/pages/TasksPage/TaskActionButton.tsx rename to site/src/modules/tasks/TaskActionButton.tsx diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx index e4f3404b05..c4b55b2f39 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -1,5 +1,6 @@ import { MockDisplayNameTasks, + MockTask, MockTasks, MockUserOwner, mockApiError, @@ -131,3 +132,69 @@ export const OpenDeleteDialog: Story = { }); }, }; + +export const PauseMenuOpen: Story = { + beforeEach: () => { + spyOn(API, "getTasks").mockResolvedValue(MockTasks); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const optionButtons = await canvas.findAllByRole("button", { + name: /task options/i, + }); + await userEvent.click(optionButtons[0]); + }, +}; + +export const ResumeMenuOpen: Story = { + beforeEach: () => { + spyOn(API, "getTasks").mockResolvedValue([ + { ...MockTask, status: "paused" }, + ...MockTasks.slice(1), + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const optionButtons = await canvas.findAllByRole("button", { + name: /task options/i, + }); + await userEvent.click(optionButtons[0]); + }, +}; + +export const MixedStatuses: Story = { + beforeEach: () => { + spyOn(API, "getTasks").mockResolvedValue([ + MockTask, + { + ...MockTask, + id: "paused-task", + name: "paused-task", + display_name: "Paused task", + status: "paused", + }, + { + ...MockTask, + id: "error-task", + name: "error-task", + display_name: "Error task", + status: "error", + }, + { + ...MockTask, + id: "init-task", + name: "init-task", + display_name: "Initializing task", + status: "initializing", + }, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const optionButtons = await canvas.findAllByRole("button", { + name: /task options/i, + }); + // Open menu on the error task (third item) to show both Pause and Resume. + await userEvent.click(optionButtons[2]); + }, +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index 69f77f2a73..0f2001fd42 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -1,5 +1,6 @@ import { API } from "api/api"; import { getErrorMessage } from "api/errors"; +import { pauseTask, resumeTask } from "api/queries/tasks"; import type { Task, TasksFilter } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { @@ -7,11 +8,14 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; +import { displayError } from "components/GlobalSnackbar/utils"; import { CoderIcon } from "components/Icons/CoderIcon"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Skeleton } from "components/Skeleton/Skeleton"; +import { Spinner } from "components/Spinner/Spinner"; import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator"; import { Tooltip, @@ -21,13 +25,21 @@ import { } from "components/Tooltip/Tooltip"; import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; -import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react"; +import { + EditIcon, + EllipsisIcon, + PanelLeftIcon, + PauseIcon, + PlayIcon, + TrashIcon, +} from "lucide-react"; import { type FC, useState } from "react"; -import { useQuery } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; import { cn } from "utils/cn"; import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog"; import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus"; +import { canPauseTask, canResumeTask, isPauseDisabled } from "../taskActions"; import { UserCombobox } from "./UserCombobox"; export const TasksSidebar: FC = () => { @@ -180,6 +192,23 @@ const TaskSidebarMenuItem: FC = ({ task }) => { const isActive = task.id === taskId; const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); + const queryClient = useQueryClient(); + const pauseMutation = useMutation({ + ...pauseTask(task, queryClient), + onError: (error: unknown) => { + displayError(getErrorMessage(error, "Failed to pause task.")); + }, + }); + const resumeMutation = useMutation({ + ...resumeTask(task, queryClient), + onError: (error: unknown) => { + displayError(getErrorMessage(error, "Failed to resume task.")); + }, + }); + + const showPause = canPauseTask(task.status) && task.workspace_id; + const pauseDisabled = isPauseDisabled(task.status); + const showResume = canResumeTask(task.status) && task.workspace_id; return ( <> @@ -227,6 +256,35 @@ const TaskSidebarMenuItem: FC = ({ task }) => { + {showPause && ( + { + e.stopPropagation(); + pauseMutation.mutate(); + }} + > + + + + Pause + + )} + {showResume && ( + { + e.stopPropagation(); + resumeMutation.mutate(); + }} + > + + + + Resume + + )} + {(showPause || showResume) && } { diff --git a/site/src/modules/tasks/taskActions.ts b/site/src/modules/tasks/taskActions.ts new file mode 100644 index 0000000000..6e3ce61f4e --- /dev/null +++ b/site/src/modules/tasks/taskActions.ts @@ -0,0 +1,43 @@ +import type { TaskStatus } from "api/typesGenerated"; + +/** + * Task statuses that allow pausing. + */ +const PAUSABLE_STATUSES: TaskStatus[] = [ + "active", + "initializing", + "pending", + "error", + "unknown", +]; + +/** + * Task statuses where the pause button should be disabled (in transition). + */ +const PAUSE_DISABLED_STATUSES: TaskStatus[] = ["pending", "initializing"]; + +/** + * Task statuses that allow resuming. + */ +const RESUMABLE_STATUSES: TaskStatus[] = ["paused", "error", "unknown"]; + +/** + * Checks if a task can be paused based on its status. + */ +export function canPauseTask(status: TaskStatus): boolean { + return PAUSABLE_STATUSES.includes(status); +} + +/** + * Checks if the pause action should be disabled for a task status. + */ +export function isPauseDisabled(status: TaskStatus): boolean { + return PAUSE_DISABLED_STATUSES.includes(status); +} + +/** + * Checks if a task can be resumed based on its status. + */ +export function canResumeTask(status: TaskStatus): boolean { + return RESUMABLE_STATUSES.includes(status); +} diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index 581b331b7b..7c4edaaf24 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -1,9 +1,13 @@ import { + MockCanceledWorkspace, + MockCancelingWorkspace, MockDeletedWorkspace, + MockDeletingWorkspace, MockDisplayNameTasks, MockFailedWorkspace, MockStartingWorkspace, MockStoppedWorkspace, + MockStoppingWorkspace, MockTask, MockTasks, MockUserOwner, @@ -14,6 +18,7 @@ import { MockWorkspaceAgentStarting, MockWorkspaceApp, MockWorkspaceAppStatus, + MockWorkspaceBuildStop, MockWorkspaceResource, mockApiError, } from "testHelpers/entities"; @@ -180,6 +185,77 @@ export const DeletedWorkspace: Story = { }, }; +export const TaskPausing: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "active", + }); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppingWorkspace, + ); + }, +}; + +export const TaskPaused: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "paused", + }); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppedWorkspace, + ); + }, +}; + +export const TaskPausedTimeout: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "paused", + }); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({ + ...MockStoppedWorkspace, + latest_build: { + ...MockWorkspaceBuildStop, + status: "stopped", + reason: "autostop", + }, + }); + }, +}; + +export const TaskCanceled: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "paused", + }); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockCanceledWorkspace, + ); + }, +}; + +export const TaskCanceling: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockCancelingWorkspace, + ); + }, +}; + +export const TaskDeleting: Story = { + beforeEach: () => { + spyOn(API, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockDeletingWorkspace, + ); + }, +}; + export const WaitingStartupScripts: Story = { beforeEach: () => { spyOn(API, "getTask").mockResolvedValue(MockTask); @@ -403,7 +479,7 @@ export const MainAppHealthy: Story = mainAppHealthStory("healthy"); export const MainAppInitializing: Story = mainAppHealthStory("initializing"); export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy"); -export const OutdatedWorkspace: Story = { +export const TaskPausedOutdated: Story = { // Given: an 'outdated' workspace (that is, the latest build does not use template's active version) parameters: { queries: [ @@ -487,10 +563,13 @@ export const ActivePreview: Story = { }, }; -export const WorkspaceStarting: Story = { +export const TaskResuming: Story = { decorators: [withGlobalSnackbar], beforeEach: () => { - spyOn(API, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "paused", + }); spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( MockStoppedWorkspace, ); @@ -514,10 +593,10 @@ export const WorkspaceStarting: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const startButton = await canvas.findByText("Start workspace"); - expect(startButton).toBeInTheDocument(); + const resumeButton = await canvas.findByText("Resume"); + expect(resumeButton).toBeInTheDocument(); - await userEvent.click(startButton); + await userEvent.click(resumeButton); await waitFor(async () => { expect(API.startWorkspace).toBeCalled(); @@ -525,10 +604,13 @@ export const WorkspaceStarting: Story = { }, }; -export const WorkspaceStartFailure: Story = { +export const TaskResumeFailure: Story = { decorators: [withGlobalSnackbar], beforeEach: () => { - spyOn(API, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getTask").mockResolvedValue({ + ...MockTask, + status: "paused", + }); spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( MockStoppedWorkspace, ); @@ -552,10 +634,10 @@ export const WorkspaceStartFailure: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const startButton = await canvas.findByText("Start workspace"); - expect(startButton).toBeInTheDocument(); + const resumeButton = await canvas.findByText("Resume"); + expect(resumeButton).toBeInTheDocument(); - await userEvent.click(startButton); + await userEvent.click(resumeButton); await waitFor(async () => { const errorMessage = await canvas.findByText("Some unexpected error"); @@ -564,7 +646,7 @@ export const WorkspaceStartFailure: Story = { }, }; -export const WorkspaceStartFailureWithDialog: Story = { +export const TaskResumeFailureWithDialog: Story = { beforeEach: () => { spyOn(API, "getTask").mockResolvedValue(MockTask); spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( @@ -594,10 +676,10 @@ export const WorkspaceStartFailureWithDialog: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const startButton = await canvas.findByText("Start workspace"); - expect(startButton).toBeInTheDocument(); + const resumeButton = await canvas.findByText("Resume"); + expect(resumeButton).toBeInTheDocument(); - await userEvent.click(startButton); + await userEvent.click(resumeButton); await waitFor(async () => { const body = within(canvasElement.ownerDocument.body); diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 0808fe851d..5067daf1a0 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -1,12 +1,10 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage, isApiError } from "api/errors"; +import { pauseTask, resumeTask } from "api/queries/tasks"; import { template as templateQueryOptions } from "api/queries/templates"; -import { workspaceBuildParameters } from "api/queries/workspaceBuilds"; -import { - startWorkspace, - workspaceByOwnerAndName, -} from "api/queries/workspaces"; +import { workspaceByOwnerAndName } from "api/queries/workspaces"; import type { + Task, Workspace, WorkspaceAgent, WorkspaceStatus, @@ -19,11 +17,17 @@ import { Margins } from "components/Margins/Margins"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Spinner } from "components/Spinner/Spinner"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; -import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react"; +import { + ArrowLeftIcon, + PauseIcon, + RotateCcwIcon, + TriangleAlertIcon, +} from "lucide-react"; import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs"; import { useAgentLogs } from "modules/resources/useAgentLogs"; import { getAllAppsWithAgent } from "modules/tasks/apps"; import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar"; +import { isPauseDisabled } from "modules/tasks/taskActions"; import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip"; @@ -134,33 +138,48 @@ const TaskPage = () => { ); } else if (workspace.latest_build.status === "failed") { content = ( -
-
-

- Task build failed -

- - Please check the logs for more details. - - -
-
+ ); - } else if (workspace.latest_build.status !== "running") { + } else if (workspace.latest_build.status === "stopping") { content = ( - + ); + } else if ( + workspace.latest_build.status === "stopped" || + workspace.latest_build.status === "canceled" + ) { + content = ( + setIsModifyDialogOpen(true)} /> ); + } else if (workspace.latest_build.status === "canceling") { + content = ( + + ); + } else if (workspace.latest_build.status === "deleting") { + content = ( + + ); + } else if (workspace.latest_build.status === "deleted") { + content = ; } else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) { - content = ; + content = ; } else { const chatApp = getAllAppsWithAgent(workspace).find( (app) => app.id === task.workspace_app_id, @@ -213,111 +232,187 @@ const TaskPage = () => { export default TaskPage; -type WorkspaceNotRunningProps = { +/** + * Common component for task state messages (paused, deleted, transitioning, etc.) + * Similar to EmptyState but styled for task states. + */ +type TaskStateMessageProps = { + title: string; + description?: string; + icon?: ReactNode; + actions?: ReactNode; + detail?: ReactNode; +}; + +const TaskStateMessage: FC = ({ + title, + description, + icon, + actions, + detail, +}) => { + return ( + +
+
+

+ {icon} + {title} +

+ {description && ( + + {description} + + )} + {detail} + {actions &&
{actions}
} +
+
+
+ ); +}; + +type TaskTransitioningProps = { + title: string; + subtitle: string; +}; + +const TaskTransitioning: FC = ({ title, subtitle }) => { + return ( + } + /> + ); +}; + +const TaskDeleted: FC = () => { + return ( + + + Create a new task + + + } + /> + ); +}; + +type TaskBuildFailedProps = { + workspaceOwner: string; + workspaceName: string; + buildNumber: number; +}; + +const TaskBuildFailed: FC = ({ + workspaceOwner, + workspaceName, + buildNumber, +}) => { + return ( + } + actions={ + + } + /> + ); +}; + +type TaskPausedProps = { + task: Task; workspace: Workspace; onEditPrompt: () => void; }; -const WorkspaceNotRunning: FC = ({ - workspace, - onEditPrompt, -}) => { +const TaskPaused: FC = ({ task, workspace, onEditPrompt }) => { const queryClient = useQueryClient(); - const { data: buildParameters } = useQuery( - workspaceBuildParameters(workspace.latest_build.id), - ); - - const mutateStartWorkspace = useMutation({ - ...startWorkspace(workspace, queryClient), + // Use mutation config directly to customize error handling: + // API errors are shown in a dialog, other errors show a toast. + const resumeMutation = useMutation({ + ...resumeTask(task, queryClient), onError: (error: unknown) => { if (!isApiError(error)) { - displayError(getErrorMessage(error, "Failed to build workspace.")); + displayError(getErrorMessage(error, "Failed to resume task.")); } }, }); - // After requesting a workspace start, it may take a while to become ready. - // Show a loading state in the meantime. + // After requesting a task resume, it may take a while to become ready. const isWaitingForStart = - mutateStartWorkspace.isPending || mutateStartWorkspace.isSuccess; + resumeMutation.isPending || resumeMutation.isSuccess; - const apiError = isApiError(mutateStartWorkspace.error) - ? mutateStartWorkspace.error + // Determine if this was a timeout (autostop) or manual pause. + const isTimeout = workspace.latest_build.reason === "autostop"; + + const apiError = isApiError(resumeMutation.error) + ? resumeMutation.error : undefined; - const deleted = workspace.latest_build?.transition === ("delete" as const); - - return deleted ? ( - -
-
-

- Task workspace was deleted. -

- - This task cannot be resumed. Delete this task and create a new one. - - -
-
-
- ) : ( - -
-
-

- Workspace is not running -

- - Apps and previous statuses are not available - - {workspace.outdated && ( + return ( + <> + } + detail={ + workspace.outdated && (
- You can update your task workspace to a newer version + A newer template version is available
- )} -
+ ) + } + actions={ +
-
-
+ } + /> - + ); }; @@ -405,12 +500,21 @@ const BuildingWorkspace: FC = ({ }; type TaskStartingAgentProps = { + task: Task; agent: WorkspaceAgent; }; -const TaskStartingAgent: FC = ({ agent }) => { +const TaskStartingAgent: FC = ({ task, agent }) => { const logs = useAgentLogs({ agentId: agent.id }); const listRef = useRef(null); + const queryClient = useQueryClient(); + const pauseMutation = useMutation({ + ...pauseTask(task, queryClient), + onError: (error: unknown) => { + displayError(getErrorMessage(error, "Failed to pause task.")); + }, + }); + const pauseDisabled = isPauseDisabled(task.status); useLayoutEffect(() => { if (listRef.current) { @@ -422,13 +526,26 @@ const TaskStartingAgent: FC = ({ agent }) => {
-
-

- Running startup scripts -

-

- Your task will be running in a few moments -

+
+
+

+ Running startup scripts +

+

+ Your task will be running in a few moments +

+
+
diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index c2ada09947..1d45a599ed 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -1,6 +1,6 @@ -import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; -import type { Task, TaskStatus as TaskStatusType } from "api/typesGenerated"; +import { pauseTask, resumeTask } from "api/queries/tasks"; +import type { Task } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; @@ -28,13 +28,18 @@ import { } from "components/TableLoader/TableLoader"; import { useClickableTableRow } from "hooks"; import { EllipsisVertical, RotateCcwIcon, TrashIcon } from "lucide-react"; +import { TaskActionButton } from "modules/tasks/TaskActionButton"; import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog"; import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus"; +import { + canPauseTask, + canResumeTask, + isPauseDisabled, +} from "modules/tasks/taskActions"; 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; @@ -173,16 +178,6 @@ 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; @@ -199,42 +194,20 @@ const TaskRow: FC = ({ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const templateDisplayName = task.template_display_name ?? task.template_name; const navigate = useNavigate(); + + const showPause = canPauseTask(task.status) && task.workspace_id; + const pauseDisabled = isPauseDisabled(task.status); + const showResume = canResumeTask(task.status) && task.workspace_id; + 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"] }); - }, + ...pauseTask(task, queryClient), 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"] }); - }, + ...resumeTask(task, queryClient), onError: (error: unknown) => { displayError(getErrorMessage(error, "Failed to resume task.")); },