diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx index 2eb0ae1970..53533f5dbc 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/UserSidebarFooter.tsx @@ -16,11 +16,13 @@ export const UserSidebarFooter: FC = () => { return (
-
+ {/* This footer is resizable, so child sizing must follow its container width instead of the viewport. */} +
diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 6f024a07fb..dbdb6c4bf4 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useQueryClient } from "react-query"; import { expect, userEvent, within } from "storybook/test"; @@ -57,11 +57,22 @@ const withUnavailableWorkspaceCount = (Story: FC) => { return ; }; -const withUsageIndicatorFrame = (Story: FC) => ( -
- -
-); +// Mirrors the sidebar footer wrapper: a fixed-width container with +// container-type set so the trigger inside reacts to the wrapper's width +// instead of the viewport's. +const withUsageIndicatorFrame = ( + widthClassName = "w-[320px]", + frameTestId?: string, +): Decorator => { + return (Story) => ( +
+ +
+ ); +}; const openUsageMenu = async (canvasElement: HTMLElement) => { const canvas = within(canvasElement); @@ -104,7 +115,7 @@ const meta: Meta = { decorators: [ withAuthProvider, withDashboardProvider, - withUsageIndicatorFrame, + withUsageIndicatorFrame(), ], parameters: { user: MockUserOwner, @@ -167,6 +178,9 @@ export const WorkspaceQuotaOnly: Story = { withWorkspaceCount(3), ], play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("30/100")).toBeVisible(); await openUsageMenu(canvasElement); }, }; @@ -181,12 +195,44 @@ export const UsageAndWorkspaceQuota: Story = { const canvas = within(canvasElement); const progressBars = canvas.getAllByRole("progressbar"); - expect(canvas.getByText("Usage")).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); + expect(canvas.getByText("$12.50")).toBeVisible(); + expect(canvas.getByText("30/100")).toBeVisible(); expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ "Monthly spend usage", "Workspace quota usage", ]); - await userEvent.click(canvas.getByRole("button")); + await openUsageMenu(canvasElement); + }, +}; + +// The tiny story covers the responsive edge case where the trigger keeps +// the bars visible and drops the numeric details. +const expectTriggerContentFits = (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + const frame = canvas.getByTestId("usage-indicator-frame"); + + expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); + expect(frame.scrollWidth).toBeLessThanOrEqual(frame.clientWidth); +}; + +const expectTriggerDetailsHidden = (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("$12.50")).not.toBeVisible(); + expect(canvas.getByText("30/100")).not.toBeVisible(); +}; + +export const TriggerTiny: Story = { + decorators: [ + withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"), + withUsageLimitStatus(limitedUsageStatus()), + withWorkspaceQuota(defaultWorkspaceQuota), + withWorkspaceCount(3), + ], + play: ({ canvasElement }) => { + expectTriggerContentFits(canvasElement); + expectTriggerDetailsHidden(canvasElement); }, }; @@ -220,7 +266,7 @@ export const WorkspaceQuotaWithoutBudget: Story = { name: "Workspace quota usage", }); - expect(canvas.getByText("Workspace quota")).toBeInTheDocument(); + expect(canvas.getByText("20")).toBeInTheDocument(); expect(progressbar).toHaveAttribute("aria-valuenow", "100"); await openUsageMenu(canvasElement); diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 2a07878f12..00516e6260 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { InfoIcon } from "lucide-react"; +import { CoinsIcon, InfoIcon, ServerIcon } from "lucide-react"; import { type FC, Fragment, type ReactNode } from "react"; import { useQuery } from "react-query"; import { Link } from "react-router"; @@ -36,6 +36,8 @@ type UsageSectionData = { progressLabel: string; percent: number; detail: ReactNode; + icon: ReactNode; + summaryValue: string; secondaryDetail?: ReactNode; tooltip?: ReactNode; severity?: UsageSeverity; @@ -79,6 +81,8 @@ export const UsageIndicator: FC = () => { progressLabel: `${periodLabel} spend usage`, percent: getPercent(currentSpend, spendLimit), severity: getSeverity(currentSpend, spendLimit), + icon: , + summaryValue: formatCostMicros(currentSpend), detail: ( <> {formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "} @@ -112,6 +116,11 @@ export const UsageIndicator: FC = () => { progressLabel: "Workspace quota usage", percent: getPercent(creditsConsumed, quota.budget), severity: getSeverity(creditsConsumed, quota.budget), + icon: , + summaryValue: + quota.budget > 0 + ? `${formatNumber(creditsConsumed)}/${formatNumber(quota.budget)}` + : formatNumber(creditsConsumed), detail: quotaDetail, tooltip: "Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.", @@ -128,7 +137,7 @@ export const UsageIndicator: FC = () => { const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ sections, }) => { - const triggerLabel = + const triggerAriaLabel = sections.length > 1 ? "Usage" : (sections[0]?.title ?? "Usage"); return ( @@ -136,11 +145,9 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ @@ -166,19 +173,35 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({ sections, }) => { - const size = sections.length > 1 ? "compact" : "default"; - return ( -
+
{sections.map((section) => ( - +
+ + + + {section.summaryValue} + +
))}
);