mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Bruno Quaresma
parent
c6e551f538
commit
51d3abb904
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
Generated
+21
-15
@@ -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": {
|
||||
|
||||
Generated
+21
-15
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -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).
|
||||
|
||||
|
||||
Generated
+64
-58
@@ -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
@@ -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> => {
|
||||
|
||||
Generated
+13
@@ -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) => ({
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user