From d77c3d0226c984902c383fc36991127c699747da Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 21 Aug 2025 15:06:11 -0300 Subject: [PATCH] feat: filter tasks that are waiting for user input (#19377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/coder/coder/issues/19324 Screenshot 2025-08-15 at 14 45 39 --------- Co-authored-by: ケイラ --- .vscode/settings.json | 4 +- site/src/api/api.ts | 25 + site/src/components/Badge/Badge.tsx | 1 + site/src/pages/TasksPage/TaskPrompt.tsx | 434 +++++++++ .../src/pages/TasksPage/TasksPage.stories.tsx | 124 +-- site/src/pages/TasksPage/TasksPage.tsx | 827 +++--------------- site/src/pages/TasksPage/TasksTable.tsx | 179 ++++ site/src/pages/TasksPage/UsersCombobox.tsx | 72 +- site/src/pages/TasksPage/data.ts | 24 + 9 files changed, 891 insertions(+), 799 deletions(-) create mode 100644 site/src/pages/TasksPage/TaskPrompt.tsx create mode 100644 site/src/pages/TasksPage/TasksTable.tsx create mode 100644 site/src/pages/TasksPage/data.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index eaea72e750..7fef4af975 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,7 +60,5 @@ "typos.config": ".github/workflows/typos.toml", "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "biome.configurationPath": "./site/biome.jsonc", - "biome.lsp.bin": "./site/node_modules/.bin/biome" + } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7bad235d6b..a6a6f4f383 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,6 +21,7 @@ */ import globalAxios, { type AxiosInstance, isAxiosError } from "axios"; import type dayjs from "dayjs"; +import type { Task } from "modules/tasks/tasks"; import userAgentParser from "ua-parser-js"; import { delay } from "../utils/delay"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; @@ -422,6 +423,10 @@ export type GetProvisionerDaemonsParams = { limit?: number; }; +export type TasksFilter = { + username?: string; +}; + /** * This is the container for all API methods. It's split off to make it more * clear where API methods should go, but it is eventually merged into the Api @@ -2687,6 +2692,26 @@ class ExperimentalApiMethods { return response.data; }; + + getTasks = async (filter: TasksFilter): Promise => { + const queryExpressions = ["has-ai-task:true"]; + + if (filter.username) { + queryExpressions.push(`owner:${filter.username}`); + } + + const workspaces = await API.getWorkspaces({ + q: queryExpressions.join(" "), + }); + const prompts = await API.experimental.getAITasksPrompts( + workspaces.workspaces.map((workspace) => workspace.latest_build.id), + ); + + return workspaces.workspaces.map((workspace) => ({ + workspace, + prompt: prompts.prompts[workspace.latest_build.id], + })); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index a042b5cf72..c3d0b27475 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -24,6 +24,7 @@ const badgeVariants = cva( "border border-solid border-border-destructive bg-surface-red text-highlight-red shadow", green: "border border-solid border-surface-green bg-surface-green text-highlight-green shadow", + info: "border border-solid border-surface-sky bg-surface-sky text-highlight-sky shadow", }, size: { xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5", diff --git a/site/src/pages/TasksPage/TaskPrompt.tsx b/site/src/pages/TasksPage/TaskPrompt.tsx new file mode 100644 index 0000000000..13e75dae51 --- /dev/null +++ b/site/src/pages/TasksPage/TaskPrompt.tsx @@ -0,0 +1,434 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; +import { templateVersionPresets } from "api/queries/templates"; +import type { + Preset, + Template, + TemplateVersionExternalAuth, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Link } from "components/Link/Link"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Skeleton } from "components/Skeleton/Skeleton"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { useAuthenticated } from "hooks/useAuthenticated"; +import { useExternalAuth } from "hooks/useExternalAuth"; +import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react"; +import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; +import { type FC, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate } from "react-router"; +import TextareaAutosize from "react-textarea-autosize"; +import { docs } from "utils/docs"; +import { data } from "./data"; + +const textareaPlaceholder = "Prompt your AI agent to start a task..."; + +type TaskPromptProps = { + templates: Template[] | undefined; + error: unknown; + onRetry: () => void; +}; + +export const TaskPrompt: FC = ({ + templates, + error, + onRetry, +}) => { + const navigate = useNavigate(); + + if (error) { + return ; + } + if (templates === undefined) { + return ; + } + if (templates.length === 0) { + return ; + } + return ( + { + navigate(`/tasks/${task.workspace.owner_name}/${task.workspace.name}`); + }} + /> + ); +}; + +const TaskPromptLoadingError: FC<{ + error: unknown; + onRetry: () => void; +}> = ({ error, onRetry }) => { + return ( +
+
+

+ {getErrorMessage(error, "Error loading Task templates")} +

+ + {getErrorDetail(error) ?? "Please try again"} + + +
+
+ ); +}; + +const TaskPromptSkeleton: FC = () => { + return ( +
+
+ {/* Textarea skeleton */} + + + {/* Bottom controls skeleton */} +
+ + +
+
+
+ ); +}; + +const TaskPromptEmpty: FC = () => { + return ( +
+
+

+ No Task templates found +

+ + + Learn about Tasks + {" "} + to get started. + +
+
+ ); +}; + +type CreateTaskMutationFnProps = { + prompt: string; +}; + +type CreateTaskFormProps = { + templates: Template[]; + onSuccess: (task: Task) => void; +}; + +const CreateTaskForm: FC = ({ templates, onSuccess }) => { + const { user } = useAuthenticated(); + const queryClient = useQueryClient(); + const [selectedTemplateId, setSelectedTemplateId] = useState( + templates[0].id, + ); + const [selectedPresetId, setSelectedPresetId] = useState(); + const selectedTemplate = templates.find( + (t) => t.id === selectedTemplateId, + ) as Template; + + const { + externalAuth, + externalAuthError, + isPollingExternalAuth, + isLoadingExternalAuth, + } = useExternalAuth(selectedTemplate.active_version_id); + + // Fetch presets when template changes + const { data: presets, isLoading: isLoadingPresets } = useQuery( + templateVersionPresets(selectedTemplate.active_version_id), + ); + const defaultPreset = presets?.find((p) => p.Default); + + // Handle preset selection when data changes + useEffect(() => { + setSelectedPresetId(defaultPreset?.ID); + }, [defaultPreset?.ID]); + + // Extract AI prompt from selected preset + const selectedPreset = presets?.find((p) => p.ID === selectedPresetId); + const presetAIPrompt = selectedPreset?.Parameters?.find( + (param) => param.Name === AI_PROMPT_PARAMETER_NAME, + )?.Value; + const isPromptReadOnly = !!presetAIPrompt; + + const missedExternalAuth = externalAuth?.filter( + (auth) => !auth.optional && !auth.authenticated, + ); + const isMissingExternalAuth = missedExternalAuth + ? missedExternalAuth.length > 0 + : true; + + const createTaskMutation = useMutation({ + mutationFn: async ({ prompt }: CreateTaskMutationFnProps) => + data.createTask( + prompt, + user.id, + selectedTemplate.active_version_id, + selectedPresetId, + ), + onSuccess: async (task) => { + await queryClient.invalidateQueries({ + queryKey: ["tasks"], + }); + onSuccess(task); + }, + }); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const form = e.currentTarget; + const formData = new FormData(form); + const prompt = presetAIPrompt || (formData.get("prompt") as string); + + try { + await createTaskMutation.mutateAsync({ + prompt, + }); + } catch (error) { + const message = getErrorMessage(error, "Error creating task"); + const detail = getErrorDetail(error) ?? "Please try again"; + displayError(message, detail); + } + }; + + return ( +
+ {externalAuthError && } + +
+ + +
+
+
+ + +
+ + {isLoadingPresets ? ( +
+ + +
+ ) : ( + presets && + presets.length > 0 && ( +
+ + +
+ ) + )} +
+ +
+ {missedExternalAuth && ( + + )} + + +
+
+
+ + ); +}; + +type ExternalAuthButtonProps = { + template: Template; + missedExternalAuth: TemplateVersionExternalAuth[]; +}; + +const ExternalAuthButtons: FC = ({ + template, + missedExternalAuth, +}) => { + const { + startPollingExternalAuth, + isPollingExternalAuth, + externalAuthPollingState, + } = useExternalAuth(template.active_version_id); + const shouldRetry = externalAuthPollingState === "abandoned"; + + return missedExternalAuth.map((auth) => { + return ( +
+ + + {shouldRetry && !auth.authenticated && ( + + + + + + + Retry connecting to {auth.display_name} + + + + )} +
+ ); + }); +}; + +function sortByDefault(a: Preset, b: Preset) { + // Default preset should come first + if (a.Default && !b.Default) return -1; + if (!a.Default && b.Default) return 1; + // Otherwise, sort alphabetically by name + return a.Name.localeCompare(b.Name); +} diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index a42f1a7a3f..a10e4f29e7 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -19,12 +19,13 @@ import { API } from "api/api"; import { MockUsers } from "pages/UsersPage/storybookData/users"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import TasksPage, { data } from "./TasksPage"; +import { data } from "./data"; +import TasksPage from "./TasksPage"; const meta: Meta = { title: "pages/TasksPage", component: TasksPage, - decorators: [withAuthProvider], + decorators: [withAuthProvider, withProxyProvider()], parameters: { user: MockUserOwner, permissions: { @@ -38,7 +39,7 @@ const meta: Meta = { users: MockUsers, count: MockUsers.length, }); - spyOn(data, "fetchAITemplates").mockResolvedValue([ + spyOn(API, "getTemplates").mockResolvedValue([ MockTemplate, { ...MockTemplate, @@ -55,7 +56,7 @@ type Story = StoryObj; export const LoadingAITemplates: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockImplementation( + spyOn(API, "getTemplates").mockImplementation( () => new Promise(() => 1000 * 60 * 60), ); }, @@ -63,7 +64,7 @@ export const LoadingAITemplates: Story = { export const LoadingAITemplatesError: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockRejectedValue( + spyOn(API, "getTemplates").mockRejectedValue( mockApiError({ message: "Failed to load AI templates", detail: "You don't have permission to access this resource.", @@ -74,14 +75,15 @@ export const LoadingAITemplatesError: Story = { export const EmptyAITemplates: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([]); + spyOn(API, "getTemplates").mockResolvedValue([]); + spyOn(API.experimental, "getTasks").mockResolvedValue([]); }, }; export const LoadingTasks: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockImplementation( + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockImplementation( () => new Promise(() => 1000 * 60 * 60), ); }, @@ -98,8 +100,8 @@ export const LoadingTasks: Story = { export const LoadingTasksError: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockRejectedValue( + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockRejectedValue( mockApiError({ message: "Failed to load tasks", }), @@ -109,21 +111,19 @@ export const LoadingTasksError: Story = { export const EmptyTasks: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockResolvedValue([]); + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue([]); }, }; export const LoadedTasks: Story = { - decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); }, }; export const LoadedTasksWithPresets: Story = { - decorators: [withProxyProvider()], beforeEach: () => { const mockTemplateWithPresets = { ...MockTemplate, @@ -132,11 +132,11 @@ export const LoadedTasksWithPresets: Story = { display_name: "Template with Presets", }; - spyOn(data, "fetchAITemplates").mockResolvedValue([ + spyOn(API, "getTemplates").mockResolvedValue([ MockTemplate, mockTemplateWithPresets, ]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); spyOn(API, "getTemplateVersionPresets").mockImplementation( async (versionId) => { // Return presets only for the second template @@ -150,7 +150,6 @@ export const LoadedTasksWithPresets: Story = { }; export const LoadedTasksWithAIPromptPresets: Story = { - decorators: [withProxyProvider()], beforeEach: () => { const mockTemplateWithPresets = { ...MockTemplate, @@ -159,11 +158,11 @@ export const LoadedTasksWithAIPromptPresets: Story = { display_name: "Template with AI Prompt Presets", }; - spyOn(data, "fetchAITemplates").mockResolvedValue([ + spyOn(API, "getTemplates").mockResolvedValue([ MockTemplate, mockTemplateWithPresets, ]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); spyOn(API, "getTemplateVersionPresets").mockImplementation( async (versionId) => { // Return presets only for the second template @@ -176,28 +175,57 @@ export const LoadedTasksWithAIPromptPresets: Story = { }, }; -export const LoadedTasksEdgeCases: Story = { - decorators: [withProxyProvider()], +export const LoadedTasksWaitingForInput: Story = { beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); - - // Test various edge cases for presets - spyOn(API, "getTemplateVersionPresets").mockImplementation(async () => { - return [ - { - ID: "malformed", - Name: "Malformed Preset", - Default: true, + const [firstTask, ...otherTasks] = MockTasks; + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue([ + { + ...firstTask, + workspace: { + ...firstTask.workspace, + latest_app_status: { + ...firstTask.workspace.latest_app_status, + state: "idle", + }, }, - // biome-ignore lint/suspicious/noExplicitAny: Testing malformed data edge cases - ] as any; + }, + ...otherTasks, + ]); + }, +}; + +export const LoadedTasksWaitingForInputTab: Story = { + beforeEach: () => { + const [firstTask, ...otherTasks] = MockTasks; + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue([ + { + ...firstTask, + workspace: { + ...firstTask.workspace, + latest_app_status: { + ...firstTask.workspace.latest_app_status, + state: "idle" as const, + }, + }, + }, + ...otherTasks, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Switch to 'Waiting for input' tab", async () => { + const waitingForInputTab = await canvas.findByRole("button", { + name: /waiting for input/i, + }); + await userEvent.click(waitingForInputTab); }); }, }; export const CreateTaskSuccessfully: Story = { - decorators: [withProxyProvider()], parameters: { reactRouter: reactRouterParameters({ location: { @@ -216,8 +244,8 @@ export const CreateTaskSuccessfully: Story = { }), }, beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks") + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks") .mockResolvedValueOnce(MockTasks) .mockResolvedValue([MockNewTaskData, ...MockTasks]); spyOn(data, "createTask").mockResolvedValue(MockNewTaskData); @@ -240,10 +268,10 @@ export const CreateTaskSuccessfully: Story = { }; export const CreateTaskError: Story = { - decorators: [withProxyProvider(), withGlobalSnackbar], + decorators: [withGlobalSnackbar], beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); spyOn(data, "createTask").mockRejectedValue( mockApiError({ message: "Failed to create task", @@ -269,9 +297,8 @@ export const CreateTaskError: Story = { }; export const WithAuthenticatedExternalAuth: Story = { - decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchTasks") + spyOn(API.experimental, "getTasks") .mockResolvedValueOnce(MockTasks) .mockResolvedValue([MockNewTaskData, ...MockTasks]); spyOn(data, "createTask").mockResolvedValue(MockNewTaskData); @@ -296,9 +323,8 @@ export const WithAuthenticatedExternalAuth: Story = { }; export const MissingExternalAuth: Story = { - decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchTasks") + spyOn(API.experimental, "getTasks") .mockResolvedValueOnce(MockTasks) .mockResolvedValue([MockNewTaskData, ...MockTasks]); spyOn(data, "createTask").mockResolvedValue(MockNewTaskData); @@ -323,9 +349,8 @@ export const MissingExternalAuth: Story = { }; export const ExternalAuthError: Story = { - decorators: [withProxyProvider()], beforeEach: () => { - spyOn(data, "fetchTasks") + spyOn(API.experimental, "getTasks") .mockResolvedValueOnce(MockTasks) .mockResolvedValue([MockNewTaskData, ...MockTasks]); spyOn(data, "createTask").mockResolvedValue(MockNewTaskData); @@ -352,15 +377,14 @@ export const ExternalAuthError: Story = { }; export const NonAdmin: Story = { - decorators: [withProxyProvider()], parameters: { permissions: { viewDeploymentConfig: false, }, }, beforeEach: () => { - spyOn(data, "fetchAITemplates").mockResolvedValue([MockTemplate]); - spyOn(data, "fetchTasks").mockResolvedValue(MockTasks); + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index b7b1d3f599..1d3340a456 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,82 +1,54 @@ -import Skeleton from "@mui/material/Skeleton"; -import { API } from "api/api"; -import { getErrorDetail, getErrorMessage } from "api/errors"; -import { templateVersionPresets } from "api/queries/templates"; -import { disabledRefetchOptions } from "api/queries/util"; -import type { - Preset, - Template, - TemplateVersionExternalAuth, -} 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 { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { API, type TasksFilter } from "api/api"; +import { templates } from "api/queries/templates"; +import { Badge } from "components/Badge/Badge"; +import { Button, type ButtonProps } from "components/Button/Button"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { Link } from "components/Link/Link"; 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 { - TableLoaderSkeleton, - TableRowSkeleton, -} from "components/TableLoader/TableLoader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "components/Tooltip/Tooltip"; import { useAuthenticated } from "hooks"; -import { useExternalAuth } from "hooks/useExternalAuth"; -import { RedoIcon, RotateCcwIcon, SendIcon } from "lucide-react"; -import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; -import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import type { FC } from "react"; import { Helmet } from "react-helmet-async"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link as RouterLink, useNavigate } from "react-router"; -import TextareaAutosize from "react-textarea-autosize"; -import { docs } from "utils/docs"; +import { useQuery } from "react-query"; +import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; -import { relativeTime } from "utils/time"; -import { type UserOption, UsersCombobox } from "./UsersCombobox"; - -type TasksFilter = { - user: UserOption | undefined; -}; +import { TaskPrompt } from "./TaskPrompt"; +import { TasksTable } from "./TasksTable"; +import { UsersCombobox } from "./UsersCombobox"; const TasksPage: FC = () => { + const aiTemplatesQuery = useQuery( + templates({ + q: "has-ai-task:true", + }), + ); + const { user, permissions } = useAuthenticated(); - const [filter, setFilter] = useState({ - user: { - value: user.username, - label: user.name || user.username, - avatarUrl: user.avatar_url, - }, + const userFilter = useSearchParamsKey({ + key: "username", + defaultValue: user.username, }); + const tab = useSearchParamsKey({ + key: "tab", + defaultValue: "all", + }); + const filter: TasksFilter = { + username: userFilter.value, + }; + const tasksQuery = useQuery({ + queryKey: ["tasks", filter], + queryFn: () => API.experimental.getTasks(filter), + refetchInterval: 10_000, + }); + const idleTasks = tasksQuery.data?.filter( + (task) => task.workspace.latest_app_status?.state === "idle", + ); + const displayedTasks = + tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data; return ( <> @@ -93,675 +65,82 @@ const TasksPage: FC = () => {
- - + {aiTemplatesQuery.isSuccess && ( +
+ {permissions.viewDeploymentConfig && ( +
+
+ tab.setValue("all")} + > + All tasks + + tab.setValue("waiting-for-input")} + > + Waiting for input + {idleTasks && idleTasks.length > 0 && ( + + {idleTasks.length} + + )} + +
+ + { + userFilter.setValue( + username === userFilter.value ? "" : username, + ); + }} + /> +
+ )} + + +
+ )}
); }; -const textareaPlaceholder = "Prompt your AI agent to start a task..."; +type PillButtonProps = ButtonProps & { + active?: boolean; +}; -const LoadingTemplatesPlaceholder: FC = () => { +const PillButton: FC = ({ className, active, ...props }) => { return ( -
-
- {/* Textarea skeleton */} - - - {/* Bottom controls skeleton */} -
- - -
-
-
+ - - - ); -}; - -const TaskFormSection: FC<{ - showFilter: boolean; - filter: TasksFilter; - onFilterChange: (filter: TasksFilter) => void; -}> = ({ showFilter, filter, onFilterChange }) => { - const navigate = useNavigate(); - 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 ( - <> - { - navigate( - `/tasks/${task.workspace.owner_name}/${task.workspace.name}`, - ); - }} - /> - {showFilter && ( - - )} - - ); -}; - -type CreateTaskMutationFnProps = { - prompt: string; - templateVersionId: string; - presetId: string | null; -}; - -type TaskFormProps = { - templates: Template[]; - onSuccess: (task: Task) => void; -}; - -const TaskForm: FC = ({ templates, onSuccess }) => { - const { user } = useAuthenticated(); - const queryClient = useQueryClient(); - const [selectedTemplateId, setSelectedTemplateId] = useState( - templates[0].id, - ); - const [selectedPresetId, setSelectedPresetId] = useState(null); - const selectedTemplate = templates.find( - (t) => t.id === selectedTemplateId, - ) as Template; - - const { - externalAuth, - externalAuthError, - isPollingExternalAuth, - isLoadingExternalAuth, - } = useExternalAuth(selectedTemplate.active_version_id); - - // Fetch presets when template changes - const { data: presetsData, isLoading: isLoadingPresets } = useQuery< - Preset[] | null, - Error - >(templateVersionPresets(selectedTemplate.active_version_id)); - - // Handle preset selection when data changes - useEffect(() => { - if (presetsData === undefined) { - // Still loading - return; - } - - if (!presetsData || presetsData.length === 0) { - setSelectedPresetId(null); - return; - } - - // Always select the default preset when new data arrives - const defaultPreset = presetsData.find((p: Preset) => p.Default); - const defaultPresetID = defaultPreset?.ID || null; - setSelectedPresetId(defaultPresetID); - }, [presetsData]); - - // Extract AI prompt from selected preset - const selectedPreset = presetsData?.find((p) => p.ID === selectedPresetId); - const presetAIPrompt = selectedPreset?.Parameters?.find( - (param) => param.Name === AI_PROMPT_PARAMETER_NAME, - )?.Value; - const isPromptReadOnly = !!presetAIPrompt; - - const missedExternalAuth = externalAuth?.filter( - (auth) => !auth.optional && !auth.authenticated, - ); - const isMissingExternalAuth = missedExternalAuth - ? missedExternalAuth.length > 0 - : true; - - const createTaskMutation = useMutation({ - mutationFn: async ({ - prompt, - templateVersionId, - presetId, - }: CreateTaskMutationFnProps) => - data.createTask(prompt, user.id, templateVersionId, presetId), - onSuccess: async (task) => { - await queryClient.invalidateQueries({ - queryKey: ["tasks"], - }); - onSuccess(task); - }, - }); - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const form = e.currentTarget; - const formData = new FormData(form); - const prompt = presetAIPrompt || (formData.get("prompt") as string); - - try { - await createTaskMutation.mutateAsync({ - prompt, - templateVersionId: selectedTemplate.active_version_id, - presetId: selectedPresetId, - }); - } catch (error) { - const message = getErrorMessage(error, "Error creating task"); - const detail = getErrorDetail(error) ?? "Please try again"; - displayError(message, detail); - } - }; - - return ( -
- {externalAuthError && } - -
- - -
-
-
- - -
- - {isLoadingPresets ? ( -
- - -
- ) : ( - presetsData && - presetsData.length > 0 && ( -
- - -
- ) - )} -
- -
- {missedExternalAuth && ( - - )} - - -
-
-
- - ); -}; - -type ExternalAuthButtonProps = { - template: Template; - missedExternalAuth: TemplateVersionExternalAuth[]; -}; - -const ExternalAuthButtons: FC = ({ - template, - missedExternalAuth, -}) => { - const { - startPollingExternalAuth, - isPollingExternalAuth, - externalAuthPollingState, - } = useExternalAuth(template.active_version_id); - const shouldRetry = externalAuthPollingState === "abandoned"; - - return missedExternalAuth.map((auth) => { - return ( -
- - - {shouldRetry && !auth.authenticated && ( - - - - - - - Retry connecting to {auth.display_name} - - - - )} -
- ); - }); -}; - -type TasksFilterProps = { - filter: TasksFilter; - onFilterChange: (filter: TasksFilter) => void; -}; - -const TasksFilter: FC = ({ filter, onFilterChange }) => { - return ( -
-

- Filters -

- - onFilterChange({ - ...filter, - user: userOption, - }) - } - /> -
- ); -}; - -type TasksTableProps = { - filter: TasksFilter; -}; - -const TasksTable: FC = ({ filter }) => { - const { - data: tasks, - error, - refetch, - } = useQuery({ - queryKey: ["tasks", filter], - queryFn: () => data.fetchTasks(filter), - refetchInterval: 10_000, - }); - - let body: ReactNode = null; - - if (error) { - const message = getErrorMessage(error, "Error loading tasks"); - const detail = getErrorDetail(error) ?? "Please try again"; - - body = ( - - -
-
-

- {message} -

- {detail} - -
-
-
-
- ); - } else if (tasks) { - body = - tasks.length === 0 ? ( - - -
-
-

- No tasks found -

- - Use the form above to run a task - -
-
-
-
- ) : ( - tasks.map(({ workspace, prompt }) => { - const templateDisplayName = - workspace.template_display_name ?? workspace.template_name; - - return ( - - - - - {prompt} - - - Access task - - - } - subtitle={templateDisplayName} - avatar={ - - } - /> - - - - - - - {relativeTime(new Date(workspace.created_at))} - - } - src={workspace.owner_avatar_url} - /> - - - ); - }) - ); - } else { - body = ( - - - - - - - - - - - - - - ); - } - - return ( - - - - Task - Status - Created by - - - {body} -
- ); -}; - -export const data = { - async fetchAITemplates() { - return API.getTemplates({ q: "has-ai-task:true" }); - }, - - 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 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( - prompt: string, - userId: string, - templateVersionId: string, - presetId: string | null = null, - ): Promise { - // If no preset is selected, get the default preset - let preset_id = presetId; - if (!preset_id) { - const presets = await API.getTemplateVersionPresets(templateVersionId); - const defaultPreset = presets?.find((p) => p.Default); - if (defaultPreset) { - preset_id = defaultPreset.ID; - } - } - - const workspace = await API.experimental.createTask(userId, { - template_version_id: templateVersionId, - template_version_preset_id: preset_id || undefined, - prompt, - }); - - return { - workspace, - prompt, - }; - }, -}; - -// sortedPresets sorts presets with the default preset first, -// followed by the rest sorted alphabetically by name ascending. -const sortedPresets = (presets: Preset[]): Preset[] => { - return presets.sort((a, b) => { - // Default preset should come first - if (a.Default && !b.Default) return -1; - if (!a.Default && b.Default) return 1; - // Otherwise, sort alphabetically by name - return a.Name.localeCompare(b.Name); - }); -}; - export default TasksPage; diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx new file mode 100644 index 0000000000..883f3dd84c --- /dev/null +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -0,0 +1,179 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; +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 { Skeleton } from "components/Skeleton/Skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; +import { RotateCcwIcon } from "lucide-react"; +import type { Task } from "modules/tasks/tasks"; +import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; +import type { FC, ReactNode } from "react"; +import { Link as RouterLink } from "react-router"; +import { relativeTime } from "utils/time"; + +type TasksTableProps = { + tasks: Task[] | undefined; + error: unknown; + onRetry: () => void; +}; + +export const TasksTable: FC = ({ tasks, error, onRetry }) => { + let body: ReactNode = null; + + if (error) { + body = ; + } else if (!tasks) { + body = ; + } else if (tasks.length === 0) { + body = ; + } else { + body = ; + } + + return ( + + + + Task + Status + Created by + + + {body} +
+ ); +}; + +type TasksErrorBodyProps = { + error: unknown; + onRetry: () => void; +}; + +const TasksErrorBody: FC = ({ error, onRetry }) => { + return ( + + +
+
+

+ {getErrorMessage(error, "Error loading tasks")} +

+ + {getErrorDetail(error) ?? "Please try again"} + + +
+
+
+
+ ); +}; + +const TasksEmpty: FC = () => { + return ( + + +
+
+

+ No tasks found +

+ + Use the form above to run a task + +
+
+
+
+ ); +}; + +type TasksProps = { tasks: Task[] }; + +const Tasks: FC = ({ tasks }) => { + return tasks.map(({ workspace, prompt }) => { + const templateDisplayName = + workspace.template_display_name ?? workspace.template_name; + + return ( + + + + + {prompt} + + + Access task + + + } + subtitle={templateDisplayName} + avatar={ + + } + /> + + + + + + + {relativeTime(new Date(workspace.created_at))} + + } + src={workspace.owner_avatar_url} + /> + + + ); + }); +}; + +const TasksSkeleton: FC = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/site/src/pages/TasksPage/UsersCombobox.tsx b/site/src/pages/TasksPage/UsersCombobox.tsx index 603085f28d..e3e443754a 100644 --- a/site/src/pages/TasksPage/UsersCombobox.tsx +++ b/site/src/pages/TasksPage/UsersCombobox.tsx @@ -1,5 +1,6 @@ import Skeleton from "@mui/material/Skeleton"; import { users } from "api/queries/users"; +import type { User } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { @@ -15,44 +16,41 @@ import { PopoverContent, PopoverTrigger, } from "components/Popover/Popover"; +import { useAuthenticated } from "hooks"; import { useDebouncedValue } from "hooks/debounce"; import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { cn } from "utils/cn"; -export type UserOption = { +type UserOption = { label: string; - value: string; // Username + /** + * The username of the user. + */ + value: string; avatarUrl?: string; }; type UsersComboboxProps = { - selectedOption: UserOption | undefined; - onSelect: (option: UserOption | undefined) => void; + value: string; + onValueChange: (value: string) => void; }; export const UsersCombobox: FC = ({ - selectedOption, - onSelect, + value, + onValueChange, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); - const usersQuery = useQuery({ + const { user } = useAuthenticated(); + const { data: options } = useQuery({ ...users({ q: debouncedSearch }), - select: (data) => - data.users.toSorted((a, _b) => { - return selectedOption && a.username === selectedOption.value ? -1 : 0; - }), + select: (res) => mapUsersToOptions(res.users, user, value), placeholderData: keepPreviousData, }); - - const options = usersQuery.data?.map((user) => ({ - label: user.name || user.username, - value: user.username, - avatarUrl: user.avatar_url, - })); + const selectedOption = options?.find((o) => o.value === value); return ( @@ -91,11 +89,7 @@ export const UsersCombobox: FC = ({ key={option.value} value={option.value} onSelect={() => { - onSelect( - option.value === selectedOption?.value - ? undefined - : option, - ); + onValueChange(option.value); setOpen(false); }} > @@ -131,3 +125,37 @@ const UserItem: FC = ({ option, className }) => { ); }; + +function mapUsersToOptions( + users: readonly User[], + /** + * Includes the authenticated user in the list if they are not already + * present. So the current user can always select themselves easily. + */ + authUser: User, + /** + * Username of the currently selected user. + */ + selectedValue: string, +): UserOption[] { + const includeAuthenticatedUser = (users: readonly User[]) => { + const hasAuthenticatedUser = users.some( + (u) => u.username === authUser.username, + ); + if (hasAuthenticatedUser) { + return users; + } + return [authUser, ...users]; + }; + + const sortSelectedFirst = (a: User) => + selectedValue && a.username === selectedValue ? -1 : 0; + + return includeAuthenticatedUser(users) + .toSorted(sortSelectedFirst) + .map((user) => ({ + label: user.name || user.username, + value: user.username, + avatarUrl: user.avatar_url, + })); +} diff --git a/site/src/pages/TasksPage/data.ts b/site/src/pages/TasksPage/data.ts new file mode 100644 index 0000000000..0795dab2bb --- /dev/null +++ b/site/src/pages/TasksPage/data.ts @@ -0,0 +1,24 @@ +import { API } from "api/api"; +import type { Task } from "modules/tasks/tasks"; + +// TODO: This is a temporary solution while the BE does not return the Task in a +// right shape with a custom name. This should be removed once the BE is fixed. +export const data = { + async createTask( + prompt: string, + userId: string, + templateVersionId: string, + presetId: string | undefined, + ): Promise { + const workspace = await API.experimental.createTask(userId, { + template_version_id: templateVersionId, + template_version_preset_id: presetId, + prompt, + }); + + return { + workspace, + prompt, + }; + }, +};