From 21489b0094c21815abd2ba7e47c768a8e17c8ae9 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 19 Jun 2025 14:36:31 +0000 Subject: [PATCH] use the ai task information from a workspace build on the task page --- site/src/pages/TaskPage/TaskApps.tsx | 5 +- site/src/pages/TaskPage/TaskPage.stories.tsx | 180 ++++++++++++++----- site/src/pages/TaskPage/TaskPage.tsx | 30 +++- site/src/pages/TaskPage/TaskSidebar.tsx | 170 ++++++++---------- site/src/pages/TaskPage/constants.ts | 2 - 5 files changed, 231 insertions(+), 156 deletions(-) delete mode 100644 site/src/pages/TaskPage/constants.ts diff --git a/site/src/pages/TaskPage/TaskApps.tsx b/site/src/pages/TaskPage/TaskApps.tsx index 1469d5784146a..cad76e1262778 100644 --- a/site/src/pages/TaskPage/TaskApps.tsx +++ b/site/src/pages/TaskPage/TaskApps.tsx @@ -15,7 +15,6 @@ import { type FC, useState } from "react"; import { Link as RouterLink } from "react-router-dom"; import { cn } from "utils/cn"; import { TaskAppIFrame } from "./TaskAppIframe"; -import { AI_APP_CHAT_SLUG } from "./constants"; type TaskAppsProps = { task: Task; @@ -30,7 +29,9 @@ export const TaskApps: FC = ({ task }) => { // it here const apps = agents .flatMap((a) => a?.apps) - .filter((a) => !!a && a.slug !== AI_APP_CHAT_SLUG); + .filter( + (a) => !!a && a.id !== task.workspace.latest_build.ai_task_sidebar_app_id, + ); const embeddedApps = apps.filter((app) => !app.external); const externalApps = apps.filter((app) => app.external); diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index cc2ab25ce2767..a24968d483e38 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, spyOn, within } from "@storybook/test"; -import type { Workspace, WorkspaceApp } from "api/typesGenerated"; +import type { + Workspace, + WorkspaceApp, + WorkspaceResource, +} from "api/typesGenerated"; import { MockFailedWorkspace, MockStartingWorkspace, @@ -13,11 +17,12 @@ import { mockApiError, } from "testHelpers/entities"; import { withProxyProvider } from "testHelpers/storybook"; -import TaskPage, { data } from "./TaskPage"; +import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage"; const meta: Meta = { title: "pages/TaskPage", component: TaskPage, + decorators: [withProxyProvider()], parameters: { layout: "fullscreen", }, @@ -96,61 +101,142 @@ export const TerminatedBuildWithStatus: Story = { }, }; -function activeWorkspace(apps: WorkspaceApp[]): Workspace { +export const SidebarAppDisabled: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + has_ai_task: true, + ai_task_sidebar_app_id: "claude-code", + resources: mockResources({ + claudeCodeAppOverrides: { + health: "disabled", + }, + }), + }, + }, + }); + }, +}; + +export const SidebarAppLoading: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + has_ai_task: true, + ai_task_sidebar_app_id: "claude-code", + resources: mockResources({ + claudeCodeAppOverrides: { + health: "initializing", + }, + }), + }, + }, + }); + }, +}; + +export const SidebarAppHealthy: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + has_ai_task: true, + ai_task_sidebar_app_id: "claude-code", + resources: mockResources({ + claudeCodeAppOverrides: { + health: "healthy", + }, + }), + }, + }, + }); + }, +}; + +export const BuildNoAITask: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockImplementation(() => { + throw new WorkspaceDoesNotHaveAITaskError(MockWorkspace); + }); + }, +}; + +interface MockResourcesProps { + apps?: WorkspaceApp[]; + claudeCodeAppOverrides?: Partial; +} + +const mockResources = ( + props?: MockResourcesProps, +): readonly WorkspaceResource[] => [ + { + ...MockWorkspaceResource, + agents: [ + { + ...MockWorkspaceAgent, + apps: [ + ...(props?.apps ?? []), + { + ...MockWorkspaceApp, + id: "claude-code", + display_name: "Claude Code", + slug: "claude-code", + icon: "/icon/claude.svg", + statuses: [ + MockWorkspaceAppStatus, + { + ...MockWorkspaceAppStatus, + id: "2", + message: "Planning changes", + state: "working", + }, + ], + ...(props?.claudeCodeAppOverrides ?? {}), + }, + { + ...MockWorkspaceApp, + id: "vscode", + slug: "vscode", + display_name: "VS Code Web", + icon: "/icon/code.svg", + }, + { + ...MockWorkspaceApp, + slug: "zed", + id: "zed", + display_name: "Zed", + icon: "/icon/zed.svg", + }, + ], + }, + ], + }, +]; + +const activeWorkspace = (apps: WorkspaceApp[]): Workspace => { return { ...MockWorkspace, latest_build: { ...MockWorkspace.latest_build, - resources: [ - { - ...MockWorkspaceResource, - agents: [ - { - ...MockWorkspaceAgent, - apps: [ - ...apps, - { - ...MockWorkspaceApp, - id: "claude-code", - display_name: "Claude Code", - slug: "claude-code", - icon: "/icon/claude.svg", - statuses: [ - MockWorkspaceAppStatus, - { - ...MockWorkspaceAppStatus, - id: "2", - message: "Planning changes", - state: "working", - }, - ], - }, - { - ...MockWorkspaceApp, - id: "vscode", - slug: "vscode", - display_name: "VS Code Web", - icon: "/icon/code.svg", - }, - { - ...MockWorkspaceApp, - slug: "zed", - id: "zed", - display_name: "Zed", - icon: "/icon/zed.svg", - }, - ], - }, - ], - }, - ], + resources: mockResources({ apps }), }, latest_app_status: { ...MockWorkspaceAppStatus, app_id: "claude-code", }, }; -} +}; export const Active: Story = { decorators: [withProxyProvider()], diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 1b90b7b775e07..a46e0f09c7cc9 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -1,6 +1,6 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; -import type { WorkspaceStatus } from "api/typesGenerated"; +import type { Workspace, WorkspaceStatus } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; @@ -164,7 +164,7 @@ const TaskPage = () => { return ( <> - {pageTitle(ellipsizeText(task.prompt, 64)!)} + {pageTitle(ellipsizeText(task.prompt, 64) ?? "Task")}
@@ -177,22 +177,34 @@ const TaskPage = () => { export default TaskPage; +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; - - if (!prompt) { - return; - } + const prompt = + parameters.find((p) => p.name === AI_PROMPT_PARAMETER_NAME)?.value ?? + "Unknown prompt"; return { workspace, diff --git a/site/src/pages/TaskPage/TaskSidebar.tsx b/site/src/pages/TaskPage/TaskSidebar.tsx index 9ed19c41fa4f1..f3ac6de61a185 100644 --- a/site/src/pages/TaskPage/TaskSidebar.tsx +++ b/site/src/pages/TaskPage/TaskSidebar.tsx @@ -1,4 +1,5 @@ import GitHub from "@mui/icons-material/GitHub"; +import type { WorkspaceApp } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { DropdownMenu, @@ -6,7 +7,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Spinner } from "components/Spinner/Spinner"; import { Tooltip, @@ -21,27 +21,72 @@ import { ExternalLinkIcon, GitPullRequestArrowIcon, } from "lucide-react"; -import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon"; import type { Task } from "modules/tasks/tasks"; import type { FC } from "react"; import { Link as RouterLink } from "react-router-dom"; import { cn } from "utils/cn"; -import { timeFrom } from "utils/time"; import { truncateURI } from "utils/uri"; import { TaskAppIFrame } from "./TaskAppIframe"; -import { AI_APP_CHAT_SLUG, AI_APP_CHAT_URL_PATHNAME } from "./constants"; type TaskSidebarProps = { task: Task; }; -export const TaskSidebar: FC = ({ task }) => { - const chatApp = task.workspace.latest_build.resources +type SidebarAppStatus = "error" | "loading" | "healthy"; + +const getSidebarApp = (task: Task): [WorkspaceApp | null, SidebarAppStatus] => { + const sidebarAppId = task.workspace.latest_build.ai_task_sidebar_app_id; + // a task workspace with a finished build must have a sidebar app id + if (!sidebarAppId && task.workspace.latest_build.job.completed_at) { + console.error( + "Task workspace has a finished build but no sidebar app id", + task.workspace, + ); + return [null, "error"]; + } + + const sidebarApp = task.workspace.latest_build.resources .flatMap((r) => r.agents) .flatMap((a) => a?.apps) - .find((a) => a?.slug === AI_APP_CHAT_SLUG); - const showChatApp = - chatApp && (chatApp.health === "disabled" || chatApp.health === "healthy"); + .find((a) => a?.id === sidebarAppId); + + if (!task.workspace.latest_build.job.completed_at) { + // while the workspace build is running, we don't have a sidebar app yet + return [null, "loading"]; + } + if (!sidebarApp) { + // The workspace build is complete but the expected sidebar app wasn't found in the resources. + // This could happen due to timing issues or temporary inconsistencies in the data. + // We return "loading" instead of "error" to avoid showing an error state if the app + // becomes available shortly after. The tradeoff is that users may see a loading state + // indefinitely if there's a genuine issue, but this is preferable to false error alerts. + return [null, "loading"]; + } + if (sidebarApp.health === "disabled") { + return [sidebarApp, "error"]; + } + if (sidebarApp.health === "healthy") { + return [sidebarApp, "healthy"]; + } + if (sidebarApp.health === "initializing") { + return [sidebarApp, "loading"]; + } + if (sidebarApp.health === "unhealthy") { + return [sidebarApp, "error"]; + } + + // exhaustiveness check + const _: never = sidebarApp.health; + // this should never happen + console.error( + "Task workspace has a finished build but the sidebar app is in an unknown health state", + task.workspace, + ); + return [null, "error"]; +}; + +export const TaskSidebar: FC = ({ task }) => { + const [sidebarApp, sidebarAppStatus] = getSidebarApp(task); return (

- {task.prompt} + {task.prompt || task.workspace.name}

{task.workspace.latest_app_status?.uri && ( @@ -108,100 +152,34 @@ export const TaskSidebar: FC = ({ task }) => { )} - {showChatApp ? ( + {sidebarAppStatus === "healthy" && sidebarApp ? ( + ) : sidebarAppStatus === "loading" ? ( +
+ +
) : ( - +
+

+ Error +

+ + Failed to load the sidebar app. + {sidebarApp?.health != null && ( + The app is {sidebarApp.health}. + )} + +
)} ); }; -type TaskStatusesProps = { - task: Task; -}; - -const TaskStatuses: FC = ({ task }) => { - let statuses = task.workspace.latest_build.resources - .flatMap((r) => r.agents) - .flatMap((a) => a?.apps) - .flatMap((a) => a?.statuses) - .filter((s) => !!s) - .sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - - // This happens when the workspace is not running so it has no resources to - // get the statuses so we can fallback to the latest status received from the - // workspace. - if (statuses.length === 0 && task.workspace.latest_app_status) { - statuses = [task.workspace.latest_app_status]; - } - - return statuses ? ( - - {statuses.length === 0 && ( -
-
-

- Running your task -

- -
- - -
- )} - {statuses.map((status, index) => { - return ( -
-
-

- {status.message} -

- -
- - -
- ); - })} -
- ) : ( - - ); -}; - type TaskStatusLinkProps = { uri: string; }; diff --git a/site/src/pages/TaskPage/constants.ts b/site/src/pages/TaskPage/constants.ts deleted file mode 100644 index e3d8133802db2..0000000000000 --- a/site/src/pages/TaskPage/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const AI_APP_CHAT_SLUG = "claude-code-web"; -export const AI_APP_CHAT_URL_PATHNAME = "/chat/embed";