From b71d6713d17821fc3741b1d9b68fd05d9a9b1cb3 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 23 Sep 2025 09:05:49 +0100 Subject: [PATCH] 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 --- site/src/pages/TaskPage/TaskPage.stories.tsx | 124 ++++++++++++++++++- site/src/pages/TaskPage/TaskPage.tsx | 96 ++++++++++---- 2 files changed, 195 insertions(+), 25 deletions(-) diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index e44fece019..7c165f82a9 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -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 = { @@ -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(); + }); + }, +}; diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index b5d551bb6e..f8556391da 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -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 = () => { ); } else if (task.workspace.latest_build.status !== "running") { - content = ( - -
-
-

- Workspace is not running -

- - Apps and previous statuses are not available - - -
-
-
- ); + content = ; } else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) { content = ; } else { @@ -174,6 +157,73 @@ const TaskPage = () => { export default TaskPage; +type WorkspaceNotRunningProps = { + task: Task; +}; + +const WorkspaceNotRunning: FC = ({ 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 ( + +
+
+

+ Workspace is not running +

+ + Apps and previous statuses are not available + +
+ +
+
+
+ + +
+ ); +}; + type TaskBuildingWorkspaceProps = { task: Task }; const TaskBuildingWorkspace: FC = ({ task }) => {