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
+