+
{isHardLimitExceeded
? maxConcurrentChatsOverHardLimit
: "Unlimited"}
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
index a4dc6c138e51d..6ad40264ec266 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
@@ -213,9 +213,10 @@ export const PremiumWithAgentHours: Story = {
limit: 20000,
soft_limit: 16000,
hard_limit: 25000,
- actual: 16264,
- // 16,264 hours and 18 minutes: renders as 16,264.3.
- actual_ms: 16_264 * 3_600_000 + 18 * 60_000,
+ actual: 12264,
+ // 12,264 hours and 18 minutes: renders as 12,264.3, below
+ // the 16,000-hour advisory soft limit.
+ actual_ms: 12_264 * 3_600_000 + 18 * 60_000,
usage_period: winningUsagePeriod,
},
},
@@ -226,7 +227,7 @@ export const PremiumWithAgentHours: Story = {
getIncludedProducts(canvas, "Workspaces + Agents"),
).toBeInTheDocument();
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
- "16,264.3 / 20,000",
+ "12,264.3 / 20,000",
);
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
@@ -240,6 +241,39 @@ export const PremiumWithAgentHours: Story = {
},
};
+export const PremiumWithAgentHoursSoftLimitReached: Story = {
+ args: {
+ license: premiumLicenseWithAgentHours(20000),
+ agentRuntimeHoursFeature: {
+ enabled: true,
+ entitlement: "entitled",
+ limit: 20000,
+ soft_limit: 16000,
+ hard_limit: 25000,
+ actual: 16264,
+ // 16,264 hours and 18 minutes: renders as 16,264.3, at or
+ // above the 16,000-hour advisory soft limit.
+ actual_ms: 16_264 * 3_600_000 + 18 * 60_000,
+ usage_period: winningUsagePeriod,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText("Active")).toBeInTheDocument();
+ const hoursValue = getMetricValue(canvas, "Total Agent hours");
+ await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000");
+ await expect(hoursValue?.querySelector("span")).toHaveClass(
+ "text-border-warning",
+ );
+ await expect(
+ canvas.getByText("Coder Agents").closest(".coder-agents-product-card"),
+ ).toHaveClass("border-border-warning");
+ await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
+ "Unlimited",
+ );
+ },
+};
+
export const PremiumWithAgentHoursExceeded: Story = {
args: {
license: premiumLicenseWithAgentHours(20000),
@@ -282,13 +316,16 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument();
+ await expect(canvas.getByText("Limit exceeded")).toBeInTheDocument();
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"25,000.0 / 20,000",
);
- await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
- "5",
- );
+ const concurrentChats = getMetricValue(canvas, "Concurrent chats");
+ await expect(concurrentChats).toHaveTextContent("5");
+ await expect(concurrentChats).toHaveClass("text-content-destructive");
+ await expect(
+ canvas.getByRole("status", { name: "Limit reached" }),
+ ).toBeInTheDocument();
},
};
@@ -469,8 +506,8 @@ export const EnterpriseWithAgentHours: Story = {
limit: 20000,
soft_limit: 16000,
hard_limit: 25000,
- actual: 16264,
- actual_ms: 16_264 * 3_600_000 + 18 * 60_000,
+ actual: 12264,
+ actual_ms: 12_264 * 3_600_000 + 18 * 60_000,
usage_period: winningUsagePeriod,
},
},
@@ -485,7 +522,7 @@ export const EnterpriseWithAgentHours: Story = {
getIncludedProducts(canvas, "Workspaces + Agents"),
).not.toBeInTheDocument();
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
- "16,264.3 / 20,000",
+ "12,264.3 / 20,000",
);
},
};
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
index 75424699dab3b..6be6cbb8d5bf1 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
@@ -190,6 +190,19 @@ export const LicenseCard: FC = ({
agentHoursAllocation > 0 &&
agentHoursDisplayActual !== undefined &&
agentHoursDisplayActual > agentHoursAllocation;
+ // Advisory only: at or above the soft threshold, still inside the
+ // purchased allocation. Allocation and hard-limit overage supersede
+ // this so the product card never stacks warning on destructive.
+ const isAgentHoursSoftLimitReached =
+ canUseAgentHoursUsageForThisLicense &&
+ !isAgentHoursHardLimitExceeded &&
+ !isAgentHoursExceeded &&
+ agentHoursAllocation !== undefined &&
+ agentHoursAllocation > 0 &&
+ agentHoursSoftLimit !== undefined &&
+ agentHoursDisplayActual !== undefined &&
+ agentHoursDisplayActual >= agentHoursSoftLimit &&
+ agentHoursDisplayActual < agentHoursAllocation;
const statusClassName =
isAgentHoursHardLimitExceeded ||
@@ -201,7 +214,7 @@ export const LicenseCard: FC = ({
? "text-content-warning"
: "text-content-success";
const statusText = isAgentHoursHardLimitExceeded
- ? "Hard limit exceeded"
+ ? "Limit exceeded"
: isAgentHoursExceeded
? "Agent hours exceeded"
: isAiGovernanceAddOnExceeded
@@ -367,6 +380,7 @@ export const LicenseCard: FC = ({
From 5ad5e7d0b4ad1dbddf0a881d4601943380c86a6b Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 18 Aug 2026 04:54:44 +0000
Subject: [PATCH 52/56] fix: format
---
.../LicensesSettingsPage/CoderAgentsProductCard.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
index aafcc77d9da75..a926308777246 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
@@ -185,8 +185,8 @@ export const CoderAgentsProductCard: FC = ({
"Unlimited"
) : (
<>
- {actualLabel}{" "}
- / {allocation.toLocaleString("en-US")}
+ {actualLabel} /{" "}
+ {allocation.toLocaleString("en-US")}
>
)}
From 3fda674b3e47cdc6daf8853fe4130a86efa36d7d Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 18 Aug 2026 05:02:31 +0000
Subject: [PATCH 53/56] chore(site/src): tighten comments in license products
section
---
site/src/api/api.ts | 11 ++---
.../CoderAgentsProductCard.stories.tsx | 2 -
.../CoderAgentsProductCard.tsx | 22 ++++------
.../LicenseCard.stories.tsx | 35 ++++++---------
.../LicensesSettingsPage/LicenseCard.tsx | 43 +++++++------------
.../licenseApplicability.ts | 6 +--
6 files changed, 41 insertions(+), 78 deletions(-)
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 65d97cea0f451..e870ba8d6164e 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -365,14 +365,11 @@ type Claims = {
license_expires: number;
// nbf is a standard JWT claim for "not before" - the license valid from date
nbf?: number;
- // iat is a standard JWT claim for "issued at". Valid licenses always
- // carry it; the merged entitlement's usage_period.issued_at is stamped
- // from the winning license's iat.
+ // iat is a standard JWT claim for "issued at"; the merged
+ // usage_period.issued_at is stamped from the winning license's iat.
iat?: number;
- // exp is a standard JWT claim for "expires at": the end of the grace
- // period (identical to license_expires when there is no grace period).
- // The merged entitlement's usage_period.end is stamped from the
- // winning license's exp, and usage_period.start from its nbf.
+ // exp is a standard JWT claim for "expires at" (end of grace period);
+ // it stamps usage_period.end, and nbf stamps usage_period.start.
exp?: number;
account_type?: string;
account_id?: string;
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
index 8547da840e1a7..2d59db9ff0722 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
@@ -8,8 +8,6 @@ const meta: Meta = {
component: CoderAgentsProductCard,
args: {
allocation: 20000,
- // Fractional usage renders with one decimal; whole values render
- // with a trailing .0 (see Exceeded).
actual: 16264.3,
isSoftLimitReached: false,
isExceeded: false,
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
index a926308777246..3e1142983509a 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
@@ -11,28 +11,23 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
-// Sentinel allocation claim value meaning the license grants unlimited
-// agent runtime hours (AgentRuntimeHoursUnlimitedAllocation in
-// enterprise/coderd/license).
+// Allocation sentinel for unlimited agent runtime hours
+// (AgentRuntimeHoursUnlimitedAllocation in enterprise/coderd/license).
const unlimitedAllocation = -1;
-// Mirrors defaultMaxConcurrentRootAgents in coderd/x/chatd, which caps
-// concurrent chats once the hard limit is reached. It is not exposed via
-// the API, so keep this value in sync with the backend.
+// Concurrent chat cap once the hard limit is reached. Mirrors
+// defaultMaxConcurrentRootAgents in coderd/x/chatd; keep in sync.
const maxConcurrentChatsOverHardLimit = 5;
type CoderAgentsProductCardProps = {
/**
* The license's agent_runtime_hours_allocation claim, in hours.
- * Undefined or non-positive (other than the -1 unlimited sentinel)
- * means the license does not include Coder Agents hours.
+ * Undefined or non-positive (except -1, unlimited) grants no hours.
*/
allocation?: number;
/**
- * Agent runtime hours used in the current usage period, from the
- * merged entitlements, floored to tenths of an hour. Undefined when
- * usage does not apply to this license (another license provides the
- * feature) or is unknown.
+ * Hours used in the current usage period, floored to tenths.
+ * Undefined when usage does not apply to this license.
*/
actual?: number;
/**
@@ -97,8 +92,7 @@ const concurrentChatsTooltip =
"Number of Coder Agents chats that can run at the same time.";
const concurrentChatsHardLimitTooltip = `${concurrentChatsTooltip} You've reached your limit: concurrent chats are now capped at ${maxConcurrentChatsOverHardLimit} (down from unlimited).`;
-// Usage always renders with exactly one decimal (e.g. 42.0, 10.3). The
-// value is already floored to tenths, so no rounding happens here.
+// The value is already floored to tenths, so no rounding happens here.
const formatHoursUsed = (hours: number) =>
hours.toLocaleString("en-US", {
minimumFractionDigits: 1,
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
index 6ad40264ec266..a345519c4c6cc 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
@@ -138,9 +138,8 @@ export const Premium: Story = {
actual: 100,
limit: 1000,
},
- // The backend grandfathers premium licenses without agent hour
- // claims into a zero-hour allocation, so the merged entitlement is
- // always present: disabled, zero limit, usage measured.
+ // Premium licenses without agent hour claims are grandfathered
+ // into a zero-hour allocation, so the merged entitlement exists.
agentRuntimeHoursFeature: {
enabled: false,
entitlement: "entitled",
@@ -170,10 +169,8 @@ export const Premium: Story = {
},
};
-// Issued-at of the license that supplies the merged entitlement. The
-// merged usage period is copied from that license's iat/nbf/exp claims,
-// so only the license whose claims reproduce the whole period shows
-// usage and overage.
+// Issued-at of the license supplying the merged entitlement; only the
+// license whose iat/nbf/exp reproduce the merged period shows usage.
const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z");
const winningUsagePeriod = {
issued_at: WINNING_ISSUED_AT.toISOString(),
@@ -338,9 +335,8 @@ export const PremiumWithAgentHoursExceededByFraction: Story = {
limit: 20000,
soft_limit: 16000,
hard_limit: 25000,
- // The whole-hour actual sits exactly at the allocation, but the
- // extra 6 minutes push the tenths-precision value past it, so
- // the fraction alone flips the exceeded state.
+ // The extra 6 minutes push the tenths-precision value past the
+ // allocation, so the fraction alone flips the exceeded state.
actual: 20000,
actual_ms: 20_000 * 3_600_000 + 6 * 60_000,
usage_period: winningUsagePeriod,
@@ -398,8 +394,6 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- // Usage belongs to the winning 20,000-hour license, so this card
- // shows no usage and no overage.
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"\u2014 / 10,000",
);
@@ -411,10 +405,8 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = {
export const ReplacedDuplicateAllocationShowsNoUsage: Story = {
args: {
- // An older license with the same allocation as the winning renewal.
- // Only the license whose iat matches the merged usage period shows
- // usage, so this card stays free of usage and overage even though
- // its allocation equals the merged limit.
+ // Same allocation as the winning renewal but an older usage
+ // period, so the merged usage does not belong to this license.
license: premiumLicenseWithAgentHours(
20000,
WINNING_ISSUED_AT.subtract(1, "year"),
@@ -455,10 +447,8 @@ const sameIssuedAtShorterTermLicense = (() => {
export const SameIssuedAtDifferentTermEndShowsNoUsage: Story = {
args: {
- // Same iat and allocation as the winning license, but a shorter
- // term. Feature.Compare tie-breaks equal issued-at values on the
- // period end, so this license loses and must not display the
- // merged usage or overage.
+ // Same iat and allocation as the winning license but a shorter
+ // term; the backend tie-breaks equal issued-at on the period end.
license: sameIssuedAtShorterTermLicense,
agentRuntimeHoursFeature: {
enabled: true,
@@ -496,9 +486,8 @@ const enterpriseLicenseWithAgentHours = (() => {
export const EnterpriseWithAgentHours: Story = {
args: {
- // The backend accepts runtime hour claims on any feature set, so
- // an Enterprise license carrying an allocation renders the Coder
- // Agents product with its usage.
+ // Runtime hour claims apply to any feature set, so an Enterprise
+ // license with an allocation renders the Coder Agents product.
license: enterpriseLicenseWithAgentHours,
agentRuntimeHoursFeature: {
enabled: true,
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
index 6be6cbb8d5bf1..8758c8a354c8d 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
@@ -88,26 +88,20 @@ export const LicenseCard: FC = ({
? aiGovernanceActual
: undefined;
- // Agent runtime hour claims, in hours. The -1 allocation is the
- // unlimited sentinel; other negative allocations are ignored by the
- // backend, and a zero allocation grants the feature disabled.
+ // Agent runtime hour claims, in hours. -1 means unlimited; other
+ // negatives are ignored and zero grants the feature disabled.
const agentHoursAllocation =
license.claims.features.agent_runtime_hours_allocation;
- // The backend decodes runtime hour claims for every license
- // regardless of feature set, so an Enterprise license carrying a
- // usable allocation claim also gets the Coder Agents product.
- // Premium licenses without claims are grandfathered into the
- // zero-hour (upgrade) display.
+ // The backend decodes these claims for any feature set, so a
+ // non-Premium license with a usable claim also shows Coder Agents.
const hasAgentHoursClaim =
agentHoursAllocation !== undefined &&
(agentHoursAllocation >= 0 || agentHoursAllocation === -1);
const licenseGrantsAgentHours =
agentHoursAllocation !== undefined &&
(agentHoursAllocation > 0 || agentHoursAllocation === -1);
- // Thresholds after the backend's claim validation: soft must be
- // non-negative and below a positive allocation, hard at or above it.
- // Invalid threshold claims are ignored rather than disqualifying the
- // license.
+ // Mirror the backend's threshold validation; invalid claims are
+ // ignored rather than disqualifying the license.
const agentHoursSoftLimitClaim =
license.claims.features.agent_runtime_hours_limit_soft;
const agentHoursHardLimitClaim =
@@ -131,12 +125,9 @@ export const LicenseCard: FC = ({
license,
agentRuntimeHoursFeature,
);
- // The merged entitlement's usage period is copied verbatim from the
- // license the backend selected (issued_at from iat, start from nbf,
- // end from exp), so a license only "wins" when all three match.
- // Feature.Compare tie-breaks equal issued-at values on the period
- // end, so matching issued-at alone could mark two licenses with the
- // same second-granularity iat as the winner.
+ // The merged usage period is copied from the winning license's
+ // iat/nbf/exp claims. All three must match: issued-at alone can
+ // collide across licenses.
const mergedUsagePeriod = agentRuntimeHoursFeature?.usage_period;
const matchesMergedUsagePeriod =
license.claims.iat !== undefined &&
@@ -146,11 +137,8 @@ export const LicenseCard: FC = ({
dayjs.unix(license.claims.iat).isSame(mergedUsagePeriod.issued_at) &&
dayjs.unix(license.claims.nbf).isSame(mergedUsagePeriod.start) &&
dayjs.unix(license.claims.exp).isSame(mergedUsagePeriod.end);
- // Beyond the usage period, the license's allocation and validated
- // thresholds must equal the merged entitlement's: equal limits (or an
- // unlimited allocation with the merged limit omitted) and equal
- // soft/hard thresholds, since the backend retains only the selected
- // license's thresholds.
+ // The winner's allocation and thresholds must also equal the merged
+ // entitlement's; an unlimited allocation reports no merged limit.
const isWinningAgentHoursLicense =
matchesMergedUsagePeriod &&
agentHoursSoftLimit === agentRuntimeHoursFeature?.soft_limit &&
@@ -163,16 +151,15 @@ export const LicenseCard: FC = ({
agentHoursAllocation === agentRuntimeHoursFeature?.limit);
const canUseAgentHoursUsageForThisLicense =
isAgentHoursLicenseApplicable && isWinningAgentHoursLicense;
- // Precise usage in tenths of hours, floored via integer math so the
- // displayed number and the exceeded state below flip at the same
- // instant as the backend's whole-hour warning thresholds.
+ // Usage floored to tenths of an hour via integer math so the display
+ // and the exceeded states below flip at the same instant.
const agentHoursActualMs = agentRuntimeHoursFeature?.actual_ms;
const agentHoursActual =
agentHoursActualMs === undefined
? undefined
: Math.floor(agentHoursActualMs / 360_000) / 10;
- // Usage applies to the winning license's quota. Licenses without an
- // allocation show deployment-wide usage in their upgrade card instead.
+ // Licenses without an allocation show deployment-wide usage in their
+ // upgrade card.
const agentHoursDisplayActual =
isAgentHoursLicenseApplicable &&
(isWinningAgentHoursLicense || !licenseGrantsAgentHours)
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts
index 52ea45d47e32c..6a419cd7788f1 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts
@@ -3,10 +3,8 @@ import type { GetLicensesResponse } from "#/api/api";
import type { Feature } from "#/api/typesGenerated";
/**
- * Usage and overage indicators only apply to licenses that are currently
- * effective: past their nbf and not expired, unless the merged entitlement
- * for the feature is in its grace period (an expired license can still be
- * the one granting the feature while the grace period lasts).
+ * A license is applicable when past its nbf and not expired, or when the
+ * feature is in its grace period (an expired license can still grant it).
*/
export function isLicenseApplicableForFeatureUsage(
license: GetLicensesResponse,
From d1257d1e0b19f42daab7576c10b4368038baa5db Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 18 Aug 2026 05:18:01 +0000
Subject: [PATCH 54/56]
fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): repair story
queries broken by design updates
---
.../CoderAgentsProductCard.stories.tsx | 4 +---
.../LicensesSettingsPage/LicenseCard.stories.tsx | 7 +++----
2 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
index 2d59db9ff0722..a50dcd2887fad 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
@@ -152,9 +152,7 @@ export const HardLimitExceeded: Story = {
const concurrentChats = getMetricValue(canvas, "Concurrent chats");
await expect(concurrentChats).toHaveTextContent("5");
await expect(concurrentChats).toHaveClass("text-content-destructive");
- await expect(
- canvas.getByRole("status", { name: "Limit reached" }),
- ).toBeInTheDocument();
+ await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached");
await userEvent.hover(
canvas.getByRole("button", { name: "Concurrent chats information" }),
);
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
index a345519c4c6cc..67771f5e9544a 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
@@ -320,9 +320,7 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = {
const concurrentChats = getMetricValue(canvas, "Concurrent chats");
await expect(concurrentChats).toHaveTextContent("5");
await expect(concurrentChats).toHaveClass("text-content-destructive");
- await expect(
- canvas.getByRole("status", { name: "Limit reached" }),
- ).toBeInTheDocument();
+ await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached");
},
};
@@ -539,7 +537,8 @@ export const PremiumWithAIGovernance: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument();
- await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument();
+ // Matches both the included-products line and the add-on card title.
+ await expect(canvas.getAllByText(/ai governance/i)).toHaveLength(2);
await expect(
getIncludedProducts(canvas, "Workspaces + AI Governance"),
).toBeInTheDocument();
From 1863dc2286b9f95805562d8f58c24e572172df86 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 18 Aug 2026 05:57:58 +0000
Subject: [PATCH 55/56]
fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): assert
product card states semantically in stories
---
.../CoderAgentsProductCard.stories.tsx | 22 ++++++++-----------
.../CoderAgentsProductCard.tsx | 8 ++++++-
.../LicenseCard.stories.tsx | 18 +++++++--------
3 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
index a50dcd2887fad..51c13d33b40aa 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx
@@ -106,14 +106,12 @@ export const SoftLimitReached: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const hoursValue = getMetricValue(canvas, "Total Agent hours");
- await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000");
- await expect(hoursValue?.querySelector("span")).toHaveClass(
- "text-border-warning",
+ await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
+ "16,264.3 / 20,000",
+ );
+ await expect(canvas.getByRole("status")).toHaveTextContent(
+ "Approaching hours limit",
);
- await expect(
- canvas.getByText("Coder Agents").closest(".coder-agents-product-card"),
- ).toHaveClass("border-border-warning");
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
);
@@ -133,9 +131,7 @@ export const Exceeded: Story = {
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
);
- await expect(
- canvas.queryByRole("status", { name: "Limit reached" }),
- ).not.toBeInTheDocument();
+ await expect(canvas.queryByRole("status")).not.toBeInTheDocument();
},
};
@@ -149,9 +145,9 @@ export const HardLimitExceeded: Story = {
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"25,000.0 / 20,000",
);
- const concurrentChats = getMetricValue(canvas, "Concurrent chats");
- await expect(concurrentChats).toHaveTextContent("5");
- await expect(concurrentChats).toHaveClass("text-content-destructive");
+ await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
+ "5",
+ );
await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached");
await userEvent.hover(
canvas.getByRole("button", { name: "Concurrent chats information" }),
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
index 3e1142983509a..f2db2d3c98af6 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx
@@ -71,7 +71,7 @@ const CardContainer: FC<{
}> = ({ className, headerEnd, children }) => (
@@ -165,6 +165,12 @@ export const CoderAgentsProductCard: FC = ({
Limit reached
+ ) : isSoftLimitReached && !isOverage ? (
+ // The soft limit is otherwise only conveyed by the warning
+ // colors, so announce it for assistive technology too.
+
+ Approaching hours limit
+
) : undefined
}
>
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
index 67771f5e9544a..6b81958871577 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
@@ -257,14 +257,12 @@ export const PremiumWithAgentHoursSoftLimitReached: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Active")).toBeInTheDocument();
- const hoursValue = getMetricValue(canvas, "Total Agent hours");
- await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000");
- await expect(hoursValue?.querySelector("span")).toHaveClass(
- "text-border-warning",
+ await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
+ "16,264.3 / 20,000",
+ );
+ await expect(canvas.getByRole("status")).toHaveTextContent(
+ "Approaching hours limit",
);
- await expect(
- canvas.getByText("Coder Agents").closest(".coder-agents-product-card"),
- ).toHaveClass("border-border-warning");
await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
"Unlimited",
);
@@ -317,9 +315,9 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = {
await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
"25,000.0 / 20,000",
);
- const concurrentChats = getMetricValue(canvas, "Concurrent chats");
- await expect(concurrentChats).toHaveTextContent("5");
- await expect(concurrentChats).toHaveClass("text-content-destructive");
+ await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent(
+ "5",
+ );
await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached");
},
};
From c26d4c0581b6ee85e428b78d845a459186547b78 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 18 Aug 2026 06:33:08 +0000
Subject: [PATCH 56/56]
fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): treat usage
at the allocation boundary as exceeded
---
.../LicenseCard.stories.tsx | 29 +++++++++++++++++--
.../LicensesSettingsPage/LicenseCard.tsx | 4 ++-
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
index 6b81958871577..9b19c823e75be 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx
@@ -322,6 +322,31 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = {
},
};
+export const PremiumWithAgentHoursAtAllocation: Story = {
+ args: {
+ license: premiumLicenseWithAgentHours(20000),
+ agentRuntimeHoursFeature: {
+ enabled: true,
+ entitlement: "entitled",
+ limit: 20000,
+ soft_limit: 16000,
+ hard_limit: 25000,
+ // Usage equal to the allocation is already over: the backend
+ // reports the allocation as reached at this exact boundary.
+ actual: 20000,
+ actual_ms: 20_000 * 3_600_000,
+ usage_period: winningUsagePeriod,
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument();
+ await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent(
+ "20,000.0 / 20,000",
+ );
+ },
+};
+
export const PremiumWithAgentHoursExceededByFraction: Story = {
args: {
license: premiumLicenseWithAgentHours(20000),
@@ -331,8 +356,8 @@ export const PremiumWithAgentHoursExceededByFraction: Story = {
limit: 20000,
soft_limit: 16000,
hard_limit: 25000,
- // The extra 6 minutes push the tenths-precision value past the
- // allocation, so the fraction alone flips the exceeded state.
+ // The extra 6 minutes render as a tenth past the allocation,
+ // so the display shows fractional overage.
actual: 20000,
actual_ms: 20_000 * 3_600_000 + 6 * 60_000,
usage_period: winningUsagePeriod,
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
index 8758c8a354c8d..994f7ea4832f4 100644
--- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
+++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx
@@ -170,13 +170,15 @@ export const LicenseCard: FC = ({
agentHoursHardLimit !== undefined &&
agentHoursDisplayActual !== undefined &&
agentHoursDisplayActual >= agentHoursHardLimit;
+ // Inclusive: usage equal to the allocation is already over, matching
+ // the backend's "allocation reached" warning boundary.
const isAgentHoursExceeded =
canUseAgentHoursUsageForThisLicense &&
!isAgentHoursHardLimitExceeded &&
agentHoursAllocation !== undefined &&
agentHoursAllocation > 0 &&
agentHoursDisplayActual !== undefined &&
- agentHoursDisplayActual > agentHoursAllocation;
+ agentHoursDisplayActual >= agentHoursAllocation;
// Advisory only: at or above the soft threshold, still inside the
// purchased allocation. Allocation and hard-limit overage supersede
// this so the product card never stacks warning on destructive.