mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add AI Tasks page (#18047)
**Preview:** <img width="1624" alt="Screenshot 2025-05-26 at 21 25 04" src="https://github.com/user-attachments/assets/2a51915d-2527-4467-bf99-1f2d876b953b" />
This commit is contained in:
Generated
+5
-2
@@ -12649,9 +12649,11 @@ const docTemplate = `{
|
||||
"web-push",
|
||||
"dynamic-parameters",
|
||||
"workspace-prebuilds",
|
||||
"agentic-chat"
|
||||
"agentic-chat",
|
||||
"ai-tasks"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAITasks": "Enables the new AI tasks feature.",
|
||||
"ExperimentAgenticChat": "Enables the new agentic AI chat feature.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.",
|
||||
@@ -12669,7 +12671,8 @@ const docTemplate = `{
|
||||
"ExperimentWebPush",
|
||||
"ExperimentDynamicParameters",
|
||||
"ExperimentWorkspacePrebuilds",
|
||||
"ExperimentAgenticChat"
|
||||
"ExperimentAgenticChat",
|
||||
"ExperimentAITasks"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
|
||||
Generated
+5
-2
@@ -11351,9 +11351,11 @@
|
||||
"web-push",
|
||||
"dynamic-parameters",
|
||||
"workspace-prebuilds",
|
||||
"agentic-chat"
|
||||
"agentic-chat",
|
||||
"ai-tasks"
|
||||
],
|
||||
"x-enum-comments": {
|
||||
"ExperimentAITasks": "Enables the new AI tasks feature.",
|
||||
"ExperimentAgenticChat": "Enables the new agentic AI chat feature.",
|
||||
"ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.",
|
||||
"ExperimentDynamicParameters": "Enables dynamic parameters when creating a workspace.",
|
||||
@@ -11371,7 +11373,8 @@
|
||||
"ExperimentWebPush",
|
||||
"ExperimentDynamicParameters",
|
||||
"ExperimentWorkspacePrebuilds",
|
||||
"ExperimentAgenticChat"
|
||||
"ExperimentAgenticChat",
|
||||
"ExperimentAITasks"
|
||||
]
|
||||
},
|
||||
"codersdk.ExternalAuth": {
|
||||
|
||||
@@ -3346,6 +3346,7 @@ const (
|
||||
ExperimentDynamicParameters Experiment = "dynamic-parameters" // Enables dynamic parameters when creating a workspace.
|
||||
ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature.
|
||||
ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature.
|
||||
ExperimentAITasks Experiment = "ai-tasks" // Enables the new AI tasks feature.
|
||||
)
|
||||
|
||||
// ExperimentsSafe should include all experiments that are safe for
|
||||
|
||||
Generated
+1
@@ -3379,6 +3379,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `dynamic-parameters` |
|
||||
| `workspace-prebuilds` |
|
||||
| `agentic-chat` |
|
||||
| `ai-tasks` |
|
||||
|
||||
## codersdk.ExternalAuth
|
||||
|
||||
|
||||
Generated
+1
@@ -826,6 +826,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning";
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export type Experiment =
|
||||
| "ai-tasks"
|
||||
| "agentic-chat"
|
||||
| "auto-fill-parameters"
|
||||
| "dynamic-parameters"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API } from "api/api";
|
||||
import { experiments } from "api/queries/experiments";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
@@ -6,8 +7,10 @@ import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import type { ProxyContextValue } from "contexts/ProxyContext";
|
||||
import { useAgenticChat } from "contexts/useAgenticChat";
|
||||
import { useWebpushNotifications } from "contexts/useWebpushNotifications";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { cn } from "utils/cn";
|
||||
import { DeploymentDropdown } from "./DeploymentDropdown";
|
||||
@@ -141,6 +144,8 @@ interface NavItemsProps {
|
||||
const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
const location = useLocation();
|
||||
const agenticChat = useAgenticChat();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const experimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
|
||||
return (
|
||||
<nav className={cn("flex items-center gap-4 h-full", className)}>
|
||||
@@ -163,7 +168,7 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
>
|
||||
Templates
|
||||
</NavLink>
|
||||
{agenticChat.enabled ? (
|
||||
{agenticChat.enabled && (
|
||||
<NavLink
|
||||
className={({ isActive }) => {
|
||||
return cn(linkStyles.default, isActive ? linkStyles.active : "");
|
||||
@@ -172,7 +177,17 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
|
||||
>
|
||||
Chat
|
||||
</NavLink>
|
||||
) : null}
|
||||
)}
|
||||
{experimentsQuery.data?.includes("ai-tasks") && (
|
||||
<NavLink
|
||||
className={({ isActive }) => {
|
||||
return cn(linkStyles.default, isActive ? linkStyles.active : "");
|
||||
}}
|
||||
to="/tasks"
|
||||
>
|
||||
Tasks
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, spyOn, userEvent, within } from "@storybook/test";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockUserOwner,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAppStatus,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withGlobalSnackbar,
|
||||
withProxyProvider,
|
||||
} from "testHelpers/storybook";
|
||||
import TasksPage, { data } from "./TasksPage";
|
||||
|
||||
const meta: Meta<typeof TasksPage> = {
|
||||
title: "pages/TasksPage",
|
||||
component: TasksPage,
|
||||
decorators: [withAuthProvider],
|
||||
parameters: {
|
||||
user: MockUserOwner,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TasksPage>;
|
||||
|
||||
export const LoadingAITemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockImplementation(
|
||||
() => new Promise((res) => 1000 * 60 * 60),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingAITemplatesError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load AI templates",
|
||||
detail: "You don't have permission to access this resource.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAITemplates: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingTasks: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockImplementation(
|
||||
() => new Promise((res) => 1000 * 60 * 60),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Select the first AI template", async () => {
|
||||
const combobox = await canvas.findByRole("combobox");
|
||||
expect(combobox).toHaveTextContent(MockTemplate.display_name);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingTasksError: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to load tasks",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyTasks: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue([]);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadedTasks: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskSuccessfully: Story = {
|
||||
decorators: [withProxyProvider()],
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(data, "createTask").mockImplementation((prompt: string) => {
|
||||
return Promise.resolve({
|
||||
prompt,
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
message: "Task created successfully!",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, "Create a new task");
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Verify task in the table", async () => {
|
||||
await canvas.findByRole("row", {
|
||||
name: /create a new task/i,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateTaskError: Story = {
|
||||
decorators: [withProxyProvider(), withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(data, "fetchTasks").mockResolvedValue(MockTasks);
|
||||
spyOn(data, "createTask").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Failed to create task",
|
||||
detail: "You don't have permission to create tasks.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("Run task", async () => {
|
||||
const prompt = await canvas.findByLabelText(/prompt/i);
|
||||
await userEvent.type(prompt, "Create a new task");
|
||||
const submitButton = canvas.getByRole("button", { name: /run task/i });
|
||||
await userEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await step("Verify error", async () => {
|
||||
await canvas.findByText(/failed to create task/i);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const MockTasks = [
|
||||
{
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_app_status: MockWorkspaceAppStatus,
|
||||
},
|
||||
prompt: "Create competitors page",
|
||||
},
|
||||
{
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
id: "workspace-2",
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
message: "Avatar size fixed!",
|
||||
},
|
||||
},
|
||||
prompt: "Fix user avatar size",
|
||||
},
|
||||
{
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
id: "workspace-3",
|
||||
latest_app_status: {
|
||||
...MockWorkspaceAppStatus,
|
||||
message: "Accessibility issues fixed!",
|
||||
},
|
||||
},
|
||||
prompt: "Fix accessibility issues",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,539 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { disabledRefetchOptions } from "api/queries/util";
|
||||
import type {
|
||||
Template,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "components/Select/Select";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
|
||||
import { useAppLink } from "modules/apps/useAppLink";
|
||||
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 { pageTitle } from "utils/page";
|
||||
import { relativeTime } from "utils/time";
|
||||
|
||||
const TasksPage: FC = () => {
|
||||
const {
|
||||
data: templates,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", "ai"],
|
||||
queryFn: data.fetchAITemplates,
|
||||
...disabledRefetchOptions,
|
||||
});
|
||||
|
||||
let content: ReactNode = null;
|
||||
|
||||
if (error) {
|
||||
const message = getErrorMessage(error, "Error loading AI templates");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
|
||||
content = (
|
||||
<div className="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">
|
||||
{message}
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">{detail}</span>
|
||||
<Button size="sm" onClick={() => refetch()} className="mt-4">
|
||||
<RotateCcwIcon />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (templates) {
|
||||
content =
|
||||
templates.length === 0 ? (
|
||||
<div className="rounded-lg border border-solid border-border w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
No AI templates found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Create an AI template to get started
|
||||
</span>
|
||||
<Button size="sm" className="mt-4">
|
||||
<ExternalLinkIcon />
|
||||
Read the docs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TaskForm templates={templates} />
|
||||
<TasksTable templates={templates} />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="rounded-lg border border-solid border-border w-full min-h-80 p-4 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">
|
||||
Loading AI templates
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This might take a few minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("AI Tasks")}</title>
|
||||
</Helmet>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline">
|
||||
<ExternalLinkIcon />
|
||||
Read the docs
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>Tasks</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
{content}
|
||||
</Margins>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateTaskMutationFnProps = { prompt: string; templateId: string };
|
||||
|
||||
type TaskFormProps = {
|
||||
templates: Template[];
|
||||
};
|
||||
|
||||
const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
const { user } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) =>
|
||||
data.createTask(prompt, user.id, templateId),
|
||||
onSuccess: (newTask) => {
|
||||
// The current data loading is heavy, so we manually update the cache to
|
||||
// avoid re-fetching. Once we improve data loading, we can replace the
|
||||
// manual update with queryClient.invalidateQueries.
|
||||
queryClient.setQueryData<Task[]>(["tasks"], (oldTasks = []) => {
|
||||
return [newTask, ...oldTasks];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const prompt = formData.get("prompt") as string;
|
||||
const templateID = formData.get("templateID") as string;
|
||||
|
||||
if (!prompt || !templateID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createTaskMutation.mutateAsync({
|
||||
prompt,
|
||||
templateId: templateID,
|
||||
});
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "Error creating task");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
displayError(message, detail);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="border border-border border-solid rounded-lg p-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<fieldset disabled={createTaskMutation.isPending}>
|
||||
<label htmlFor="prompt" className="sr-only">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
placeholder="Write an action for your AI agent to perform..."
|
||||
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
|
||||
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Select name="templateID" defaultValue={templates[0].id} required>
|
||||
<SelectTrigger className="w-52 text-xs [&_svg]:size-icon-xs border-0 bg-surface-secondary h-8 px-3">
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((template) => {
|
||||
return (
|
||||
<SelectItem value={template.id} key={template.id}>
|
||||
<span className="overflow-hidden text-ellipsis block">
|
||||
{template.display_name ?? template.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm" type="submit">
|
||||
<Spinner loading={createTaskMutation.isPending}>
|
||||
<SendIcon />
|
||||
</Spinner>
|
||||
Run task
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type TasksTableProps = {
|
||||
templates: Template[];
|
||||
};
|
||||
|
||||
const TasksTable: FC<TasksTableProps> = ({ templates }) => {
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: () => data.fetchTasks(templates),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
let body: ReactNode = null;
|
||||
|
||||
if (error) {
|
||||
const message = getErrorMessage(error, "Error loading tasks");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
<div className="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">
|
||||
{message}
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">{detail}</span>
|
||||
<Button size="sm" onClick={() => refetch()} className="mt-4">
|
||||
<RotateCcwIcon />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} else if (tasks) {
|
||||
body =
|
||||
tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
<div className="w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
No tasks found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Use the form above to run a task
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tasks.map(({ workspace, prompt }) => {
|
||||
const templateDisplayName =
|
||||
workspace.template_display_name ?? workspace.template_name;
|
||||
const status = workspace.latest_app_status;
|
||||
const agent = workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.find((a) => a?.id === status?.agent_id);
|
||||
const app = agent?.apps.find((a) => a.id === status?.app_id);
|
||||
|
||||
return (
|
||||
<TableRow key={workspace.id}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={
|
||||
<span className="block max-w-[520px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{prompt}
|
||||
</span>
|
||||
}
|
||||
subtitle={templateDisplayName}
|
||||
avatar={
|
||||
<Avatar
|
||||
size="lg"
|
||||
variant="icon"
|
||||
src={workspace.template_icon}
|
||||
fallback={templateDisplayName}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WorkspaceAppStatus status={workspace.latest_app_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={workspace.owner_name}
|
||||
subtitle={
|
||||
<span className="block first-letter:uppercase">
|
||||
{relativeTime(new Date(workspace.created_at))}
|
||||
</span>
|
||||
}
|
||||
src={workspace.owner_avatar_url}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="pl-10">
|
||||
{agent && app && (
|
||||
<IconAppLink app={app} workspace={workspace} agent={agent} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<div className="rounded-lg 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">
|
||||
Loading tasks
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This might take a few minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table className="mt-4">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
<TableHead className="w-0" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{body}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
type IconAppLinkProps = {
|
||||
app: WorkspaceApp;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
const IconAppLink: FC<IconAppLinkProps> = ({ app, workspace, agent }) => {
|
||||
const link = useAppLink(app, {
|
||||
workspace,
|
||||
agent,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseIconLink
|
||||
key={app.id}
|
||||
label={`Open ${link.label}`}
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
>
|
||||
<ExternalImage src={app.icon ?? "/icon/widgets.svg"} />
|
||||
</BaseIconLink>
|
||||
);
|
||||
};
|
||||
|
||||
type BaseIconLinkProps = PropsWithChildren<{
|
||||
label: string;
|
||||
href: string;
|
||||
isLoading?: boolean;
|
||||
target?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}>;
|
||||
|
||||
const BaseIconLink: FC<BaseIconLinkProps> = ({
|
||||
href,
|
||||
isLoading,
|
||||
label,
|
||||
children,
|
||||
target,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon-lg" asChild>
|
||||
<a
|
||||
target={target}
|
||||
className={isLoading ? "animate-pulse" : ""}
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
// performance. After confirming the requirements, consider adding a backend
|
||||
// endpoint that returns only AI templates (those with an "AI Prompt" parameter)
|
||||
// in a single request.
|
||||
async fetchAITemplates() {
|
||||
const templates = await API.getTemplates();
|
||||
const parameters = await Promise.all(
|
||||
templates.map(async (template) =>
|
||||
API.getTemplateVersionRichParameters(template.active_version_id),
|
||||
),
|
||||
);
|
||||
return templates.filter((_template, index) => {
|
||||
return parameters[index].some((p) => p.name === AI_PROMPT_PARAMETER_NAME);
|
||||
});
|
||||
},
|
||||
|
||||
// TODO: This function is inefficient because it fetches workspaces for each
|
||||
// template individually and its build parameters resulting in excessive API
|
||||
// calls and slow performance. Consider implementing a backend endpoint that
|
||||
// returns all AI-related workspaces in a single request to improve efficiency.
|
||||
async fetchTasks(aiTemplates: Template[]) {
|
||||
const workspaces = await Promise.all(
|
||||
aiTemplates.map((template) => {
|
||||
return API.getWorkspaces({
|
||||
q: `template:${template.name}`,
|
||||
limit: 100,
|
||||
});
|
||||
}),
|
||||
).then((results) =>
|
||||
results
|
||||
.flatMap((r) => r.workspaces)
|
||||
.toSorted((a, b) => {
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
workspaces.map(async (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;
|
||||
}
|
||||
|
||||
return {
|
||||
workspace,
|
||||
prompt,
|
||||
} satisfies Task;
|
||||
}),
|
||||
).then((tasks) => tasks.filter((t) => t !== undefined));
|
||||
},
|
||||
|
||||
async createTask(
|
||||
prompt: string,
|
||||
userId: string,
|
||||
templateId: string,
|
||||
): Promise<Task> {
|
||||
const workspace = await API.createWorkspace(userId, {
|
||||
name: `ai-task-${new Date().getTime()}`,
|
||||
template_id: templateId,
|
||||
rich_parameter_values: [
|
||||
{ name: AI_PROMPT_PARAMETER_NAME, value: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
workspace,
|
||||
prompt,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default TasksPage;
|
||||
@@ -325,6 +325,7 @@ const ProvisionerJobsPage = lazy(
|
||||
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
|
||||
),
|
||||
);
|
||||
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
|
||||
|
||||
const RoutesWithSuspense = () => {
|
||||
return (
|
||||
@@ -436,6 +437,8 @@ export const router = createBrowserRouter(
|
||||
<Route path=":chatID" element={<ChatMessages />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
|
||||
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
|
||||
<Route path="new" element={<CreateOrganizationPage />} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user