mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
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:
@@ -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"] });
|
||||
},
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user