From b5ffb82fe25033d356047fa827cbc1297bb534a8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 13 Aug 2025 19:43:59 +0000 Subject: [PATCH 01/16] chore: cleanup and organize code --- site/src/api/api.ts | 33 + site/src/pages/TasksPage/TaskPrompt.tsx | 432 ++++++++++ .../src/pages/TasksPage/TasksPage.stories.tsx | 58 +- site/src/pages/TasksPage/TasksPage.tsx | 769 ++---------------- site/src/pages/TasksPage/TasksTable.tsx | 175 ++++ site/src/pages/TasksPage/data.ts | 26 + 6 files changed, 748 insertions(+), 745 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/site/src/api/api.ts b/site/src/api/api.ts index b9d5f06924519..3ee34ffef37ab 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -30,6 +30,7 @@ import type { PostWorkspaceUsageRequest, } from "./typesGenerated"; import * as TypesGen from "./typesGenerated"; +import { Task } from "modules/tasks/tasks"; const getMissingParameters = ( oldBuildParameters: TypesGen.WorkspaceBuildParameter[], @@ -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 @@ -2677,6 +2682,34 @@ class ExperimentalApiMethods { return response.data; }; + + getTasks = async (filter: TasksFilter) => { + 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) => { + let prompt = prompts.prompts[workspace.latest_build.id]; + + if (prompt === "") { + prompt = "Empty prompt"; + } + + return { + workspace, + prompt, + } satisfies Task; + }); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/pages/TasksPage/TaskPrompt.tsx b/site/src/pages/TasksPage/TaskPrompt.tsx new file mode 100644 index 0000000000000..759be164c3ba1 --- /dev/null +++ b/site/src/pages/TasksPage/TaskPrompt.tsx @@ -0,0 +1,432 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; +import { templateVersionPresets } from "api/queries/templates"; +import type { + Preset, + Template, + TemplateVersionExternalAuth, +} from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { Link } from "components/Link/Link"; +import { Skeleton } from "components/Skeleton/Skeleton"; +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 { useEffect, useState, type FC } 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"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Spinner } from "components/Spinner/Spinner"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; + +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); + }, [presets]); + + // 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 cfa47d3539fee..1abd91e9460fa 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -19,7 +19,8 @@ import { withGlobalSnackbar, withProxyProvider, } from "testHelpers/storybook"; -import TasksPage, { data } from "./TasksPage"; +import TasksPage from "./TasksPage"; +import { data } from "./data"; const meta: Meta = { title: "pages/TasksPage", @@ -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,16 +111,16 @@ 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); }, }; @@ -132,11 +134,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 @@ -159,11 +161,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 @@ -179,8 +181,8 @@ export const LoadedTasksWithAIPromptPresets: Story = { export const LoadedTasksEdgeCases: 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); // Test various edge cases for presets spyOn(API, "getTemplateVersionPresets").mockImplementation(async () => { @@ -216,8 +218,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); @@ -242,8 +244,8 @@ export const CreateTaskSuccessfully: Story = { export const CreateTaskError: Story = { decorators: [withProxyProvider(), 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", @@ -271,7 +273,7 @@ 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); @@ -298,7 +300,7 @@ 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); @@ -325,7 +327,7 @@ 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); @@ -359,8 +361,8 @@ export const NonAdmin: Story = { }, }, 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 2f6405e796134..7a38fea785006 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,83 +1,42 @@ -import Skeleton from "@mui/material/Skeleton"; -import { API } from "api/api"; -import { getErrorDetail, getErrorMessage } from "api/errors"; -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 { displayError } from "components/GlobalSnackbar/utils"; -import { Link } from "components/Link/Link"; +import { API, type TasksFilter } from "api/api"; 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 { templateVersionPresets } from "api/queries/templates"; -import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { templates } from "api/queries/templates"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; -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 { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { type FC, useState } 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 { pageTitle } from "utils/page"; -import { relativeTime } from "utils/time"; import { type UserOption, UsersCombobox } from "./UsersCombobox"; - -type TasksFilter = { - user: UserOption | undefined; -}; +import { TasksTable } from "./TasksTable"; +import { TaskPrompt } from "./TaskPrompt"; const TasksPage: FC = () => { + const AITemplatesQuery = useQuery( + templates({ + q: "has-ai-task:true", + }), + ); + + // Tasks const { user, permissions } = useAuthenticated(); - const [filter, setFilter] = useState({ - user: { - value: user.username, - label: user.name || user.username, - avatarUrl: user.avatar_url, - }, + const [userOption, setUserOption] = useState({ + value: user.username, + label: user.name || user.username, + avatarUrl: user.avatar_url, + }); + const filter: TasksFilter = { + username: userOption?.value, + }; + const tasksQuery = useQuery({ + queryKey: ["tasks", filter], + queryFn: () => API.experimental.getTasks(filter), + refetchInterval: 10_000, }); return ( @@ -95,676 +54,52 @@ const TasksPage: FC = () => {
- - + {AITemplatesQuery.isSuccess && ( +
+ {permissions.viewDeploymentConfig && ( + + )} + +
+ )}
); }; -const textareaPlaceholder = "Prompt your AI agent to start a task..."; - -const LoadingTemplatesPlaceholder: FC = () => { - return ( -
-
- {/* Textarea skeleton */} - - - {/* Bottom controls skeleton */} -
- - -
-
-
- ); +type TasksControlsProps = { + userOption: UserOption | undefined; + onUserOptionChange: (userOption: UserOption | undefined) => void; }; -const NoTemplatesPlaceholder: FC = () => { - return ( -
-
-

- No Task templates found -

- - - Learn about Tasks - {" "} - 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 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 TasksControls: FC = ({ + userOption, + onUserOptionChange, }) => { - 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, - }) - } + selectedOption={userOption} + onSelect={onUserOptionChange} />
); }; -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, { - name: `task-${generateWorkspaceName()}`, - 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 0000000000000..38283e9d9c4a2 --- /dev/null +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -0,0 +1,175 @@ +import { getErrorMessage, getErrorDetail } from "api/errors"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton"; +import { Button } from "components/Button/Button"; +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 { relativeTime } from "utils/time"; +import { Link as RouterLink } from "react-router"; +import { Avatar } from "components/Avatar/Avatar"; +import { Skeleton } from "components/Skeleton/Skeleton"; + +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 = tasks.length === 0 ? : ; + } 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 + +
+
+
+
+ ); +}; + +const Tasks: FC<{ tasks: Task[] }> = ({ 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/data.ts b/site/src/pages/TasksPage/data.ts new file mode 100644 index 0000000000000..9cd269fbab23e --- /dev/null +++ b/site/src/pages/TasksPage/data.ts @@ -0,0 +1,26 @@ +import { API } from "api/api"; +import type { Task } from "modules/tasks/tasks"; +import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; + +// 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, { + name: `task-${generateWorkspaceName()}`, + template_version_id: templateVersionId, + template_version_preset_id: presetId, + prompt, + }); + + return { + workspace, + prompt, + }; + }, +}; From d92ee0f64690c341d5f0950927eb0b55f01b1751 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 15 Aug 2025 14:46:15 +0000 Subject: [PATCH 02/16] chore: fix biome settings for VSCode --- .vscode/settings.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index eaea72e7501b5..7fef4af975bc2 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" + } } From d3053e9fdc82aa8ca94695e556f7425d1e8f160b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 15 Aug 2025 14:46:30 +0000 Subject: [PATCH 03/16] chore: user search params for user filtering --- site/src/pages/TasksPage/TaskPrompt.tsx | 12 ++-- site/src/pages/TasksPage/TasksPage.tsx | 42 +++++++------ site/src/pages/TasksPage/UsersCombobox.tsx | 72 ++++++++++++++-------- 3 files changed, 76 insertions(+), 50 deletions(-) diff --git a/site/src/pages/TasksPage/TaskPrompt.tsx b/site/src/pages/TasksPage/TaskPrompt.tsx index 759be164c3ba1..7eca2c8b3f067 100644 --- a/site/src/pages/TasksPage/TaskPrompt.tsx +++ b/site/src/pages/TasksPage/TaskPrompt.tsx @@ -136,7 +136,6 @@ const TaskPromptEmpty: FC = () => { type CreateTaskMutationFnProps = { prompt: string; - }; type CreateTaskFormProps = { @@ -188,10 +187,13 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { : true; const createTaskMutation = useMutation({ - mutationFn: async ({ - prompt, - }: CreateTaskMutationFnProps) => - data.createTask(prompt, user.id, selectedTemplate.active_version_id, selectedPresetId), + mutationFn: async ({ prompt }: CreateTaskMutationFnProps) => + data.createTask( + prompt, + user.id, + selectedTemplate.active_version_id, + selectedPresetId, + ), onSuccess: async (task) => { await queryClient.invalidateQueries({ queryKey: ["tasks"], diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index 7a38fea785006..b0dc767adb0d7 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,20 +1,21 @@ import { API, type TasksFilter } from "api/api"; +import { templates } from "api/queries/templates"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Margins } from "components/Margins/Margins"; import { PageHeader, PageHeaderSubtitle, PageHeaderTitle, } from "components/PageHeader/PageHeader"; -import { templates } from "api/queries/templates"; -import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { useAuthenticated } from "hooks"; -import { type FC, useState } from "react"; +import { useSearchParamsKey } from "hooks/useSearchParamsKey"; +import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { pageTitle } from "utils/page"; -import { type UserOption, UsersCombobox } from "./UsersCombobox"; -import { TasksTable } from "./TasksTable"; import { TaskPrompt } from "./TaskPrompt"; +import { TasksTable } from "./TasksTable"; +import { UsersCombobox } from "./UsersCombobox"; const TasksPage: FC = () => { const AITemplatesQuery = useQuery( @@ -25,13 +26,12 @@ const TasksPage: FC = () => { // Tasks const { user, permissions } = useAuthenticated(); - const [userOption, setUserOption] = useState({ - value: user.username, - label: user.name || user.username, - avatarUrl: user.avatar_url, + const userFilter = useSearchParamsKey({ + key: "username", + defaultValue: user.username, }); const filter: TasksFilter = { - username: userOption?.value, + username: userFilter.value, }; const tasksQuery = useQuery({ queryKey: ["tasks", filter], @@ -63,8 +63,13 @@ const TasksPage: FC = () => {
{permissions.viewDeploymentConfig && ( + // When selecting a selected user, clear the filter + userFilter.setValue( + username === userFilter.value ? "" : username, + ) + } /> )} { }; type TasksControlsProps = { - userOption: UserOption | undefined; - onUserOptionChange: (userOption: UserOption | undefined) => void; + username: string; + onUsernameChange: (username: string) => void; }; const TasksControls: FC = ({ - userOption, - onUserOptionChange, + username, + onUsernameChange, }) => { return (

Filters

- +
); }; diff --git a/site/src/pages/TasksPage/UsersCombobox.tsx b/site/src/pages/TasksPage/UsersCombobox.tsx index 603085f28d678..83256a1211bb5 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,33 @@ 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 + value: string; // username 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({ - ...users({ q: debouncedSearch }), - select: (data) => - data.users.toSorted((a, _b) => { - return selectedOption && a.username === selectedOption.value ? -1 : 0; - }), - placeholderData: keepPreviousData, - }); - - const options = usersQuery.data?.map((user) => ({ - label: user.name || user.username, - value: user.username, - avatarUrl: user.avatar_url, - })); + const options = useUsersOptions({ query: debouncedSearch, value }); + const selectedOption = options?.find((o) => o.value === value); return ( @@ -91,11 +81,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 +117,39 @@ const UserItem: FC = ({ option, className }) => { ); }; + +type UseUsersOptionsOptions = { + query: string; + value?: string; +}; + +function useUsersOptions({ query, value }: UseUsersOptionsOptions) { + const { user } = useAuthenticated(); + + const includeAuthenticatedUser = (users: readonly User[]) => { + const hasAuthenticatedUser = users.some( + (u) => u.username === user?.username, + ); + if (hasAuthenticatedUser) { + return users; + } + return [user, ...users]; + }; + + const sortSelectedFirst = (a: User) => + value && a.username === value ? -1 : 0; + + const usersQuery = useQuery({ + ...users({ q: query }), + select: (data) => { + return includeAuthenticatedUser(data.users).toSorted(sortSelectedFirst); + }, + placeholderData: keepPreviousData, + }); + + return usersQuery.data?.map((user) => ({ + label: user.name || user.username, + value: user.username, + avatarUrl: user.avatar_url, + })); +} From b4c861bfb29038bc90b241afcb0025e6a7712790 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 15 Aug 2025 17:45:14 +0000 Subject: [PATCH 04/16] feat: add tabs --- site/src/components/Badge/Badge.tsx | 2 + site/src/pages/TasksPage/TasksPage.tsx | 87 +++++++++++++++++++------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index a042b5cf7203c..74b34efa5e848 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -24,6 +24,8 @@ 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/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index b0dc767adb0d7..be25968e22065 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -1,5 +1,7 @@ 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 { Margins } from "components/Margins/Margins"; import { @@ -12,6 +14,7 @@ import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; +import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { TaskPrompt } from "./TaskPrompt"; import { TasksTable } from "./TasksTable"; @@ -24,12 +27,15 @@ const TasksPage: FC = () => { }), ); - // Tasks const { user, permissions } = useAuthenticated(); const userFilter = useSearchParamsKey({ key: "username", defaultValue: user.username, }); + const tab = useSearchParamsKey({ + key: "tab", + defaultValue: "all", + }); const filter: TasksFilter = { username: userFilter.value, }; @@ -38,6 +44,11 @@ const TasksPage: FC = () => { 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 ( <> @@ -62,18 +73,44 @@ const TasksPage: FC = () => { {AITemplatesQuery.isSuccess && (
{permissions.viewDeploymentConfig && ( - - // When selecting a selected user, clear the filter - userFilter.setValue( - username === userFilter.value ? "" : username, - ) - } - /> +
+
+ 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, + ); + }} + /> +
)} + @@ -85,22 +122,24 @@ const TasksPage: FC = () => { ); }; -type TasksControlsProps = { - username: string; - onUsernameChange: (username: string) => void; +type PillButtonProps = ButtonProps & { + active?: boolean; }; -const TasksControls: FC = ({ - username, - onUsernameChange, -}) => { +const PillButton = ({ className, active, ...props }: PillButtonProps) => { return ( -
-

- Filters -

- -
+