From bf03799d73605a300cd3e242d14ad0f41c937274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 28 Aug 2026 03:15:29 +0000 Subject: [PATCH] chore(site): remove tasks frontend --- site/package.json | 1 - site/pnpm-lock.yaml | 14 - site/src/api/api.ts | 97 -- site/src/api/queries/tasks.ts | 37 - site/src/api/queries/templates.ts | 2 +- .../components/DropdownMenu/DropdownMenu.tsx | 2 - site/src/components/ScrollArea/ScrollArea.tsx | 2 +- site/src/components/Select/Select.tsx | 2 +- site/src/hooks/useEmbeddedMetadata.test.ts | 10 - site/src/hooks/useEmbeddedMetadata.ts | 2 - .../dashboard/Navbar/NavbarView.stories.tsx | 31 - .../modules/dashboard/Navbar/NavbarView.tsx | 66 - site/src/modules/notifications/utils.tsx | 4 - site/src/modules/resources/AgentRow.tsx | 15 - .../tasks/TaskActionButton.stories.tsx | 52 - site/src/modules/tasks/TaskActionButton.tsx | 64 - .../TaskDeleteDialog.stories.tsx | 48 - .../TaskDeleteDialog/TaskDeleteDialog.tsx | 55 - .../PromptSelectTrigger.stories.tsx | 36 - .../tasks/TaskPrompt/PromptSelectTrigger.tsx | 49 - .../tasks/TaskPrompt/TaskPrompt.stories.tsx | 694 --------- .../modules/tasks/TaskPrompt/TaskPrompt.tsx | 542 ------- .../TemplateVersionSelect.stories.tsx | 80 -- .../TaskPrompt/TemplateVersionSelect.tsx | 65 - .../tasks/TaskStatus/TaskStatus.stories.tsx | 52 - .../modules/tasks/TaskStatus/TaskStatus.tsx | 41 - .../TasksSidebar/TasksSidebar.stories.tsx | 200 --- .../tasks/TasksSidebar/TasksSidebar.tsx | 343 ----- .../TasksSidebar/UserCombobox.stories.tsx | 104 -- .../tasks/TasksSidebar/UserCombobox.tsx | 174 --- site/src/modules/tasks/taskActions.ts | 43 - site/src/modules/tasks/useAITasksEnabled.ts | 26 - .../WorkspaceDeleteDialog.stories.tsx | 21 +- .../WorkspaceDeleteDialog.tsx | 25 - .../ManagedAgentsConsumption.tsx | 11 +- .../NotificationsPage.stories.tsx | 2 +- .../NotificationsPage/NotificationsPage.tsx | 10 +- .../CreateEditRolePageView.tsx | 14 +- site/src/pages/TaskPage/FollowUpDialog.tsx | 95 -- .../TaskPage/ModifyPromptDialog.stories.tsx | 286 ---- .../src/pages/TaskPage/ModifyPromptDialog.tsx | 158 -- site/src/pages/TaskPage/TaskApps.stories.tsx | 153 -- site/src/pages/TaskPage/TaskApps.tsx | 255 ---- site/src/pages/TaskPage/TaskPage.stories.tsx | 1279 ----------------- site/src/pages/TaskPage/TaskPage.tsx | 903 ------------ .../TaskStartupWarningButton.stories.tsx | 37 - .../TaskPage/TaskStartupWarningButton.tsx | 109 -- .../pages/TaskPage/TaskStatusLink.stories.tsx | 96 -- site/src/pages/TaskPage/TaskStatusLink.tsx | 72 - site/src/pages/TaskPage/TaskTopbar.tsx | 132 -- .../BatchDeleteConfirmation.stories.tsx | 64 - .../TasksPage/BatchDeleteConfirmation.tsx | 157 -- .../src/pages/TasksPage/TasksPage.stories.tsx | 664 --------- site/src/pages/TasksPage/TasksPage.tsx | 336 ----- site/src/pages/TasksPage/TasksTable.tsx | 334 ----- site/src/pages/TasksPage/UsersCombobox.tsx | 165 --- site/src/pages/TasksPage/batchActions.ts | 36 - .../NotificationsPage.stories.tsx | 83 +- .../NotificationsPage/NotificationsPage.tsx | 33 +- .../WorkspacePage/AppStatuses.stories.tsx | 3 +- .../pages/WorkspacePage/Workspace.stories.tsx | 4 +- .../WorkspaceActions/ShareButton.tsx | 6 +- .../WorkspacePage/WorkspaceBuildProgress.tsx | 39 +- .../WorkspacesPageView.stories.tsx | 16 - .../pages/WorkspacesPage/WorkspacesTable.tsx | 7 - site/src/router.tsx | 8 - site/src/testHelpers/entities.ts | 237 --- 67 files changed, 38 insertions(+), 8765 deletions(-) delete mode 100644 site/src/api/queries/tasks.ts delete mode 100644 site/src/modules/tasks/TaskActionButton.stories.tsx delete mode 100644 site/src/modules/tasks/TaskActionButton.tsx delete mode 100644 site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx delete mode 100644 site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.stories.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx delete mode 100644 site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx delete mode 100644 site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx delete mode 100644 site/src/modules/tasks/TaskStatus/TaskStatus.tsx delete mode 100644 site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx delete mode 100644 site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx delete mode 100644 site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx delete mode 100644 site/src/modules/tasks/TasksSidebar/UserCombobox.tsx delete mode 100644 site/src/modules/tasks/taskActions.ts delete mode 100644 site/src/modules/tasks/useAITasksEnabled.ts delete mode 100644 site/src/pages/TaskPage/FollowUpDialog.tsx delete mode 100644 site/src/pages/TaskPage/ModifyPromptDialog.stories.tsx delete mode 100644 site/src/pages/TaskPage/ModifyPromptDialog.tsx delete mode 100644 site/src/pages/TaskPage/TaskApps.stories.tsx delete mode 100644 site/src/pages/TaskPage/TaskApps.tsx delete mode 100644 site/src/pages/TaskPage/TaskPage.stories.tsx delete mode 100644 site/src/pages/TaskPage/TaskPage.tsx delete mode 100644 site/src/pages/TaskPage/TaskStartupWarningButton.stories.tsx delete mode 100644 site/src/pages/TaskPage/TaskStartupWarningButton.tsx delete mode 100644 site/src/pages/TaskPage/TaskStatusLink.stories.tsx delete mode 100644 site/src/pages/TaskPage/TaskStatusLink.tsx delete mode 100644 site/src/pages/TaskPage/TaskTopbar.tsx delete mode 100644 site/src/pages/TasksPage/BatchDeleteConfirmation.stories.tsx delete mode 100644 site/src/pages/TasksPage/BatchDeleteConfirmation.tsx delete mode 100644 site/src/pages/TasksPage/TasksPage.stories.tsx delete mode 100644 site/src/pages/TasksPage/TasksPage.tsx delete mode 100644 site/src/pages/TasksPage/TasksTable.tsx delete mode 100644 site/src/pages/TasksPage/UsersCombobox.tsx delete mode 100644 site/src/pages/TasksPage/batchActions.ts diff --git a/site/package.json b/site/package.json index a02d642cc95..0e7a2a9a2e5 100644 --- a/site/package.json +++ b/site/package.json @@ -94,7 +94,6 @@ "react-dom": "19.2.8", "react-markdown": "9.1.0", "react-query": "npm:@tanstack/react-query@5.82.0", - "react-resizable-panels": "3.0.6", "react-router": "7.18.2", "react-syntax-highlighter": "15.6.6", "react-textarea-autosize": "8.5.9", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 34ad1a8dabd..d17717b5a2c 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -200,9 +200,6 @@ importers: react-query: specifier: npm:@tanstack/react-query@5.82.0 version: '@tanstack/react-query@5.82.0(react@19.2.8)' - react-resizable-panels: - specifier: 3.0.6 - version: 3.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-router: specifier: 7.18.2 version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -5206,12 +5203,6 @@ packages: '@types/react': optional: true - react-resizable-panels@3.0.6: - resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==, tarball: https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz} - peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-router@7.18.2: resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==, tarball: https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz} engines: {node: '>=20.0.0'} @@ -11494,11 +11485,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-resizable-panels@3.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: cookie: 1.1.1 diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d68dae8476f..7840c86570e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3052,103 +3052,6 @@ class ApiMethods { await this.axios.put("/api/v2/notifications/inbox/mark-all-as-read"); }; - createTask = async ( - user: string, - req: TypesGen.CreateTaskRequest, - ): Promise => { - const response = await this.axios.post( - `/api/v2/tasks/${user}`, - req, - ); - - return response.data; - }; - - getTasks = async ( - filter: TypesGen.TasksFilter, - ): Promise => { - const query: string[] = []; - if (filter.owner) { - query.push(`owner:${filter.owner}`); - } - if (filter.status) { - query.push(`status:${filter.status}`); - } - - const res = await this.axios.get( - "/api/v2/tasks", - { - params: { - q: query.join(", "), - }, - }, - ); - - return res.data.tasks; - }; - - getTask = async (user: string, id: string): Promise => { - const response = await this.axios.get( - `/api/v2/tasks/${user}/${id}`, - ); - - return response.data; - }; - - deleteTask = async (user: string, id: string): Promise => { - await this.axios.delete(`/api/v2/tasks/${user}/${id}`); - }; - - updateTaskInput = async ( - user: string, - id: string, - input: string, - ): Promise => { - await this.axios.patch(`/api/v2/tasks/${user}/${id}/input`, { - input, - } satisfies TypesGen.UpdateTaskInputRequest); - }; - - getTaskLogs = async ( - user: string, - id: string, - ): Promise => { - const response = await this.axios.get( - `/api/v2/tasks/${user}/${id}/logs`, - ); - return response.data; - }; - - pauseTask = async ( - user: string, - id: string, - ): Promise => { - const response = await this.axios.post( - `/api/v2/tasks/${user}/${id}/pause`, - ); - return response.data; - }; - - resumeTask = async ( - user: string, - id: string, - ): Promise => { - const response = await this.axios.post( - `/api/v2/tasks/${user}/${id}/resume`, - ); - return response.data; - }; - - sendTaskInput = async ( - user: string, - id: string, - input: string, - ): Promise => { - await this.axios.post(`/api/v2/tasks/${user}/${id}/send`, { - input, - } satisfies TypesGen.TaskSendRequest); - }; - getAIBridgeModels = async (options: SearchParamOptions) => { const url = getURLWithSearchParams(`${aiGatewayPath}/models`, options); diff --git a/site/src/api/queries/tasks.ts b/site/src/api/queries/tasks.ts deleted file mode 100644 index 4902862c866..00000000000 --- a/site/src/api/queries/tasks.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { QueryClient } from "react-query"; -import { API } from "#/api/api"; -import type { Task } from "#/api/typesGenerated"; - -export const taskLogsKey = (user: string, taskId: string) => [ - "tasks", - user, - taskId, - "logs", -]; - -export const taskLogs = (user: string, taskId: string) => ({ - queryKey: taskLogsKey(user, taskId), - queryFn: () => API.getTaskLogs(user, taskId), -}); - -export const pauseTask = (task: Task, queryClient: QueryClient) => { - return { - mutationFn: async () => { - return API.pauseTask(task.owner_name, task.id); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - }; -}; - -export const resumeTask = (task: Task, queryClient: QueryClient) => { - return { - mutationFn: async () => { - return API.resumeTask(task.owner_name, task.id); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - }; -}; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 22cab6363c0..982046fae14 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -375,7 +375,7 @@ export const previousTemplateVersion = ( }; }; -export const templateVersionPresetsKey = (versionId: string) => [ +const templateVersionPresetsKey = (versionId: string) => [ templateVersionRoot, versionId, "presets", diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index 34cbad41b68..2cd77f9c7da 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -18,8 +18,6 @@ export const DropdownMenu = DropdownMenuPrimitive.Root; export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -export const DropdownMenuGroup = DropdownMenuPrimitive.Group; - export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; export const DropdownMenuContent: React.FC< diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index b99c445722c..9c5b2b19e65 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -67,7 +67,7 @@ export const ScrollArea: React.FC = ({ ); }; -export const ScrollBar: React.FC< +const ScrollBar: React.FC< React.ComponentPropsWithRef< typeof ScrollAreaPrimitive.ScrollAreaScrollbar > & { diff --git a/site/src/components/Select/Select.tsx b/site/src/components/Select/Select.tsx index f65da039484..815176ec3d9 100644 --- a/site/src/components/Select/Select.tsx +++ b/site/src/components/Select/Select.tsx @@ -17,7 +17,7 @@ export const SelectGroup = SelectPrimitive.Group; export const SelectValue = SelectPrimitive.Value; -export type SelectTriggerProps = React.ComponentPropsWithRef< +type SelectTriggerProps = React.ComponentPropsWithRef< typeof SelectPrimitive.Trigger >; diff --git a/site/src/hooks/useEmbeddedMetadata.test.ts b/site/src/hooks/useEmbeddedMetadata.test.ts index 0df6e7b51d7..97a9d86c4d7 100644 --- a/site/src/hooks/useEmbeddedMetadata.test.ts +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -2,7 +2,6 @@ import { act, renderHook } from "@testing-library/react"; import type { Region, User } from "#/api/typesGenerated"; import { MockAIGatewayEnabled, - MockAITasksEnabled, MockAppearanceConfig, MockBuildInfo, MockEntitlements, @@ -45,7 +44,6 @@ const mockDataForTags = { user: MockUserOwner, userAppearance: MockUserAppearanceSettings, regions: MockRegions, - "ai-tasks-enabled": MockAITasksEnabled, "ai-gateway-enabled": MockAIGatewayEnabled, permissions: MockPermissions, organizations: [MockOrganization], @@ -80,10 +78,6 @@ const emptyMetadata: RuntimeHtmlMetadata = { available: false, value: undefined, }, - "ai-tasks-enabled": { - available: false, - value: undefined, - }, "ai-gateway-enabled": { available: false, value: undefined, @@ -127,10 +121,6 @@ const populatedMetadata: RuntimeHtmlMetadata = { available: true, value: MockUserAppearanceSettings, }, - "ai-tasks-enabled": { - available: true, - value: MockAITasksEnabled, - }, "ai-gateway-enabled": { available: true, value: MockAIGatewayEnabled, diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index 32e0a2ea832..a3be7132267 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -31,7 +31,6 @@ type AvailableMetadata = Readonly<{ entitlements: Entitlements; regions: readonly Region[]; "build-info": BuildInfoResponse; - "ai-tasks-enabled": boolean; "ai-gateway-enabled": boolean; permissions: Permissions; organizations: Organization[]; @@ -96,7 +95,6 @@ export class MetadataManager implements MetadataManagerApi { experiments: this.registerValue("experiments"), "build-info": this.registerValue("build-info"), regions: this.registerRegionValue(), - "ai-tasks-enabled": this.registerValue("ai-tasks-enabled"), "ai-gateway-enabled": this.registerValue("ai-gateway-enabled"), permissions: this.registerValue("permissions"), organizations: this.registerValue("organizations"), diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index 0f336881e75..701a5cbc26a 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import type { TasksFilter } from "#/api/typesGenerated"; import { AuthProvider } from "#/contexts/auth/AuthProvider"; import { DashboardContext } from "#/modules/dashboard/DashboardProvider"; import { AISettingsIndexRedirect } from "#/pages/AISettingsPage/AISettingsIndexRedirect"; @@ -11,7 +10,6 @@ import { MockDefaultOrganization, MockEntitlements, MockNoPermissions, - MockTasks, MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; @@ -22,14 +20,6 @@ import { } from "#/testHelpers/storybook"; import { NavbarView } from "./NavbarView"; -const tasksFilter: TasksFilter = { - owner: MockUserOwner.username, -}; - -const memberTasksFilter: TasksFilter = { - owner: MockUserMember.username, -}; - const AISettingsIndexRedirectWithProviders = () => ( = { parameters: { pixel: { matrix: pixelWithTablet }, layout: "fullscreen", - queries: [ - { - key: ["tasks", tasksFilter], - data: [], - }, - ], }, component: NavbarView, args: { @@ -130,7 +114,6 @@ export const ForTemplateUpdateOnlyAdmin: Story = { decorators: [withAuthProvider], parameters: { pixel: { matrix: pixelWithDesktop }, - queries: [{ key: ["tasks", memberTasksFilter], data: [] }], user: MockUserMember, permissions: { ...MockNoPermissions, @@ -176,7 +159,6 @@ export const ForMCPUpdateOnlyAdmin: Story = { decorators: [withAuthProvider], parameters: { pixel: { matrix: pixelWithDesktop }, - queries: [{ key: ["tasks", memberTasksFilter], data: [] }], user: MockUserMember, permissions: { ...MockNoPermissions, @@ -222,7 +204,6 @@ export const ForMCPDeleteOnlyAdmin: Story = { decorators: [withAuthProvider], parameters: { pixel: { matrix: pixelWithDesktop }, - queries: [{ key: ["tasks", memberTasksFilter], data: [] }], user: MockUserMember, permissions: { ...MockNoPermissions, @@ -266,7 +247,6 @@ export const ForMCPCreateOnlyAdmin: Story = { decorators: [withAuthProvider], parameters: { pixel: { matrix: pixelWithDesktop }, - queries: [{ key: ["tasks", memberTasksFilter], data: [] }], user: MockUserMember, permissions: { ...MockNoPermissions, @@ -370,17 +350,6 @@ export const ForMember: Story = { }, }; -export const IdleTasks: Story = { - parameters: { - queries: [ - { - key: ["tasks", tasksFilter], - data: MockTasks, - }, - ], - }, -}; - export const SupportLinks: Story = { args: { user: MockUserMember, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 4e6c5161165..495bf7d819c 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,19 +1,12 @@ import type { FC } from "react"; -import { useQuery } from "react-query"; import { NavLink, useLocation } from "react-router"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { ProductLogo } from "#/components/Icons/ProductLogo"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; import type { ProxyContextValue } from "#/contexts/ProxyContext"; import { NotificationsInbox } from "#/modules/notifications/NotificationsInbox/NotificationsInbox"; -import { useAITasksEnabled } from "#/modules/tasks/useAITasksEnabled"; import { getPrereleaseFlag } from "#/utils/buildInfo"; import { cn } from "#/utils/cn"; import { @@ -83,7 +76,6 @@ export const NavbarView: FC = ({ @@ -165,14 +157,12 @@ export const NavbarView: FC = ({ interface NavItemsProps { className?: string; - user: TypesGen.User; canViewModels: boolean; canCreateChat: boolean; } const NavItems: FC = ({ className, - user, canCreateChat, canViewModels, }) => { @@ -199,7 +189,6 @@ const NavItems: FC = ({ > Templates - {canViewModels && ( @@ -224,61 +213,6 @@ const NavItems: FC = ({ ); }; -type TasksNavItemProps = { - user: TypesGen.User; -}; - -const TasksNavItem: FC = ({ user }) => { - const canSeeTasks = useAITasksEnabled(); - const filter: TypesGen.TasksFilter = { - owner: user.username, - }; - const { data: idleCount } = useQuery({ - queryKey: ["tasks", filter], - queryFn: () => API.getTasks(filter), - refetchInterval: 1_000 * 60, - enabled: canSeeTasks, - refetchOnWindowFocus: true, - initialData: [], - select: (data) => - data.filter((task) => task.current_state?.state === "idle").length, - }); - - if (!canSeeTasks) { - return null; - } - - return ( - { - return cn(linkStyles.default, { [linkStyles.active]: isActive }); - }} - > - Tasks - {idleCount > 0 && ( - - - - {idleCount} - - - {idleTasksLabel(idleCount)} - - )} - - ); -}; - -function idleTasksLabel(count: number) { - return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`; -} - function isNavbarLink(link: TypesGen.LinkConfig): boolean { return link.location === "navbar"; } diff --git a/site/src/modules/notifications/utils.tsx b/site/src/modules/notifications/utils.tsx index 757bd12d5a7..87be8dc1cf8 100644 --- a/site/src/modules/notifications/utils.tsx +++ b/site/src/modules/notifications/utils.tsx @@ -31,10 +31,6 @@ export const castNotificationMethod = (value: string) => { ); }; -export function isTaskNotification(tmpl: NotificationTemplate): boolean { - return tmpl.group === "Task Events"; -} - // Determines if a notification is disabled based on user preferences and system defaults // A notification is considered disabled if: // 1. It's NOT enabled by default AND the user hasn't set any preference (undefined), OR diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 0a354d34640..5a225d21de6 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -4,7 +4,6 @@ import { InfoIcon, PackageIcon, PlayIcon, - SquareCheckBigIcon, TriangleAlertIcon, } from "lucide-react"; import { @@ -15,7 +14,6 @@ import { useRef, useState, } from "react"; -import { Link as RouterLink } from "react-router"; import AutoSizer from "react-virtualized-auto-sizer"; import type { VariableSizeList as List, ListOnScrollProps } from "react-window"; import type { @@ -62,7 +60,6 @@ import { import { useProxy } from "#/contexts/ProxyContext"; import { useClipboard } from "#/hooks/useClipboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; -import { useAITasksEnabled } from "#/modules/tasks/useAITasksEnabled"; import { getAgentConnectivityIssues, getAgentScriptIssues, @@ -156,7 +153,6 @@ export const AgentRow: FC = ({ initialMetadata, }) => { const { browser_only, workspace_external_agent } = useFeatureVisibility(); - const aiTasksEnabled = useAITasksEnabled(); const appSections = organizeAgentApps(agent.apps); const hasAppsToDisplay = !browser_only || appSections.some((it) => it.apps.length > 0); @@ -477,17 +473,6 @@ export const AgentRow: FC = ({ )} - {aiTasksEnabled && workspace.task_id && ( - - )} - {shouldShowWildcardWarning && } {shouldDisplayAppsSection && ( diff --git a/site/src/modules/tasks/TaskActionButton.stories.tsx b/site/src/modules/tasks/TaskActionButton.stories.tsx deleted file mode 100644 index 4b685897622..00000000000 --- a/site/src/modules/tasks/TaskActionButton.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; -import { TaskActionButton } from "./TaskActionButton"; - -const meta: Meta = { - title: "modules/tasks/TaskActionButton", - component: TaskActionButton, - args: { - onClick: fn(), - }, -}; - -export default meta; -type Story = StoryObj; - -export const Pause: Story = { - args: { - action: "pause", - }, -}; - -export const Resume: Story = { - args: { - action: "resume", - }, -}; - -export const Loading: Story = { - args: { - action: "pause", - loading: true, - }, -}; - -export const Disabled: Story = { - args: { - action: "pause", - disabled: true, - }, -}; - -export const ClickHandler: Story = { - args: { - action: "pause", - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const button = canvas.getByRole("button", { name: /pause task/i }); - await userEvent.click(button); - expect(args.onClick).toHaveBeenCalledTimes(1); - }, -}; diff --git a/site/src/modules/tasks/TaskActionButton.tsx b/site/src/modules/tasks/TaskActionButton.tsx deleted file mode 100644 index fac461a4e62..00000000000 --- a/site/src/modules/tasks/TaskActionButton.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { PauseIcon, PlayIcon } from "lucide-react"; -import type { FC } from "react"; -import { Button } from "#/components/Button/Button"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; - -type TaskActionButtonProps = { - action: "pause" | "resume"; - disabled?: boolean; - loading?: boolean; - onClick: () => void; -}; - -const actionConfig = { - pause: { - icon: PauseIcon, - label: "Pause task", - tooltip: "Pause the task to save resources. You can resume later.", - }, - resume: { - icon: PlayIcon, - label: "Resume task", - tooltip: "Resuming takes time while the workspace starts.", - }, -} as const; - -export const TaskActionButton: FC = ({ - action, - disabled, - loading, - onClick, -}) => { - const config = actionConfig[action]; - const Icon = config.icon; - - return ( - - - - - - {config.tooltip} - - - ); -}; diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx deleted file mode 100644 index d2f08ef651c..00000000000 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; -import { API } from "#/api/api"; -import { MockTask } from "#/testHelpers/entities"; -import { withToaster } from "#/testHelpers/storybook"; -import { TaskDeleteDialog } from "./TaskDeleteDialog"; - -const meta: Meta = { - title: "modules/tasks/TaskDeleteDialog", - component: TaskDeleteDialog, - decorators: [withToaster], -}; - -export default meta; -type Story = StoryObj; - -export const DeleteTaskSuccess: Story = { - decorators: [withToaster], - args: { - open: true, - task: MockTask, - onClose: () => {}, - }, - parameters: { - pixel: { exclude: false }, - }, - beforeEach: () => { - spyOn(API, "deleteTask").mockResolvedValue(); - }, - play: async ({ canvasElement, step }) => { - const body = within(canvasElement.ownerDocument.body); - - await step("Confirm delete", async () => { - const confirmButton = await body.findByRole("button", { - name: /delete/i, - }); - await userEvent.click(confirmButton); - await step("Confirm delete", async () => { - await waitFor(() => { - expect(API.deleteTask).toHaveBeenCalledWith( - MockTask.owner_name, - MockTask.id, - ); - }); - }); - }); - }, -}; diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx deleted file mode 100644 index b9c9b49481a..00000000000 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { FC } from "react"; -import { QueryClient, useMutation } from "react-query"; -import { toast } from "sonner"; -import { API } from "#/api/api"; -import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import type { Task } from "#/api/typesGenerated"; -import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; - -type TaskDeleteDialogProps = { - open: boolean; - task: Task; - onClose: () => void; - onSuccess?: () => void; -}; - -export const TaskDeleteDialog: FC = ({ - task, - onSuccess, - ...props -}) => { - const queryClient = new QueryClient(); - const deleteTaskMutation = useMutation({ - mutationFn: () => API.deleteTask(task.owner_name, task.id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - }); - - return ( - { - const mutation = deleteTaskMutation.mutateAsync(); - toast.promise(mutation, { - loading: `Deleting "${task.name}"...`, - success: `"${task.name}" was deleted successfully.`, - error: (e) => ({ - message: getErrorMessage(e, `Failed to delete ${task.name}.`), - description: getErrorDetail(e), - }), - }); - mutation.then(() => onSuccess?.()).finally(() => props.onClose()); - }} - description={ -

- This action is irreversible and removes all workspace resources and - data. -

- } - /> - ); -}; diff --git a/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.stories.tsx b/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.stories.tsx deleted file mode 100644 index e694e9f6431..00000000000 --- a/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { userEvent, within } from "storybook/test"; -import { Select, SelectContent, SelectItem } from "#/components/Select/Select"; -import { PromptSelectTrigger } from "./PromptSelectTrigger"; - -const meta: Meta = { - title: "modules/tasks/TaskPrompt/PromptSelectTrigger", - component: PromptSelectTrigger, - args: { - children: "Select a version", - tooltip: "Template version", - }, - render: (args) => ( - - ), -}; - -export default meta; -type Story = StoryObj; - -export const Closed: Story = {}; - -export const Open: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const trigger = canvas.getByRole("combobox"); - await userEvent.click(trigger); - }, -}; diff --git a/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.tsx b/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.tsx deleted file mode 100644 index deac0b406f8..00000000000 --- a/site/src/modules/tasks/TaskPrompt/PromptSelectTrigger.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { FC } from "react"; -import { - SelectTrigger, - type SelectTriggerProps, -} from "#/components/Select/Select"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { cn } from "#/utils/cn"; - -type PromptSelectTriggerProps = SelectTriggerProps & { - tooltip: string; -}; - -export const PromptSelectTrigger: FC = ({ - className, - tooltip, - children, - ...props -}) => { - return ( - - - - svg]:p-0.5 cursor-pointer hover:bg-surface-quaternary rounded-full - h-10 md:h-8 data-[state=open]:bg-surface-tertiary`, - className, - ])} - > - - {children} - - - - {tooltip} - - - ); -}; diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx deleted file mode 100644 index 9e61e5cac70..00000000000 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ /dev/null @@ -1,694 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; -import { API } from "#/api/api"; -import { templateVersionPresetsKey } from "#/api/queries/templates"; -import type { Task } from "#/api/typesGenerated"; -import { - MockPresets, - MockTask, - MockTaskPresets, - MockTasks, - MockTemplate, - MockTemplateVersion, - MockTemplateVersionExternalAuthAzure, - MockTemplateVersionExternalAuthGithub, - MockTemplateVersionExternalAuthGithubAuthenticated, - MockUserOwner, - mockApiError, -} from "#/testHelpers/entities"; -import { withAuthProvider, withToaster } from "#/testHelpers/storybook"; -import type TasksPage from "../../../pages/TasksPage/TasksPage"; -import { TaskPrompt } from "./TaskPrompt"; - -const MockNewTaskData: Task = { - ...MockTask, - current_state: { - ...MockTask.current_state, - message: "Task created successfully!", - }, -}; - -const meta: Meta = { - title: "modules/tasks/TaskPrompt", - component: TaskPrompt, - decorators: [withAuthProvider], - parameters: { - user: MockUserOwner, - permissions: { - updateTemplates: true, - }, - }, - beforeEach: () => { - spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); - spyOn(API, "getTemplateVersions").mockResolvedValue([ - { - ...MockTemplateVersion, - name: "v1.0.0", - }, - ]); - spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); - }, - args: { - templates: [MockTemplate], - }, -}; - -export default meta; -type Story = StoryObj; - -export const LoadingTemplates: Story = { - args: { - templates: undefined, - }, -}; - -export const EmptyTemplates: Story = { - args: { - templates: [], - }, -}; - -export const WithPresets: Story = { - beforeEach: () => { - spyOn(API, "getTemplateVersionPresets").mockResolvedValue(MockPresets); - }, -}; - -export const WithAIPresets: Story = { - beforeEach: () => { - spyOn(API, "getTemplateVersionPresets").mockResolvedValue(MockTaskPresets); - }, -}; - -export const SubmitEnabledWhenPromptNotEmpty: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.initial_prompt); - - const submitButton = canvas.getByRole("button", { name: /run task/i }); - expect(submitButton).toBeEnabled(); - }, -}; - -export const SubmitDisabledWhenPromptEmpty: Story = { - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("No prompt", async () => { - const submitButton = canvas.getByRole("button", { name: /run task/i }); - expect(submitButton).toBeDisabled(); - }); - - await step("Whitespace prompt", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, " "); - - const submitButton = canvas.getByRole("button", { name: /run task/i }); - expect(submitButton).toBeDisabled(); - }); - }, -}; - -export const Submitting: Story = { - decorators: [withToaster], - beforeEach: () => { - spyOn(API, "createTask").mockImplementation( - () => - // Never resolve to keep the component in the submitting state for visual testing. - new Promise(() => {}), - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type( - prompt, - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.{enter}{enter}Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - ); - - const submitButton = canvas.getByRole("button", { name: /run task/i }); - await waitFor(() => expect(submitButton).toBeEnabled()); - await userEvent.click(submitButton); - }, - parameters: { - pixel: { exclude: true }, - }, -}; - -export const OnSuccess: Story = { - decorators: [withToaster], - parameters: { - permissions: { - updateTemplates: false, - }, - }, - beforeEach: () => { - const activeVersionId = `${MockTemplate.active_version_id}-latest`; - spyOn(API, "getTemplate").mockResolvedValue({ - ...MockTemplate, - active_version_id: activeVersionId, - }); - spyOn(API, "createTask").mockResolvedValue(MockTask); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Run task", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.initial_prompt); - const submitButton = canvas.getByRole("button", { name: /run task/i }); - await waitFor(() => expect(submitButton).toBeEnabled()); - await userEvent.click(submitButton); - }); - - await step("Uses latest template version", () => { - expect(API.createTask).toHaveBeenCalledWith(MockUserOwner.id, { - input: MockNewTaskData.initial_prompt, - template_version_id: `${MockTemplate.active_version_id}-latest`, - template_version_preset_id: undefined, - }); - }); - - await step("Displays success message", async () => { - const body = within(canvasElement.ownerDocument.body); - const successMessage = await body.findByText(/created successfully/i); - expect(successMessage).toBeInTheDocument(); - }); - - await step("Clears prompt", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - expect(prompt).toHaveValue(""); - }); - }, -}; - -export const ChangeTemplate: Story = { - decorators: [withToaster], - args: { - templates: [ - { - ...MockTemplate, - id: "claude-code", - name: "claude-code", - display_name: "Claude Code", - active_version_id: "claude-code-version", - }, - { - ...MockTemplate, - id: "codex", - name: "codex", - display_name: "Codex", - active_version_id: "codex-version", - }, - ], - }, - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockImplementation((templateId) => { - if (templateId === "claude-code") { - return Promise.resolve([ - { - ...MockTemplateVersion, - id: "claude-code-version", - name: "claude-code-version", - }, - ]); - } - if (templateId === "codex") { - return Promise.resolve([ - { - ...MockTemplateVersion, - id: "codex-version", - name: "codex-version", - }, - ]); - } - return Promise.resolve([]); - }); - spyOn(API, "createTask").mockResolvedValue(MockTask); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - - await step("Change template", async () => { - const templateSelect = await canvas.findByLabelText(/select template/i); - await userEvent.click(templateSelect); - const templateOption = await body.findByRole("option", { - name: /codex/i, - }); - await userEvent.click(templateOption); - }); - - await step("Default version is selected", async () => { - const versionSelect = await canvas.findByLabelText(/version/i); - expect(versionSelect).toHaveTextContent("codex-version"); - }); - }, -}; - -export const SelectTemplateVersion: Story = { - decorators: [withToaster], - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockResolvedValue([ - { - ...MockTemplateVersion, - id: "test-template-version-2", - name: "v2.0.0", - }, - { - ...MockTemplateVersion, - name: "v1.0.0", - }, - ]); - spyOn(API, "createTask").mockResolvedValue(MockTask); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Fill prompt", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.initial_prompt); - }); - - await step("Select version", async () => { - const body = within(canvasElement.ownerDocument.body); - const versionSelect = await canvas.findByLabelText(/template version/i); - await userEvent.click(versionSelect); - const versionOption = await body.findByRole("option", { - name: /v2.0.0/i, - }); - await userEvent.click(versionOption); - }); - - await step("Submit form", async () => { - const submitButton = canvas.getByRole("button", { name: /run task/i }); - await waitFor(() => expect(submitButton).toBeEnabled()); - await userEvent.click(submitButton); - }); - - await step("Uses selected version", () => { - expect(API.createTask).toHaveBeenCalledWith(MockUserOwner.id, { - input: MockNewTaskData.initial_prompt, - template_version_id: "test-template-version-2", - template_version_preset_id: undefined, - }); - }); - - await step("Displays success message", async () => { - const body = within(canvasElement.ownerDocument.body); - const successMessage = await body.findByText(/created successfully/i); - expect(successMessage).toBeInTheDocument(); - }); - }, -}; - -export const OnError: Story = { - decorators: [withToaster], - beforeEach: () => { - spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - spyOn(API, "createTask").mockRejectedValue( - mockApiError({ - message: "Failed to create task", - detail: "You don't have permission to create tasks.", - }), - ); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Run task", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, "Create a new task"); - const submitButton = canvas.getByRole("button", { name: /run task/i }); - await waitFor(() => expect(submitButton).toBeEnabled()); - await userEvent.click(submitButton); - }); - - await step("Verify error", async () => { - await canvas.findByText(/failed to create task/i); - }); - }, -}; - -export const AuthenticatedExternalAuth: Story = { - beforeEach: () => { - spyOn(API, "getTasks") - .mockResolvedValueOnce(MockTasks) - .mockResolvedValue([MockNewTaskData, ...MockTasks]); - spyOn(API, "createTask").mockResolvedValue(MockTask); - spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ - MockTemplateVersionExternalAuthGithubAuthenticated, - ]); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Does not render external auth", async () => { - expect( - canvas.queryByText(/external authentication/), - ).not.toBeInTheDocument(); - }); - }, - parameters: { - pixel: { exclude: true }, - }, -}; - -export const MissingExternalAuth: Story = { - beforeEach: () => { - spyOn(API, "getTasks") - .mockResolvedValueOnce(MockTasks) - .mockResolvedValue([MockNewTaskData, ...MockTasks]); - spyOn(API, "createTask").mockResolvedValue(MockTask); - spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ - MockTemplateVersionExternalAuthGithub, - ]); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Submit is disabled", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.initial_prompt); - const submitButton = canvas.getByRole("button", { name: /run task/i }); - expect(submitButton).toBeDisabled(); - }); - - await step("Renders external authentication", async () => { - await canvas.findByRole("button", { name: /connect to github/i }); - }); - }, -}; - -export const MissingExternalAuthMultipleProviders: Story = { - beforeEach: () => { - spyOn(API, "getTasks") - .mockResolvedValueOnce(MockTasks) - .mockResolvedValue([MockNewTaskData, ...MockTasks]); - spyOn(API, "createTask").mockResolvedValue(MockTask); - spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ - MockTemplateVersionExternalAuthGithub, - MockTemplateVersionExternalAuthAzure, - ]); - // Prevent the auth button from actually opening a popup. - spyOn(window, "open").mockReturnValue(null); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - const githubButton = await canvas.findByRole("button", { - name: /connect to github/i, - }); - const azureButton = await canvas.findByRole("button", { - name: /connect to azure/i, - }); - - await step("Click GitHub auth button", async () => { - await userEvent.click(githubButton); - }); - - await step("Azure button remains enabled", () => { - expect(azureButton).toBeEnabled(); - }); - }, -}; - -export const ExternalAuthError: Story = { - beforeEach: () => { - spyOn(API, "getTasks") - .mockResolvedValueOnce(MockTasks) - .mockResolvedValue([MockNewTaskData, ...MockTasks]); - spyOn(API, "createTask").mockResolvedValue(MockTask); - spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue( - mockApiError({ - message: "Failed to load external auth", - }), - ); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Submit is disabled", async () => { - const prompt = await canvas.findByLabelText(/prompt/i); - await userEvent.type(prompt, MockNewTaskData.initial_prompt); - const submitButton = canvas.getByRole("button", { name: /run task/i }); - expect(submitButton).toBeDisabled(); - }); - - await step("Renders error", async () => { - const alert = await canvas.findByRole("alert"); - await within(alert).findByRole("heading", { - name: /failed to load external auth/i, - }); - }); - }, -}; - -const tmplWithExternalAuth = { - ...MockTemplateVersion, - id: "2", - name: "With external", -}; - -export const CheckExternalAuthOnChangingVersions: Story = { - args: { - templates: [ - { - ...MockTemplate, - active_version_id: tmplWithExternalAuth.id, - }, - ], - }, - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockResolvedValue([ - { - ...MockTemplateVersion, - id: "1", - name: "No external", - }, - tmplWithExternalAuth, - ]); - spyOn(API, "getTemplateVersionExternalAuth").mockImplementation( - (versionId: string) => { - return Promise.resolve( - versionId === tmplWithExternalAuth.id - ? [MockTemplateVersionExternalAuthGithub] - : [], - ); - }, - ); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Renders external authentication", async () => { - await canvas.findByRole("button", { name: /connect to github/i }); - }); - - await step("Change into version without external auth", async () => { - const body = within(canvasElement.ownerDocument.body); - const versionSelect = await canvas.findByLabelText(/template version/i); - await userEvent.click(versionSelect); - const versionOption = await body.findByRole("option", { - name: /no external/i, - }); - await userEvent.click(versionOption); - }); - - await step("Don't render external authentication", async () => { - expect( - canvas.queryByRole("button", { name: /connect to github/i }), - ).not.toBeInTheDocument(); - }); - }, -}; - -// Regression test introduced in https://github.com/coder/coder/pull/22032 -// A change was introduced that cause the focused selector to be mostly -// hidden due to an introduced `overflow-hidden`. The change wasn't spotted -// in the PR it was introduced as no stories triggered that behavior, so we -// have added one to ensure the behavior doesn't regress. -export const PresetSelectorFocused: Story = { - beforeEach: () => { - spyOn(API, "getTemplateVersionPresets").mockResolvedValue( - MockPresets.map((preset, i) => ({ - ...preset, - Icon: i === 0 ? "/icon/code.svg" : i === 1 ? "/icon/database.svg" : "", - Description: i === 0 ? "For everyday development work" : "", - })), - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const presetSelect = await canvas.findByLabelText(/preset/i); - presetSelect.focus(); - }, -}; - -// Regression test for https://github.com/coder/coder/issues/22245 -// Dark monochrome icons (like GitHub or Tasks) were invisible on dark -// backgrounds because icons used a plain instead of -// ExternalImage, which applies theme-aware CSS filters. -export const IconContrast: Story = { - args: { - templates: [ - { - ...MockTemplate, - id: "github-template", - name: "github-template", - display_name: "GitHub", - icon: "/icon/github.svg", - active_version_id: MockTemplateVersion.id, - }, - { - ...MockTemplate, - id: "tasks-template", - name: "tasks-template", - display_name: "Tasks", - icon: "/icon/tasks.svg", - active_version_id: MockTemplateVersion.id, - }, - ], - }, - parameters: { - queries: [ - { - key: templateVersionPresetsKey(MockTemplateVersion.id), - data: [ - { - ...MockPresets[0], - Icon: "/icon/github.svg", - }, - { - ...MockPresets[1], - Icon: "/icon/tasks.svg", - }, - ], - }, - ], - }, -}; - -export const CheckPresetsWhenChangingTemplate: Story = { - args: { - templates: [ - { - ...MockTemplate, - id: "claude-code", - name: "claude-code", - display_name: "Claude Code", - active_version_id: "claude-code-version", - }, - { - ...MockTemplate, - id: "codex", - name: "codex", - display_name: "Codex", - active_version_id: "codex-version", - }, - ], - }, - beforeEach: () => { - spyOn(API, "getTemplateVersionPresets").mockImplementation((versionId) => { - if (versionId === "claude-code-version") { - return Promise.resolve([ - { - ...MockPresets[0], - ID: "claude-code-preset-1", - Name: "Claude Code Dev", - }, - ]); - } - if (versionId === "codex-version") { - return Promise.resolve([ - { - ...MockPresets[0], - ID: "codex-preset-1", - Name: "Codex Dev", - }, - ]); - } - return Promise.resolve([]); - }); - spyOn(API, "getTemplateVersions").mockImplementation((templateId) => { - if (templateId === "claude-code") { - return Promise.resolve([ - { - ...MockTemplateVersion, - id: "claude-code-version", - name: "claude-code-version", - }, - ]); - } - if (templateId === "codex") { - return Promise.resolve([ - { - ...MockTemplateVersion, - id: "codex-version", - name: "codex-version", - }, - ]); - } - return Promise.resolve([]); - }); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - - await step("Presets are initially present", async () => { - const presetSelect = await canvas.findByLabelText(/preset/i); - await userEvent.click(presetSelect); - - const options = await body.findAllByRole("option"); - expect(options).toHaveLength(1); - expect(options[0]).toContainHTML("Claude Code Dev"); - - await userEvent.click(options[0]); - }); - - await step("Switch template", async () => { - const templateSelect = await canvas.findByLabelText(/select template/i); - await userEvent.click(templateSelect); - - const codexTemplateOption = await body.findByRole("option", { - name: /codex/i, - }); - await userEvent.click(codexTemplateOption); - }); - - await step("Presets are present in new template", async () => { - const presetSelect = await canvas.findByLabelText(/preset/i); - await userEvent.click(presetSelect); - - const options = await body.findAllByRole("option"); - expect(options).toHaveLength(1); - expect(options[0]).toContainHTML("Codex Dev"); - - await userEvent.click(options[0]); - }); - - await step("Switch template back", async () => { - const templateSelect = await canvas.findByLabelText(/select template/i); - await userEvent.click(templateSelect); - - const codexTemplateOption = await body.findByRole("option", { - name: /claude code/i, - }); - await userEvent.click(codexTemplateOption); - }); - - await step("Presets are present in original template", async () => { - const presetSelect = await canvas.findByLabelText(/preset/i); - await userEvent.click(presetSelect); - - const options = await body.findAllByRole("option"); - expect(options).toHaveLength(1); - expect(options[0]).toContainHTML("Claude Code Dev"); - }); - }, -}; diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx deleted file mode 100644 index 01bc625b044..00000000000 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ /dev/null @@ -1,542 +0,0 @@ -import { ArrowUpIcon, InfoIcon, RedoIcon, RotateCcwIcon } from "lucide-react"; -import { type FC, useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { useNavigate } from "react-router"; -import TextareaAutosize, { - type TextareaAutosizeProps, -} from "react-textarea-autosize"; -import { toast } from "sonner"; -import { API } from "#/api/api"; -import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { templateVersionPresets } from "#/api/queries/templates"; -import type { - Preset, - Task, - Template, - TemplateVersionExternalAuth, -} from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Badge } from "#/components/Badge/Badge"; -import { Button } from "#/components/Button/Button"; -import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; -import { Kbd, KbdGroup } from "#/components/Kbd/Kbd"; -import { - Select, - SelectContent, - SelectItem, - SelectValue, -} from "#/components/Select/Select"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { useExternalAuth } from "#/hooks/useExternalAuth"; -import { getOSKey } from "#/utils/platform"; -import { PromptSelectTrigger } from "./PromptSelectTrigger"; -import { TemplateVersionSelect } from "./TemplateVersionSelect"; - -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 ( - { - toast.success(`Task "${task.name}" created successfully.`, { - description: `"${task.initial_prompt}"`, - action: { - label: "View task", - onClick: () => navigate(`/tasks/${task.owner_name}/${task.id}`), - }, - }); - }} - /> - ); -}; - -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 -

- - Ask a template administrator to add a Task template to get started. - -
-
- ); -}; - -type CreateTaskMutationFnProps = { - prompt: string; -}; - -type CreateTaskFormProps = { - templates: Template[]; - onSuccess: (task: Task) => void; -}; - -const CreateTaskForm: FC = ({ templates, onSuccess }) => { - const { user, permissions } = useAuthenticated(); - const queryClient = useQueryClient(); - const [prompt, setPrompt] = useState(""); - - // Template - const [selectedTemplateId, setSelectedTemplateId] = useState( - templates[0].id, - ); - const selectedTemplate = templates.find( - (t) => t.id === selectedTemplateId, - ) as Template; - - // Template versions - const [selectedVersionId, setSelectedVersionId] = useState( - selectedTemplate.active_version_id, - ); - useEffect(() => { - setSelectedVersionId(selectedTemplate.active_version_id); - }, [selectedTemplate]); - - // Presets - const { data: presets, isLoading: isLoadingPresets } = useQuery( - templateVersionPresets(selectedVersionId), - ); - const [selectedPresetId, setSelectedPresetId] = useState(); - useEffect(() => { - const defaultPreset = presets?.find((p) => p.Default); - setSelectedPresetId(defaultPreset?.ID ?? presets?.[0]?.ID); - }, [presets]); - // External Auth - const { - externalAuth, - externalAuthError, - isPollingExternalAuth, - isLoadingExternalAuth, - } = useExternalAuth(selectedVersionId, "me"); - const missedExternalAuth = externalAuth?.filter( - (auth) => !auth.optional && !auth.authenticated, - ); - const isMissingExternalAuth = missedExternalAuth - ? missedExternalAuth.length > 0 - : true; - - const createTaskMutation = useMutation({ - mutationFn: async ({ prompt }: CreateTaskMutationFnProps) => { - // Users with updateTemplates permission can select the version to use. - if (permissions.updateTemplates) { - return API.createTask(user.id, { - input: prompt, - template_version_id: selectedVersionId, - template_version_preset_id: selectedPresetId, - }); - } - - // For regular users we want to enforce task creation to always use the latest - // active template version, to avoid issues when the active version changes - // between template load and user action. - return createTaskWithLatestTemplateVersion( - prompt, - user.id, - selectedTemplate.id, - selectedPresetId, - ); - }, - onSuccess: async (task) => { - await queryClient.invalidateQueries({ queryKey: ["tasks"] }); - onSuccess(task); - }, - }); - - const onSubmit = async (e: React.SyntheticEvent) => { - e.preventDefault(); - - try { - await createTaskMutation.mutateAsync({ - prompt, - }); - setPrompt(""); - } catch (error) { - const message = getErrorMessage(error, "Error creating task"); - const detail = getErrorDetail(error) ?? "Please try again"; - toast.error(message, { description: detail }); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Submit form on Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - onSubmit(e); - } - }; - - return ( -
- {externalAuthError && } - -
- - setPrompt(e.target.value)} - isSubmitting={createTaskMutation.isPending} - onKeyDown={handleKeyDown} - /> -
-
-
- - -
- - {permissions.updateTemplates && ( -
- - -
- )} - -
- - {isLoadingPresets ? ( - - ) : ( - presets && - presets.length > 0 && - selectedPresetId && ( - - ) - )} -
-
- -
- {missedExternalAuth && ( - - )} - - - - - - - - {getOSKey()} - + - Enter - - - -
-
-
- - ); -}; - -type ExternalAuthButtonProps = { - versionId: string; - missedExternalAuth: TemplateVersionExternalAuth[]; -}; - -const ExternalAuthButtons: FC = ({ - versionId, - missedExternalAuth, -}) => { - const { startPollingExternalAuth, externalAuthPollingState } = - useExternalAuth(versionId, "me"); - - return missedExternalAuth.map((auth) => { - const isPollingExternalAuth = - externalAuthPollingState[auth.id] === "polling"; - const shouldRetry = externalAuthPollingState[auth.id] === "abandoned"; - - 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); -} - -// TODO: Enforce task creation to always use the latest active template version. -// During task creation, the active version might change between template load -// and user action. Since handling this in the FE cannot guarantee correctness, -// we should move the logic to the BE after the experimental phase. -async function createTaskWithLatestTemplateVersion( - input: string, - userId: string, - templateId: string, - presetId: string | undefined, -): Promise { - const template = await API.getTemplate(templateId); - return API.createTask(userId, { - input, - template_version_id: template.active_version_id, - template_version_preset_id: presetId, - }); -} - -type PromptTextareaProps = TextareaAutosizeProps & { - isSubmitting?: boolean; -}; - -const PromptTextarea: FC = ({ - isSubmitting, - ...props -}) => { - return ( -
- - {isSubmitting && ( -
-
-
- )} -
- ); -}; diff --git a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx deleted file mode 100644 index 3df8198dc63..00000000000 --- a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useState } from "react"; -import { spyOn, userEvent, within } from "storybook/test"; -import { API } from "#/api/api"; -import { MockTemplate, MockTemplateVersion } from "#/testHelpers/entities"; -import { daysAgo } from "#/utils/time"; -import { TemplateVersionSelect } from "./TemplateVersionSelect"; - -const meta: Meta = { - title: "modules/tasks/TaskPrompt/TemplateVersionSelect", - component: TemplateVersionSelect, - args: { - activeVersionId: MockTemplateVersion.id, - templateId: MockTemplate.id, - value: MockTemplateVersion.id, - }, - render: ({ value: defaultValue, ...args }) => { - const [value, setValue] = useState(defaultValue); - return ( - - ); - }, -}; - -export default meta; -type Story = StoryObj; - -const MockVersions = [ - { - ...MockTemplateVersion, - id: "v1.0.0", - name: "v1.0.0", - created_at: daysAgo(3), - }, - { - ...MockTemplateVersion, - id: "v2.0.0", - name: "v2.0.0", - created_at: daysAgo(2), - }, - { - ...MockTemplateVersion, - id: "v3.0.0", - name: "v3.0.0", - created_at: daysAgo(1), - }, -]; - -export const Loading: Story = { - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockImplementation(() => { - return new Promise(() => {}); - }); - }, -}; - -export const Loaded: Story = { - args: { - activeVersionId: MockVersions[2].id, - value: MockVersions[2].id, - }, - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockResolvedValue(MockVersions); - }, -}; - -export const Open: Story = { - args: { - activeVersionId: MockVersions[2].id, - value: MockVersions[2].id, - }, - beforeEach: () => { - spyOn(API, "getTemplateVersions").mockResolvedValue(MockVersions); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const trigger = await canvas.findByRole("combobox"); - await userEvent.click(trigger); - }, -}; diff --git a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx deleted file mode 100644 index dfeb695d25b..00000000000 --- a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { FC } from "react"; -import { useQuery } from "react-query"; -import { templateVersions } from "#/api/queries/templates"; -import { Badge } from "#/components/Badge/Badge"; -import { - Select, - SelectContent, - SelectItem, - SelectValue, -} from "#/components/Select/Select"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { PromptSelectTrigger } from "./PromptSelectTrigger"; - -type TemplateVersionSelectProps = { - value: string; - templateId: string; - activeVersionId: string; - onValueChange: (value: string) => void; -}; - -export const TemplateVersionSelect: FC = ({ - templateId, - activeVersionId, - ...props -}) => { - const { data: versions } = useQuery({ - ...templateVersions(templateId), - select: (versions) => - versions - .filter((v) => !v.archived) - .toSorted((a, b) => { - return ( - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - }), - }); - - if (!versions) { - return ; - } - - return ( - - ); -}; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx deleted file mode 100644 index 9d20b357dd7..00000000000 --- a/site/src/modules/tasks/TaskStatus/TaskStatus.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { TaskStatus } from "./TaskStatus"; - -const meta: Meta = { - title: "modules/tasks/TaskStatus", - component: TaskStatus, -}; - -export default meta; -type Story = StoryObj; - -export const Active: Story = { - args: { - status: "active", - stateMessage: "Task is running smoothly", - }, -}; - -export const Failed: Story = { - args: { - status: "error", - stateMessage: "Task encountered an error", - }, -}; - -export const Initializing: Story = { - args: { - status: "initializing", - stateMessage: "Task is initializing", - }, -}; - -export const Pending: Story = { - args: { - status: "pending", - stateMessage: "Task is pending", - }, -}; - -export const Paused: Story = { - args: { - status: "paused", - stateMessage: "Task is paused", - }, -}; - -export const Unknown: Story = { - args: { - status: "unknown", - stateMessage: "Task status is unknown", - }, -}; diff --git a/site/src/modules/tasks/TaskStatus/TaskStatus.tsx b/site/src/modules/tasks/TaskStatus/TaskStatus.tsx deleted file mode 100644 index bae371e93e7..00000000000 --- a/site/src/modules/tasks/TaskStatus/TaskStatus.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { FC } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; -import { - StatusIndicator, - StatusIndicatorDot, - type StatusIndicatorProps, -} from "#/components/StatusIndicator/StatusIndicator"; - -type TaskStatusProps = { - status: TypesGen.TaskStatus; - stateMessage: string; -}; - -export const taskStatusToStatusIndicatorVariant: Record< - TypesGen.TaskStatus, - StatusIndicatorProps["variant"] -> = { - active: "success", - error: "failed", - initializing: "pending", - pending: "pending", - paused: "inactive", - unknown: "warning", -}; - -export const TaskStatus: FC = ({ status, stateMessage }) => { - return ( - - -
- {status} - - {stateMessage} - -
-
- ); -}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx deleted file mode 100644 index 096ea894f0b..00000000000 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { spyOn, userEvent, within } from "storybook/test"; -import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import { API } from "#/api/api"; -import { - MockDisplayNameTasks, - MockTask, - MockTasks, - MockUserOwner, - mockApiError, -} from "#/testHelpers/entities"; -import { withAuthProvider } from "#/testHelpers/storybook"; -import { MockUsers } from "#/testHelpers/users"; -import { TasksSidebar } from "./TasksSidebar"; - -const meta: Meta = { - title: "modules/tasks/TasksSidebar", - component: TasksSidebar, - decorators: [withAuthProvider], - parameters: { - user: MockUserOwner, - layout: "fullscreen", - permissions: { - viewAllUsers: true, - }, - reactRouter: reactRouterParameters({ - location: { - path: `/tasks/${MockTasks[0].owner_name}/${MockTasks[0].id}`, - pathParams: { - owner_name: MockTasks[0].owner_name, - taskId: MockTasks[0].id, - }, - }, - routing: [ - { path: "/tasks/:username/:taskId", useStoryElement: true }, - { path: "/tasks", element:
Tasks Index Page
}, - ], - }), - }, - beforeEach: () => { - spyOn(API, "getUsers").mockResolvedValue({ - users: MockUsers, - count: MockUsers.length, - }); - }, -}; - -export default meta; -type Story = StoryObj; - -export const Loading: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockReturnValue(new Promise(() => {})); - }, -}; - -export const Failed: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockRejectedValue( - mockApiError({ - message: "Failed to fetch tasks", - }), - ); - }, -}; - -export const Loaded: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - }, -}; - -export const DisplayName: Story = { - parameters: { - queries: [ - { - key: ["tasks", { owner: MockUserOwner.username }], - data: MockDisplayNameTasks, - }, - ], - }, -}; - -export const Empty: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue([]); - }, -}; - -export const Closed: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const button = canvas.getByRole("button", { name: /close sidebar/i }); - await userEvent.click(button); - }, -}; - -export const OpenOptionsMenu: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - await userEvent.click(optionButtons[0]); - }, -}; - -export const OpenDeleteDialog: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - }, - play: async ({ canvasElement, step }) => { - await step("Open menu", async () => { - const canvas = within(canvasElement); - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - await userEvent.click(optionButtons[0]); - }); - await step("Open delete dialog", async () => { - const body = within(canvasElement.ownerDocument.body); - const deleteButton = await body.findByRole("menuitem", { - name: /delete/i, - }); - await userEvent.click(deleteButton); - }); - }, -}; - -export const PauseMenuOpen: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue(MockTasks); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - await userEvent.click(optionButtons[0]); - }, -}; - -export const ResumeMenuOpen: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue([ - { ...MockTask, status: "paused" }, - ...MockTasks.slice(1), - ]); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - await userEvent.click(optionButtons[0]); - }, -}; - -export const MixedStatuses: Story = { - beforeEach: () => { - spyOn(API, "getTasks").mockResolvedValue([ - MockTask, - { - ...MockTask, - id: "paused-task", - name: "paused-task", - display_name: "Paused task", - status: "paused", - }, - { - ...MockTask, - id: "error-task", - name: "error-task", - display_name: "Error task", - status: "error", - }, - { - ...MockTask, - id: "init-task", - name: "init-task", - display_name: "Initializing task", - status: "initializing", - }, - ]); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - // Open menu on the error task (third item) to show both Pause and Resume. - await userEvent.click(optionButtons[2]); - }, -}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx deleted file mode 100644 index 6b78f11067e..00000000000 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { - EditIcon, - EllipsisIcon, - PanelLeftIcon, - PauseIcon, - PlayIcon, - TrashIcon, -} from "lucide-react"; -import { type FC, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, useNavigate, useParams } from "react-router"; -import { toast } from "sonner"; -import { API } from "#/api/api"; -import { getErrorDetail, getErrorMessage } from "#/api/errors"; -import { pauseTask, resumeTask } from "#/api/queries/tasks"; -import type { Task, TasksFilter } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { ProductLogo } from "#/components/Icons/ProductLogo"; -import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; -import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { StatusIndicatorDot } from "#/components/StatusIndicator/StatusIndicator"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { useSearchParamsKey } from "#/hooks/useSearchParamsKey"; -import { cn } from "#/utils/cn"; -import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog"; -import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus"; -import { canPauseTask, canResumeTask, isPauseDisabled } from "../taskActions"; -import { useAITasksEnabled } from "../useAITasksEnabled"; -import { UserCombobox } from "./UserCombobox"; - -export const TasksSidebar: FC = () => { - const { user, permissions } = useAuthenticated(); - const aiTasksEnabled = useAITasksEnabled(); - const ownerParam = useSearchParamsKey({ - key: "owner", - defaultValue: user.username, - }); - - const [isCollapsed, setIsCollapsed] = useState(false); - - if (!aiTasksEnabled) { - return null; - } - - return ( -
-
-
- {!isCollapsed && ( - - - Navigate to tasks - - )} - - - - - - - - {isCollapsed ? "Open" : "Close"} Sidebar - - - -
- - - - - - - - New task - - - - - {!isCollapsed && permissions.viewAllUsers && ( - { - if (username === ownerParam.value) { - ownerParam.setValue(""); - return; - } - ownerParam.setValue(username); - }} - /> - )} -
- - {!isCollapsed && } -
- ); -}; - -type TasksSidebarGroupProps = { - owner: string; -}; - -const TasksSidebarGroup: FC = ({ owner }) => { - const filter: TasksFilter = { owner }; - const tasksQuery = useQuery({ - queryKey: ["tasks", filter], - queryFn: () => API.getTasks(filter), - refetchInterval: 10_000, - }); - - return ( - -
-
Tasks
-
- {tasksQuery.data ? ( - tasksQuery.data.length > 0 ? ( - tasksQuery.data.map((task) => ( - - )) - ) : ( -
- No tasks found -
- ) - ) : tasksQuery.error ? ( -
- {getErrorMessage(tasksQuery.error, "Failed to load tasks")} -
- ) : ( -
- {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
- )} -
-
-
- ); -}; - -type TaskSidebarMenuItemProps = { - task: Task; -}; - -const TaskSidebarMenuItem: FC = ({ task }) => { - const { taskId } = useParams<{ taskId: string }>(); - const isActive = task.id === taskId; - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const pauseMutation = useMutation({ - ...pauseTask(task, queryClient), - onError: (error: unknown) => { - toast.error( - getErrorMessage(error, `Failed to pause task "${task.name}".`), - { - description: getErrorDetail(error), - }, - ); - }, - }); - const resumeMutation = useMutation({ - ...resumeTask(task, queryClient), - onError: (error: unknown) => { - toast.error( - getErrorMessage(error, `Failed to resume task "${task.name}".`), - { - description: getErrorDetail(error), - }, - ); - }, - }); - - const showPause = canPauseTask(task.status) && task.workspace_id; - const pauseDisabled = isPauseDisabled(task.status); - const showResume = canResumeTask(task.status) && task.workspace_id; - - return ( - <> - - - - - - {showPause && ( - { - e.stopPropagation(); - pauseMutation.mutate(); - }} - > - - - - Pause - - )} - {showResume && ( - { - e.stopPropagation(); - resumeMutation.mutate(); - }} - > - - - - Resume - - )} - {(showPause || showResume) && } - { - e.stopPropagation(); - setIsDeleteDialogOpen(true); - }} - > - - Delete… - - - - - - - - { - setIsDeleteDialogOpen(false); - }} - onSuccess={() => { - if (isActive) { - navigate("/tasks"); - } - }} - /> - - ); -}; - -const TaskSidebarMenuItemStatus: FC<{ task: Task }> = ({ task }) => { - return ( - - - - - - - {task.status} - - - - ); -}; diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx deleted file mode 100644 index 5628ccc7af3..00000000000 --- a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useState } from "react"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; -import { API } from "#/api/api"; -import { MockUserOwner } from "#/testHelpers/entities"; -import { withAuthProvider } from "#/testHelpers/storybook"; -import { MockUsers } from "#/testHelpers/users"; -import { UserCombobox } from "./UserCombobox"; - -const meta: Meta = { - title: "modules/tasks/TasksSidebar/UserCombobox", - component: UserCombobox, - decorators: [withAuthProvider], - parameters: { - user: MockUserOwner, - }, - render: (args) => { - const [value, setValue] = useState(""); - return ; - }, -}; - -export default meta; -type Story = StoryObj; - -export const Loading: Story = { - beforeEach: () => { - spyOn(API, "getUsers").mockImplementation(() => { - return new Promise(() => { - // never resolves - }); - }); - }, -}; - -export const Loaded: Story = { - beforeEach: () => { - spyOn(API, "getUsers").mockResolvedValue({ - count: MockUsers.length, - users: MockUsers, - }); - }, -}; - -export const SelectUser: Story = { - beforeEach: () => { - spyOn(API, "getUsers").mockResolvedValue({ - count: MockUsers.length, - users: MockUsers, - }); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - const user = userEvent.setup(); - - await step("open combobox", async () => { - const trigger = await canvas.findByText(/all users/i, { exact: false }); - await user.click(trigger); - }); - - await step("select user", async () => { - const option = await body.findByText(MockUsers[1].name!, { - exact: false, - }); - await user.click(option); - }); - }, -}; - -export const SearchUser: Story = { - beforeEach: () => { - spyOn(API, "getUsers").mockImplementation((options) => { - let users = MockUsers; - - if (options.q?.includes("Ivan")) { - users = users.filter((u) => u.name?.includes("Ivan")); - } - - return Promise.resolve({ - count: MockUsers.length, - users: MockUsers, - }); - }); - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); - const user = userEvent.setup(); - - await step("open combobox", async () => { - const trigger = await canvas.findByText(/all users/i, { exact: false }); - await user.click(trigger); - }); - - await step("search user", async () => { - const searchInput = await body.findByLabelText("Search user"); - await user.type(searchInput, "Ivan"); - await waitFor(() => { - expect(API.getUsers).toHaveBeenCalledTimes(2); - }); - }); - }, -}; diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx deleted file mode 100644 index 55f0e0b5d6b..00000000000 --- a/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; -import { type FC, useState } from "react"; -import { keepPreviousData, useQuery } from "react-query"; -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 { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "#/components/Command/Command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; -import { useDebouncedValue } from "#/hooks/debounce"; -import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { cn } from "#/utils/cn"; - -type UserOption = { - label: string; - /** - * The username of the user. - */ - value: string; - avatarUrl?: string; -}; - -type UserComboboxProps = { - value: string; - onValueChange: (value: string) => void; -}; - -export const UserCombobox: FC = ({ - value, - onValueChange, -}) => { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebouncedValue(search, 250); - // By default, this combobox filters by the authenticated user. - // To ensure consistent behavior, we must always include the - // authenticated user in the list of options. - const { user } = useAuthenticated(); - const { data: options, isFetched } = useQuery({ - ...users({ q: debouncedSearch }), - select: (res) => mapUsersToOptions(res.users, user, value), - placeholderData: keepPreviousData, - }); - const selectedOption = options?.find((o) => o.value === value); - - return ( - - - - - - - - - No users found. - - {options?.map((option) => ( - { - onValueChange(option.value); - setOpen(false); - }} - > - - - - ))} - - - - - - ); -}; - -type UserItemProps = { - option: UserOption; - className?: string; -}; - -const UserItem: FC = ({ option, className }) => { - return ( -
- - {option.label} -
- ); -}; - -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/modules/tasks/taskActions.ts b/site/src/modules/tasks/taskActions.ts deleted file mode 100644 index eb4a2265224..00000000000 --- a/site/src/modules/tasks/taskActions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TaskStatus } from "#/api/typesGenerated"; - -/** - * Task statuses that allow pausing. - */ -const PAUSABLE_STATUSES: TaskStatus[] = [ - "active", - "initializing", - "pending", - "error", - "unknown", -]; - -/** - * Task statuses where the pause button should be disabled (in transition). - */ -const PAUSE_DISABLED_STATUSES: TaskStatus[] = ["pending", "initializing"]; - -/** - * Task statuses that allow resuming. - */ -const RESUMABLE_STATUSES: TaskStatus[] = ["paused", "error", "unknown"]; - -/** - * Checks if a task can be paused based on its status. - */ -export function canPauseTask(status: TaskStatus): boolean { - return PAUSABLE_STATUSES.includes(status); -} - -/** - * Checks if the pause action should be disabled for a task status. - */ -export function isPauseDisabled(status: TaskStatus): boolean { - return PAUSE_DISABLED_STATUSES.includes(status); -} - -/** - * Checks if a task can be resumed based on its status. - */ -export function canResumeTask(status: TaskStatus): boolean { - return RESUMABLE_STATUSES.includes(status); -} diff --git a/site/src/modules/tasks/useAITasksEnabled.ts b/site/src/modules/tasks/useAITasksEnabled.ts deleted file mode 100644 index 45e5b3d8305..00000000000 --- a/site/src/modules/tasks/useAITasksEnabled.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - defaultMetadataManager, - useEmbeddedMetadata, -} from "#/hooks/useEmbeddedMetadata"; - -// The `ai-tasks-enabled` metadata mirrors the CODER_ENABLE_AI_TASKS deployment -// value and is the master switch for every Coder Tasks surface. The Go server -// always embeds it, so a missing value means the frontend is being served by -// Vite, Storybook, or a test runner. Those environments fall back to enabled so -// local development and stories keep rendering Tasks. -function isEnabled(value: boolean | undefined): boolean { - return value ?? true; -} - -export function useAITasksEnabled(): boolean { - const { metadata } = useEmbeddedMetadata(); - return isEnabled(metadata["ai-tasks-enabled"].value); -} - -// Module-scope equivalent of `useAITasksEnabled` for code that runs outside of -// React, such as route registration in `router.tsx`. -export function aiTasksEnabled(): boolean { - return isEnabled( - defaultMetadataManager.getMetadata()["ai-tasks-enabled"].value, - ); -} diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx index c5847671993..1424e16c2f5 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx @@ -1,10 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import { - MockFailedWorkspace, - MockTaskWorkspace, - MockWorkspace, -} from "#/testHelpers/entities"; +import { MockFailedWorkspace, MockWorkspace } from "#/testHelpers/entities"; import { daysAgo } from "#/utils/time"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; @@ -75,21 +71,6 @@ export const UnhealthyAdminView: Story = { }, }; -export const WithTask: Story = { - args: { - workspace: MockTaskWorkspace, - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - await expect( - body.getByText("This workspace is related to a task"), - ).toBeInTheDocument(); - await expect( - body.getByRole("link", { name: /this task/i }), - ).toBeInTheDocument(); - }, -}; - export const FilledWrong: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index a752c9a2132..62443ffb658 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -9,7 +9,6 @@ import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { Link } from "#/components/Link/Link"; -import { useAITasksEnabled } from "#/modules/tasks/useAITasksEnabled"; import { docs } from "#/utils/docs"; const warnBoxClassName = @@ -33,7 +32,6 @@ export const WorkspaceDeleteDialog: FC = ({ const confirmId = useId(); const errorId = `${confirmId}-error`; const orphanId = `${confirmId}-orphan`; - const aiTasksEnabled = useAITasksEnabled(); const [userConfirmationText, setUserConfirmationText] = useState(""); const [orphanWorkspace, setOrphanWorkspace] = @@ -64,8 +62,6 @@ export const WorkspaceDeleteDialog: FC = ({ (workspace.latest_build.status === "failed" || workspace.latest_build.status === "canceled"); - const hasTask = aiTasksEnabled && Boolean(workspace.task_id); - return ( = ({ )} - {hasTask && ( -
-
-

- This workspace is related to a task -

- - Deleting this workspace will also delete{" "} - - this task - - . - -
-
- )} - {canOrphan && (