From 7b2079e11942f8ff6c7655b625fd177361bd9e3f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 9 Aug 2026 13:10:09 +0000 Subject: [PATCH 01/20] feat(site): show total agent hours consumption on the licenses page --- .../LicensesSettingsPageView.stories.tsx | 32 ++++ .../LicensesSettingsPageView.tsx | 3 + .../TotalAgentHoursCard.stories.tsx | 176 ++++++++++++++++++ .../TotalAgentHoursCard.tsx | 126 +++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx index afcd0065e34..9668b06f0b4 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.stories.tsx @@ -27,6 +27,10 @@ const meta: Meta = { enabled: false, entitlement: "not_entitled", } satisfies Feature, + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, }, }; @@ -71,3 +75,31 @@ export const ActiveAIGovernanceAddOnUsage: Story = { await expect(canvas.getByText("1,000")).toBeInTheDocument(); }, }; + +/** The Total agent hours panel renders full width, directly above Agent Workspace Builds. */ +export const TotalAgentHoursUsage: Story = { + args: { + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 2000, + soft_limit: 1700, + actual: 435, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const agentHoursHeading = canvas.getByRole("heading", { + name: "Total agent hours", + }); + await expect(canvas.getByText("435")).toBeInTheDocument(); + await expect(canvas.getByText("2,000")).toBeInTheDocument(); + const managedAgentsSection = canvas.getByText( + "Agent Workspace Builds Disabled", + ); + await expect( + agentHoursHeading.compareDocumentPosition(managedAgentsSection) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 5a46201f2e6..b2c99b41950 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -25,6 +25,7 @@ import { LicenseCard } from "./LicenseCard"; import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart"; import { ManagedAgentsConsumption } from "./ManagedAgentsConsumption"; import { SeatUsageBarCard } from "./SeatUsageBarCard"; +import { TotalAgentHoursCard } from "./TotalAgentHoursCard"; type Props = { showConfetti: boolean; @@ -192,6 +193,8 @@ const LicensesSettingsPageView: FC = ({ /> + + diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx new file mode 100644 index 00000000000..5efa5548301 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import type { Feature } from "#/api/typesGenerated"; +import { TotalAgentHoursCard } from "./TotalAgentHoursCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard", + component: TotalAgentHoursCard, + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 400, + } satisfies Feature, + }, +}; + +export default meta; +type Story = StoryObj; + +const hoverInfoIcon = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + await userEvent.hover( + canvas.getByRole("button", { name: "Total agent hours information" }), + ); + return within(canvasElement.ownerDocument.body); +}; + +// Tooltip text appears in both the tooltip popover and its hidden +// accessibility duplicate, so assertions match all occurrences. +const expectTooltipText = async ( + body: ReturnType, + text: RegExp, +) => { + const matches = await body.findAllByText(text); + expect(matches.length).toBeGreaterThanOrEqual(1); +}; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 85%/, + ); + }, +}; + +export const NoSoftLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + actual: 400, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\.$/, + ); + }, +}; + +export const ReachedSoftLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 850, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("850")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 85% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./, + ); + }, +}; + +export const ReachedAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 1000, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText("1,000")).toHaveLength(2); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 100% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +export const OverAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + actual: 1200, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + }, +}; + +export const MissingActual: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("\u2014")).toBeInTheDocument(); + }, +}; + +export const Disabled: Story = { + args: { + feature: { + enabled: false, + entitlement: "not_entitled", + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole("heading", { name: "Total agent hours" }), + ).not.toBeInTheDocument(); + }, +}; + +export const ErrorInvalidLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + actual: 100, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("Invalid license usage limits"), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx new file mode 100644 index 00000000000..fab0036945d --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -0,0 +1,126 @@ +import { InfoIcon } from "lucide-react"; +import type { FC } from "react"; +import type { Feature } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +type TotalAgentHoursCardProps = { + feature?: Feature; +}; + +export const TotalAgentHoursCard: FC = ({ + feature, +}) => { + // A zero-hour allocation arrives with enabled=false, which hides the + // panel entirely rather than showing an empty bar. + if (!feature?.enabled) { + return null; + } + + const { limit, soft_limit: softLimit, actual } = feature; + + if (limit === undefined || limit < 0) { + return ( +
+
+ +
+
+ ); + } + + const usedHours = actual ?? 0; + // The backend warns with >= for both thresholds, so "reached" (not + // "exceeded") flips the bar color. + const reachedAllocation = actual !== undefined && usedHours >= limit; + const reachedSoftLimit = + !reachedAllocation && softLimit !== undefined && usedHours >= softLimit; + const usagePercentage = + limit > 0 ? Math.min((usedHours / limit) * 100, 100) : 0; + + const usedLabel = + actual === undefined ? "\u2014" : usedHours.toLocaleString("en-US"); + const limitLabel = limit.toLocaleString("en-US"); + + const softLimitPercent = + softLimit !== undefined && limit > 0 + ? Math.round((softLimit / limit) * 100) + : undefined; + + let tooltip: string; + if (reachedAllocation) { + tooltip = + "You've used 100% of your Total Agent hours for this license. Contact sales to receive more Agent hours."; + } else if (reachedSoftLimit) { + tooltip = `You've used ${softLimitPercent}% or more of your Total Agent hours for this license. Agent sessions are still working normally, but you'll want to plan for the 100% limit.`; + } else if (softLimitPercent !== undefined) { + tooltip = `Total time agents have been working across all workspaces this license. A soft-limit warning appears at ${softLimitPercent}%`; + } else { + tooltip = + "Total time agents have been working across all workspaces this license."; + } + + return ( +
+
+
+
+

Total agent hours

+ + + + + + {tooltip} + + +
+ + +
+
+ ); +}; From 20803d775f7f797ade2b86cf874e4d0a20a57a26 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 08:05:09 +0000 Subject: [PATCH 02/20] feat(site): render unlimited agent runtime hours allocation An enabled agent_runtime_hours feature with the limit omitted means the license grants unlimited runtime hours (allocation claim -1). The card previously rendered the invalid-limits error for that shape; it now shows a full neutral bar with "Limit: Unlimited" and the tooltip without threshold copy, mirroring SeatUsageBarCard's unlimited state. The invalid-limits error is kept for negative limits, which can only come from a decoding bug. --- .../TotalAgentHoursCard.stories.tsx | 31 ++++++++++++++++ .../TotalAgentHoursCard.tsx | 35 ++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx index 5efa5548301..0fde0c0dc90 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -159,11 +159,42 @@ export const Disabled: Story = { }, }; +// An enabled feature with the limit omitted means the license grants +// unlimited runtime hours: the bar renders full and neutral with no +// threshold copy in the tooltip, mirroring SeatUsageBarCard's unlimited +// state. +export const Unlimited: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + actual: 1200, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,200")).toBeInTheDocument(); + await expect(canvas.getByText("Unlimited")).toBeInTheDocument(); + await expect( + canvas.queryByText("Invalid license usage limits"), + ).not.toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\.$/, + ); + }, +}; + export const ErrorInvalidLimit: Story = { args: { feature: { enabled: true, entitlement: "entitled", + // A negative limit can only come from a decoding bug: the + // claim-level unlimited sentinel decodes to an omitted limit, + // never a negative one. + limit: -100, actual: 100, } satisfies Feature, }, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx index fab0036945d..2f6c7136302 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -24,7 +24,13 @@ export const TotalAgentHoursCard: FC = ({ const { limit, soft_limit: softLimit, actual } = feature; - if (limit === undefined || limit < 0) { + // An enabled feature with the limit omitted is the unlimited + // allocation: the license grants unlimited runtime hours and carries no + // thresholds, so the bar renders full and neutral like + // SeatUsageBarCard's allowUnlimited state. + const isUnlimited = limit === undefined; + + if (!isUnlimited && limit < 0) { return (
@@ -34,22 +40,33 @@ export const TotalAgentHoursCard: FC = ({ ); } + const meteredLimit = limit ?? 0; const usedHours = actual ?? 0; // The backend warns with >= for both thresholds, so "reached" (not - // "exceeded") flips the bar color. - const reachedAllocation = actual !== undefined && usedHours >= limit; + // "exceeded") flips the bar color. An unlimited allocation has no + // thresholds to reach. + const reachedAllocation = + !isUnlimited && actual !== undefined && usedHours >= meteredLimit; const reachedSoftLimit = - !reachedAllocation && softLimit !== undefined && usedHours >= softLimit; - const usagePercentage = - limit > 0 ? Math.min((usedHours / limit) * 100, 100) : 0; + !isUnlimited && + !reachedAllocation && + softLimit !== undefined && + usedHours >= softLimit; + const usagePercentage = isUnlimited + ? 100 + : meteredLimit > 0 + ? Math.min((usedHours / meteredLimit) * 100, 100) + : 0; const usedLabel = actual === undefined ? "\u2014" : usedHours.toLocaleString("en-US"); - const limitLabel = limit.toLocaleString("en-US"); + const limitLabel = isUnlimited + ? "Unlimited" + : meteredLimit.toLocaleString("en-US"); const softLimitPercent = - softLimit !== undefined && limit > 0 - ? Math.round((softLimit / limit) * 100) + !isUnlimited && softLimit !== undefined && meteredLimit > 0 + ? Math.round((softLimit / meteredLimit) * 100) : undefined; let tooltip: string; From eafd9113065a0ad33c4779624c530904e8982431 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 05:50:43 +0000 Subject: [PATCH 03/20] fix(site): report exact threshold percentages on total agent hours card The allocation-reached tooltip now reports the floored actual usage percentage (1,200 of 1,000 hours shows 120%) instead of always claiming 100%. The soft-limit percentage is floored rather than rounded so a 999/1,000-hour soft limit displays as 99% instead of crossing the allocation boundary. Missing usage data no longer counts as reaching a zero soft limit, and story tooltip assertions query the role=tooltip node so they fail when the popover does not open. --- .../TotalAgentHoursCard.stories.tsx | 62 +++++++++++++++++-- .../TotalAgentHoursCard.tsx | 20 +++++- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx index 0fde0c0dc90..02358f9f293 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -29,14 +29,16 @@ const hoverInfoIcon = async (canvasElement: HTMLElement) => { return within(canvasElement.ownerDocument.body); }; -// Tooltip text appears in both the tooltip popover and its hidden -// accessibility duplicate, so assertions match all occurrences. +// Radix mounts the role="tooltip" node only while the popover is open, +// so this fails when hovering does not actually open the popover. The +// role node is the visually hidden accessibility copy of the content, +// which is intentionally clipped, so there is no visibility to assert. const expectTooltipText = async ( body: ReturnType, text: RegExp, ) => { - const matches = await body.findAllByText(text); - expect(matches.length).toBeGreaterThanOrEqual(1); + const tooltip = await body.findByRole("tooltip"); + expect(tooltip).toHaveTextContent(text); }; export const Default: Story = { @@ -126,6 +128,34 @@ export const OverAllocation: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("1,200")).toBeInTheDocument(); await expect(canvas.getByText("1,000")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 120% of your Total Agent hours for this license\. Contact sales to receive more Agent hours\./, + ); + }, +}; + +// 999 of 1,000 hours is 99.9% used: the tooltip floors to 99% because +// rounding up would falsely claim the allocation is reached. +export const NearAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 999, + actual: 999, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("999")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 99% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./, + ); }, }; @@ -144,6 +174,30 @@ export const MissingActual: Story = { }, }; +// A zero soft limit is valid and reached by any usage, but usage data +// missing because the usage query failed must not count as reaching it: +// the card keeps the neutral informational tooltip instead of warning +// about usage it does not know. +export const MissingActualZeroSoftLimit: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 0, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("\u2014")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 0%$/, + ); + }, +}; + export const Disabled: Story = { args: { feature: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx index 2f6c7136302..5fb78493685 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -47,9 +47,13 @@ export const TotalAgentHoursCard: FC = ({ // thresholds to reach. const reachedAllocation = !isUnlimited && actual !== undefined && usedHours >= meteredLimit; + // Missing usage data (the usage query failed) must not count as + // reaching the soft limit: a soft limit of zero is valid and would + // otherwise compare as reached against the defaulted zero usage. const reachedSoftLimit = !isUnlimited && !reachedAllocation && + actual !== undefined && softLimit !== undefined && usedHours >= softLimit; const usagePercentage = isUnlimited @@ -64,15 +68,25 @@ export const TotalAgentHoursCard: FC = ({ ? "Unlimited" : meteredLimit.toLocaleString("en-US"); + // Floored so the percentage never crosses a boundary the underlying + // hours have not reached: a soft limit of 999/1,000 hours displays as + // 99%, never rounding up to a false 100%. const softLimitPercent = !isUnlimited && softLimit !== undefined && meteredLimit > 0 - ? Math.round((softLimit / meteredLimit) * 100) + ? Math.floor((softLimit / meteredLimit) * 100) : undefined; let tooltip: string; if (reachedAllocation) { - tooltip = - "You've used 100% of your Total Agent hours for this license. Contact sales to receive more Agent hours."; + // Flooring cannot understate reaching the allocation because usage + // is at least the limit here, so the percentage is at least 100 and + // reports over-allocation (1,200 of 1,000 hours shows 120%). The + // zero-limit fallback is unreachable for well-formed licenses: the + // backend reports a zero-hour allocation as a disabled feature, + // which hides the card entirely. + const usedPercent = + meteredLimit > 0 ? Math.floor((usedHours / meteredLimit) * 100) : 100; + tooltip = `You've used ${usedPercent}% of your Total Agent hours for this license. Contact sales to receive more Agent hours.`; } else if (reachedSoftLimit) { tooltip = `You've used ${softLimitPercent}% or more of your Total Agent hours for this license. Agent sessions are still working normally, but you'll want to plan for the 100% limit.`; } else if (softLimitPercent !== undefined) { From 783eac92bb506df8eab0e93564481002c3cdc8aa Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 06:14:03 +0000 Subject: [PATCH 04/20] feat(site): render unlimited agent hours allocation as striped bar A full green bar with no thresholds read as 100% usage. Unlimited allocations now fill the track with purple diagonal stripes so the full-width bar reads as an unmetered allocation instead of usage approaching a limit. --- .../TotalAgentHoursCard.stories.tsx | 6 ++--- .../TotalAgentHoursCard.tsx | 23 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx index 02358f9f293..c75cc35273a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -214,9 +214,9 @@ export const Disabled: Story = { }; // An enabled feature with the limit omitted means the license grants -// unlimited runtime hours: the bar renders full and neutral with no -// threshold copy in the tooltip, mirroring SeatUsageBarCard's unlimited -// state. +// unlimited runtime hours: the bar renders as a full track of purple +// diagonal stripes (an unmetered allocation, not 100% usage) with no +// threshold copy in the tooltip. export const Unlimited: Story = { args: { feature: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx index 5fb78493685..90ec6a495f2 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -25,9 +25,8 @@ export const TotalAgentHoursCard: FC = ({ const { limit, soft_limit: softLimit, actual } = feature; // An enabled feature with the limit omitted is the unlimited - // allocation: the license grants unlimited runtime hours and carries no - // thresholds, so the bar renders full and neutral like - // SeatUsageBarCard's allowUnlimited state. + // allocation: the license grants unlimited runtime hours and carries + // no thresholds to warn about. const isUnlimited = limit === undefined; if (!isUnlimited && limit < 0) { @@ -76,6 +75,18 @@ export const TotalAgentHoursCard: FC = ({ ? Math.floor((softLimit / meteredLimit) * 100) : undefined; + // An unlimited allocation renders as a full bar of purple diagonal + // stripes: the hatched pattern reads as an unmetered allocation, so + // the full-width bar cannot be mistaken for 100% usage. The stripes + // use the theme's purple highlight over the track color. + const barClassName = isUnlimited + ? "bg-[repeating-linear-gradient(-45deg,hsl(var(--highlight-purple)),hsl(var(--highlight-purple))_6px,transparent_6px,transparent_12px)]" + : reachedAllocation + ? "bg-highlight-red" + : reachedSoftLimit + ? "bg-highlight-orange" + : "bg-highlight-green"; + let tooltip: string; if (reachedAllocation) { // Flooring cannot understate reaching the allocation because usage @@ -125,11 +136,7 @@ export const TotalAgentHoursCard: FC = ({
From e350d5acbb3e681ebf3041ae510c0ed9f62fd0f8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 06:32:43 +0000 Subject: [PATCH 05/20] feat(site): fade out unlimited agent hours stripes in green The unlimited allocation bar keeps its diagonal stripes but switches from purple back to the green highlight, and the right half of the bar fades out into the track so the pattern reads as an open-ended allocation rather than a full meter. --- .../TotalAgentHoursCard.stories.tsx | 6 +++--- .../LicensesSettingsPage/TotalAgentHoursCard.tsx | 14 +++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx index c75cc35273a..8aa4d03361b 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -214,9 +214,9 @@ export const Disabled: Story = { }; // An enabled feature with the limit omitted means the license grants -// unlimited runtime hours: the bar renders as a full track of purple -// diagonal stripes (an unmetered allocation, not 100% usage) with no -// threshold copy in the tooltip. +// unlimited runtime hours: the bar renders as green diagonal stripes +// fading out over the right half (an unmetered allocation, not 100% +// usage) with no threshold copy in the tooltip. export const Unlimited: Story = { args: { feature: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx index 90ec6a495f2..31adcebee0a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -75,12 +75,16 @@ export const TotalAgentHoursCard: FC = ({ ? Math.floor((softLimit / meteredLimit) * 100) : undefined; - // An unlimited allocation renders as a full bar of purple diagonal - // stripes: the hatched pattern reads as an unmetered allocation, so - // the full-width bar cannot be mistaken for 100% usage. The stripes - // use the theme's purple highlight over the track color. + // An unlimited allocation renders as green diagonal stripes whose + // right half fades out into the track: the hatched, trailing-off bar + // reads as an unmetered allocation rather than 100% usage. The mask + // needs the -webkit- prefix for Safari. const barClassName = isUnlimited - ? "bg-[repeating-linear-gradient(-45deg,hsl(var(--highlight-purple)),hsl(var(--highlight-purple))_6px,transparent_6px,transparent_12px)]" + ? cn( + "bg-[repeating-linear-gradient(-45deg,hsl(var(--highlight-green)),hsl(var(--highlight-green))_6px,transparent_6px,transparent_12px)]", + "[mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", + "[-webkit-mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", + ) : reachedAllocation ? "bg-highlight-red" : reachedSoftLimit From 98c5a6c069d28c91f15cfce73bda835311050bd3 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 07:02:57 +0000 Subject: [PATCH 06/20] feat(site): mark agent hours hard cap on the usage bar When a license carries a hard limit, the track scales to the enforcement range: a solid red line marks the hard cap at the right edge and a dotted yellow line marks the allocation, whose limit label follows the marker. If the allocation sits close enough to the hard cap for the labels to collide, the hard cap label moves above the bar. Usage at or beyond the hard cap fills the track with red diagonal stripes and the tooltip names the cap. Threshold percentages now render with one floored decimal place (999/1,000 hours shows 99.9% instead of 99%), still never crossing a boundary the underlying hours have not reached. --- .../TotalAgentHoursCard.stories.tsx | 82 +++++++++- .../TotalAgentHoursCard.tsx | 143 +++++++++++++++--- 2 files changed, 197 insertions(+), 28 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx index 8aa4d03361b..d833e146498 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.stories.tsx @@ -136,8 +136,8 @@ export const OverAllocation: Story = { }, }; -// 999 of 1,000 hours is 99.9% used: the tooltip floors to 99% because -// rounding up would falsely claim the allocation is reached. +// 999 of 1,000 hours is 99.9% used: the tooltip shows the one-decimal +// percentage, floored so it never reads as a false 100%. export const NearAllocation: Story = { args: { feature: { @@ -154,7 +154,7 @@ export const NearAllocation: Story = { const body = await hoverInfoIcon(canvasElement); await expectTooltipText( body, - /You've used 99% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./, + /You've used 99\.9% or more of your Total Agent hours for this license\. Agent sessions are still working normally, but you'll want to plan for the 100% limit\./, ); }, }; @@ -198,6 +198,82 @@ export const MissingActualZeroSoftLimit: Story = { }, }; +// A hard cap scales the track to the enforcement range: the right edge +// carries a solid red line with the hard cap label while the allocation +// keeps a dotted yellow marker with the limit label at its interior +// position. +export const HardCap: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + hard_limit: 1500, + actual: 400, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("400")).toBeInTheDocument(); + await expect(canvas.getByText("1,000")).toBeInTheDocument(); + await expect(canvas.getByText(/Hard cap:/)).toBeInTheDocument(); + await expect(canvas.getByText("1,500")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /^Total time agents have been working across all workspaces this license\. A soft-limit warning appears at 85%$/, + ); + }, +}; + +// When the allocation sits within the last 15% of the hard-cap track, +// the limit label under its marker would collide with the hard cap +// label at the right edge, so the hard cap text renders above the bar. +export const HardCapNearAllocation: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1400, + soft_limit: 1200, + hard_limit: 1500, + actual: 400, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,400")).toBeInTheDocument(); + await expect(canvas.getByText(/Hard cap:/)).toBeInTheDocument(); + await expect(canvas.getByText("1,500")).toBeInTheDocument(); + }, +}; + +// Usage at or beyond the hard cap fills the track with red diagonal +// stripes and the tooltip reports the enforcement ceiling. The usage +// percentage stays measured against the allocation. +export const ReachedHardCap: Story = { + args: { + feature: { + enabled: true, + entitlement: "entitled", + limit: 1000, + soft_limit: 850, + hard_limit: 1500, + actual: 1600, + } satisfies Feature, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("1,600")).toBeInTheDocument(); + const body = await hoverInfoIcon(canvasElement); + await expectTooltipText( + body, + /You've used 160% of your Total Agent hours for this license and reached the hard cap of 1,500 hours\. Contact sales to receive more Agent hours\./, + ); + }, +}; + export const Disabled: Story = { args: { feature: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx index 31adcebee0a..54c4ec4e8dd 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/TotalAgentHoursCard.tsx @@ -22,7 +22,12 @@ export const TotalAgentHoursCard: FC = ({ return null; } - const { limit, soft_limit: softLimit, actual } = feature; + const { + limit, + soft_limit: softLimit, + hard_limit: hardLimit, + actual, + } = feature; // An enabled feature with the limit omitted is the unlimited // allocation: the license grants unlimited runtime hours and carries @@ -41,11 +46,24 @@ export const TotalAgentHoursCard: FC = ({ const meteredLimit = limit ?? 0; const usedHours = actual ?? 0; + // The backend only attaches a hard limit to a positive allocation and + // guarantees hard >= allocation, ignoring unusable hard limit claims, + // so anything else here comes from a decoding bug and is ignored the + // same way. + const hardCap = + !isUnlimited && + hardLimit !== undefined && + hardLimit > 0 && + hardLimit >= meteredLimit + ? hardLimit + : undefined; // The backend warns with >= for both thresholds, so "reached" (not // "exceeded") flips the bar color. An unlimited allocation has no // thresholds to reach. const reachedAllocation = !isUnlimited && actual !== undefined && usedHours >= meteredLimit; + const reachedHardCap = + hardCap !== undefined && actual !== undefined && usedHours >= hardCap; // Missing usage data (the usage query failed) must not count as // reaching the soft limit: a soft limit of zero is valid and would // otherwise compare as reached against the defaulted zero usage. @@ -55,53 +73,78 @@ export const TotalAgentHoursCard: FC = ({ actual !== undefined && softLimit !== undefined && usedHours >= softLimit; + // With a hard cap the track spans the full enforcement range: its + // right edge is the hard cap and the allocation falls at an interior + // marker. + const barScale = hardCap ?? meteredLimit; const usagePercentage = isUnlimited ? 100 - : meteredLimit > 0 - ? Math.min((usedHours / meteredLimit) * 100, 100) + : barScale > 0 + ? Math.min((usedHours / barScale) * 100, 100) : 0; + const allocationMarkerPercent = + hardCap === undefined + ? undefined + : Math.min((meteredLimit / hardCap) * 100, 100); + // When the allocation marker lands near the track's right edge, the + // limit label under the marker would collide with the hard cap label, + // so the hard cap text moves above the bar. + const hardCapLabelAboveBar = + allocationMarkerPercent !== undefined && allocationMarkerPercent > 85; const usedLabel = actual === undefined ? "\u2014" : usedHours.toLocaleString("en-US"); const limitLabel = isUnlimited ? "Unlimited" : meteredLimit.toLocaleString("en-US"); + const hardCapLabel = hardCap?.toLocaleString("en-US"); + + // Percentages are floored at one decimal place so the shown value + // never crosses a threshold the underlying hours have not reached: a + // soft limit of 999/1,000 hours renders as 99.9%, never a false 100%. + // Whole values render without a trailing decimal. + const formatPercent = (value: number): string => + (Math.floor(value * 10) / 10).toLocaleString("en-US"); - // Floored so the percentage never crosses a boundary the underlying - // hours have not reached: a soft limit of 999/1,000 hours displays as - // 99%, never rounding up to a false 100%. const softLimitPercent = !isUnlimited && softLimit !== undefined && meteredLimit > 0 - ? Math.floor((softLimit / meteredLimit) * 100) + ? formatPercent((softLimit / meteredLimit) * 100) : undefined; // An unlimited allocation renders as green diagonal stripes whose // right half fades out into the track: the hatched, trailing-off bar // reads as an unmetered allocation rather than 100% usage. The mask - // needs the -webkit- prefix for Safari. + // needs the -webkit- prefix for Safari. Usage at or beyond the hard + // cap swaps the solid red fill for red diagonal stripes. const barClassName = isUnlimited ? cn( "bg-[repeating-linear-gradient(-45deg,hsl(var(--highlight-green)),hsl(var(--highlight-green))_6px,transparent_6px,transparent_12px)]", "[mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", "[-webkit-mask-image:linear-gradient(to_right,black_50%,transparent_100%)]", ) - : reachedAllocation - ? "bg-highlight-red" - : reachedSoftLimit - ? "bg-highlight-orange" - : "bg-highlight-green"; + : reachedHardCap + ? "bg-[repeating-linear-gradient(-45deg,hsl(var(--highlight-red)),hsl(var(--highlight-red))_6px,transparent_6px,transparent_12px)]" + : reachedAllocation + ? "bg-highlight-red" + : reachedSoftLimit + ? "bg-highlight-orange" + : "bg-highlight-green"; let tooltip: string; if (reachedAllocation) { - // Flooring cannot understate reaching the allocation because usage - // is at least the limit here, so the percentage is at least 100 and - // reports over-allocation (1,200 of 1,000 hours shows 120%). The - // zero-limit fallback is unreachable for well-formed licenses: the - // backend reports a zero-hour allocation as a disabled feature, - // which hides the card entirely. + // The percentage is measured against the allocation even when a + // hard cap widens the bar, and flooring cannot understate reaching + // the allocation because usage is at least the limit here (1,200 of + // 1,000 hours shows 120%). The zero-limit fallback is unreachable + // for well-formed licenses: the backend reports a zero-hour + // allocation as a disabled feature, which hides the card entirely. const usedPercent = - meteredLimit > 0 ? Math.floor((usedHours / meteredLimit) * 100) : 100; - tooltip = `You've used ${usedPercent}% of your Total Agent hours for this license. Contact sales to receive more Agent hours.`; + meteredLimit > 0 + ? formatPercent((usedHours / meteredLimit) * 100) + : "100"; + tooltip = reachedHardCap + ? `You've used ${usedPercent}% of your Total Agent hours for this license and reached the hard cap of ${hardCapLabel} hours. Contact sales to receive more Agent hours.` + : `You've used ${usedPercent}% of your Total Agent hours for this license. Contact sales to receive more Agent hours.`; } else if (reachedSoftLimit) { tooltip = `You've used ${softLimitPercent}% or more of your Total Agent hours for this license. Agent sessions are still working normally, but you'll want to plan for the 100% limit.`; } else if (softLimitPercent !== undefined) { @@ -133,6 +176,13 @@ export const TotalAgentHoursCard: FC = ({
+ {hardCapLabelAboveBar && ( +

+ Hard cap:{" "} + {hardCapLabel} +

+ )} + - {hardCapLabelAboveBar && ( -

- Hard cap:{" "} - {hardCapLabel} -

+ {(reachedHardCap || hardCapLabelAboveBar) && ( +
+ {reachedHardCap && ( + + + Hard cap reached - chat concurrency enforced + + )} + {hardCapLabelAboveBar && ( +

+ Hard cap:{" "} + {hardCapLabel} +

+ )} +
)} -