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"; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy