feat(site): allow starting task workspace from task page (#19790)

Closes https://github.com/coder/coder/issues/19622

When viewing a task with a stopped workspace, instead show a 'Start
workspace' button instead of a 'View workspace'. The user can still view
the workspace by clicking the workspace button at the top right of the
page.


https://github.com/user-attachments/assets/4424c251-5f20-4e82-9ee0-c87a0b30a193
This commit is contained in:
Danielle Maywood
2025-09-23 09:05:49 +01:00
committed by GitHub
parent fb0ce389a6
commit b71d6713d1
2 changed files with 195 additions and 25 deletions
+122 -2
View File
@@ -11,14 +11,20 @@ import {
MockWorkspaceResource,
mockApiError,
} from "testHelpers/entities";
import { withProxyProvider, withWebSocket } from "testHelpers/storybook";
import {
withGlobalSnackbar,
withProxyProvider,
withWebSocket,
} from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type {
Workspace,
WorkspaceApp,
WorkspaceResource,
} from "api/typesGenerated";
import { expect, spyOn, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage";
const meta: Meta<typeof TaskPage> = {
@@ -351,3 +357,117 @@ export const ActivePreview: Story = {
});
},
};
export const WorkspaceStartFailure: Story = {
decorators: [withGlobalSnackbar],
beforeEach: () => {
spyOn(API, "startWorkspace").mockRejectedValue(
new Error("Some unexpected error"),
);
},
parameters: {
reactRouter: reactRouterParameters({
location: {
pathParams: {
username: MockStoppedWorkspace.owner_name,
workspace: MockStoppedWorkspace.name,
},
},
routing: {
path: "/tasks/:username/:workspace",
},
}),
queries: [
{
key: [
"tasks",
MockStoppedWorkspace.owner_name,
MockStoppedWorkspace.name,
],
data: {
prompt: "Create competitors page",
workspace: MockStoppedWorkspace,
},
},
{
key: ["workspace", MockStoppedWorkspace.id, "parameters"],
data: {
templateVersionRichParameters: [],
buildParameters: [],
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
await userEvent.click(startButton);
await waitFor(async () => {
const errorMessage = await canvas.findByText("Some unexpected error");
expect(errorMessage).toBeInTheDocument();
});
},
};
export const WorkspaceStartFailureWithDialog: Story = {
beforeEach: () => {
spyOn(API, "startWorkspace").mockRejectedValue({
...mockApiError({
message: "Bad Request",
detail: "Invalid build parameters provided",
}),
code: "ERR_BAD_REQUEST",
});
},
parameters: {
reactRouter: reactRouterParameters({
location: {
pathParams: {
username: MockStoppedWorkspace.owner_name,
workspace: MockStoppedWorkspace.name,
},
},
routing: {
path: "/tasks/:username/:workspace",
},
}),
queries: [
{
key: [
"tasks",
MockStoppedWorkspace.owner_name,
MockStoppedWorkspace.name,
],
data: {
prompt: "Create competitors page",
workspace: MockStoppedWorkspace,
},
},
{
key: ["workspace", MockStoppedWorkspace.id, "parameters"],
data: {
templateVersionRichParameters: [],
buildParameters: [],
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const startButton = await canvas.findByText("Start workspace");
expect(startButton).toBeInTheDocument();
await userEvent.click(startButton);
await waitFor(async () => {
const body = within(canvasElement.ownerDocument.body);
const dialogTitle = await body.findByText("Error building workspace");
expect(dialogTitle).toBeInTheDocument();
});
},
};
+73 -23
View File
@@ -1,6 +1,7 @@
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
import { template as templateQueryOptions } from "api/queries/templates";
import { startWorkspace } from "api/queries/workspaces";
import type {
Workspace,
WorkspaceAgent,
@@ -8,6 +9,7 @@ import type {
} from "api/typesGenerated";
import isChromatic from "chromatic/isChromatic";
import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
@@ -16,10 +18,11 @@ import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
import { useAgentLogs } from "modules/resources/useAgentLogs";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { Link as RouterLink, useParams } from "react-router";
import type { FixedSizeList } from "react-window";
@@ -119,27 +122,7 @@ const TaskPage = () => {
</div>
);
} else if (task.workspace.latest_build.status !== "running") {
content = (
<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>
<Button size="sm" className="mt-4" asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</Button>
</div>
</div>
</Margins>
);
content = <WorkspaceNotRunning task={task} />;
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
} else {
@@ -174,6 +157,73 @@ const TaskPage = () => {
export default TaskPage;
type WorkspaceNotRunningProps = {
task: Task;
};
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({ task }) => {
const queryClient = useQueryClient();
const { data: parameters } = useQuery({
queryKey: ["workspace", task.workspace.id, "parameters"],
queryFn: () => API.getWorkspaceParameters(task.workspace),
});
const mutateStartWorkspace = useMutation({
...startWorkspace(task?.workspace, queryClient),
onError: (error: unknown) => {
if (!isApiError(error)) {
displayError(getErrorMessage(error, "Failed to build workspace."));
}
},
});
const apiError = isApiError(mutateStartWorkspace.error)
? mutateStartWorkspace.error
: undefined;
return (
<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>
<div className="flex flex-row mt-4 gap-4">
<Button
size="sm"
disabled={mutateStartWorkspace.isPending}
onClick={() => {
mutateStartWorkspace.mutate({
buildParameters: parameters?.buildParameters,
});
}}
>
{mutateStartWorkspace.isPending
? "Starting workspace..."
: "Start workspace"}
</Button>
</div>
</div>
</div>
<WorkspaceErrorDialog
open={apiError !== undefined}
error={apiError}
onClose={mutateStartWorkspace.reset}
showDetail={true}
workspaceOwner={task.workspace.owner_name}
workspaceName={task.workspace.name}
templateVersionId={task.workspace.latest_build.template_version_id}
isDeleting={false}
/>
</Margins>
);
};
type TaskBuildingWorkspaceProps = { task: Task };
const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {