From 2152323cd1b7090deb5f3642a9f3edbc5d8ce2ba Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 01:25:26 +0000 Subject: [PATCH] feat(#541): budget settings UI with usage stats and 402 handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated BudgetSection component to the workspace details panel: - GET /workspaces/:id/budget on mount — populates live stats (used/limit/remaining) - Stats row + blue-500 progress bar (capped at 100%; hidden when unlimited) - PATCH /workspaces/:id/budget for saving; input blank → budget_limit: null - "Budget exceeded — messages blocked" amber/zinc-950 banner on any 402 response (GET or PATCH); banner clears on a successful subsequent save - 'use client'; dark zinc theme throughout (zinc-800/700 inputs, blue-500 accents) DetailsTab refactored: inline budget_limit fields removed; BudgetSection mounted as a self-contained section between Workspace and Skills. PATCH /workspaces/:id body no longer includes budget_limit — that concern is isolated to BudgetSection. Tests: 21 new cases in BudgetSection.test.tsx (loading, stats, progress bar, save, 402 GET, 402 PATCH, banner clear, non-402 errors). BudgetLimit.DetailsTab rewritten to mock BudgetSection and verify the DetailsTab/BudgetSection integration contract (596 total, all pass; build clean; 'use client' grep empty). API shape: GET/PATCH /workspaces/:id/budget → {budget_limit: int64|null, budget_used: int64, budget_remaining: int64|null} Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/BudgetLimit.DetailsTab.test.tsx | 272 ++++++------- .../__tests__/BudgetSection.test.tsx | 371 ++++++++++++++++++ canvas/src/components/tabs/BudgetSection.tsx | 251 ++++++++++++ canvas/src/components/tabs/DetailsTab.tsx | 55 +-- 4 files changed, 742 insertions(+), 207 deletions(-) create mode 100644 canvas/src/components/__tests__/BudgetSection.test.tsx create mode 100644 canvas/src/components/tabs/BudgetSection.tsx diff --git a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx index 67be41cd..a9515374 100644 --- a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx +++ b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx @@ -1,8 +1,13 @@ // @vitest-environment jsdom /** - * Tests for the budget_limit field in DetailsTab (issue #541). - * Covers: display in read view, editing + PATCH, exceeded badge, - * null/unlimited states, and cancel-revert. + * DetailsTab integration tests for issue #541. + * + * Budget-specific logic (stats, progress bar, PATCH /budget, 402 handling) is + * fully covered by BudgetSection.test.tsx — this file focuses on: + * 1. BudgetSection being mounted inside DetailsTab + * 2. The workspace edit form (name / role / tier) no longer carrying + * budget_limit — that concern lives in BudgetSection now + * 3. PATCH /workspaces/:id body integrity (no accidental budget_limit leak) */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; @@ -30,6 +35,15 @@ vi.mock("@/store/canvas", () => ({ vi.mock("../StatusDot", () => ({ StatusDot: () => null })); +// Mock BudgetSection — it has its own test suite (BudgetSection.test.tsx). +// Without this mock its internal api.get would fire against the shared mock +// and cause type errors when the return is not a valid BudgetData object. +vi.mock("../tabs/BudgetSection", () => ({ + BudgetSection: ({ workspaceId }: { workspaceId: string }) => ( +
+ ), +})); + import { api } from "@/lib/api"; import { DetailsTab } from "../tabs/DetailsTab"; @@ -37,7 +51,7 @@ const mockPatch = vi.mocked(api.patch); const mockGet = vi.mocked(api.get); const mockUpdateNodeData = vi.fn(); -// ── Base workspace data ──────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── function makeData(overrides: Record = {}) { return { @@ -73,195 +87,135 @@ afterEach(() => { cleanup(); }); -// ── Read view ───────────────────────────────────────────────────────────────── +async function openEdit() { + const editBtn = screen.getAllByRole("button").find((b) => b.textContent === "Edit"); + fireEvent.click(editBtn!); + await waitFor(() => + expect(screen.getAllByRole("button").some((b) => b.textContent === "Save")).toBe(true) + ); +} -describe("DetailsTab — budget_limit read view", () => { - it("shows 'Unlimited' when budgetLimit is null", () => { - render(); - expect(screen.getByText("Unlimited")).toBeTruthy(); - }); +// ── BudgetSection mounting ──────────────────────────────────────────────────── - it("shows formatted dollar amount when budgetLimit is set", () => { - render(); - expect(screen.getByText("$100.00")).toBeTruthy(); - }); - - it("shows budget used row when budgetUsed is present", () => { - render( - - ); - expect(screen.getByText("$42.50")).toBeTruthy(); - }); - - it("does NOT show budget used row when budgetUsed is null", () => { - render( - - ); - // "Budget used" label should not appear - expect(screen.queryByText("Budget used")).toBeNull(); +describe("DetailsTab — BudgetSection integration", () => { + it("renders BudgetSection with the correct workspaceId", () => { + render(); + const stub = screen.getByTestId("budget-section-stub"); + expect(stub).toBeTruthy(); + expect(stub.getAttribute("data-ws")).toBe("ws-42"); }); }); -// ── Budget exceeded badge ───────────────────────────────────────────────────── +// ── Workspace edit form (no budget_limit) ────────────────────────────────────── -describe("DetailsTab — budget exceeded badge", () => { - it("shows exceeded badge when budgetUsed > budgetLimit", () => { - render( - - ); - expect(screen.getByTestId("budget-exceeded-badge")).toBeTruthy(); - expect(screen.getByText("Budget limit exceeded")).toBeTruthy(); - }); - - it("does NOT show exceeded badge when budgetUsed equals budgetLimit", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetUsed < budgetLimit", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetLimit is null (unlimited)", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetUsed is null", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("exceeded badge has role='status' for accessible announcement", () => { - render( - - ); - const badge = screen.getByTestId("budget-exceeded-badge"); - expect(badge.getAttribute("role")).toBe("status"); - }); -}); - -// ── Edit + PATCH ────────────────────────────────────────────────────────────── - -describe("DetailsTab — budget_limit editing", () => { - async function openEdit() { - const editBtn = screen.getAllByRole("button").find((b) => b.textContent === "Edit"); - fireEvent.click(editBtn!); - await waitFor(() => expect(screen.getByPlaceholderText("Leave blank for unlimited")).toBeTruthy()); - } - - it("shows budget_limit input with placeholder 'Leave blank for unlimited' when editing", async () => { - render(); +describe("DetailsTab — workspace edit form does not include budget_limit", () => { + it("does NOT show a 'Budget limit (USD)' input in the edit form", async () => { + render(); await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input).toBeTruthy(); - expect(input.value).toBe(""); + // Budget limit (USD) was the old inline field label — must be absent now + expect(screen.queryByPlaceholderText("Leave blank for unlimited")).toBeNull(); + expect(screen.queryByText("Budget limit (USD)")).toBeNull(); }); - it("pre-fills input with existing budgetLimit value", async () => { - render(); + it("PATCH /workspaces/:id body does NOT include budget_limit", async () => { + render(); await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input.value).toBe("150"); - }); - - it("sends budget_limit as a number in PATCH body", async () => { - render(); - await openEdit(); - - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "300" }, - }); const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); const body = mockPatch.mock.calls[0][1] as Record; - expect(body.budget_limit).toBe(300); + expect(Object.prototype.hasOwnProperty.call(body, "budget_limit")).toBe(false); }); - it("sends budget_limit as null when field is cleared", async () => { - render(); + it("PATCH /workspaces/:id body includes name, role, and tier", async () => { + render( + + ); await openEdit(); - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "" }, - }); - const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); const body = mockPatch.mock.calls[0][1] as Record; - expect(body.budget_limit).toBeNull(); + expect(body.name).toBe("Alpha"); + expect(body.role).toBe("Writer"); + expect(body.tier).toBe(2); }); - it("calls updateNodeData with the new budgetLimit on successful save", async () => { - render(); + it("Cancel reverts name, role, tier without touching budget state", async () => { + render( + + ); await openEdit(); - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "500" }, - }); + // Modify name + fireEvent.change( + screen.getAllByRole("textbox").find((i) => (i as HTMLInputElement).value === "Original")!, + { target: { value: "Modified" } } + ); + + const cancelBtn = screen.getAllByRole("button").find((b) => b.textContent === "Cancel"); + fireEvent.click(cancelBtn!); + + // Should be back in read view — no Save button visible + expect(screen.queryAllByRole("button").some((b) => b.textContent === "Save")).toBe(false); + // Workspace info unchanged in read view + expect(screen.getByText("Original")).toBeTruthy(); + }); + + it("updateNodeData is called with name/role/tier but NOT budgetLimit on save", async () => { + render( + + ); + await openEdit(); const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockUpdateNodeData).toHaveBeenCalled()); const updateArgs = mockUpdateNodeData.mock.calls[0][1] as Record; - expect(updateArgs.budgetLimit).toBe(500); - }); - - it("restores original budgetLimit when Cancel is clicked", async () => { - render(); - await openEdit(); - - // Change the value - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "9999" }, - }); - - // Cancel - const cancelBtn = screen.getAllByRole("button").find((b) => b.textContent === "Cancel"); - fireEvent.click(cancelBtn!); - - // Re-enter edit mode — should show original value - await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input.value).toBe("75"); + expect(updateArgs.name).toBe("Bot"); + expect(updateArgs.role).toBe("Analyst"); + expect(updateArgs.tier).toBe(1); + expect(Object.prototype.hasOwnProperty.call(updateArgs, "budgetLimit")).toBe(false); + }); +}); + +// ── budget-exceeded-badge removed from DetailsTab ──────────────────────────── + +describe("DetailsTab — no inline budget-exceeded-badge", () => { + it("does NOT render budget-exceeded-badge even when budgetUsed > budgetLimit (BudgetSection owns that)", () => { + render( + + ); + // The old inline badge is gone — BudgetSection.tsx owns the exceeded state + expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); + }); + + it("does NOT render inline Budget limit row in read view", () => { + render( + + ); + // "$100.00" and "Unlimited" are rendered by BudgetSection now + expect(screen.queryByText("$100.00")).toBeNull(); + expect(screen.queryByText("Unlimited")).toBeNull(); }); }); diff --git a/canvas/src/components/__tests__/BudgetSection.test.tsx b/canvas/src/components/__tests__/BudgetSection.test.tsx new file mode 100644 index 00000000..c9616b06 --- /dev/null +++ b/canvas/src/components/__tests__/BudgetSection.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment jsdom +/** + * Tests for BudgetSection (issue #541). + * + * Covers: + * - Loading state + * - Stats row: used / limit, "Unlimited" when null + * - Progress bar: correct percentage, capped at 100%, absent when no limit + * - Budget remaining text + * - Input pre-fill (existing limit / blank when null) + * - Save: PATCH with number, PATCH with null (blank input) + * - 402 on GET → exceeded banner, no fetch-error text + * - 402 on PATCH → exceeded banner + * - Non-402 fetch error → error text + * - Non-402 save error → save error alert + * - Section header and subheading + * - Fetch error does not show stats + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, + act, +} from "@testing-library/react"; + +// ── Mock api ────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(), + patch: vi.fn(), + }, +})); + +import { api } from "@/lib/api"; +import { BudgetSection } from "../tabs/BudgetSection"; + +const mockGet = vi.mocked(api.get); +const mockPatch = vi.mocked(api.patch); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function budgetResponse(overrides: Partial<{ + budget_limit: number | null; + budget_used: number; + budget_remaining: number | null; +}> = {}) { + return { + budget_limit: 1000, + budget_used: 250, + budget_remaining: 750, + ...overrides, + }; +} + +function make402Error(): Error { + return new Error("API GET /workspaces/ws-1/budget: 402 Payment Required"); +} + +function make402PatchError(): Error { + return new Error("API PATCH /workspaces/ws-1/budget: 402 Payment Required"); +} + +function makeGenericError(msg = "network timeout"): Error { + return new Error(`API GET /workspaces/ws-1/budget: 500 ${msg}`); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +// ── Rendering helpers ───────────────────────────────────────────────────────── + +async function renderLoaded(budgetData = budgetResponse()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetData as any); + render(); + // Wait for loading to finish + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); +} + +// ── Loading state ───────────────────────────────────────────────────────────── + +describe("BudgetSection — loading state", () => { + it("shows loading indicator while fetch is in flight", () => { + // Never resolve + mockGet.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByTestId("budget-loading")).toBeTruthy(); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("hides loading indicator after fetch resolves", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetResponse() as any); + render(); + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + }); +}); + +// ── Section header ──────────────────────────────────────────────────────────── + +describe("BudgetSection — header and subheading", () => { + it("renders 'Budget' as the section heading", async () => { + await renderLoaded(); + expect(screen.getByText("Budget")).toBeTruthy(); + }); + + it("renders the subheading 'Limit total message credits for this workspace'", async () => { + await renderLoaded(); + expect( + screen.getByText("Limit total message credits for this workspace") + ).toBeTruthy(); + }); + + it("renders 'Budget limit (credits)' label for the input", async () => { + await renderLoaded(); + expect(screen.getByText("Budget limit (credits)")).toBeTruthy(); + }); +}); + +// ── Stats row ───────────────────────────────────────────────────────────────── + +describe("BudgetSection — stats row", () => { + it("shows budget_used in the stats row", async () => { + await renderLoaded(budgetResponse({ budget_used: 350, budget_limit: 1000 })); + expect(screen.getByTestId("budget-used-value").textContent).toBe("350"); + }); + + it("shows budget_limit in the stats row", async () => { + await renderLoaded(budgetResponse({ budget_used: 100, budget_limit: 500 })); + expect(screen.getByTestId("budget-limit-value").textContent).toBe("500"); + }); + + it("shows 'Unlimited' when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + expect(screen.getByTestId("budget-limit-value").textContent).toBe("Unlimited"); + }); + + it("shows budget_remaining when present", async () => { + await renderLoaded(budgetResponse({ budget_remaining: 750 })); + expect(screen.getByTestId("budget-remaining").textContent).toContain("750"); + expect(screen.getByTestId("budget-remaining").textContent).toContain("credits remaining"); + }); + + it("hides budget_remaining row when null", async () => { + await renderLoaded(budgetResponse({ budget_remaining: null })); + expect(screen.queryByTestId("budget-remaining")).toBeNull(); + }); +}); + +// ── Progress bar ────────────────────────────────────────────────────────────── + +describe("BudgetSection — progress bar", () => { + it("renders the progress bar when budget_limit is set", async () => { + await renderLoaded(budgetResponse({ budget_used: 250, budget_limit: 1000 })); + expect(screen.getByRole("progressbar")).toBeTruthy(); + }); + + it("does NOT render progress bar when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + expect(screen.queryByRole("progressbar")).toBeNull(); + }); + + it("fills to the correct percentage (25%)", async () => { + await renderLoaded(budgetResponse({ budget_used: 250, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("25%"); + }); + + it("fills to the correct percentage (50%)", async () => { + await renderLoaded(budgetResponse({ budget_used: 500, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("50%"); + }); + + it("caps fill at 100% when budget_used exceeds budget_limit", async () => { + await renderLoaded(budgetResponse({ budget_used: 1500, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("100%"); + }); + + it("progress bar has aria-valuenow equal to the calculated percentage", async () => { + await renderLoaded(budgetResponse({ budget_used: 300, budget_limit: 1000 })); + const bar = screen.getByRole("progressbar"); + expect(bar.getAttribute("aria-valuenow")).toBe("30"); + }); +}); + +// ── Input pre-fill ──────────────────────────────────────────────────────────── + +describe("BudgetSection — input pre-fill", () => { + it("pre-fills input with existing budget_limit", async () => { + await renderLoaded(budgetResponse({ budget_limit: 500 })); + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + expect(input.value).toBe("500"); + }); + + it("leaves input empty when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + expect(input.value).toBe(""); + }); +}); + +// ── Save — PATCH calls ──────────────────────────────────────────────────────── + +describe("BudgetSection — save", () => { + it("calls PATCH /workspaces/:id/budget with budget_limit as integer", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(budgetResponse({ budget_limit: 800 }) as any); + await renderLoaded(budgetResponse({ budget_limit: 1000 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "800" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => expect(mockPatch).toHaveBeenCalled()); + expect(mockPatch.mock.calls[0][0]).toBe("/workspaces/ws-1/budget"); + const body = mockPatch.mock.calls[0][1] as Record; + expect(body.budget_limit).toBe(800); + }); + + it("sends budget_limit: null when input is blank", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(budgetResponse({ budget_limit: null, budget_remaining: null }) as any); + await renderLoaded(budgetResponse({ budget_limit: 1000 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => expect(mockPatch).toHaveBeenCalled()); + const body = mockPatch.mock.calls[0][1] as Record; + expect(body.budget_limit).toBeNull(); + }); + + it("updates displayed stats after successful save", async () => { + const updated = budgetResponse({ budget_limit: 2000, budget_used: 500, budget_remaining: 1500 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(updated as any); + await renderLoaded(budgetResponse({ budget_limit: 1000, budget_used: 250 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "2000" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-limit-value").textContent).toBe("2,000") + ); + }); + + it("shows save error message on non-402 PATCH failure", async () => { + mockPatch.mockRejectedValueOnce( + new Error("API PATCH /workspaces/ws-1/budget: 500 server error") + ); + await renderLoaded(); + + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-save-error")).toBeTruthy() + ); + expect(screen.getByTestId("budget-save-error").textContent).toContain("500"); + }); +}); + +// ── 402 handling ────────────────────────────────────────────────────────────── + +describe("BudgetSection — 402 handling", () => { + it("shows exceeded banner when GET returns 402", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + expect(screen.getByText("Budget exceeded — messages blocked")).toBeTruthy(); + }); + + it("does NOT show fetch error text when GET returns 402 (only banner)", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + + await waitFor(() => + expect(screen.queryByTestId("budget-loading")).toBeNull() + ); + expect(screen.queryByTestId("budget-fetch-error")).toBeNull(); + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + + it("shows exceeded banner when PATCH returns 402", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetResponse() as any); + mockPatch.mockRejectedValueOnce(make402PatchError()); + render(); + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + // Should NOT also show the save-error alert + expect(screen.queryByTestId("budget-save-error")).toBeNull(); + }); + + it("clears exceeded banner after a successful save", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + + // Now a successful PATCH (limit was raised) + const updated = budgetResponse({ budget_limit: 5000, budget_used: 250, budget_remaining: 4750 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(updated as any); + + await act(async () => { + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "5000" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + }); + + await waitFor(() => + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull() + ); + }); +}); + +// ── Non-402 fetch error ─────────────────────────────────────────────────────── + +describe("BudgetSection — non-402 fetch errors", () => { + it("shows fetch error text on non-402 GET failure", async () => { + mockGet.mockRejectedValueOnce(makeGenericError("internal server error")); + render(); + + await waitFor(() => + expect(screen.getByTestId("budget-fetch-error")).toBeTruthy() + ); + expect(screen.getByTestId("budget-fetch-error").textContent).toContain("500"); + }); + + it("does NOT show stats row on fetch error", async () => { + mockGet.mockRejectedValueOnce(makeGenericError()); + render(); + + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + expect(screen.queryByTestId("budget-stats-row")).toBeNull(); + }); + + it("does NOT show exceeded banner on non-402 fetch error", async () => { + mockGet.mockRejectedValueOnce(makeGenericError()); + render(); + + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull(); + }); +}); diff --git a/canvas/src/components/tabs/BudgetSection.tsx b/canvas/src/components/tabs/BudgetSection.tsx new file mode 100644 index 00000000..86b74daa --- /dev/null +++ b/canvas/src/components/tabs/BudgetSection.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface BudgetData { + budget_limit: number | null; + budget_used: number; + budget_remaining: number | null; +} + +interface Props { + workspaceId: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** True when an API error carries a 402 status code. */ +function isApiError402(e: unknown): boolean { + return e instanceof Error && /: 402( |$)/.test(e.message); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * BudgetSection — dedicated "Budget" section in the workspace details panel. + * + * - Fetches GET /workspaces/:id/budget on mount for live usage stats + * - Shows a progress bar (budget_used / budget_limit, blue-500, capped 100%) + * - Allows updating budget_limit via PATCH /workspaces/:id/budget + * - Shows a 402-specific "Budget exceeded" amber banner for any blocked state + */ +export function BudgetSection({ workspaceId }: Props) { + const [budget, setBudget] = useState(null); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + + const [limitInput, setLimitInput] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + /** True when a 402 has been seen from any API call in this section. */ + const [budgetExceeded, setBudgetExceeded] = useState(false); + + // ── Fetch current budget data ───────────────────────────────────────────── + + const loadBudget = useCallback(async () => { + setLoading(true); + setFetchError(null); + try { + const data = await api.get(`/workspaces/${workspaceId}/budget`); + setBudget(data); + setLimitInput(data.budget_limit != null ? String(data.budget_limit) : ""); + } catch (e) { + if (isApiError402(e)) { + setBudgetExceeded(true); + } else { + setFetchError(e instanceof Error ? e.message : "Failed to load budget"); + } + } finally { + setLoading(false); + } + }, [workspaceId]); + + useEffect(() => { + loadBudget(); + }, [loadBudget]); + + // ── Save handler ────────────────────────────────────────────────────────── + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + const raw = limitInput.trim(); + const parsedLimit = raw ? parseInt(raw, 10) : null; + + try { + const updated = await api.patch(`/workspaces/${workspaceId}/budget`, { + budget_limit: parsedLimit, + }); + setBudget(updated); + setLimitInput(updated.budget_limit != null ? String(updated.budget_limit) : ""); + // Clear exceeded state if the save succeeded (limit was raised or removed) + setBudgetExceeded(false); + } catch (e) { + if (isApiError402(e)) { + setBudgetExceeded(true); + } else { + setSaveError(e instanceof Error ? e.message : "Failed to save budget"); + } + } finally { + setSaving(false); + } + }; + + // ── Progress calculation ────────────────────────────────────────────────── + + const progressPct = + budget && budget.budget_limit != null && budget.budget_limit > 0 + ? Math.min(100, Math.round((budget.budget_used / budget.budget_limit) * 100)) + : 0; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Section header */} +
+

+ Budget +

+

+ Limit total message credits for this workspace +

+
+ + {/* 402 exceeded banner */} + {budgetExceeded && ( +
+ + Budget exceeded — messages blocked +
+ )} + + {/* Usage stats */} + {loading ? ( +

+ Loading… +

+ ) : fetchError ? ( +

+ {fetchError} +

+ ) : budget ? ( +
+ {/* Stats row */} +
+ Credits used + + {budget.budget_used.toLocaleString()} + / + + {budget.budget_limit != null + ? budget.budget_limit.toLocaleString() + : "Unlimited"} + + +
+ + {/* Progress bar (only when limit is set) */} + {budget.budget_limit != null && ( +
+
+
+ )} + + {/* Remaining credits */} + {budget.budget_remaining != null && ( +

+ {budget.budget_remaining.toLocaleString()} credits remaining +

+ )} +
+ ) : null} + + {/* Input + Save */} +
+ + setLimitInput(e.target.value)} + placeholder="e.g. 1000 — blank for unlimited" + data-testid="budget-limit-input" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-300 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors" + /> +

Leave blank for unlimited

+ + {saveError && ( +
+ {saveError} +
+ )} + + +
+
+ ); +} diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 6ca9efa1..b9f9042f 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { StatusDot } from "../StatusDot"; +import { BudgetSection } from "./BudgetSection"; import { WorkspaceUsage } from "../WorkspaceUsage"; interface Props { @@ -24,9 +25,6 @@ export function DetailsTab({ workspaceId, data }: Props) { const [name, setName] = useState(data.name); const [role, setRole] = useState(data.role || ""); const [tier, setTier] = useState(data.tier); - const [budgetLimit, setBudgetLimit] = useState( - data.budgetLimit != null ? String(data.budgetLimit) : "" - ); const [peers, setPeers] = useState([]); const [saving, setSaving] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -43,8 +41,7 @@ export function DetailsTab({ workspaceId, data }: Props) { setName(data.name); setRole(data.role || ""); setTier(data.tier); - setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : ""); - }, [data.name, data.role, data.tier, data.budgetLimit]); + }, [data.name, data.role, data.tier]); const loadPeers = useCallback(async () => { setPeersError(null); @@ -63,17 +60,13 @@ export function DetailsTab({ workspaceId, data }: Props) { const handleSave = async () => { setSaving(true); setSaveError(null); - const parsedBudget = budgetLimit.trim() - ? parseFloat(budgetLimit) - : null; try { await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier, - budget_limit: parsedBudget, }); - updateNodeData(workspaceId, { name, role: role || "", tier, budgetLimit: parsedBudget }); + updateNodeData(workspaceId, { name, role: role || "", tier }); setEditing(false); } catch (e) { setSaveError(e instanceof Error ? e.message : "Failed to save"); @@ -107,10 +100,6 @@ export function DetailsTab({ workspaceId, data }: Props) { }; const isRestartable = data.status === "offline" || data.status === "failed" || data.status === "degraded"; - const budgetExceeded = - data.budgetLimit != null && - data.budgetUsed != null && - data.budgetUsed > data.budgetLimit; const agentCard = data.agentCard; const skills = getSkills(agentCard); @@ -148,18 +137,6 @@ export function DetailsTab({ workspaceId, data }: Props) { - - setBudgetLimit(e.target.value)} - placeholder="Leave blank for unlimited" - className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20" - /> -

Leave blank for unlimited

-
{saveError && (
{saveError} @@ -180,7 +157,6 @@ export function DetailsTab({ workspaceId, data }: Props) { setName(data.name); setRole(data.role || ""); setTier(data.tier); - setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : ""); }} className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300" > @@ -190,29 +166,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
) : (
- {budgetExceeded && ( -
- - Budget limit exceeded -
- )} - - {data.budgetUsed != null && ( - - )} @@ -246,7 +202,10 @@ export function DetailsTab({ workspaceId, data }: Props) { )} - {/* Token usage + spend (scaffold — wired to GET /workspaces/:id/metrics once #593 lands) */} + {/* Budget — dedicated section with live usage stats (#541) */} + + + {/* Token usage + spend — wired to GET /workspaces/:id/metrics (#592) */} {/* Agent Card / Skills */}