From 1d2b96b01f19a6227a56c15e4d99ccf9d4aabaf5 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 24 Jun 2025 16:25:52 +0200 Subject: [PATCH] feat: implement efficient backend querying on the tasks page (#18488) Use the `/workspaces?q=has-ai-task=true`, `/templates?q=has-ai-task=true` and `/aitasks/prompts` endpoints to fetch Task templates and workspaces on the `/tasks` page. Also: - remove documentation link placeholders: the documentation is not in place yet and is not going to be available before the June 24th code freeze - load workspaces and templates in parallel - replace loading spinners with content skeletons Related to https://github.com/coder/coder/issues/18454 and https://github.com/coder/internal/issues/660. --- site/src/pages/TasksPage/TasksPage.tsx | 326 ++++++++++++------------- 1 file changed, 159 insertions(+), 167 deletions(-) diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 02f7f56510..f86979f8ea 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,3 +1,4 @@ +import Skeleton from "@mui/material/Skeleton"; import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { disabledRefetchOptions } from "api/queries/util"; @@ -5,6 +6,7 @@ import type { Template } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; +import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; import { Button } from "components/Button/Button"; import { Form, FormFields, FormSection } from "components/Form/Form"; import { displayError } from "components/GlobalSnackbar/utils"; @@ -30,10 +32,14 @@ import { TableHeader, TableRow, } from "components/Table/Table"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; import { useAuthenticated } from "hooks"; import { useExternalAuth } from "hooks/useExternalAuth"; -import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react"; +import { RotateCcwIcon, SendIcon } from "lucide-react"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; @@ -50,17 +56,7 @@ import { type UserOption, UsersCombobox } from "./UsersCombobox"; type TasksFilter = { user: UserOption | undefined; }; - const TasksPage: FC = () => { - const { - data: templates, - error, - refetch, - } = useQuery({ - queryKey: ["templates", "ai"], - queryFn: data.fetchAITemplates, - ...disabledRefetchOptions, - }); const { user, permissions } = useAuthenticated(); const [filter, setFilter] = useState({ user: { @@ -70,92 +66,132 @@ const TasksPage: FC = () => { }, }); - 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 - - -
-
- ) : ( - <> - - {permissions.viewDeploymentConfig && ( - - )} - - - ); - } else { - content = ( -
-
- -

- Loading AI templates -

- - This might take a few minutes - -
-
- ); - } - return ( <> {pageTitle("AI Tasks")} - - - Read the docs - - } - > + Tasks Automate tasks with AI -
{content}
+
+ + +
); }; +const textareaPlaceholder = "Prompt your AI agent to start a task..."; + +const LoadingTemplatesPlaceholder: FC = () => { + return ( +
+
+ {/* Textarea skeleton */} + + + {/* Bottom controls skeleton */} +
+ + +
+
+
+ ); +}; + +const NoTemplatesPlaceholder: FC = () => { + return ( +
+
+

+ No Task templates found +

+ + Create a Task template to get started + +
+
+ ); +}; + +const ErrorContent: FC<{ + title: string; + detail: string; + onRetry: () => void; +}> = ({ title, detail, onRetry }) => { + return ( +
+
+

+ {title} +

+ {detail} + +
+
+ ); +}; + +const TaskFormSection: FC<{ + showFilter: boolean; + filter: TasksFilter; + onFilterChange: (filter: TasksFilter) => void; +}> = ({ showFilter, filter, onFilterChange }) => { + const { + data: templates, + error, + refetch, + } = useQuery({ + queryKey: ["templates", "ai"], + queryFn: data.fetchAITemplates, + ...disabledRefetchOptions, + }); + + if (error) { + return ( + refetch()} + /> + ); + } + if (templates === undefined) { + return ; + } + if (templates.length === 0) { + return ; + } + return ( + <> + + {showFilter && ( + + )} + + ); +}; + type CreateTaskMutationFnProps = { prompt: string; templateId: string }; type TaskFormProps = { @@ -211,7 +247,7 @@ const TaskForm: FC = ({ templates }) => { form.reset(); } catch (error) { const message = getErrorMessage(error, "Error creating task"); - const detail = getErrorDetail(error) ?? "Please, try again"; + const detail = getErrorDetail(error) ?? "Please try again"; displayError(message, detail); } }; @@ -231,7 +267,7 @@ const TaskForm: FC = ({ templates }) => { required id="prompt" name="prompt" - placeholder="Prompt your AI agent to start a task..." + placeholder={textareaPlaceholder} 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`} /> @@ -322,18 +358,17 @@ const TasksFilter: FC = ({ filter, onFilterChange }) => { }; type TasksTableProps = { - templates: Template[]; filter: TasksFilter; }; -const TasksTable: FC = ({ templates, filter }) => { +const TasksTable: FC = ({ filter }) => { const { data: tasks, error, refetch, } = useQuery({ queryKey: ["tasks", filter], - queryFn: () => data.fetchTasks(templates, filter), + queryFn: () => data.fetchTasks(filter), refetchInterval: 10_000, }); @@ -341,7 +376,7 @@ const TasksTable: FC = ({ templates, filter }) => { if (error) { const message = getErrorMessage(error, "Error loading tasks"); - const detail = getErrorDetail(error) ?? "Please, try again"; + const detail = getErrorDetail(error) ?? "Please try again"; body = ( @@ -382,11 +417,6 @@ const TasksTable: FC = ({ templates, filter }) => { 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 ( @@ -439,21 +469,19 @@ const TasksTable: FC = ({ templates, filter }) => { ); } else { body = ( - - -
-
- -

- Loading tasks -

- - This might take a few minutes - -
-
-
-
+ + + + + + + + + + + + + ); } @@ -472,69 +500,33 @@ const TasksTable: FC = ({ templates, filter }) => { }; 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); - }); + return API.getTemplates({ q: "has-ai-task:true" }); }, - // 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[], filter: TasksFilter) { - const workspaces = await Promise.all( - aiTemplates.map((template) => { - const queryParts = [`template:${template.name}`]; - if (filter.user) { - queryParts.push(`owner:${filter.user.value}`); - } - - return API.getWorkspaces({ - q: queryParts.join(" "), - 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() - ); - }), + async fetchTasks(filter: TasksFilter) { + let filterQuery = "has-ai-task:true"; + if (filter.user) { + filterQuery += ` owner:${filter.user.value}`; + } + const workspaces = await API.getWorkspaces({ + q: filterQuery, + }); + const prompts = await API.experimental.getAITasksPrompts( + workspaces.workspaces.map((workspace) => workspace.latest_build.id), ); - - 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)); + return workspaces.workspaces.map((workspace) => { + let prompt = prompts.prompts[workspace.latest_build.id]; + if (prompt === undefined) { + prompt = "Unknown prompt"; + } else if (prompt === "") { + prompt = "Empty prompt"; + } + return { + workspace, + prompt, + } satisfies Task; + }); }, async createTask(