feat(site): use new task data model and endpoints (#20431)

Updates the UI to use the new API endpoints for tasks and use its new
data model.

Disclaimer: Since the base data model for tasks changed, we had to do a
quite large refactor and I'm sorry for that 🙏, but you'll notice most of
the changes are to adjust the types.

Closes coder/internal#976

---------

Co-authored-by: Bruno Quaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Mathias Fredriksson
2025-10-24 10:45:19 -03:00
committed by GitHub
co-authored by Bruno Quaresma
parent c6e551f538
commit 51d3abb904
32 changed files with 607 additions and 659 deletions
+1
View File
@@ -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",
+4 -10
View File
@@ -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:<username/uuid/me>, organization:<org-name/uuid>, status:<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),
})
+2
View File
@@ -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")
+21 -15
View File
@@ -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": {
+21 -15
View File
@@ -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": {
+11 -6
View File
@@ -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
}
+3 -3
View File
@@ -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).
+64 -58
View File
@@ -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
+25 -23
View File
@@ -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<Task[]> => {
const queryExpressions = ["has-ai-task:true"];
if (filter.username) {
queryExpressions.push(`owner:${filter.username}`);
getTasks = async (
filter: TypesGen.TasksFilter,
): Promise<readonly TypesGen.Task[]> => {
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<TypesGen.TasksListResponse>(
"/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<TypesGen.Task> => {
const response = await this.axios.get<TypesGen.Task>(
`/api/experimental/tasks/${user}/${id}`,
);
return response.data;
};
deleteTask = async (user: string, id: string): Promise<void> => {
+13
View File
@@ -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;
@@ -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<typeof NavbarView> = {
@@ -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,
},
],
},
@@ -200,8 +200,8 @@ const TasksNavItem: FC<TasksNavItemProps> = ({ 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<TasksNavItemProps> = ({ 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) {
@@ -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,
);
});
});
@@ -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<TaskDeleteDialogProps> = ({
}) => {
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"] });
},
@@ -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<typeof TasksPage> = {
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();
});
@@ -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;
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TaskStatus } from "./TaskStatus";
const meta: Meta<typeof TaskStatus> = {
title: "modules/tasks/TaskStatus",
component: TaskStatus,
};
export default meta;
type Story = StoryObj<typeof TaskStatus>;
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",
},
};
@@ -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<TaskStatusProps> = ({ status, stateMessage }) => {
return (
<StatusIndicator
variant={taskStatusToStatusIndicatorVariant[status]}
className="items-start"
>
<StatusIndicatorDot className="mt-1" />
<div className="flex flex-col">
<span className="[&:first-letter]:uppercase">{status}</span>
<span className="text-xs font-normal text-content-secondary truncate max-w-sm">
{stateMessage}
</span>
</div>
</StatusIndicator>
);
};
@@ -19,13 +19,14 @@ const meta: Meta<typeof TasksSidebar> = {
},
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: <div>Tasks Index Page</div> },
],
}),
@@ -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 = () => {
<div
className={cn(
"h-full bg-surface-secondary w-full max-w-80",
"border-solid border-0 border-r transition-all",
"border-solid border-0 border-r transition-all flex flex-col",
{ "max-w-14": isCollapsed },
)}
>
<div className="p-3 flex flex-col flex-1 gap-6">
<div className="p-3 flex flex-col gap-6">
<div className="flex items-center place-content-between">
{!isCollapsed && (
<Button
@@ -109,29 +110,29 @@ export const TasksSidebar: FC = () => {
{!isCollapsed && permissions.viewAllUsers && (
<UserCombobox
value={usernameParam.value}
value={ownerParam.value}
onValueChange={(username) => {
if (username === usernameParam.value) {
usernameParam.setValue("");
if (username === ownerParam.value) {
ownerParam.setValue("");
return;
}
usernameParam.setValue(username);
ownerParam.setValue(username);
}}
/>
)}
</div>
{!isCollapsed && <TasksSidebarGroup username={usernameParam.value} />}
{!isCollapsed && <TasksSidebarGroup owner={ownerParam.value} />}
</div>
);
};
type TasksSidebarGroupProps = {
username: string;
owner: string;
};
const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ username }) => {
const filter = { username };
const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ owner }) => {
const filter: TasksFilter = { owner };
const tasksQuery = useQuery({
queryKey: ["tasks", filter],
queryFn: () => API.experimental.getTasks(filter),
@@ -139,14 +140,14 @@ const TasksSidebarGroup: FC<TasksSidebarGroupProps> = ({ username }) => {
});
return (
<ScrollArea>
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 p-3">
<div className="text-content-secondary text-xs">Tasks</div>
<div className="flex flex-col flex-1 gap-1">
{tasksQuery.data ? (
tasksQuery.data.length > 0 ? (
tasksQuery.data.map((t) => (
<TaskSidebarMenuItem key={t.workspace.id} task={t} />
tasksQuery.data.map((task) => (
<TaskSidebarMenuItem key={task.id} task={task} />
))
) : (
<div className="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
@@ -175,8 +176,8 @@ type TaskSidebarMenuItemProps = {
};
const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ 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<TaskSidebarMenuItemProps> = ({ task }) => {
>
<RouterLink
to={{
pathname: `/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
pathname: `/tasks/${task.owner_name}/${task.id}`,
search: window.location.search,
}}
>
<TaskSidebarMenuItemStatus task={task} />
<span className="truncate">{task.workspace.name}</span>
<span className="truncate">{task.name}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -256,40 +257,18 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
);
};
const taskStatusVariants = cva("block size-2 rounded-full shrink-0", {
variants: {
state: {
default: "border border-content-secondary border-solid",
complete: "bg-content-success",
failure: "bg-content-destructive",
idle: "bg-content-secondary",
working: "bg-highlight-sky",
},
},
defaultVariants: {
state: "default",
},
});
const TaskSidebarMenuItemStatus: FC<{ task: Task }> = ({ task }) => {
const statusText = task.workspace.latest_app_status
? task.workspace.latest_app_status.state
: "No activity yet";
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={taskStatusVariants({
state: task.workspace.latest_app_status?.state ?? "default",
})}
>
<span className="sr-only">{statusText}</span>
</div>
<StatusIndicatorDot
variant={taskStatusToStatusIndicatorVariant[task.status]}
aria-label={task.status}
/>
</TooltipTrigger>
<TooltipContent className="first-letter:capitalize">
{statusText}
{task.status}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -4,19 +4,14 @@ import type {
WorkspaceApp,
} from "api/typesGenerated";
export const AI_PROMPT_PARAMETER_NAME = "AI Prompt";
export type Task = {
workspace: Workspace;
prompt: string;
};
export type WorkspaceAppWithAgent = WorkspaceApp & {
agent: WorkspaceAgent;
};
export function getTaskApps(task: Task): WorkspaceAppWithAgent[] {
return task.workspace.latest_build.resources
export function getAllAppsWithAgent(
workspace: Workspace,
): WorkspaceAppWithAgent[] {
return workspace.latest_build.resources
.flatMap((r) => r.agents ?? [])
.flatMap((agent) =>
agent.apps.map((app) => ({
+6 -6
View File
@@ -1,3 +1,4 @@
import type { Workspace } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
@@ -9,26 +10,26 @@ import { Spinner } from "components/Spinner/Spinner";
import { useProxy } from "contexts/ProxyContext";
import { EllipsisVertical, ExternalLinkIcon, HouseIcon } from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { Task, WorkspaceAppWithAgent } from "modules/tasks/tasks";
import type { WorkspaceAppWithAgent } from "modules/tasks/apps";
import { type FC, type HTMLProps, useRef } from "react";
import { Link as RouterLink } from "react-router";
import { cn } from "utils/cn";
import { TaskWildcardWarning } from "./TaskWildcardWarning";
type TaskAppIFrameProps = {
task: Task;
workspace: Workspace;
app: WorkspaceAppWithAgent;
active: boolean;
};
export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
task,
workspace,
app,
active,
}) => {
const link = useAppLink(app, {
agent: app.agent,
workspace: task.workspace,
workspace,
});
const proxy = useProxy();
const frameRef = useRef<HTMLIFrameElement>(null);
@@ -115,8 +116,7 @@ export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
<code className="font-mono text-content-primary select-all">
/tmp/coder-agent.log
</code>{" "}
inside your workspace "{task.workspace.name}" for more
information.
inside your workspace "{workspace.name}" for more information.
</li>
</ul>
</div>
+22 -27
View File
@@ -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<typeof TaskApps>;
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,
},
],
},
],
},
};
}
+29 -21
View File
@@ -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<TaskAppsProps> = ({ task }) => {
const apps = getTaskApps(task).filter(
export const TaskApps: FC<TaskAppsProps> = ({ 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<TaskAppsProps> = ({ 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<TaskAppsProps> = ({ task }) => {
{embeddedApps.map((app) => (
<TaskAppTab
key={app.id}
task={task}
workspace={workspace}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
@@ -83,7 +83,10 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
</ScrollArea>
{externalApps.length > 0 && (
<ExternalAppsDropdown task={task} externalApps={externalApps} />
<ExternalAppsDropdown
workspace={workspace}
externalApps={externalApps}
/>
)}
</div>
)}
@@ -95,7 +98,7 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
key={app.id}
active={activeAppId === app.id}
app={app}
task={task}
workspace={workspace}
/>
))}
@@ -130,12 +133,12 @@ export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
};
type ExternalAppsDropdownProps = {
task: Task;
workspace: Workspace;
externalApps: WorkspaceAppWithAgent[];
};
const ExternalAppsDropdown: FC<ExternalAppsDropdownProps> = ({
task,
workspace,
externalApps,
}) => {
return (
@@ -149,7 +152,7 @@ const ExternalAppsDropdown: FC<ExternalAppsDropdownProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent>
{externalApps.map((app) => (
<ExternalAppMenuItem key={app.id} app={app} task={task} />
<ExternalAppMenuItem key={app.id} app={app} workspace={workspace} />
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -159,11 +162,11 @@ const ExternalAppsDropdown: FC<ExternalAppsDropdownProps> = ({
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<HTMLAnchorElement>) => void;
};
const TaskAppTab: FC<TaskAppTabProps> = ({ task, app, active, onClick }) => {
const TaskAppTab: FC<TaskAppTabProps> = ({
workspace,
app,
active,
onClick,
}) => {
const link = useAppLink(app, {
agent: app.agent,
workspace: task.workspace,
workspace,
});
return (
+107 -150
View File
@@ -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<typeof TaskPage> = {
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<typeof TaskPage> = {
export default meta;
type Story = StoryObj<typeof TaskPage>;
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);
+52 -83
View File
@@ -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<PropsWithChildren> = ({ 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 (
<TaskPageLayout>
<title>{pageTitle("Loading task")}</title>
@@ -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 = <TaskBuildingWorkspace task={task} />;
} else if (task.workspace.latest_build.status === "failed") {
if (waitingStatuses.includes(workspace.latest_build.status)) {
content = <BuildingWorkspace workspace={workspace} />;
} 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">
@@ -129,7 +134,7 @@ const TaskPage = () => {
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}/builds/${task.workspace.latest_build.build_number}`}
to={`/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`}
>
View logs
</RouterLink>
@@ -137,19 +142,19 @@ const TaskPage = () => {
</div>
</div>
);
} else if (task.workspace.latest_build.status !== "running") {
content = <WorkspaceNotRunning task={task} />;
} else if (workspace.latest_build.status !== "running") {
content = <WorkspaceNotRunning workspace={workspace} />;
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
content = <TaskStartingAgent agent={agent} />;
} 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 = (
<PanelGroup autoSaveId="task" direction="horizontal">
<Panel defaultSize={25} minSize={20}>
{chatApp ? (
<TaskAppIFrame active task={task} app={chatApp} />
<TaskAppIFrame active workspace={workspace} app={chatApp} />
) : (
<div className="h-full flex items-center justify-center p-6 text-center">
<div className="flex flex-col items-center">
@@ -168,7 +173,7 @@ const TaskPage = () => {
<div className="w-1 bg-border h-full hover:bg-border-hover transition-all relative" />
</PanelResizeHandle>
<Panel className="[&>*]:h-full">
<TaskApps task={task} />
<TaskApps workspace={workspace} />
</Panel>
</PanelGroup>
);
@@ -176,9 +181,9 @@ const TaskPage = () => {
return (
<TaskPageLayout>
<title>{pageTitle(task.workspace.name)}</title>
<title>{pageTitle(task.name)}</title>
<TaskTopbar task={task} />
<TaskTopbar task={task} workspace={workspace} />
{content}
</TaskPageLayout>
);
@@ -187,19 +192,19 @@ const TaskPage = () => {
export default TaskPage;
type WorkspaceNotRunningProps = {
task: Task;
workspace: Workspace;
};
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({ task }) => {
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({ 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<WorkspaceNotRunningProps> = ({ 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}
/>
</Margins>
);
};
type TaskBuildingWorkspaceProps = { task: Task };
type BuildingWorkspaceProps = { workspace: Workspace };
const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({ 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<TaskBuildingWorkspaceProps> = ({ task }) => {
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
<WorkspaceBuildProgress
workspace={task.workspace}
workspace={workspace}
transitionStats={transitionStats}
variant="task"
/>
@@ -376,44 +381,8 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ 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);
+9 -13
View File
@@ -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<TaskTopbarProps> = ({ task }) => {
export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
return (
<header className="flex flex-shrink-0 items-center p-3 border-solid border-border border-0 border-b">
<TooltipProvider>
@@ -37,13 +37,11 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
</Tooltip>
</TooltipProvider>
<h1 className="m-0 pl-2 text-base font-medium truncate">
{task.workspace.name}
</h1>
<h1 className="m-0 pl-2 text-base font-medium truncate">{task.name}</h1>
{task.workspace.latest_app_status?.uri && (
{task.current_state?.uri && (
<div className="flex items-center gap-2 flex-wrap ml-4">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
<TaskStatusLink uri={task.current_state.uri} />
</div>
)}
@@ -58,17 +56,15 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
</TooltipTrigger>
<TooltipContent className="max-w-xs bg-surface-secondary p-4">
<p className="m-0 mb-2 select-all text-sm font-normal text-content-primary leading-snug">
{task.prompt}
{task.initial_prompt}
</p>
<CopyPromptButton prompt={task.prompt} />
<CopyPromptButton prompt={task.initial_prompt} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button asChild variant="outline" size="sm">
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
<RouterLink to={`/@${workspace.owner_name}/${workspace.name}`}>
<LaptopMinimalIcon />
Workspace
</RouterLink>
+3 -26
View File
@@ -105,26 +105,6 @@ export const LoadedTasks: Story = {
},
};
export const LoadedTasksWaitingForInput: Story = {
beforeEach: () => {
const [firstTask, ...otherTasks] = MockTasks;
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API.experimental, "getTasks").mockResolvedValue([
{
...firstTask,
workspace: {
...firstTask.workspace,
latest_app_status: {
...firstTask.workspace.latest_app_status,
state: "idle",
},
},
},
...otherTasks,
]);
},
};
export const LoadedTasksWaitingForInputTab: Story = {
beforeEach: () => {
const [firstTask, ...otherTasks] = MockTasks;
@@ -132,12 +112,9 @@ export const LoadedTasksWaitingForInputTab: Story = {
spyOn(API.experimental, "getTasks").mockResolvedValue([
{
...firstTask,
workspace: {
...firstTask.workspace,
latest_app_status: {
...firstTask.workspace.latest_app_status,
state: "idle" as const,
},
current_state: {
...firstTask.current_state,
state: "idle",
},
},
...otherTasks,
+9 -17
View File
@@ -1,5 +1,6 @@
import { API, type TasksFilter } from "api/api";
import { API } from "api/api";
import { templates } from "api/queries/templates";
import type { TasksFilter } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button, type ButtonProps } from "components/Button/Button";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
@@ -27,8 +28,8 @@ const TasksPage: FC = () => {
);
const { user, permissions } = useAuthenticated();
const userFilter = useSearchParamsKey({
key: "username",
const ownerFilter = useSearchParamsKey({
key: "owner",
defaultValue: user.username,
});
const tab = useSearchParamsKey({
@@ -36,24 +37,15 @@ const TasksPage: FC = () => {
defaultValue: "all",
});
const filter: TasksFilter = {
username: userFilter.value,
owner: ownerFilter.value,
};
const tasksQuery = useQuery({
queryKey: ["tasks", filter],
queryFn: () => API.experimental.getTasks(filter),
refetchInterval: 10_000,
// TODO: Switch to sorting by latest_status_app.created_at once it’s reliable.
// Currently, it doesn’t always update fast enough for a good UX, so we’re
// temporarily sorting by workspace.created_at instead.
select: (tasks) =>
tasks.toSorted(
(a, b) =>
new Date(b.workspace.created_at).getTime() -
new Date(a.workspace.created_at).getTime(),
),
});
const idleTasks = tasksQuery.data?.filter(
(task) => task.workspace.latest_app_status?.state === "idle",
(task) => task.current_state?.state === "idle",
);
const displayedTasks =
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
@@ -105,10 +97,10 @@ const TasksPage: FC = () => {
</div>
<UsersCombobox
value={userFilter.value}
value={ownerFilter.value}
onValueChange={(username) => {
userFilter.setValue(
username === userFilter.value ? "" : username,
ownerFilter.setValue(
username === ownerFilter.value ? "" : username,
);
}}
/>
+19 -27
View File
@@ -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<TasksTableProps> = ({ tasks, error, onRetry }) => {
} else if (tasks.length === 0) {
body = <TasksEmpty />;
} else {
body = tasks.map((task) => <TaskRow key={task.workspace.id} task={task} />);
body = tasks.map((task) => <TaskRow key={task.id} task={task} />);
}
return (
@@ -55,8 +54,7 @@ export const TasksTable: FC<TasksTableProps> = ({ tasks, error, onRetry }) => {
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Agent status</TableHead>
<TableHead>Workspace status</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created by</TableHead>
<TableHead />
</TableRow>
@@ -74,7 +72,7 @@ type TasksErrorBodyProps = {
const TasksErrorBody: FC<TasksErrorBodyProps> = ({ error, onRetry }) => {
return (
<TableRow>
<TableCell colSpan={5} className="text-center">
<TableCell colSpan={999} className="text-center">
<div className="rounded-lg 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">
@@ -97,7 +95,7 @@ const TasksErrorBody: FC<TasksErrorBodyProps> = ({ error, onRetry }) => {
const TasksEmpty: FC = () => {
return (
<TableRow>
<TableCell colSpan={5} className="text-center">
<TableCell colSpan={999} className="text-center">
<div className="w-full min-h-80 p-4 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
@@ -117,21 +115,20 @@ type TaskRowProps = { task: Task };
const TaskRow: FC<TaskRowProps> = ({ 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 (
<>
<TableRow key={task.workspace.id} className="relative" hover>
<TableRow className="relative" hover>
<TableCell>
<AvatarData
title={
<>
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
{task.prompt}
{task.initial_prompt}
</span>
<RouterLink
to={`/tasks/${task.workspace.owner_name}/${task.workspace.name}`}
to={`/tasks/${task.owner_name}/${task.id}`}
className="absolute inset-0"
>
<span className="sr-only">Access task</span>
@@ -143,30 +140,28 @@ const TaskRow: FC<TaskRowProps> = ({ task }) => {
<Avatar
size="lg"
variant="icon"
src={task.workspace.template_icon}
src={task.template_icon}
fallback={templateDisplayName}
/>
}
/>
</TableCell>
<TableCell>
<WorkspaceAppStatus
disabled={task.workspace.latest_build.status !== "running"}
status={task.workspace.latest_app_status}
<TaskStatus
status={task.status}
stateMessage={task.current_state?.message || "No message available"}
/>
</TableCell>
<TableCell>
<WorkspaceStatus workspace={task.workspace} />
</TableCell>
<TableCell>
<AvatarData
title={task.workspace.owner_name}
title={task.owner_name}
subtitle={
<span className="block first-letter:uppercase">
{relativeTime(new Date(task.workspace.created_at))}
{relativeTime(new Date(task.created_at))}
</span>
}
src={task.workspace.owner_avatar_url}
src={task.owner_avatar_url}
/>
</TableCell>
<TableCell className="text-right">
@@ -210,9 +205,6 @@ const TasksSkeleton: FC = () => {
<TableCell>
<Skeleton className="w-[100px] h-6" />
</TableCell>
<TableCell>
<Skeleton className="w-[100px] h-6" />
</TableCell>
<TableCell>
<AvatarDataSkeleton />
</TableCell>
+1 -1
View File
@@ -597,7 +597,7 @@ export const router = createBrowserRouter(
/>
<Route path="/cli-auth" element={<CliAuthPage />} />
<Route path="/icons" element={<IconsPage />} />
<Route path="/tasks/:username/:workspace" element={<TaskPage />} />
<Route path="/tasks/:username/:taskId" element={<TaskPage />} />
</Route>
</Route>,
),
+25 -47
View File
@@ -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[];