mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add task page (#18076)
**Demo:** 
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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"])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user