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:
Bruno Quaresma
2025-05-27 11:34:07 -03:00
committed by GitHub
parent ce134bc63a
commit 9827c97f32
9 changed files with 763 additions and 6 deletions
+5 -2
View File
@@ -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": {
+5 -2
View File
@@ -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": {
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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",
},
];
+539
View File
@@ -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;
+3
View File
@@ -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 />} />