feat: add task page (#18076)

**Demo:**


![image](https://github.com/user-attachments/assets/ca59ba92-a73a-4613-ae41-910f3f0455d2)
This commit is contained in:
Bruno Quaresma
2025-05-28 15:19:05 -03:00
committed by GitHub
parent b330c0803c
commit bc3b8d5a51
10 changed files with 676 additions and 64 deletions
+26 -2
View File
@@ -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<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLTableRowElement> &
VariantProps<typeof tableRowVariants>
>(({ className, hover, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-0 border-b border-solid border-border transition-colors",
"data-[state=selected]:bg-muted",
tableRowVariants({ hover }),
className,
)}
{...props}
+47
View File
@@ -0,0 +1,47 @@
import type { WorkspaceAppStatus } from "api/typesGenerated";
import { Spinner } from "components/Spinner/Spinner";
import {
CircleAlertIcon,
CircleCheckIcon,
HourglassIcon,
TriangleAlertIcon,
} from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
type AppStatusIconProps = {
status: WorkspaceAppStatus;
latest: boolean;
className?: string;
};
export const AppStatusIcon: FC<AppStatusIconProps> = ({
status,
latest,
className: customClassName,
}) => {
const className = cn(["size-4 shrink-0", customClassName]);
switch (status.state) {
case "complete":
return (
<CircleCheckIcon className={cn([className, "text-content-success"])} />
);
case "failure":
return (
<CircleAlertIcon className={cn([className, "text-content-warning"])} />
);
case "working":
return latest ? (
<Spinner size="sm" className="shrink-0" loading />
) : (
<HourglassIcon className={cn([className, "text-highlight-sky"])} />
);
default:
return (
<TriangleAlertIcon
className={cn([className, "text-content-secondary"])}
/>
);
}
};
+8
View File
@@ -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;
};
@@ -34,7 +34,7 @@ export const WorkspaceAppStatus = ({
}
return (
<div className="flex flex-col">
<div className="flex flex-col text-content-secondary">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -48,7 +48,9 @@ export const WorkspaceAppStatus = ({
<TooltipContent>{status.message}</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="first-letter:uppercase block pl-6">{status.state}</span>
<span className="text-xs first-letter:uppercase block pl-6">
{status.state}
</span>
</div>
);
};
@@ -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<typeof TaskPage> = {
title: "pages/TaskPage",
component: TaskPage,
parameters: {
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof TaskPage>;
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",
},
},
});
},
};
+407
View File
@@ -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 (
<>
<Helmet>
<title>{pageTitle("Error loading task")}</title>
</Helmet>
<div className="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">
{getErrorMessage(error, "Failed to load task")}
</h3>
<span className="text-content-secondary text-sm">
{getErrorDetail(error)}
</span>
<div className="mt-4 flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<RouterLink to="/tasks">
<ArrowLeftIcon />
Back to tasks
</RouterLink>
</Button>
<Button size="sm" onClick={() => refetch()}>
<RotateCcwIcon />
Try again
</Button>
</div>
</div>
</div>
</>
);
}
if (!task) {
return (
<>
<Helmet>
<title>{pageTitle("Loading task")}</title>
</Helmet>
<Loader fullscreen />
</>
);
}
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 = (
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<Spinner loading className="mb-4" />
<h3 className="m-0 font-medium text-content-primary text-base">
Building your task
</h3>
<span className="text-content-secondary text-sm">
Your task is being built and will be ready soon
</span>
</div>
</div>
);
} else if (task.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">
<h3 className="m-0 font-medium text-content-primary text-base">
Task build failed
</h3>
<span className="text-content-secondary text-sm">
Please check the logs for more details.
</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}`}
>
View logs
</RouterLink>
</Button>
</div>
</div>
);
} else if (terminatedStatuses.includes(task.workspace.latest_build.status)) {
content = (
<Margins>
<div className="py-6 flex flex-col gap-3">
{task.workspace.latest_app_status && (
<div className="p-3 border border-border border-solid rounded-lg">
<WorkspaceAppStatus status={task.workspace.latest_app_status} />
</div>
)}
<div className="border border-border border-solid 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">
Task build terminated
</h3>
<span className="text-content-secondary text-sm">
So apps and previous statuses are not available
</span>
</div>
</div>
</div>
</Margins>
);
} else if (!task.workspace.latest_app_status) {
content = (
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<Spinner loading className="mb-4" />
<h3 className="m-0 font-medium text-content-primary text-base">
Running your task
</h3>
<span className="text-content-secondary text-sm">
The status should be available soon
</span>
</div>
</div>
);
} 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 = (
<div className="flex-1 flex justify-stretch overflow-hidden">
<aside className="w-full max-w-xs border-0 border-r border-border border-solid">
<ScrollArea className="h-full py-3">
{statuses.map((status, index) => {
return (
<article
className={cn(["px-4 py-3 flex gap-3"], {
"opacity-75 hover:opacity-100": index !== 0,
})}
key={status.id}
>
<AppStatusIcon
status={status}
latest={index === 0}
className="size-4 mt-1"
/>
<div className="flex flex-col gap-1">
<h3 className="m-0 font-medium text-sm">
{status.message}
</h3>
<time
dateTime={status.created_at}
className="font-medium text-xs text-content-secondary"
>
{timeFrom(new Date(status.created_at))}
</time>
</div>
</article>
);
})}
</ScrollArea>
</aside>
<TaskApps task={task} />
</div>
);
}
return (
<>
<Helmet>
<title>{pageTitle(task.prompt)}</title>
</Helmet>
<section className="h-full flex flex-col">
<header className="h-20 border-0 border-b border-solid border-border px-4 flex items-center shrink-0">
<div className="flex items-center gap-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon-lg" variant="outline" asChild>
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex flex-col">
<h1 className="m-0 text-sm font-medium">{task.prompt}</h1>
<span className="text-xs text-content-secondary">
Created by {task.workspace.owner_name}{" "}
{timeFrom(new Date(task.workspace.created_at))}
</span>
</div>
</div>
</header>
{content}
</section>
</>
);
};
export default TaskPage;
type TaskAppsProps = {
task: Task;
};
const TaskApps: FC<TaskAppsProps> = ({ 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<string>(() => {
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 (
<main className="flex-1 flex flex-col">
<div className="border-0 border-b border-border border-solid w-full p-1 flex gap-2">
{apps.map((app) => (
<TaskAppButton
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
if (app.external) {
return;
}
e.preventDefault();
setActiveAppId(app.id);
setIframeSrc(e.currentTarget.href);
}}
/>
))}
</div>
<div className="flex-1">
<iframe
title={activeApp.display_name ?? activeApp.slug}
className="w-full h-full border-0"
src={iframeSrc}
/>
</div>
</main>
);
};
type TaskAppButtonProps = {
task: Task;
app: WorkspaceApp;
active: boolean;
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
const TaskAppButton: FC<TaskAppButtonProps> = ({
task,
app,
active,
onClick,
}) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<Button
size="sm"
variant="subtle"
key={app.id}
asChild
className={cn([
{ "text-content-primary": active },
{ "opacity-75 hover:opacity-100": !active },
])}
>
<RouterLink to={link.href} onClick={onClick}>
{app.icon ? <ExternalImage src={app.icon} /> : <LayoutGridIcon />}
{link.label}
</RouterLink>
</Button>
);
};
export const data = {
fetchTask: async (workspaceOwnerUsername: string, workspaceName: string) => {
const workspace = await API.getWorkspaceByOwnerAndName(
workspaceOwnerUsername,
workspaceName,
);
const parameters = await API.getWorkspaceBuildParameters(
workspace.latest_build.id,
);
const prompt = parameters.find(
(p) => p.name === AI_PROMPT_PARAMETER_NAME,
)?.value;
if (!prompt) {
return;
}
return {
workspace,
prompt,
} satisfies Task;
},
};
+21 -14
View File
@@ -43,10 +43,13 @@ import {
import { useAuthenticated } from "hooks";
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
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 { FC, PropsWithChildren, ReactNode } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { relativeTime } from "utils/time";
@@ -138,7 +141,7 @@ const TasksPage: FC = () => {
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
</PageHeader>
{content}
<main className="pb-8">{content}</main>
</Margins>
</>
);
@@ -306,13 +309,21 @@ const TasksTable: FC<TasksTableProps> = ({ templates }) => {
const app = agent?.apps.find((a) => a.id === status?.app_id);
return (
<TableRow key={workspace.id}>
<TableRow key={workspace.id} className="relative" hover>
<TableCell>
<AvatarData
title={
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
{prompt}
</span>
<>
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
{prompt}
</span>
<RouterLink
to={`/tasks/${workspace.owner_name}/${workspace.name}`}
className="absolute inset-0"
>
<span className="sr-only">Access task</span>
</RouterLink>
</>
}
subtitle={templateDisplayName}
avatar={
@@ -400,7 +411,10 @@ const IconAppLink: FC<IconAppLinkProps> = ({ app, workspace, agent }) => {
key={app.id}
label={`Open ${link.label}`}
href={link.href}
onClick={link.onClick}
onClick={(e) => {
link.onClick?.(e);
e.stopPropagation();
}}
>
<ExternalImage src={app.icon ?? "/icon/widgets.svg"} />
</BaseIconLink>
@@ -430,7 +444,7 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
<Button variant="outline" size="icon-lg" asChild>
<a
target={target}
className={isLoading ? "animate-pulse" : ""}
className={cn(["z-10 relative", { "animate-pulse": isLoading }])}
href={href}
onClick={(e) => {
e.stopPropagation();
@@ -448,13 +462,6 @@ const BaseIconLink: FC<BaseIconLinkProps> = ({
);
};
type Task = {
workspace: Workspace;
prompt: string;
};
const AI_PROMPT_PARAMETER_NAME = "AI Prompt";
export const data = {
// TODO: This function is currently inefficient because it fetches all templates
// and their parameters individually, resulting in many API calls and slow
+4 -2
View File
@@ -242,9 +242,11 @@ const TerminalPage: FC = () => {
let disposed = false;
// Open the web socket and hook it up to the terminal.
terminalWebsocketUrl(
proxy.preferredPathAppURL,
// When on development mode we can bypass the proxy and connect directly.
process.env.NODE_ENV !== "development"
? proxy.preferredPathAppURL
: undefined,
reconnectionToken,
workspaceAgent.id,
command,
+7 -44
View File
@@ -6,7 +6,6 @@ import type {
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
@@ -18,53 +17,13 @@ import { timeFrom } from "utils/time";
import {
ChevronDownIcon,
ChevronUpIcon,
CircleAlertIcon,
CircleCheckIcon,
ExternalLinkIcon,
FileIcon,
HourglassIcon,
LayoutGridIcon,
TriangleAlertIcon,
} from "lucide-react";
import { AppStatusIcon } from "modules/apps/AppStatusIcon";
import { useAppLink } from "modules/apps/useAppLink";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
const getStatusColor = (state: APIWorkspaceAppStatus["state"]) => {
switch (state) {
case "complete":
return "text-content-success";
case "failure":
return "text-content-warning";
case "working":
return "text-highlight-sky";
default:
return "text-content-secondary";
}
};
const getStatusIcon = (
state: APIWorkspaceAppStatus["state"],
isLatest: boolean,
className?: string,
) => {
const iconClassName = cn(["size-[18px]", getStatusColor(state), className]);
switch (state) {
case "complete":
return <CircleCheckIcon className={iconClassName} />;
case "failure":
return <CircleAlertIcon className={iconClassName} />;
case "working":
return isLatest ? (
<Spinner size="sm" loading />
) : (
<HourglassIcon className={iconClassName} />
);
default:
return <TriangleAlertIcon className={iconClassName} />;
}
};
const formatURI = (uri: string) => {
if (uri.startsWith("file://")) {
@@ -150,7 +109,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
>
<div className="flex flex-col">
<span className="text-sm font-medium text-content-primary flex items-center gap-2">
{getStatusIcon(latestStatus.state, true)}
<AppStatusIcon status={latestStatus} latest />
{latestStatus.message}
</span>
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
@@ -227,7 +186,11 @@ export const AppStatuses: FC<AppStatusesProps> = ({
>
<div className="flex items-center justify-between w-full text-content-secondary">
<span className="text-xs flex items-center gap-2">
{getStatusIcon(status.state, false, "size-icon-xs w-[18px]")}
<AppStatusIcon
status={latestStatus}
latest={false}
className="size-icon-xs w-[18px]"
/>
{status.message}
</span>
<span className="text-2xs text-content-secondary first-letter:uppercase block pl-[26px]">
+2
View File
@@ -326,6 +326,7 @@ const ProvisionerJobsPage = lazy(
),
);
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
const RoutesWithSuspense = () => {
return (
@@ -591,6 +592,7 @@ export const router = createBrowserRouter(
/>
<Route path="/cli-auth" element={<CliAuthPage />} />
<Route path="/icons" element={<IconsPage />} />
<Route path="/tasks/:username/:workspace" element={<TaskPage />} />
</Route>
</Route>,
),