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)
This commit is contained in:
Ehab Younes
2026-02-09 23:37:44 +03:00
committed by GitHub
parent 53ceea918b
commit 8990a107a0
9 changed files with 533 additions and 157 deletions
+36
View File
@@ -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"] });
},
};
};
@@ -3,7 +3,7 @@ import { expect, fn, userEvent, within } from "storybook/test";
import { TaskActionButton } from "./TaskActionButton";
const meta: Meta<typeof TaskActionButton> = {
title: "pages/TasksPage/TaskActionButton",
title: "modules/tasks/TaskActionButton",
component: TaskActionButton,
args: {
onClick: fn(),
@@ -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]);
},
};
@@ -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<TaskSidebarMenuItemProps> = ({ 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<TaskSidebarMenuItemProps> = ({ task }) => {
<DropdownMenuContent align="end">
<DropdownMenuGroup>
{showPause && (
<DropdownMenuItem
disabled={pauseDisabled || pauseMutation.isPending}
onClick={(e) => {
e.stopPropagation();
pauseMutation.mutate();
}}
>
<Spinner loading={pauseMutation.isPending}>
<PauseIcon />
</Spinner>
Pause
</DropdownMenuItem>
)}
{showResume && (
<DropdownMenuItem
disabled={resumeMutation.isPending}
onClick={(e) => {
e.stopPropagation();
resumeMutation.mutate();
}}
>
<Spinner loading={resumeMutation.isPending}>
<PlayIcon />
</Spinner>
Resume
</DropdownMenuItem>
)}
{(showPause || showResume) && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={(e) => {
+43
View File
@@ -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);
}
+97 -15
View File
@@ -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);
+214 -97
View File
@@ -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 = (
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build failed
</h3>
<span className="text-content-secondary text-sm">
Please check the logs for more details.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink
to={`/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`}
>
View logs
</RouterLink>
</Button>
</div>
</div>
<TaskBuildFailed
workspaceOwner={workspace.owner_name}
workspaceName={workspace.name}
buildNumber={workspace.latest_build.build_number}
/>
);
} else if (workspace.latest_build.status !== "running") {
} else if (workspace.latest_build.status === "stopping") {
content = (
<WorkspaceNotRunning
<TaskTransitioning
title="Pausing task"
subtitle="Your task is being paused..."
/>
);
} else if (
workspace.latest_build.status === "stopped" ||
workspace.latest_build.status === "canceled"
) {
content = (
<TaskPaused
task={task}
workspace={workspace}
onEditPrompt={() => setIsModifyDialogOpen(true)}
/>
);
} else if (workspace.latest_build.status === "canceling") {
content = (
<TaskTransitioning
title="Canceling task"
subtitle="Your task is being canceled..."
/>
);
} else if (workspace.latest_build.status === "deleting") {
content = (
<TaskTransitioning
title="Deleting task"
subtitle="Your task workspace is being deleted..."
/>
);
} else if (workspace.latest_build.status === "deleted") {
content = <TaskDeleted />;
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
content = <TaskStartingAgent task={task} agent={agent} />;
} 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<TaskStateMessageProps> = ({
title,
description,
icon,
actions,
detail,
}) => {
return (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-base flex items-center gap-2">
{icon}
{title}
</h3>
{description && (
<span className="text-content-secondary text-sm">
{description}
</span>
)}
{detail}
{actions && <div className="mt-4">{actions}</div>}
</div>
</div>
</Margins>
);
};
type TaskTransitioningProps = {
title: string;
subtitle: string;
};
const TaskTransitioning: FC<TaskTransitioningProps> = ({ title, subtitle }) => {
return (
<TaskStateMessage
title={title}
description={subtitle}
icon={<Spinner loading />}
/>
);
};
const TaskDeleted: FC = () => {
return (
<TaskStateMessage
title="Task was deleted"
description="This task cannot be resumed. Create a new task to continue."
actions={
<Button size="sm" variant="outline" asChild>
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
}
/>
);
};
type TaskBuildFailedProps = {
workspaceOwner: string;
workspaceName: string;
buildNumber: number;
};
const TaskBuildFailed: FC<TaskBuildFailedProps> = ({
workspaceOwner,
workspaceName,
buildNumber,
}) => {
return (
<TaskStateMessage
title="Task build failed"
description="Please check the logs for more details."
icon={<TriangleAlertIcon className="size-4 text-content-destructive" />}
actions={
<Button size="sm" variant="outline" asChild>
<RouterLink
to={`/@${workspaceOwner}/${workspaceName}/builds/${buildNumber}`}
>
View full logs
</RouterLink>
</Button>
}
/>
);
};
type TaskPausedProps = {
task: Task;
workspace: Workspace;
onEditPrompt: () => void;
};
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
workspace,
onEditPrompt,
}) => {
const TaskPaused: FC<TaskPausedProps> = ({ 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 ? (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task workspace was deleted.
</h3>
<span className="text-content-secondary text-sm">
This task cannot be resumed. Delete this task and create a new one.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
</div>
</div>
</Margins>
) : (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Workspace is not running
</h3>
<span className="text-content-secondary text-sm">
Apps and previous statuses are not available
</span>
{workspace.outdated && (
return (
<>
<TaskStateMessage
title="Task paused"
description={
isTimeout
? "Your task timed out. Resume it to continue."
: "Resume the task to continue."
}
icon={<PauseIcon className="size-4" />}
detail={
workspace.outdated && (
<div
data-testid="workspace-outdated-tooltip"
className="flex items-center gap-1.5 mt-1 text-content-secondary text-sm"
>
<WorkspaceOutdatedTooltip workspace={workspace}>
You can update your task workspace to a newer version
A newer template version is available
</WorkspaceOutdatedTooltip>
</div>
)}
<div className="flex flex-row mt-4 gap-4">
)
}
actions={
<div className="flex flex-row gap-4">
<Button
size="sm"
data-testid="task-start-workspace"
disabled={isWaitingForStart}
onClick={() => {
mutateStartWorkspace.mutate({
buildParameters,
});
}}
onClick={() => resumeMutation.mutate()}
>
<Spinner loading={isWaitingForStart} />
Start workspace
Resume
</Button>
<Button size="sm" onClick={onEditPrompt} variant="outline">
Edit Prompt
Edit prompt
</Button>
</div>
</div>
</div>
}
/>
<WorkspaceErrorDialog
open={apiError !== undefined}
error={apiError}
onClose={mutateStartWorkspace.reset}
onClose={resumeMutation.reset}
showDetail={true}
workspaceOwner={workspace.owner_name}
workspaceName={workspace.name}
templateVersionId={workspace.latest_build.template_version_id}
isDeleting={false}
/>
</Margins>
</>
);
};
@@ -405,12 +500,21 @@ const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({
};
type TaskStartingAgentProps = {
task: Task;
agent: WorkspaceAgent;
};
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ task, agent }) => {
const logs = useAgentLogs({ agentId: agent.id });
const listRef = useRef<FixedSizeList>(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<TaskStartingAgentProps> = ({ agent }) => {
<section className="p-16 overflow-y-auto">
<div className="flex justify-center items-center w-full">
<div className="flex flex-col gap-8 items-center w-full">
<header className="flex flex-col items-center text-center">
<h3 className="m-0 font-medium text-content-primary text-xl">
Running startup scripts
</h3>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
<header className="flex flex-col items-center text-center gap-3">
<div>
<h3 className="m-0 font-medium text-content-primary text-xl">
Running startup scripts
</h3>
<p className="text-content-secondary m-0">
Your task will be running in a few moments
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={pauseDisabled || pauseMutation.isPending}
onClick={() => pauseMutation.mutate()}
>
<Spinner loading={pauseMutation.isPending}>
<PauseIcon className="size-4" />
</Spinner>
Pause
</Button>
</header>
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
+15 -42
View File
@@ -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<TaskRowProps> = ({
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."));
},