diff --git a/cli/exp_task_status_test.go b/cli/exp_task_status_test.go index ae6f1db29d..f15222d51b 100644 --- a/cli/exp_task_status_test.go +++ b/cli/exp_task_status_test.go @@ -240,6 +240,7 @@ func Test_TaskStatus(t *testing.T) { "template_display_name": "", "template_icon": "", "workspace_id": null, + "workspace_name": "", "workspace_status": "running", "workspace_agent_id": null, "workspace_agent_lifecycle": "ready", diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 0ad1bffc30..1d06daeae9 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -339,6 +339,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod OrganizationID: dbTask.OrganizationID, OwnerID: dbTask.OwnerID, OwnerName: ws.OwnerName, + OwnerAvatarURL: ws.OwnerAvatarURL, Name: dbTask.Name, TemplateID: ws.TemplateID, TemplateVersionID: dbTask.TemplateVersionID, @@ -346,6 +347,7 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod TemplateDisplayName: ws.TemplateDisplayName, TemplateIcon: ws.TemplateIcon, WorkspaceID: dbTask.WorkspaceID, + WorkspaceName: ws.Name, WorkspaceBuildNumber: dbTask.WorkspaceBuildNumber.Int32, WorkspaceStatus: ws.LatestBuild.Status, WorkspaceAgentID: dbTask.WorkspaceAgentID, @@ -360,21 +362,13 @@ func taskFromDBTaskAndWorkspace(dbTask database.Task, ws codersdk.Workspace) cod } } -// tasksListResponse wraps a list of experimental tasks. -// -// Experimental: Response shape is experimental and may change. -type tasksListResponse struct { - Tasks []codersdk.Task `json:"tasks"` - Count int `json:"count"` -} - // @Summary List AI tasks // @Description: EXPERIMENTAL: this endpoint is experimental and not guaranteed to be stable. // @ID list-tasks // @Security CoderSessionToken // @Tags Experimental // @Param q query string false "Search query for filtering tasks. Supports: owner:, organization:, status:" -// @Success 200 {object} coderd.tasksListResponse +// @Success 200 {object} codersdk.TasksListResponse // @Router /api/experimental/tasks [get] // // EXPERIMENTAL: This endpoint is experimental and not guaranteed to be stable. @@ -413,7 +407,7 @@ func (api *API) tasksList(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, tasksListResponse{ + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TasksListResponse{ Tasks: tasks, Count: len(tasks), }) diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 10b33f4ab9..491194ffd9 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -268,6 +268,7 @@ func TestTasks(t *testing.T) { require.True(t, ok, "task should be found in the list") assert.Equal(t, wantPrompt, got.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, task.WorkspaceID.UUID, got.WorkspaceID.UUID, "workspace id should match") + assert.Equal(t, task.WorkspaceName, got.WorkspaceName, "workspace name should match") // Status should be populated via the tasks_with_status view. assert.NotEmpty(t, got.Status, "task status should not be empty") assert.NotEmpty(t, got.WorkspaceStatus, "workspace status should not be empty") @@ -323,6 +324,7 @@ func TestTasks(t *testing.T) { assert.Equal(t, task.Name, updated.Name, "task name should match") assert.Equal(t, wantPrompt, updated.InitialPrompt, "task prompt should match the AI Prompt parameter") assert.Equal(t, task.WorkspaceID.UUID, updated.WorkspaceID.UUID, "workspace id should match") + assert.Equal(t, task.WorkspaceName, updated.WorkspaceName, "workspace name should match") assert.Equal(t, ws.LatestBuild.BuildNumber, updated.WorkspaceBuildNumber, "workspace build number should match") assert.Equal(t, agentID, updated.WorkspaceAgentID.UUID, "workspace agent id should match") assert.Equal(t, taskAppID, updated.WorkspaceAppID.UUID, "workspace app id should match") diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b4110e3a4c..b8f4b7301c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -160,7 +160,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/coderd.tasksListResponse" + "$ref": "#/definitions/codersdk.TasksListResponse" } } } @@ -11640,20 +11640,6 @@ const docTemplate = `{ } } }, - "coderd.tasksListResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Task" - } - } - } - }, "codersdk.ACLAvailable": { "type": "object", "properties": { @@ -17744,6 +17730,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "owner_avatar_url": { + "type": "string" + }, "owner_id": { "type": "string", "format": "uuid" @@ -17820,6 +17809,9 @@ const docTemplate = `{ } ] }, + "workspace_name": { + "type": "string" + }, "workspace_status": { "enum": [ "pending", @@ -17941,6 +17933,20 @@ const docTemplate = `{ "TaskStatusError" ] }, + "codersdk.TasksListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Task" + } + } + } + }, "codersdk.TelemetryConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5d0f5de4e4..909a0d5394 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -134,7 +134,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/coderd.tasksListResponse" + "$ref": "#/definitions/codersdk.TasksListResponse" } } } @@ -10336,20 +10336,6 @@ } } }, - "coderd.tasksListResponse": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "tasks": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.Task" - } - } - } - }, "codersdk.ACLAvailable": { "type": "object", "properties": { @@ -16232,6 +16218,9 @@ "type": "string", "format": "uuid" }, + "owner_avatar_url": { + "type": "string" + }, "owner_id": { "type": "string", "format": "uuid" @@ -16308,6 +16297,9 @@ } ] }, + "workspace_name": { + "type": "string" + }, "workspace_status": { "enum": [ "pending", @@ -16418,6 +16410,20 @@ "TaskStatusError" ] }, + "codersdk.TasksListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Task" + } + } + } + }, "codersdk.TelemetryConfig": { "type": "object", "properties": { diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index bf55cd6eda..9f390202e4 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -156,6 +156,7 @@ type Task struct { OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` OwnerID uuid.UUID `json:"owner_id" format:"uuid" table:"owner id"` OwnerName string `json:"owner_name" table:"owner name"` + OwnerAvatarURL string `json:"owner_avatar_url,omitempty" table:"owner avatar url"` Name string `json:"name" table:"name,default_sort"` TemplateID uuid.UUID `json:"template_id" format:"uuid" table:"template id"` TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid" table:"template version id"` @@ -163,6 +164,7 @@ type Task struct { TemplateDisplayName string `json:"template_display_name" table:"template display name"` TemplateIcon string `json:"template_icon" table:"template icon"` WorkspaceID uuid.NullUUID `json:"workspace_id" format:"uuid" table:"workspace id"` + WorkspaceName string `json:"workspace_name" table:"workspace name"` WorkspaceStatus WorkspaceStatus `json:"workspace_status,omitempty" enums:"pending,starting,running,stopping,stopped,failed,canceling,canceled,deleting,deleted" table:"workspace status"` WorkspaceBuildNumber int32 `json:"workspace_build_number,omitempty" table:"workspace build number"` WorkspaceAgentID uuid.NullUUID `json:"workspace_agent_id" format:"uuid" table:"workspace agent id"` @@ -200,6 +202,14 @@ type TasksFilter struct { FilterQuery string `json:"filter_query,omitempty"` } +// TaskListResponse is the response shape for tasks list. +// +// Experimental response shape for tasks list (server returns []Task). +type TasksListResponse struct { + Tasks []Task `json:"tasks"` + Count int `json:"count"` +} + func (f TasksFilter) asRequestOption() RequestOption { return func(r *http.Request) { var params []string @@ -242,12 +252,7 @@ func (c *ExperimentalClient) Tasks(ctx context.Context, filter *TasksFilter) ([] return nil, ReadBodyAsError(res) } - // Experimental response shape for tasks list (server returns []Task). - type tasksListResponse struct { - Tasks []Task `json:"tasks"` - Count int `json:"count"` - } - var tres tasksListResponse + var tres TasksListResponse if err := json.NewDecoder(res.Body).Decode(&tres); err != nil { return nil, err } diff --git a/docs/reference/api/experimental.md b/docs/reference/api/experimental.md index 3bb5fb03c7..34ad224bd3 100644 --- a/docs/reference/api/experimental.md +++ b/docs/reference/api/experimental.md @@ -25,9 +25,9 @@ curl -X GET http://coder-server:8080/api/v2/api/experimental/tasks \ ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [coderd.tasksListResponse](schemas.md#coderdtaskslistresponse) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TasksListResponse](schemas.md#codersdktaskslistresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 5688fb5972..8945bdf1a6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -277,64 +277,6 @@ |--------------|--------|----------|--------------|-------------| | `csp-report` | object | false | | | -## coderd.tasksListResponse - -```json -{ - "count": 0, - "tasks": [ - { - "created_at": "2019-08-24T14:15:22Z", - "current_state": { - "message": "string", - "state": "working", - "timestamp": "2019-08-24T14:15:22Z", - "uri": "string" - }, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "initial_prompt": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "status": "pending", - "template_display_name": "string", - "template_icon": "string", - "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", - "template_name": "string", - "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", - "updated_at": "2019-08-24T14:15:22Z", - "workspace_agent_health": { - "healthy": false, - "reason": "agent has lost connection" - }, - "workspace_agent_id": { - "uuid": "string", - "valid": true - }, - "workspace_agent_lifecycle": "created", - "workspace_app_id": { - "uuid": "string", - "valid": true - }, - "workspace_build_number": 0, - "workspace_id": { - "uuid": "string", - "valid": true - }, - "workspace_status": "pending" - } - ] -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------|-----------------------------------------|----------|--------------|-------------| -| `count` | integer | false | | | -| `tasks` | array of [codersdk.Task](#codersdktask) | false | | | - ## codersdk.ACLAvailable ```json @@ -7736,6 +7678,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "initial_prompt": "string", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", "status": "pending", @@ -7763,6 +7706,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "uuid": "string", "valid": true }, + "workspace_name": "string", "workspace_status": "pending" } ``` @@ -7777,6 +7721,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `initial_prompt` | string | false | | | | `name` | string | false | | | | `organization_id` | string | false | | | +| `owner_avatar_url` | string | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | | `status` | [codersdk.TaskStatus](#codersdktaskstatus) | false | | | @@ -7792,6 +7737,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `workspace_app_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | | `workspace_build_number` | integer | false | | | | `workspace_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | +| `workspace_name` | string | false | | | | `workspace_status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | | #### Enumerated Values @@ -7941,6 +7887,66 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `unknown` | | `error` | +## codersdk.TasksListResponse + +```json +{ + "count": 0, + "tasks": [ + { + "created_at": "2019-08-24T14:15:22Z", + "current_state": { + "message": "string", + "state": "working", + "timestamp": "2019-08-24T14:15:22Z", + "uri": "string" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initial_prompt": "string", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "owner_avatar_url": "string", + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "status": "pending", + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_agent_health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "workspace_agent_id": { + "uuid": "string", + "valid": true + }, + "workspace_agent_lifecycle": "created", + "workspace_app_id": { + "uuid": "string", + "valid": true + }, + "workspace_build_number": 0, + "workspace_id": { + "uuid": "string", + "valid": true + }, + "workspace_name": "string", + "workspace_status": "pending" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|-----------------------------------------|----------|--------------|-------------| +| `count` | integer | false | | | +| `tasks` | array of [codersdk.Task](#codersdktask) | false | | | + ## codersdk.TelemetryConfig ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a5c9148b01..04bf78538a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,7 +21,6 @@ */ import globalAxios, { type AxiosInstance, isAxiosError } from "axios"; import type dayjs from "dayjs"; -import type { Task } from "modules/tasks/tasks"; import userAgentParser from "ua-parser-js"; import { delay } from "../utils/delay"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; @@ -426,10 +425,6 @@ export type GetProvisionerDaemonsParams = { offline?: boolean; }; -export type TasksFilter = { - username?: string; -}; - /** * This is the container for all API methods. It's split off to make it more * clear where API methods should go, but it is eventually merged into the Api @@ -2712,28 +2707,35 @@ class ExperimentalApiMethods { return response.data; }; - getTasks = async (filter: TasksFilter): Promise => { - const queryExpressions = ["has-ai-task:true"]; - - if (filter.username) { - queryExpressions.push(`owner:${filter.username}`); + getTasks = async ( + filter: TypesGen.TasksFilter, + ): Promise => { + const query: string[] = []; + if (filter.owner) { + query.push(`owner:${filter.owner}`); + } + if (filter.status) { + query.push(`status:${filter.status}`); } - const res = await API.getWorkspaces({ - q: queryExpressions.join(" "), - }); - // Exclude prebuild workspaces as they are not user-facing. - const workspaces = res.workspaces.filter( - (workspace) => !workspace.is_prebuild, - ); - const prompts = await API.experimental.getAITasksPrompts( - workspaces.map((workspace) => workspace.latest_build.id), + const res = await this.axios.get( + "/api/experimental/tasks", + { + params: { + q: query.join(", "), + }, + }, ); - return workspaces.map((workspace) => ({ - workspace, - prompt: prompts.prompts[workspace.latest_build.id], - })); + return res.data.tasks; + }; + + getTask = async (user: string, id: string): Promise => { + const response = await this.axios.get( + `/api/experimental/tasks/${user}/${id}`, + ); + + return response.data; }; deleteTask = async (user: string, id: string): Promise => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a3bfcbbed4..0bcd2ba6cc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4701,6 +4701,7 @@ export interface Task { readonly organization_id: string; readonly owner_id: string; readonly owner_name: string; + readonly owner_avatar_url?: string; readonly name: string; readonly template_id: string; readonly template_version_id: string; @@ -4708,6 +4709,7 @@ export interface Task { readonly template_display_name: string; readonly template_icon: string; readonly workspace_id: string | null; + readonly workspace_name: string; readonly workspace_status?: WorkspaceStatus; readonly workspace_build_number?: number; readonly workspace_agent_id: string | null; @@ -4825,6 +4827,17 @@ export interface TasksFilter { readonly filter_query?: string; } +// From codersdk/aitasks.go +/** + * TaskListResponse is the response shape for tasks list. + * + * Experimental response shape for tasks list (server returns []Task). + */ +export interface TasksListResponse { + readonly tasks: readonly Task[]; + readonly count: number; +} + // From codersdk/deployment.go export interface TelemetryConfig { readonly enable: boolean; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 6428656745..d9f65c9a2a 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -1,17 +1,13 @@ import { chromaticWithTablet } from "testHelpers/chromatic"; -import { - MockUserMember, - MockUserOwner, - MockWorkspace, - MockWorkspaceAppStatus, -} from "testHelpers/entities"; +import { MockTasks, MockUserMember, MockUserOwner } from "testHelpers/entities"; import { withDashboardProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { TasksFilter } from "api/typesGenerated"; import { userEvent, within } from "storybook/test"; import { NavbarView } from "./NavbarView"; -const tasksFilter = { - username: MockUserOwner.username, +const tasksFilter: TasksFilter = { + owner: MockUserOwner.username, }; const meta: Meta = { @@ -103,29 +99,7 @@ export const IdleTasks: Story = { queries: [ { key: ["tasks", tasksFilter], - data: [ - { - prompt: "Task 1", - workspace: { - ...MockWorkspace, - latest_app_status: { - ...MockWorkspaceAppStatus, - state: "idle", - }, - }, - }, - { - prompt: "Task 2", - workspace: MockWorkspace, - }, - { - prompt: "Task 3", - workspace: { - ...MockWorkspace, - latest_app_status: MockWorkspaceAppStatus, - }, - }, - ], + data: MockTasks, }, ], }, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index a4107f0b84..eb7b8afe5c 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -200,8 +200,8 @@ const TasksNavItem: FC = ({ user }) => { process.env.NODE_ENV === "development" || process.env.STORYBOOK, ); - const filter = { - username: user.username, + const filter: TypesGen.TasksFilter = { + owner: user.username, }; const { data: idleCount } = useQuery({ queryKey: ["tasks", filter], @@ -211,8 +211,7 @@ const TasksNavItem: FC = ({ user }) => { refetchOnWindowFocus: true, initialData: [], select: (data) => - data.filter((task) => task.workspace.latest_app_status?.state === "idle") - .length, + data.filter((task) => task.current_state?.state === "idle").length, }); if (!canSeeTasks) { diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx index e595c26a78..faf4894ec7 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx @@ -1,4 +1,4 @@ -import { MockTasks, MockWorkspace } from "testHelpers/entities"; +import { MockTask } from "testHelpers/entities"; import { withGlobalSnackbar } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; @@ -18,7 +18,7 @@ export const DeleteTaskSuccess: Story = { decorators: [withGlobalSnackbar], args: { open: true, - task: { prompt: "My Task", workspace: MockWorkspace }, + task: MockTask, onClose: () => {}, }, parameters: { @@ -40,8 +40,8 @@ export const DeleteTaskSuccess: Story = { await step("Confirm delete", async () => { await waitFor(() => { expect(API.experimental.deleteTask).toHaveBeenCalledWith( - MockTasks[0].workspace.owner_name, - MockTasks[0].workspace.id, + MockTask.owner_name, + MockTask.id, ); }); }); diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx index b5bac134a6..2e8dc14ce7 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx @@ -1,10 +1,10 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { Task } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; import { QueryClient, useMutation } from "react-query"; -import type { Task } from "../tasks"; type TaskDeleteDialogProps = { open: boolean; @@ -20,8 +20,7 @@ export const TaskDeleteDialog: FC = ({ }) => { const queryClient = new QueryClient(); const deleteTaskMutation = useMutation({ - mutationFn: () => - API.experimental.deleteTask(task.workspace.owner_name, task.workspace.id), + mutationFn: () => API.experimental.deleteTask(task.owner_name, task.id), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["tasks"] }); }, diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index bac6c41aab..9572c68c75 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -1,6 +1,5 @@ import { MockAIPromptPresets, - MockNewTaskData, MockPresets, MockTask, MockTasks, @@ -14,10 +13,19 @@ import { import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; +import type { Task } from "api/typesGenerated"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import type TasksPage from "../../../pages/TasksPage/TasksPage"; import { TaskPrompt } from "./TaskPrompt"; +const MockNewTaskData: Task = { + ...MockTask, + current_state: { + ...MockTask.current_state, + message: "Task created successfully!", + }, +}; + const meta: Meta = { title: "modules/tasks/TaskPrompt", component: TaskPrompt, @@ -77,7 +85,7 @@ export const SubmitEnabledWhenPromptNotEmpty: Story = { const canvas = within(canvasElement); const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeEnabled(); @@ -152,7 +160,7 @@ export const OnSuccess: Story = { await step("Run task", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); @@ -162,7 +170,7 @@ export const OnSuccess: Story = { expect(API.experimental.createTask).toHaveBeenCalledWith( MockUserOwner.id, { - input: MockNewTaskData.prompt, + input: MockNewTaskData.initial_prompt, template_version_id: `${MockTemplate.active_version_id}-latest`, template_version_preset_id: undefined, }, @@ -267,7 +275,7 @@ export const SelectTemplateVersion: Story = { await step("Fill prompt", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); }); await step("Select version", async () => { @@ -290,7 +298,7 @@ export const SelectTemplateVersion: Story = { expect(API.experimental.createTask).toHaveBeenCalledWith( MockUserOwner.id, { - input: MockNewTaskData.prompt, + input: MockNewTaskData.initial_prompt, template_version_id: "test-template-version-2", template_version_preset_id: undefined, }, @@ -375,7 +383,7 @@ export const MissingExternalAuth: Story = { await step("Submit is disabled", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeDisabled(); }); @@ -403,7 +411,7 @@ export const ExternalAuthError: Story = { await step("Submit is disabled", async () => { const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.prompt); + await userEvent.type(prompt, MockNewTaskData.initial_prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); expect(submitButton).toBeDisabled(); }); diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 580b520d2c..b40f3253a8 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -29,7 +29,6 @@ import { import { useAuthenticated } from "hooks/useAuthenticated"; import { useExternalAuth } from "hooks/useExternalAuth"; import { ArrowUpIcon, RedoIcon, RotateCcwIcon } from "lucide-react"; -import { AI_PROMPT_PARAMETER_NAME } from "modules/tasks/tasks"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import TextareaAutosize, { @@ -39,6 +38,8 @@ import { docs } from "utils/docs"; import { PromptSelectTrigger } from "./PromptSelectTrigger"; import { TemplateVersionSelect } from "./TemplateVersionSelect"; +const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; + type TaskPromptProps = { templates: Template[] | undefined; error: unknown; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx new file mode 100644 index 0000000000..9d20b357dd --- /dev/null +++ b/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskStatus } from "./TaskStatus"; + +const meta: Meta = { + title: "modules/tasks/TaskStatus", + component: TaskStatus, +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + status: "active", + stateMessage: "Task is running smoothly", + }, +}; + +export const Failed: Story = { + args: { + status: "error", + stateMessage: "Task encountered an error", + }, +}; + +export const Initializing: Story = { + args: { + status: "initializing", + stateMessage: "Task is initializing", + }, +}; + +export const Pending: Story = { + args: { + status: "pending", + stateMessage: "Task is pending", + }, +}; + +export const Paused: Story = { + args: { + status: "paused", + stateMessage: "Task is paused", + }, +}; + +export const Unknown: Story = { + args: { + status: "unknown", + stateMessage: "Task status is unknown", + }, +}; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.tsx new file mode 100644 index 0000000000..81204dee44 --- /dev/null +++ b/site/src/modules/tasks/TaskStatus/TaskStatus.tsx @@ -0,0 +1,41 @@ +import type * as TypesGen from "api/typesGenerated"; +import { + StatusIndicator, + StatusIndicatorDot, + type StatusIndicatorProps, +} from "components/StatusIndicator/StatusIndicator"; +import type { FC } from "react"; + +type TaskStatusProps = { + status: TypesGen.TaskStatus; + stateMessage: string; +}; + +export const taskStatusToStatusIndicatorVariant: Record< + TypesGen.TaskStatus, + StatusIndicatorProps["variant"] +> = { + active: "success", + error: "failed", + initializing: "pending", + pending: "pending", + paused: "inactive", + unknown: "warning", +}; + +export const TaskStatus: FC = ({ status, stateMessage }) => { + return ( + + +
+ {status} + + {stateMessage} + +
+
+ ); +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx index 11fb2c54bb..7af5fa6b47 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -19,13 +19,14 @@ const meta: Meta = { }, reactRouter: reactRouterParameters({ location: { - path: `/tasks/${MockTasks[0].workspace.name}`, + path: `/tasks/${MockTasks[0].owner_name}/${MockTasks[0].id}`, pathParams: { - workspace: MockTasks[0].workspace.name, + owner_name: MockTasks[0].owner_name, + taskId: MockTasks[0].id, }, }, routing: [ - { path: "/tasks/:workspace", useStoryElement: true }, + { path: "/tasks/:username/:taskId", useStoryElement: true }, { path: "/tasks", element:
Tasks Index Page
}, ], }), diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index b63366e1b9..eddbfa6f3f 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -1,6 +1,6 @@ import { API } from "api/api"; import { getErrorMessage } from "api/errors"; -import { cva } from "class-variance-authority"; +import type { Task, TasksFilter } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownMenu, @@ -12,6 +12,7 @@ import { import { CoderIcon } from "components/Icons/CoderIcon"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Skeleton } from "components/Skeleton/Skeleton"; +import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator"; import { Tooltip, TooltipContent, @@ -21,18 +22,18 @@ import { import { useAuthenticated } from "hooks"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react"; -import type { Task } from "modules/tasks/tasks"; import { type FC, useState } from "react"; import { useQuery } 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 { UserCombobox } from "./UserCombobox"; export const TasksSidebar: FC = () => { const { user, permissions } = useAuthenticated(); - const usernameParam = useSearchParamsKey({ - key: "username", + const ownerParam = useSearchParamsKey({ + key: "owner", defaultValue: user.username, }); @@ -42,11 +43,11 @@ export const TasksSidebar: FC = () => {
-
+
{!isCollapsed && (
- {!isCollapsed && } + {!isCollapsed && }
); }; type TasksSidebarGroupProps = { - username: string; + owner: string; }; -const TasksSidebarGroup: FC = ({ username }) => { - const filter = { username }; +const TasksSidebarGroup: FC = ({ owner }) => { + const filter: TasksFilter = { owner }; const tasksQuery = useQuery({ queryKey: ["tasks", filter], queryFn: () => API.experimental.getTasks(filter), @@ -139,14 +140,14 @@ const TasksSidebarGroup: FC = ({ username }) => { }); return ( - +
Tasks
{tasksQuery.data ? ( tasksQuery.data.length > 0 ? ( - tasksQuery.data.map((t) => ( - + tasksQuery.data.map((task) => ( + )) ) : (
@@ -175,8 +176,8 @@ type TaskSidebarMenuItemProps = { }; const TaskSidebarMenuItem: FC = ({ task }) => { - const { workspace } = useParams<{ workspace: string }>(); - const isActive = task.workspace.name === workspace; + const { taskId } = useParams<{ taskId: string }>(); + const isActive = task.id === taskId; const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); @@ -197,12 +198,12 @@ const TaskSidebarMenuItem: FC = ({ task }) => { > - {task.workspace.name} + {task.name}
diff --git a/site/src/pages/TaskPage/TaskApps.stories.tsx b/site/src/pages/TaskPage/TaskApps.stories.tsx index 02256d82cc..752c52cfde 100644 --- a/site/src/pages/TaskPage/TaskApps.stories.tsx +++ b/site/src/pages/TaskPage/TaskApps.stories.tsx @@ -1,6 +1,5 @@ import { MockPrimaryWorkspaceProxy, - MockTasks, MockUserOwner, MockWorkspace, MockWorkspaceAgent, @@ -9,10 +8,9 @@ import { } from "testHelpers/entities"; import { withAuthProvider, withProxyProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { WorkspaceApp } from "api/typesGenerated"; +import type { Workspace, WorkspaceApp } from "api/typesGenerated"; import { getPreferredProxy } from "contexts/ProxyContext"; import kebabCase from "lodash/kebabCase"; -import type { Task } from "modules/tasks/tasks"; import { TaskApps } from "./TaskApps"; const mockExternalApp: WorkspaceApp = { @@ -35,25 +33,25 @@ type Story = StoryObj; export const NoEmbeddedApps: Story = { args: { - task: mockTask([]), + workspace: mockWorkspaceWithApps([]), }, }; export const WithExternalAppsOnly: Story = { args: { - task: mockTask([mockExternalApp]), + workspace: mockWorkspaceWithApps([mockExternalApp]), }, }; export const WithEmbeddedApps: Story = { args: { - task: mockTask([mockEmbeddedApp()]), + workspace: mockWorkspaceWithApps([mockEmbeddedApp()]), }, }; export const WithMixedApps: Story = { args: { - task: mockTask([mockEmbeddedApp(), mockExternalApp]), + workspace: mockWorkspaceWithApps([mockEmbeddedApp(), mockExternalApp]), }, }; @@ -71,7 +69,7 @@ export const WithWildcardWarning: Story = { user: MockUserOwner, }, args: { - task: mockTask([ + workspace: mockWorkspaceWithApps([ { ...mockEmbeddedApp(), subdomain: true, @@ -82,7 +80,7 @@ export const WithWildcardWarning: Story = { export const WithManyEmbeddedApps: Story = { args: { - task: mockTask([ + workspace: mockWorkspaceWithApps([ mockEmbeddedApp("Code Server"), mockEmbeddedApp("Jupyter Notebook"), mockEmbeddedApp("Web Terminal"), @@ -108,25 +106,22 @@ function mockEmbeddedApp(name = MockWorkspaceApp.display_name): WorkspaceApp { }; } -function mockTask(apps: WorkspaceApp[]): Task { +function mockWorkspaceWithApps(apps: WorkspaceApp[]): Workspace { return { - ...MockTasks[0], - workspace: { - ...MockWorkspace, - latest_build: { - ...MockWorkspace.latest_build, - resources: [ - { - ...MockWorkspace.latest_build.resources[0], - agents: [ - { - ...MockWorkspaceAgent, - apps, - }, - ], - }, - ], - }, + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspace.latest_build.resources[0], + agents: [ + { + ...MockWorkspaceAgent, + apps, + }, + ], + }, + ], }, }; } diff --git a/site/src/pages/TaskPage/TaskApps.tsx b/site/src/pages/TaskPage/TaskApps.tsx index 4bc575cf06..2f05145bca 100644 --- a/site/src/pages/TaskPage/TaskApps.tsx +++ b/site/src/pages/TaskPage/TaskApps.tsx @@ -1,3 +1,4 @@ +import type { Workspace } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownMenu, @@ -13,10 +14,9 @@ import { ChevronDownIcon, LayoutGridIcon, TerminalIcon } from "lucide-react"; import { getTerminalHref } from "modules/apps/apps"; import { useAppLink } from "modules/apps/useAppLink"; import { - getTaskApps, - type Task, + getAllAppsWithAgent, type WorkspaceAppWithAgent, -} from "modules/tasks/tasks"; +} from "modules/tasks/apps"; import { type FC, useState } from "react"; import { type LinkProps, Link as RouterLink } from "react-router"; import { cn } from "utils/cn"; @@ -24,17 +24,17 @@ import { docs } from "utils/docs"; import { TaskAppIFrame, TaskIframe } from "./TaskAppIframe"; type TaskAppsProps = { - task: Task; + workspace: Workspace; }; const TERMINAL_TAB_ID = "terminal"; -export const TaskApps: FC = ({ task }) => { - const apps = getTaskApps(task).filter( +export const TaskApps: FC = ({ workspace }) => { + const apps = getAllAppsWithAgent(workspace).filter( // The Chat UI app will be displayed in the sidebar, so we don't want to // show it as a web app. (app) => - app.id !== task.workspace.latest_build.task_app_id && + app.id !== workspace.latest_build.task_app_id && app.health !== "disabled", ); const [embeddedApps, externalApps] = splitEmbeddedAndExternalApps(apps); @@ -43,8 +43,8 @@ export const TaskApps: FC = ({ task }) => { embeddedApps.length > 0 || externalApps.length > 0; const taskAgent = apps.at(0)?.agent; const terminalHref = getTerminalHref({ - username: task.workspace.owner_name, - workspace: task.workspace.name, + username: workspace.owner_name, + workspace: workspace.name, agent: taskAgent?.name, }); const isTerminalActive = activeAppId === TERMINAL_TAB_ID; @@ -58,7 +58,7 @@ export const TaskApps: FC = ({ task }) => { {embeddedApps.map((app) => ( { @@ -83,7 +83,10 @@ export const TaskApps: FC = ({ task }) => { {externalApps.length > 0 && ( - + )}
)} @@ -95,7 +98,7 @@ export const TaskApps: FC = ({ task }) => { key={app.id} active={activeAppId === app.id} app={app} - task={task} + workspace={workspace} /> ))} @@ -130,12 +133,12 @@ export const TaskApps: FC = ({ task }) => { }; type ExternalAppsDropdownProps = { - task: Task; + workspace: Workspace; externalApps: WorkspaceAppWithAgent[]; }; const ExternalAppsDropdown: FC = ({ - task, + workspace, externalApps, }) => { return ( @@ -149,7 +152,7 @@ const ExternalAppsDropdown: FC = ({ {externalApps.map((app) => ( - + ))} @@ -159,11 +162,11 @@ const ExternalAppsDropdown: FC = ({ const ExternalAppMenuItem: FC<{ app: WorkspaceAppWithAgent; - task: Task; -}> = ({ app, task }) => { + workspace: Workspace; +}> = ({ app, workspace }) => { const link = useAppLink(app, { agent: app.agent, - workspace: task.workspace, + workspace, }); return ( @@ -177,16 +180,21 @@ const ExternalAppMenuItem: FC<{ }; type TaskAppTabProps = { - task: Task; + workspace: Workspace; app: WorkspaceAppWithAgent; active: boolean; onClick: (e: React.MouseEvent) => void; }; -const TaskAppTab: FC = ({ task, app, active, onClick }) => { +const TaskAppTab: FC = ({ + workspace, + app, + active, + onClick, +}) => { const link = useAppLink(app, { agent: app.agent, - workspace: task.workspace, + workspace, }); return ( diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index d101f59e8f..730aa1b173 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -2,6 +2,7 @@ import { MockFailedWorkspace, MockStartingWorkspace, MockStoppedWorkspace, + MockTask, MockTasks, MockUserOwner, MockWorkspace, @@ -24,7 +25,7 @@ import { API } from "api/api"; import type { Workspace, WorkspaceApp } from "api/typesGenerated"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage"; +import TaskPage from "./TaskPage"; const MockClaudeCodeApp: WorkspaceApp = { ...MockWorkspaceApp, @@ -71,10 +72,10 @@ const meta: Meta = { reactRouter: reactRouterParameters({ location: { pathParams: { - workspace: MockTasks[0].workspace.name, + taskId: MockTasks[0].id, }, }, - routing: { path: "/tasks/:workspace" }, + routing: { path: "/tasks/:taskId" }, }), }, }; @@ -82,17 +83,26 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Loading: Story = { +export const LoadingTask: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockImplementation( - () => new Promise((_res) => 1000 * 60 * 60), + spyOn(API.experimental, "getTask").mockImplementation( + () => new Promise(() => {}), ); }, }; -export const LoadingError: Story = { +export const LoadingWorkspace: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockRejectedValue( + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockImplementation( + () => new Promise(() => {}), + ); + }, +}; + +export const LoadingTaskError: Story = { + beforeEach: () => { + spyOn(API.experimental, "getTask").mockRejectedValue( mockApiError({ message: "Failed to load task", detail: "You don't have permission to access this resource.", @@ -101,58 +111,66 @@ export const LoadingError: Story = { }, }; +export const LoadingWorkspaceError: Story = { + beforeEach: () => { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockRejectedValue( + mockApiError({ + message: "Failed to load workspace", + detail: "You don't have permission to access this resource.", + }), + ); + }, +}; + export const WaitingOnBuild: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: MockStartingWorkspace, - }); + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStartingWorkspace, + ); }, }; export const FailedBuild: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: MockFailedWorkspace, - }); + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockFailedWorkspace, + ); }, }; export const TerminatedBuild: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: MockStoppedWorkspace, - }); + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppedWorkspace, + ); }, }; export const TerminatedBuildWithStatus: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: { - ...MockStoppedWorkspace, - latest_app_status: MockWorkspaceAppStatus, - }, + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({ + ...MockStoppedWorkspace, + latest_app_status: MockWorkspaceAppStatus, }); }, }; export const WaitingStartupScripts: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: { - ...MockWorkspace, - latest_build: { - ...MockWorkspace.latest_build, - has_ai_task: true, - resources: [ - { ...MockWorkspaceResource, agents: [MockWorkspaceAgentStarting] }, - ], - }, + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({ + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + has_ai_task: true, + resources: [ + { ...MockWorkspaceResource, agents: [MockWorkspaceAgentStarting] }, + ], }, }); }, @@ -184,14 +202,12 @@ export const WaitingStartupScripts: Story = { export const SidebarAppNotFound: Story = { beforeEach: () => { const workspace = mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp); - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: { - ...workspace, - latest_build: { - ...workspace.latest_build, - task_app_id: "non-existent-app-id", - }, + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({ + ...workspace, + latest_build: { + ...workspace.latest_build, + task_app_id: "non-existent-app-id", }, }); }, @@ -199,98 +215,87 @@ export const SidebarAppNotFound: Story = { export const SidebarAppHealthDisabled: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace( + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace( { ...MockClaudeCodeApp, health: "disabled", }, MockVSCodeApp, ), - }); + ); }, }; export const SidebarAppInitializing: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace( + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace( { ...MockClaudeCodeApp, health: "initializing", }, MockVSCodeApp, ), - }); + ); }, }; export const SidebarAppHealthy: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace( + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace( { ...MockClaudeCodeApp, health: "healthy", }, MockVSCodeApp, ), - }); + ); }, }; export const SidebarAppUnhealthy: Story = { beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace( + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace( { ...MockClaudeCodeApp, health: "unhealthy", }, MockVSCodeApp, ), - }); + ); }, }; const mainAppHealthStory = (health: WorkspaceApp["health"]) => ({ beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace(MockClaudeCodeApp, { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace(MockClaudeCodeApp, { ...MockVSCodeApp, health, }), - }); + ); }, }); export const MainAppHealthy: Story = mainAppHealthStory("healthy"); export const MainAppInitializing: Story = mainAppHealthStory("initializing"); export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy"); -export const MainAppHealthUnknown: Story = mainAppHealthStory( - "unknown" as unknown as WorkspaceApp["health"], -); - -export const BuildNoAITask: Story = { - beforeEach: () => { - spyOn(data, "fetchTask").mockImplementation(() => { - throw new WorkspaceDoesNotHaveAITaskError(MockWorkspace); - }); - }, -}; export const Active: Story = { decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp), - }); + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp), + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -308,10 +313,10 @@ export const Active: Story = { export const ActivePreview: Story = { decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchTask").mockResolvedValue({ - prompt: "Create competitors page", - workspace: mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp), - }); + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + mockTaskWorkspace(MockClaudeCodeApp, MockVSCodeApp), + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -323,6 +328,10 @@ export const ActivePreview: Story = { export const WorkspaceStarting: Story = { decorators: [withGlobalSnackbar], beforeEach: () => { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppedWorkspace, + ); spyOn(API, "startWorkspace").mockResolvedValue( MockStartingWorkspace.latest_build, ); @@ -332,33 +341,13 @@ export const WorkspaceStarting: Story = { location: { pathParams: { username: MockStoppedWorkspace.owner_name, - workspace: MockStoppedWorkspace.name, + taskId: MockTask.id, }, }, routing: { - path: "/tasks/:username/:workspace", + path: "/tasks/:username/:taskId", }, }), - 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); @@ -377,6 +366,10 @@ export const WorkspaceStarting: Story = { export const WorkspaceStartFailure: Story = { decorators: [withGlobalSnackbar], beforeEach: () => { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppedWorkspace, + ); spyOn(API, "startWorkspace").mockRejectedValue( new Error("Some unexpected error"), ); @@ -386,33 +379,13 @@ export const WorkspaceStartFailure: Story = { location: { pathParams: { username: MockStoppedWorkspace.owner_name, - workspace: MockStoppedWorkspace.name, + taskId: MockTask.id, }, }, routing: { - path: "/tasks/:username/:workspace", + path: "/tasks/:username/:taskId", }, }), - 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); @@ -431,6 +404,10 @@ export const WorkspaceStartFailure: Story = { export const WorkspaceStartFailureWithDialog: Story = { beforeEach: () => { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockStoppedWorkspace, + ); spyOn(API, "startWorkspace").mockRejectedValue({ ...mockApiError({ message: "Bad Request", @@ -444,33 +421,13 @@ export const WorkspaceStartFailureWithDialog: Story = { location: { pathParams: { username: MockStoppedWorkspace.owner_name, - workspace: MockStoppedWorkspace.name, + taskId: MockTask.id, }, }, routing: { - path: "/tasks/:username/:workspace", + path: "/tasks/:username/:taskId", }, }), - 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); diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 4eb95b5065..f136a16ceb 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -1,7 +1,10 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage, isApiError } from "api/errors"; import { template as templateQueryOptions } from "api/queries/templates"; -import { startWorkspace } from "api/queries/workspaces"; +import { + startWorkspace, + workspaceByOwnerAndName, +} from "api/queries/workspaces"; import type { Workspace, WorkspaceAgent, @@ -18,12 +21,8 @@ import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { ArrowLeftIcon, RotateCcwIcon } 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 { - AI_PROMPT_PARAMETER_NAME, - getTaskApps, - type Task, -} from "modules/tasks/tasks"; import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { @@ -56,20 +55,26 @@ const TaskPageLayout: FC = ({ children }) => { }; const TaskPage = () => { - const { workspace: workspaceName, username } = useParams() as { - workspace: string; + const { task: taskId, username } = useParams() as { + task: string; username: string; }; - const { - data: task, - error, - refetch, - } = useQuery({ - queryKey: ["tasks", username, workspaceName], - queryFn: () => data.fetchTask(username, workspaceName), - refetchInterval: 5_000, + const { data: task, ...taskQuery } = useQuery({ + queryKey: ["tasks", username, taskId], + queryFn: () => API.experimental.getTask(username, taskId), + refetchInterval: ({ state }) => { + return state.error ? false : 5_000; + }, }); - + const { data: workspace, ...workspaceQuery } = useQuery({ + ...workspaceByOwnerAndName(username, task?.workspace_name ?? ""), + enabled: task !== undefined, + refetchInterval: ({ state }) => { + return state.error ? false : 5_000; + }, + }); + const refetch = taskQuery.error ? taskQuery.refetch : workspaceQuery.refetch; + const error = taskQuery.error ?? workspaceQuery.error; const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"]; if (error) { @@ -103,7 +108,7 @@ const TaskPage = () => { ); } - if (!task) { + if (!task || !workspace) { return ( {pageTitle("Loading task")} @@ -113,11 +118,11 @@ const TaskPage = () => { } let content: ReactNode = null; - const agent = selectAgent(task); + const agent = selectAgent(workspace); - if (waitingStatuses.includes(task.workspace.latest_build.status)) { - content = ; - } else if (task.workspace.latest_build.status === "failed") { + if (waitingStatuses.includes(workspace.latest_build.status)) { + content = ; + } else if (workspace.latest_build.status === "failed") { content = (
@@ -129,7 +134,7 @@ const TaskPage = () => {
); - } else if (task.workspace.latest_build.status !== "running") { - content = ; + } else if (workspace.latest_build.status !== "running") { + content = ; } else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) { content = ; } else { - const chatApp = getTaskApps(task).find( - (app) => app.id === task.workspace.latest_build.task_app_id, + const chatApp = getAllAppsWithAgent(workspace).find( + (app) => app.id === workspace.latest_build.task_app_id, ); content = ( {chatApp ? ( - + ) : (
@@ -168,7 +173,7 @@ const TaskPage = () => {
- + ); @@ -176,9 +181,9 @@ const TaskPage = () => { return ( - {pageTitle(task.workspace.name)} + {pageTitle(task.name)} - + {content} ); @@ -187,19 +192,19 @@ const TaskPage = () => { export default TaskPage; type WorkspaceNotRunningProps = { - task: Task; + workspace: Workspace; }; -const WorkspaceNotRunning: FC = ({ task }) => { +const WorkspaceNotRunning: FC = ({ workspace }) => { const queryClient = useQueryClient(); const { data: parameters } = useQuery({ - queryKey: ["workspace", task.workspace.id, "parameters"], - queryFn: () => API.getWorkspaceParameters(task.workspace), + queryKey: ["workspace", workspace.id, "parameters"], + queryFn: () => API.getWorkspaceParameters(workspace), }); const mutateStartWorkspace = useMutation({ - ...startWorkspace(task?.workspace, queryClient), + ...startWorkspace(workspace, queryClient), onError: (error: unknown) => { if (!isApiError(error)) { displayError(getErrorMessage(error, "Failed to build workspace.")); @@ -248,27 +253,27 @@ const WorkspaceNotRunning: FC = ({ task }) => { error={apiError} onClose={mutateStartWorkspace.reset} showDetail={true} - workspaceOwner={task.workspace.owner_name} - workspaceName={task.workspace.name} - templateVersionId={task.workspace.latest_build.template_version_id} + workspaceOwner={workspace.owner_name} + workspaceName={workspace.name} + templateVersionId={workspace.latest_build.template_version_id} isDeleting={false} /> ); }; -type TaskBuildingWorkspaceProps = { task: Task }; +type BuildingWorkspaceProps = { workspace: Workspace }; -const TaskBuildingWorkspace: FC = ({ task }) => { +const BuildingWorkspace: FC = ({ workspace }) => { const { data: template } = useQuery( - templateQueryOptions(task.workspace.template_id), + templateQueryOptions(workspace.template_id), ); - const buildLogs = useWorkspaceBuildLogs(task?.workspace.latest_build.id); + const buildLogs = useWorkspaceBuildLogs(workspace.latest_build.id); // If no template yet, use an indeterminate progress bar. const transitionStats = (template && - getActiveTransitionStats(template, task.workspace)) || { + getActiveTransitionStats(template, workspace)) || { P50: 0, P95: null, }; @@ -303,7 +308,7 @@ const TaskBuildingWorkspace: FC = ({ task }) => {
@@ -376,44 +381,8 @@ const TaskStartingAgent: FC = ({ agent }) => { ); }; -export class WorkspaceDoesNotHaveAITaskError extends Error { - constructor(workspace: Workspace) { - super( - `Workspace ${workspace.owner_name}/${workspace.name} is not running an AI task`, - ); - this.name = "WorkspaceDoesNotHaveAITaskError"; - } -} - -export const data = { - fetchTask: async (workspaceOwnerUsername: string, workspaceName: string) => { - const workspace = await API.getWorkspaceByOwnerAndName( - workspaceOwnerUsername, - workspaceName, - ); - if ( - workspace.latest_build.job.completed_at && - !workspace.latest_build.has_ai_task - ) { - throw new WorkspaceDoesNotHaveAITaskError(workspace); - } - - const parameters = await API.getWorkspaceBuildParameters( - workspace.latest_build.id, - ); - const prompt = - parameters.find((p) => p.name === AI_PROMPT_PARAMETER_NAME)?.value ?? - "Unknown prompt"; - - return { - workspace, - prompt, - } satisfies Task; - }, -}; - -function selectAgent(task: Task) { - const agents = task.workspace.latest_build.resources +function selectAgent(workspace: Workspace) { + const agents = workspace.latest_build.resources .flatMap((r) => r.agents) .filter((a) => !!a); diff --git a/site/src/pages/TaskPage/TaskTopbar.tsx b/site/src/pages/TaskPage/TaskTopbar.tsx index 989e1b6473..3d22631ae1 100644 --- a/site/src/pages/TaskPage/TaskTopbar.tsx +++ b/site/src/pages/TaskPage/TaskTopbar.tsx @@ -1,3 +1,4 @@ +import type { Task, Workspace } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { Tooltip, @@ -13,14 +14,13 @@ import { LaptopMinimalIcon, TerminalIcon, } from "lucide-react"; -import type { Task } from "modules/tasks/tasks"; import type { FC } from "react"; import { Link as RouterLink } from "react-router"; import { TaskStatusLink } from "./TaskStatusLink"; -type TaskTopbarProps = { task: Task }; +type TaskTopbarProps = { task: Task; workspace: Workspace }; -export const TaskTopbar: FC = ({ task }) => { +export const TaskTopbar: FC = ({ task, workspace }) => { return (
@@ -37,13 +37,11 @@ export const TaskTopbar: FC = ({ task }) => { -

- {task.workspace.name} -

+

{task.name}

- {task.workspace.latest_app_status?.uri && ( + {task.current_state?.uri && (
- +
)} @@ -58,17 +56,15 @@ export const TaskTopbar: FC = ({ task }) => {

- {task.prompt} + {task.initial_prompt}

- +
{ - userFilter.setValue( - username === userFilter.value ? "" : username, + ownerFilter.setValue( + username === ownerFilter.value ? "" : username, ); }} /> diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 8af0039b63..be0700c815 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -1,4 +1,5 @@ import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { Task } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; @@ -24,15 +25,13 @@ import { } from "components/Tooltip/Tooltip"; import { RotateCcwIcon, TrashIcon } from "lucide-react"; import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog"; -import type { Task } from "modules/tasks/tasks"; -import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; -import { WorkspaceStatus } from "modules/workspaces/WorkspaceStatus/WorkspaceStatus"; +import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus"; import { type FC, type ReactNode, useState } from "react"; import { Link as RouterLink } from "react-router"; import { relativeTime } from "utils/time"; type TasksTableProps = { - tasks: Task[] | undefined; + tasks: readonly Task[] | undefined; error: unknown; onRetry: () => void; }; @@ -47,7 +46,7 @@ export const TasksTable: FC = ({ tasks, error, onRetry }) => { } else if (tasks.length === 0) { body = ; } else { - body = tasks.map((task) => ); + body = tasks.map((task) => ); } return ( @@ -55,8 +54,7 @@ export const TasksTable: FC = ({ tasks, error, onRetry }) => { Task - Agent status - Workspace status + Status Created by @@ -74,7 +72,7 @@ type TasksErrorBodyProps = { const TasksErrorBody: FC = ({ error, onRetry }) => { return ( - +

@@ -97,7 +95,7 @@ const TasksErrorBody: FC = ({ error, onRetry }) => { const TasksEmpty: FC = () => { return ( - +

@@ -117,21 +115,20 @@ type TaskRowProps = { task: Task }; const TaskRow: FC = ({ task }) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const templateDisplayName = - task.workspace.template_display_name ?? task.workspace.template_name; + const templateDisplayName = task.template_display_name ?? task.template_name; return ( <> - + - {task.prompt} + {task.initial_prompt} Access task @@ -143,30 +140,28 @@ const TaskRow: FC = ({ task }) => { } /> - - - - + - {relativeTime(new Date(task.workspace.created_at))} + {relativeTime(new Date(task.created_at))} } - src={task.workspace.owner_avatar_url} + src={task.owner_avatar_url} /> @@ -210,9 +205,6 @@ const TasksSkeleton: FC = () => { - - - diff --git a/site/src/router.tsx b/site/src/router.tsx index 6aaefb77d8..c0da37b681 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -597,7 +597,7 @@ export const router = createBrowserRouter( /> } /> } /> - } /> + } /> , ), diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index c0f11f2f13..18eb661ea7 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4984,54 +4984,20 @@ export const MockAIPromptPresets: TypesGen.Preset[] = [ }, ]; -// Mock Tasks for AI Tasks page -export const MockTasks = [ - { - workspace: { - ...MockWorkspace, - name: "create-competitors-page", - latest_app_status: MockWorkspaceAppStatus, - }, - prompt: "Create competitors page", - }, - { - workspace: { - ...MockWorkspace, - id: "workspace-2", - name: "fix-avatar-size", - latest_app_status: { - ...MockWorkspaceAppStatus, - message: "Avatar size fixed!", - }, - }, - prompt: "Fix user avatar size", - }, - { - workspace: { - ...MockWorkspace, - id: "workspace-3", - name: "fix-accessibility-issues", - latest_app_status: { - ...MockWorkspaceAppStatus, - message: "Accessibility issues fixed!", - }, - }, - prompt: "Fix accessibility issues", - }, -]; - -export const MockTask: TypesGen.Task = { +export const MockTask = { id: "test-task", name: "task-wild-test-123", organization_id: MockOrganization.id, owner_id: MockUserOwner.id, owner_name: MockUserOwner.username, + owner_avatar_url: MockUserOwner.avatar_url, template_id: MockTemplate.id, template_name: MockTemplate.name, template_display_name: MockTemplate.display_name, template_icon: MockTemplate.icon, template_version_id: MockTemplateVersion.id, workspace_id: MockWorkspace.id, + workspace_name: MockWorkspace.name, workspace_status: "running", workspace_build_number: MockWorkspaceBuild.build_number, workspace_agent_id: MockWorkspaceAgent.id, @@ -5048,16 +5014,28 @@ export const MockTask: TypesGen.Task = { }, created_at: "2022-05-17T17:39:01.382927298Z", updated_at: "2022-05-17T17:39:01.382927298Z", -}; +} satisfies TypesGen.Task; -export const MockNewTaskData = { - prompt: "Create a new task", - workspace: { - ...MockWorkspace, - id: "workspace-4", - latest_app_status: { - ...MockWorkspaceAppStatus, - message: "Task created successfully!", +export const MockTasks = [ + MockTask, + { + ...MockTask, + id: "task-2", + name: "fix-avatar-size", + current_state: { + ...MockTask.current_state, + message: "Avatar size fixed!", + state: "complete", }, }, -}; + { + ...MockTask, + id: "task-3", + name: "fix-accessibility-issues", + current_state: { + ...MockTask.current_state, + message: "Accessibility issues fixed!", + state: "complete", + }, + }, +] satisfies TypesGen.Task[];