diff --git a/site/src/components/Avatar/AvatarData.tsx b/site/src/components/Avatar/AvatarData.tsx index 702ecfc69a5..fd4ec4a1487 100644 --- a/site/src/components/Avatar/AvatarData.tsx +++ b/site/src/components/Avatar/AvatarData.tsx @@ -35,10 +35,10 @@ export const AvatarData: FC = ({ } return ( -
+
{avatar} -
+
{title} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx index 776fb9d004f..4e828a99bf7 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx @@ -1,11 +1,5 @@ import { useFormik } from "formik"; -import { - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, - InfoIcon, - PencilIcon, -} from "lucide-react"; +import { ChevronLeftIcon, InfoIcon, PencilIcon } from "lucide-react"; import { type FC, useState } from "react"; import * as Yup from "yup"; import type * as TypesGen from "#/api/typesGenerated"; @@ -41,6 +35,13 @@ import { } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { getFormHelpers } from "#/utils/formUtils"; +import { + CollapsibleSection, + CollapsibleSectionContent, + CollapsibleSectionDescription, + CollapsibleSectionHeader, + CollapsibleSectionTitle, +} from "../CollapsibleSection"; import type { ProviderState } from "./ChatModelAdminPanel"; import { GeneralModelConfigFields, @@ -116,9 +117,6 @@ export const ModelForm: FC = ({ }) => { const isEditing = Boolean(editingModel); const isDefaultModel = isEditing && editingModel?.is_default === true; - const [showAdvanced, setShowAdvanced] = useState(false); - const [showPricing, setShowPricing] = useState(false); - const [showProviderConfig, setShowProviderConfig] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const canManageModels = Boolean( @@ -488,29 +486,18 @@ export const ModelForm: FC = ({
{/* Usage Tracking */} -
- - {showPricing && ( -
+ + + + Cost Tracking + + + Set per-token pricing so Coder can track costs and enforce + spending limits. + + + +
= ({ disabled={isSaving} />
- )} -
- + + {/* Provider Configuration */} -
- - {showProviderConfig && ( -
= ({ disabled={isSaving} />
- )} -
- + + {/* Advanced */} -
- - {showAdvanced && ( -
+ + + + Advanced + + + Low-level parameters like temperature and penalties. Rarely need + changing. + + + +
= ({ )}
- )} -
+ +
+ {" "}
{isEditing && editingModel && onDeleteModel ? ( diff --git a/site/src/pages/AgentsPage/components/CollapsibleSection.stories.tsx b/site/src/pages/AgentsPage/components/CollapsibleSection.stories.tsx new file mode 100644 index 00000000000..57931283a8d --- /dev/null +++ b/site/src/pages/AgentsPage/components/CollapsibleSection.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { AdminBadge } from "./AdminBadge"; +import { + CollapsibleSection, + CollapsibleSectionContent, + CollapsibleSectionDescription, + CollapsibleSectionHeader, + CollapsibleSectionTitle, +} from "./CollapsibleSection"; + +const meta: Meta = { + title: "pages/AgentsPage/CollapsibleSection", + component: CollapsibleSection, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const Placeholder = () => ( +

Placeholder content

+); + +export const DefaultOpen: Story = { + render: () => ( + + +
+ Default spend limit + +
+ + The deployment-wide spending cap. + +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + + const header = canvas.getByRole("button", { + name: /Default spend limit/i, + }); + expect(header).toHaveAttribute("aria-expanded", "true"); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + }, +}; + +export const Collapsed: Story = { + render: () => ( + + +
+ Default spend limit + +
+ + The deployment-wide spending cap. + +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + + const header = canvas.getByRole("button", { + name: /Default spend limit/i, + }); + expect(header).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + }, +}; + +export const NoBadge: Story = { + render: () => ( + + + Group limits + + Override defaults for groups. + + + + + + + ), +}; + +export const InlineVariant: Story = { + render: () => ( + + + Cost Tracking + + Set per-token pricing so Coder can track costs and enforce spending + limits. + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + + const header = canvas.getByRole("button", { + name: /Cost Tracking/i, + }); + expect(header).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + + await userEvent.click(header); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + }, +}; + +export const KeyboardToggle: Story = { + render: () => ( + + + Keyboard section + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const header = canvas.getByRole("button", { + name: /Keyboard section/i, + }); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + + header.focus(); + + await userEvent.keyboard("{Enter}"); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + + await userEvent.keyboard("{Enter}"); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + + await userEvent.keyboard(" "); + expect(header).toHaveAttribute("aria-expanded", "false"); + expect(canvas.queryByText("Placeholder content")).not.toBeInTheDocument(); + + await userEvent.keyboard(" "); + expect(header).toHaveAttribute("aria-expanded", "true"); + expect(canvas.getByText("Placeholder content")).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/CollapsibleSection.tsx b/site/src/pages/AgentsPage/components/CollapsibleSection.tsx new file mode 100644 index 00000000000..6754eee9917 --- /dev/null +++ b/site/src/pages/AgentsPage/components/CollapsibleSection.tsx @@ -0,0 +1,211 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { ChevronDownIcon } from "lucide-react"; +import { + type ComponentProps, + createContext, + type FC, + type ReactNode, + useContext, + useState, +} from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { cn } from "#/utils/cn"; + +// --------------------------------------------------------------------------- +// Variant styles +// --------------------------------------------------------------------------- + +const wrapperStyles = cva("", { + variants: { + variant: { + card: "rounded-lg border border-solid border-border-default", + inline: "border-0 border-t border-solid border-border-default pt-4", + }, + }, + defaultVariants: { variant: "card" }, +}); + +const triggerStyles = cva( + "flex w-full cursor-pointer items-start justify-between gap-4 border-0 bg-transparent text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link", + { + variants: { + variant: { + card: "rounded-lg px-6 py-5", + inline: "rounded-md p-0", + }, + }, + defaultVariants: { variant: "card" }, + }, +); + +const titleStyles = cva("m-0 text-content-primary", { + variants: { + variant: { + card: "text-lg font-semibold", + inline: "text-sm font-medium", + }, + }, + defaultVariants: { variant: "card" }, +}); + +const descriptionStyles = cva("m-0 text-content-secondary", { + variants: { + variant: { + card: "mt-1 text-sm", + inline: "text-xs", + }, + }, + defaultVariants: { variant: "card" }, +}); + +const contentStyles = cva("", { + variants: { + variant: { + card: "border-0 border-t border-solid border-border-default px-6 pb-5 pt-4", + inline: "pt-3", + }, + }, + defaultVariants: { variant: "card" }, +}); + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +type Variant = "card" | "inline"; + +interface CollapsibleSectionContextValue { + variant: Variant; + open: boolean; +} + +const CollapsibleSectionContext = createContext( + { + variant: "card", + open: true, + }, +); + +const useCollapsibleSection = () => useContext(CollapsibleSectionContext); + +// --------------------------------------------------------------------------- +// Root +// --------------------------------------------------------------------------- + +interface CollapsibleSectionProps extends VariantProps { + defaultOpen?: boolean; + /** Controlled open state. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + children: ReactNode; +} + +export const CollapsibleSection: FC = ({ + defaultOpen, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + variant = "card", + children, +}) => { + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? true); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : uncontrolledOpen; + const onOpenChange = isControlled + ? controlledOnOpenChange + : setUncontrolledOpen; + + return ( + + +
{children}
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Header (trigger) +// --------------------------------------------------------------------------- + +interface CollapsibleSectionHeaderProps { + children: ReactNode; +} + +export const CollapsibleSectionHeader: FC = ({ + children, +}) => { + const { variant, open } = useCollapsibleSection(); + + return ( + +
{children}
+ +
+ ); +}; + +// --------------------------------------------------------------------------- +// Title — renders the heading element at whatever level the consumer picks. +// --------------------------------------------------------------------------- + +type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + +interface CollapsibleSectionTitleProps extends ComponentProps { + as?: HeadingTag; +} + +export const CollapsibleSectionTitle: FC = ({ + as: Component = "h2", + className, + ...props +}) => { + const { variant } = useCollapsibleSection(); + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Description +// --------------------------------------------------------------------------- + +export const CollapsibleSectionDescription: FC> = ({ + className, + ...props +}) => { + const { variant } = useCollapsibleSection(); + return ( +

+ ); +}; + +// --------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------- + +interface CollapsibleSectionContentProps { + children: ReactNode; +} + +export const CollapsibleSectionContent: FC = ({ + children, +}) => { + const { variant } = useCollapsibleSection(); + + return ( + +

{children}
+ + ); +}; diff --git a/site/src/pages/AgentsPage/components/SectionHeader.tsx b/site/src/pages/AgentsPage/components/SectionHeader.tsx index 9f462eddb7c..bbbf618d34d 100644 --- a/site/src/pages/AgentsPage/components/SectionHeader.tsx +++ b/site/src/pages/AgentsPage/components/SectionHeader.tsx @@ -15,8 +15,8 @@ export const SectionHeader: FC = ({ }) => ( <>
-
-
+
+

{label}