diff --git a/site/src/components/Table/Table.tsx b/site/src/components/Table/Table.tsx index c20fe99428e09..b642655f5539b 100644 --- a/site/src/components/Table/Table.tsx +++ b/site/src/components/Table/Table.tsx @@ -3,6 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/table} */ +import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; import { cn } from "utils/cn"; @@ -60,15 +61,38 @@ const TableFooter = React.forwardRef< /> )); +const tableRowVariants = cva( + [ + "border-0 border-b border-solid border-border transition-colors", + "data-[state=selected]:bg-muted", + ], + { + variants: { + hover: { + false: null, + true: cn([ + "cursor-pointer hover:outline focus:outline outline-1 -outline-offset-1 outline-border-hover", + "first:rounded-t-md last:rounded-b-md", + ]), + }, + }, + defaultVariants: { + hover: false, + }, + }, +); + export const TableRow = React.forwardRef< HTMLTableRowElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( + React.HTMLAttributes & + VariantProps +>(({ className, hover, ...props }, ref) => ( = ({ + status, + latest, + className: customClassName, +}) => { + const className = cn(["size-4 shrink-0", customClassName]); + + switch (status.state) { + case "complete": + return ( + + ); + case "failure": + return ( + + ); + case "working": + return latest ? ( + + ) : ( + + ); + default: + return ( + + ); + } +}; diff --git a/site/src/modules/tasks/tasks.ts b/site/src/modules/tasks/tasks.ts new file mode 100644 index 0000000000000..c48f5ec1c3f22 --- /dev/null +++ b/site/src/modules/tasks/tasks.ts @@ -0,0 +1,8 @@ +import type { Workspace } from "api/typesGenerated"; + +export const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; + +export type Task = { + workspace: Workspace; + prompt: string; +}; diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 76e74f17c351e..f2eab7f2086ac 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -34,7 +34,7 @@ export const WorkspaceAppStatus = ({ } return ( -
+
@@ -48,7 +48,9 @@ export const WorkspaceAppStatus = ({ {status.message} - {status.state} + + {status.state} +
); }; diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx new file mode 100644 index 0000000000000..1fd9c4b93cfa6 --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn } from "@storybook/test"; +import { + MockFailedWorkspace, + MockStartingWorkspace, + MockStoppedWorkspace, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceApp, + MockWorkspaceAppStatus, + MockWorkspaceResource, + mockApiError, +} from "testHelpers/entities"; +import { withProxyProvider } from "testHelpers/storybook"; +import TaskPage, { data } from "./TaskPage"; + +const meta: Meta = { + title: "pages/TaskPage", + component: TaskPage, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockImplementation( + () => new Promise((res) => 1000 * 60 * 60), + ); + }, +}; + +export const LoadingError: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockRejectedValue( + mockApiError({ + message: "Failed to load task", + 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, + }); + }, +}; + +export const WaitingOnStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_app_status: null, + }, + }); + }, +}; + +export const FailedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockFailedWorkspace, + }); + }, +}; + +export const TerminatedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockStoppedWorkspace, + }); + }, +}; + +export const TerminatedBuildWithStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockStoppedWorkspace, + latest_app_status: MockWorkspaceAppStatus, + }, + }); + }, +}; + +export const Active: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspaceResource, + agents: [ + { + ...MockWorkspaceAgent, + apps: [ + { + ...MockWorkspaceApp, + id: "claude-code", + display_name: "Claude Code", + icon: "/icon/claude.svg", + url: `${window.location.protocol}/iframe.html?viewMode=story&id=pages-terminal--ready&args=&globals=`, + external: true, + statuses: [ + MockWorkspaceAppStatus, + { + ...MockWorkspaceAppStatus, + id: "2", + message: "Planning changes", + state: "working", + }, + ], + }, + { + ...MockWorkspaceApp, + id: "vscode", + display_name: "VSCode", + icon: "/icon/code.svg", + }, + ], + }, + ], + }, + ], + }, + latest_app_status: { + ...MockWorkspaceAppStatus, + app_id: "claude-code", + }, + }, + }); + }, +}; diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx new file mode 100644 index 0000000000000..692c99db2d63f --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -0,0 +1,407 @@ +import { API } from "api/api"; +import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { WorkspaceApp, WorkspaceStatus } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { useProxy } from "contexts/ProxyContext"; +import { ArrowLeftIcon, LayoutGridIcon, RotateCcwIcon } from "lucide-react"; +import { AppStatusIcon } from "modules/apps/AppStatusIcon"; +import { getAppHref } from "modules/apps/apps"; +import { useAppLink } from "modules/apps/useAppLink"; +import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; +import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; +import type React from "react"; +import { type FC, type ReactNode, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; +import { cn } from "utils/cn"; +import { pageTitle } from "utils/page"; +import { timeFrom } from "utils/time"; + +const TaskPage = () => { + const { workspace: workspaceName, username } = useParams() as { + workspace: string; + username: string; + }; + const { + data: task, + error, + refetch, + } = useQuery({ + queryKey: ["tasks", username, workspaceName], + queryFn: () => data.fetchTask(username, workspaceName), + refetchInterval: 5_000, + }); + + if (error) { + return ( + <> + + {pageTitle("Error loading task")} + + +
+
+

+ {getErrorMessage(error, "Failed to load task")} +

+ + {getErrorDetail(error)} + +
+ + +
+
+
+ + ); + } + + if (!task) { + return ( + <> + + {pageTitle("Loading task")} + + + + ); + } + + let content: ReactNode = null; + const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"]; + const terminatedStatuses: WorkspaceStatus[] = [ + "canceled", + "canceling", + "deleted", + "deleting", + "stopped", + "stopping", + ]; + + if (waitingStatuses.includes(task.workspace.latest_build.status)) { + content = ( +
+
+ +

+ Building your task +

+ + Your task is being built and will be ready soon + +
+
+ ); + } else if (task.workspace.latest_build.status === "failed") { + content = ( +
+
+

+ Task build failed +

+ + Please check the logs for more details. + + +
+
+ ); + } else if (terminatedStatuses.includes(task.workspace.latest_build.status)) { + content = ( + +
+ {task.workspace.latest_app_status && ( +
+ +
+ )} +
+
+

+ Task build terminated +

+ + So apps and previous statuses are not available + +
+
+
+
+ ); + } else if (!task.workspace.latest_app_status) { + content = ( +
+
+ +

+ Running your task +

+ + The status should be available soon + +
+
+ ); + } else { + const 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(), + ); + + content = ( +
+ + + +
+ ); + } + + return ( + <> + + {pageTitle(task.prompt)} + + +
+
+
+ + + + + + Back to tasks + + + +
+

{task.prompt}

+ + Created by {task.workspace.owner_name}{" "} + {timeFrom(new Date(task.workspace.created_at))} + +
+
+
+ + {content} +
+ + ); +}; + +export default TaskPage; + +type TaskAppsProps = { + task: Task; +}; + +const TaskApps: FC = ({ task }) => { + const agents = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .filter((a) => !!a); + + const apps = agents.flatMap((a) => a?.apps).filter((a) => !!a); + + const [activeAppId, setActiveAppId] = useState(() => { + const appId = task.workspace.latest_app_status?.app_id; + if (!appId) { + throw new Error("No active app found in task"); + } + return appId; + }); + + const activeApp = apps.find((app) => app.id === activeAppId); + if (!activeApp) { + throw new Error(`Active app with ID ${activeAppId} not found in task`); + } + + const agent = agents.find((a) => + a.apps.some((app) => app.id === activeAppId), + ); + if (!agent) { + throw new Error(`Agent for app ${activeAppId} not found in task workspace`); + } + + const { proxy } = useProxy(); + const [iframeSrc, setIframeSrc] = useState(() => { + const src = getAppHref(activeApp, { + agent, + workspace: task.workspace, + path: proxy.preferredPathAppURL, + host: proxy.preferredWildcardHostname, + }); + return src; + }); + + return ( +
+
+ {apps.map((app) => ( + { + if (app.external) { + return; + } + + e.preventDefault(); + setActiveAppId(app.id); + setIframeSrc(e.currentTarget.href); + }} + /> + ))} +
+ +
+