diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx index ea753745f1e2e..281d6c2a95cee 100644 --- a/site/src/components/DropdownMenu/DropdownMenu.tsx +++ b/site/src/components/DropdownMenu/DropdownMenu.tsx @@ -10,7 +10,17 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { Check } from "lucide-react"; import { cn } from "utils/cn"; -export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenu = ({ + children, + modal = false, + ...props +}: React.ComponentPropsWithoutRef) => { + return ( + + {children} + + ); +}; export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; diff --git a/site/src/index.css b/site/src/index.css index a1e9a15043f3c..3bdecbb331b32 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -108,22 +108,26 @@ @apply border-border; } - /* - By default, Radix adds a margin to the `body` element when a dropdown is displayed, - causing some shifting when the dropdown has a full-width size, as is the case with the mobile menu. - To prevent this, we need to apply the styles below. - + /* Prevent layout shift when modals open by maintaining scrollbar width. + scrollbar-gutter: stable reserves space for the scrollbar so Radix's + scroll-bar compensation (margin-right/padding-right) is unnecessary + and causes double spacing. We zero it out with !important to win + over Radix's injected !important styles. The @supports guard ensures + we only do this on browsers that actually support scrollbar-gutter. + There’s a related issue on GitHub: Radix UI Primitives Issue #3251 https://github.com/radix-ui/primitives/issues/3251 - */ - html body[data-scroll-locked] { - --removed-body-scroll-bar-size: 0; - margin-right: 0; - } + */ + @supports (scrollbar-gutter: stable) { + html { + scrollbar-gutter: stable; + } - /* Prevent layout shift when modals open by maintaining scrollbar width */ - html { - scrollbar-gutter: stable; + html body[data-scroll-locked] { + --removed-body-scroll-bar-size: 0 !important; + margin-right: 0 !important; + overflow-y: scroll !important; + } } /* diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index e81a4db6eb35b..6637ce910eaf0 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -61,7 +61,7 @@ export const MobileMenu: FC = ({ const hasSomePermission = Object.values(permissions).some((p) => p); return ( - + {open && (
)} diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 57eea64ef3119..f06bd54d0353e 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -236,7 +236,7 @@ export const ChangeTemplate: Story = { await step("Change template", async () => { const templateSelect = await canvas.findByLabelText(/select template/i); await userEvent.click(templateSelect); - const templateOption = await body.findByRole("option", { + const templateOption = await body.findByRole("menuitemradio", { name: /codex/i, }); await userEvent.click(templateOption); @@ -277,7 +277,7 @@ export const SelectTemplateVersion: Story = { const body = within(canvasElement.ownerDocument.body); const versionSelect = await canvas.findByLabelText(/template version/i); await userEvent.click(versionSelect); - const versionOption = await body.findByRole("option", { + const versionOption = await body.findByRole("menuitemradio", { name: /v2.0.0/i, }); await userEvent.click(versionOption); @@ -459,7 +459,7 @@ export const CheckExternalAuthOnChangingVersions: Story = { const body = within(canvasElement.ownerDocument.body); const versionSelect = await canvas.findByLabelText(/template version/i); await userEvent.click(versionSelect); - const versionOption = await body.findByRole("option", { + const versionOption = await body.findByRole("menuitemradio", { name: /no external/i, }); await userEvent.click(versionOption); @@ -566,7 +566,7 @@ export const CheckPresetsWhenChangingTemplate: Story = { const presetSelect = await canvas.findByLabelText(/preset/i); await userEvent.click(presetSelect); - const options = await body.findAllByRole("option"); + const options = await body.findAllByRole("menuitemradio"); expect(options).toHaveLength(1); expect(options[0]).toContainHTML("Claude Code Dev"); @@ -577,17 +577,23 @@ export const CheckPresetsWhenChangingTemplate: Story = { const templateSelect = await canvas.findByLabelText(/select template/i); await userEvent.click(templateSelect); - const codexTemplateOption = await body.findByRole("option", { + const codexTemplateOption = await body.findByRole("menuitemradio", { name: /codex/i, }); await userEvent.click(codexTemplateOption); }); await step("Presets are present in new template", async () => { + // Wait for the template dropdown's close animation to finish + // so its menuitemradio items are removed from the DOM. + await waitFor(() => { + expect(body.queryAllByRole("menuitemradio")).toHaveLength(0); + }); + const presetSelect = await canvas.findByLabelText(/preset/i); await userEvent.click(presetSelect); - const options = await body.findAllByRole("option"); + const options = await body.findAllByRole("menuitemradio"); expect(options).toHaveLength(1); expect(options[0]).toContainHTML("Codex Dev"); @@ -598,17 +604,23 @@ export const CheckPresetsWhenChangingTemplate: Story = { const templateSelect = await canvas.findByLabelText(/select template/i); await userEvent.click(templateSelect); - const codexTemplateOption = await body.findByRole("option", { + const codexTemplateOption = await body.findByRole("menuitemradio", { name: /claude code/i, }); await userEvent.click(codexTemplateOption); }); await step("Presets are present in original template", async () => { + // Wait for the template dropdown's close animation to finish + // so its menuitemradio items are removed from the DOM. + await waitFor(() => { + expect(body.queryAllByRole("menuitemradio")).toHaveLength(0); + }); + const presetSelect = await canvas.findByLabelText(/preset/i); await userEvent.click(presetSelect); - const options = await body.findAllByRole("option"); + const options = await body.findAllByRole("menuitemradio"); 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 index 569dac5c4373a..435011bf59902 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -10,26 +10,34 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Kbd, KbdGroup } from "components/Kbd/Kbd"; import { Link } from "components/Link/Link"; -import { - Select, - SelectContent, - SelectItem, - 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 { ArrowUpIcon, InfoIcon, RedoIcon, RotateCcwIcon } from "lucide-react"; +import { + ArrowUpIcon, + ChevronDownIcon, + InfoIcon, + RedoIcon, + RotateCcwIcon, +} from "lucide-react"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import TextareaAutosize, { @@ -37,7 +45,6 @@ import TextareaAutosize, { } from "react-textarea-autosize"; import { docs } from "utils/docs"; import { getOSKey } from "utils/platform"; -import { PromptSelectTrigger } from "./PromptSelectTrigger"; import { TemplateVersionSelect } from "./TemplateVersionSelect"; type TaskPromptProps = { @@ -160,6 +167,7 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { templateVersionPresets(selectedVersionId), ); const [selectedPresetId, setSelectedPresetId] = useState(); + const selectedPreset = presets?.find((p) => p.ID === selectedPresetId); useEffect(() => { const defaultPreset = presets?.find((p) => p.Default); setSelectedPresetId(defaultPreset?.ID ?? presets?.[0]?.ID); @@ -255,41 +263,70 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { - + + + + + Template + + + + { + setSelectedTemplateId(value); + if (value !== selectedTemplateId) { + setSelectedPresetId(undefined); + } + }} + > + {templates.map((template) => { + return ( + +
+ {template.icon && ( + {template.name} + )} + + {template.display_name || template.name} + +
+
+ ); + })} +
+
+
{permissions.updateTemplates && ( @@ -316,60 +353,79 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { presets && presets.length > 0 && selectedPresetId && ( - + + + + Preset + + + + + {presets?.toSorted(sortByDefault).map((preset) => ( + +
+ {preset.Icon && ( + {preset.Name} + )} + + {preset.Name} + + {preset.Default && ( + + Default + + )} + {preset.Description && ( + + + + + + {preset.Description} + + + )} +
+
+ ))} +
+
+
) )} diff --git a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx index 8c37edc301fa7..98c8e869fb23e 100644 --- a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.stories.tsx @@ -74,7 +74,7 @@ export const Open: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const trigger = await canvas.findByRole("combobox"); + const trigger = await canvas.findByRole("button"); await userEvent.click(trigger); }, }; diff --git a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx index 6575aff67d5a1..a8c4015a61f39 100644 --- a/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx +++ b/site/src/modules/tasks/TaskPrompt/TemplateVersionSelect.tsx @@ -1,15 +1,22 @@ import { templateVersions } from "api/queries/templates"; import { Badge } from "components/Badge/Badge"; import { - Select, - SelectContent, - SelectItem, - SelectValue, -} from "components/Select/Select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "components/DropdownMenu/DropdownMenu"; import { Skeleton } from "components/Skeleton/Skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { ChevronDownIcon } from "lucide-react"; import type { FC } from "react"; import { useQuery } from "react-query"; -import { PromptSelectTrigger } from "./PromptSelectTrigger"; type TemplateVersionSelectProps = { value: string; @@ -21,7 +28,8 @@ type TemplateVersionSelectProps = { export const TemplateVersionSelect: FC = ({ templateId, activeVersionId, - ...props + value, + onValueChange, }) => { const { data: versions } = useQuery({ ...templateVersions(templateId), @@ -39,27 +47,49 @@ export const TemplateVersionSelect: FC = ({ return ; } + const selectedVersion = versions.find((v) => v.id === value); + return ( - + + + + Template version + + + + + {versions.map((version) => { + return ( + + + {version.name} + {activeVersionId === version.id && ( + + Active + + )} + + + ); + })} + + +
); }; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 4110aadce5f91..b543c078676c6 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -107,8 +107,9 @@ export const LoadingTasks: Story = { await step("Select the first AI template", async () => { const form = await canvas.findByRole("form"); - const combobox = await within(form).findByRole("combobox"); - expect(combobox).toHaveTextContent(MockTemplate.display_name); + const templateSelect = + await within(form).findByLabelText(/select template/i); + expect(templateSelect).toHaveTextContent(MockTemplate.display_name); }); }, };