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" + } } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 966c8902c3e73..503026c2bb60b 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 a042b5cf7203c..c3d0b27475bf2 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 0000000000000..13e75dae51844 --- /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 a42f1a7a3fede..a10e4f29e749d 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); + 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", + }, + }, + }, + ...otherTasks, + ]); + }, +}; - // Test various edge cases for presets - spyOn(API, "getTemplateVersionPresets").mockImplementation(async () => { - return [ - { - ID: "malformed", - Name: "Malformed Preset", - Default: true, +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, + }, }, - // biome-ignore lint/suspicious/noExplicitAny: Testing malformed data edge cases - ] as any; + }, + ...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 b7b1d3f5998ef..1d3340a456919 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 = () => {
- - -
- - - ); -}; - -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 -

- - - 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 && ( - + tab.setValue("waiting-for-input")} + > + Waiting for input + {idleTasks && idleTasks.length > 0 && ( + + {idleTasks.length} + + )} + +
+ + { + userFilter.setValue( + username === userFilter.value ? "" : username, + ); + }} + /> + + )} + + - )} - - -
- -
- - ); -}; - -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; +type PillButtonProps = ButtonProps & { + active?: boolean; }; -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 = ( - - - - - - - - - - - - - - ); - } - +const PillButton: FC = ({ className, active, ...props }) => { return ( - - - - Task - Status - Created by - - - {body} -
+ + + + + + ); +}; + +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 603085f28d678..e3e443754a17f 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 0000000000000..0795dab2bb638 --- /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, + }; + }, +}; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy