diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b6a00051bba77..2fe06eeed13f9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e5fdca7025089..5fa788900133c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 89834f163affd..696e6bda52682 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 2374c6af8800f..45ac19cf1d9e7 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5125a554cacc1..afe6dea37100d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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" diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index b55ac23f16bd7..7e56c9643c066 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -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 = ({ className }) => { const location = useLocation(); const agenticChat = useAgenticChat(); + const { metadata } = useEmbeddedMetadata(); + const experimentsQuery = useQuery(experiments(metadata.experiments)); return ( ); }; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx new file mode 100644 index 0000000000000..4dee0e7fe32a9 --- /dev/null +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -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 = { + title: "pages/TasksPage", + component: TasksPage, + decorators: [withAuthProvider], + parameters: { + user: MockUserOwner, + }, +}; + +export default meta; +type Story = StoryObj; + +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", + }, +]; diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx new file mode 100644 index 0000000000000..b1adcd790fe75 --- /dev/null +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -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 = ( +
+
+

+ {message} +

+ {detail} + +
+
+ ); + } else if (templates) { + content = + templates.length === 0 ? ( +
+
+

+ No AI templates found +

+ + Create an AI template to get started + + +
+
+ ) : ( + <> + + + + ); + } else { + content = ( +
+
+ +

+ Loading AI templates +

+ + This might take a few minutes + +
+
+ ); + } + + return ( + <> + + {pageTitle("AI Tasks")} + + + + + Read the docs + + } + > + Tasks + Automate tasks with AI + + + {content} + + + ); +}; + +type CreateTaskMutationFnProps = { prompt: string; templateId: string }; + +type TaskFormProps = { + templates: Template[]; +}; + +const TaskForm: FC = ({ 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(["tasks"], (oldTasks = []) => { + return [newTask, ...oldTasks]; + }); + }, + }); + + const onSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+ +