diff --git a/ui/src/components/BudgetIncidentCard.test.tsx b/ui/src/components/BudgetIncidentCard.test.tsx new file mode 100644 index 0000000000..2bde9f13b7 --- /dev/null +++ b/ui/src/components/BudgetIncidentCard.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { BudgetIncident } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BudgetIncidentCard } from "./BudgetIncidentCard"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function incident(overrides: Partial = {}): BudgetIncident { + return { + id: "incident-1", + companyId: "company-1", + policyId: "policy-1", + scopeType: "agent", + scopeId: "agent-1", + scopeName: "Budget Demo", + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-10-01T00:00:00Z"), + windowEnd: new Date("2026-11-01T00:00:00Z"), + thresholdType: "hard", + amountLimit: 100_000, + amountObserved: 100_000, + status: "open", + approvalId: null, + approvalStatus: null, + resolvedAt: null, + createdAt: new Date("2026-10-12T00:00:00Z"), + updatedAt: new Date("2026-10-12T00:00:00Z"), + ...overrides, + }; +} + +async function renderCard(value: BudgetIncident) { + const div = document.createElement("div"); + document.body.append(div); + const root = createRoot(div); + const onKeepPaused = vi.fn(); + const onRaiseAndResume = vi.fn(); + await act(async () => root.render( + , + )); + const buttons = () => Array.from(div.querySelectorAll("button"), (button) => button.textContent?.trim()); + return { + div, + buttons, + onKeepPaused, + cleanup: async () => { + await act(async () => root.unmount()); + div.remove(); + }, + }; +} + +describe("BudgetIncidentCard", () => { + afterEach(() => vi.restoreAllMocks()); + + it("labels a hard-stop incident as paused", async () => { + const { div, buttons, cleanup } = await renderCard(incident()); + expect(div.textContent).toContain("agent hard stop"); + expect(div.textContent).toContain("This scope is paused."); + expect(buttons()).toEqual(["Raise budget & resume", "Keep paused"]); + expect(div.querySelector("input")?.value).toBe("1010.00"); + await cleanup(); + }); + + it("labels a soft-threshold incident as a warning that does not pause the scope", async () => { + const { div, buttons, onKeepPaused, cleanup } = await renderCard( + incident({ thresholdType: "soft", amountObserved: 80_000 }), + ); + expect(div.textContent).toContain("agent budget warning"); + expect(div.textContent).not.toContain("hard stop"); + expect(div.textContent).not.toContain("paused"); + expect(div.textContent).toContain("This warning does not pause work."); + expect(div.textContent).toContain("Spending reached $800.00 against a limit of $1,000.00."); + expect(buttons()).toEqual(["Raise budget", "Dismiss warning"]); + expect(div.querySelector("input")?.value).toBe("1010.00"); + const dismiss = Array.from(div.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Dismiss warning")!; + await act(async () => dismiss.click()); + expect(onKeepPaused).toHaveBeenCalledTimes(1); + await cleanup(); + }); + + it("describes a paused project for a project hard stop", async () => { + const { div, cleanup } = await renderCard(incident({ scopeType: "project", scopeName: "Website" })); + expect(div.textContent).toContain("project hard stop"); + expect(div.textContent).toContain("Project execution is paused."); + await cleanup(); + }); +}); diff --git a/ui/src/components/BudgetIncidentCard.tsx b/ui/src/components/BudgetIncidentCard.tsx index 8be14d2542..b99b53ad1f 100644 --- a/ui/src/components/BudgetIncidentCard.tsx +++ b/ui/src/components/BudgetIncidentCard.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { BudgetIncident } from "@paperclipai/shared"; -import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react"; +import { AlertOctagon, AlertTriangle, ArrowUpRight, PauseCircle } from "lucide-react"; import { formatCents } from "../lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,46 +36,66 @@ export function BudgetIncidentCard({ onKeepPaused: () => void; isMutating?: boolean; }) { + const isWarning = incident.thresholdType === "soft"; const [draftAmount, setDraftAmount] = useState( - centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)), + centsInputValue(isWarning + ? Math.max(incident.amountObserved, incident.amountLimit) + 1000 + : Math.max(incident.amountObserved + 1000, incident.amountLimit)), ); const parsed = parseDollarInput(draftAmount); const stateLabel = incidentStateLabel(incident); return ( - +
-
- {incident.scopeType} hard stop +
+ {incident.scopeType} {isWarning ? "budget warning" : "hard stop"}
{stateLabel}
- {incident.scopeName} - + {incident.scopeName} + {incident.thresholdType === "hard" && incident.amountObserved < incident.amountLimit ? `Accounting is incomplete. Known spend is ${formatCents(incident.amountObserved)}; review pending or unpriced usage in the budget policy.` : `Spending reached ${formatCents(incident.amountObserved)} against a limit of ${formatCents(incident.amountLimit)}.`}
-
- -
+ {isWarning ? ( +
+ +
+ ) : ( +
+ +
+ )}
-
- -
- {incident.scopeType === "project" - ? "Project execution is paused. New work in this project will not start until you resolve the budget incident." - : "This scope is paused. New heartbeats will not start until you resolve the budget incident."} + {isWarning ? ( +
+ +
+ Spending crossed the warning threshold. This warning does not pause work. +
-
+ ) : ( +
+ +
+ {incident.scopeType === "project" + ? "Project execution is paused. New work in this project will not start until you resolve the budget incident." + : "This scope is paused. New heartbeats will not start until you resolve the budget incident."} +
+
+ )}
{parsed !== null && parsed <= incident.amountObserved ? ( @@ -108,7 +128,7 @@ export function BudgetIncidentCard({
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 4bee302312..e1f067c8d4 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -952,7 +952,7 @@ export function Costs({

Active incidents

- Resolve hard stops here by raising the budget or explicitly keeping the scope paused. + Raise the budget or keep a hard stop paused. Dismiss a warning when no action is needed.