feat(#541): budget settings UI with usage stats and 402 handling

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 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 01:25:26 +00:00
parent 07240e7095
commit b522484e2e
4 changed files with 742 additions and 207 deletions

View File

@ -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 }) => (
<div data-testid="budget-section-stub" data-ws={workspaceId} />
),
}));
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<string, unknown> = {}) {
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
expect(screen.getByText("Unlimited")).toBeTruthy();
});
// ── BudgetSection mounting ────────────────────────────────────────────────────
it("shows formatted dollar amount when budgetLimit is set", () => {
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 100 })} />);
expect(screen.getByText("$100.00")).toBeTruthy();
});
it("shows budget used row when budgetUsed is present", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 100, budgetUsed: 42.5 })}
/>
);
expect(screen.getByText("$42.50")).toBeTruthy();
});
it("does NOT show budget used row when budgetUsed is null", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 100, budgetUsed: null })}
/>
);
// "Budget used" label should not appear
expect(screen.queryByText("Budget used")).toBeNull();
describe("DetailsTab — BudgetSection integration", () => {
it("renders BudgetSection with the correct workspaceId", () => {
render(<DetailsTab workspaceId="ws-42" data={makeData()} />);
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(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 50, budgetUsed: 75 })}
/>
);
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(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 100, budgetUsed: 100 })}
/>
);
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
});
it("does NOT show exceeded badge when budgetUsed < budgetLimit", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 200, budgetUsed: 50 })}
/>
);
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
});
it("does NOT show exceeded badge when budgetLimit is null (unlimited)", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: null, budgetUsed: 999 })}
/>
);
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
});
it("does NOT show exceeded badge when budgetUsed is null", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 50, budgetUsed: null })}
/>
);
expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull();
});
it("exceeded badge has role='status' for accessible announcement", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 10, budgetUsed: 20 })}
/>
);
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
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(<DetailsTab workspaceId="ws-1" data={makeData()} />);
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 150 })} />);
it("PATCH /workspaces/:id body does NOT include budget_limit", async () => {
render(<DetailsTab workspaceId="ws-1" data={makeData({ name: "My Agent" })} />);
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
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<string, unknown>;
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 100 })} />);
it("PATCH /workspaces/:id body includes name, role, and tier", async () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ name: "Alpha", role: "Writer", tier: 2 })}
/>
);
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<string, unknown>;
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
it("Cancel reverts name, role, tier without touching budget state", async () => {
render(
<DetailsTab
workspaceId="ws-1"
data={makeData({ name: "Original", role: "Dev" })}
/>
);
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(
<DetailsTab
workspaceId="ws-1"
data={makeData({ name: "Bot", role: "Analyst", tier: 1 })}
/>
);
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<string, unknown>;
expect(updateArgs.budgetLimit).toBe(500);
});
it("restores original budgetLimit when Cancel is clicked", async () => {
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 75 })} />);
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(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 10, budgetUsed: 99 })}
/>
);
// 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(
<DetailsTab
workspaceId="ws-1"
data={makeData({ budgetLimit: 100 })}
/>
);
// "$100.00" and "Unlimited" are rendered by BudgetSection now
expect(screen.queryByText("$100.00")).toBeNull();
expect(screen.queryByText("Unlimited")).toBeNull();
});
});

View File

@ -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(<BudgetSection workspaceId="ws-1" />);
// 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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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<string, unknown>;
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<string, unknown>;
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
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(<BudgetSection workspaceId="ws-1" />);
await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull());
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});

View File

@ -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<BudgetData | null>(null);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [limitInput, setLimitInput] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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<BudgetData>(`/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<BudgetData>(`/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 (
<div className="space-y-3" data-testid="budget-section">
{/* Section header */}
<div>
<h3 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Budget
</h3>
<p className="text-[11px] text-zinc-400 mt-0.5">
Limit total message credits for this workspace
</p>
</div>
{/* 402 exceeded banner */}
{budgetExceeded && (
<div
role="alert"
data-testid="budget-exceeded-banner"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-950 border border-amber-700/50 text-amber-400 text-xs font-medium"
>
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<path
d="M6.5 1.5L11.5 10.5H1.5L6.5 1.5Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
<path
d="M6.5 5.5V7.5M6.5 9.5h.01"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
Budget exceeded messages blocked
</div>
)}
{/* Usage stats */}
{loading ? (
<p className="text-xs text-zinc-500" data-testid="budget-loading">
Loading
</p>
) : fetchError ? (
<p className="text-xs text-red-400" data-testid="budget-fetch-error">
{fetchError}
</p>
) : budget ? (
<div className="space-y-2">
{/* Stats row */}
<div className="flex items-baseline justify-between" data-testid="budget-stats-row">
<span className="text-xs text-zinc-400">Credits used</span>
<span className="text-xs font-mono text-zinc-300">
<span data-testid="budget-used-value">{budget.budget_used.toLocaleString()}</span>
<span className="text-zinc-500 mx-1">/</span>
<span data-testid="budget-limit-value">
{budget.budget_limit != null
? budget.budget_limit.toLocaleString()
: "Unlimited"}
</span>
</span>
</div>
{/* Progress bar (only when limit is set) */}
{budget.budget_limit != null && (
<div
role="progressbar"
aria-label="Budget usage"
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-zinc-800 overflow-hidden"
>
<div
data-testid="budget-progress-fill"
className="h-full rounded-full bg-blue-500 transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
)}
{/* Remaining credits */}
{budget.budget_remaining != null && (
<p className="text-[11px] text-zinc-500" data-testid="budget-remaining">
{budget.budget_remaining.toLocaleString()} credits remaining
</p>
)}
</div>
) : null}
{/* Input + Save */}
<div className="space-y-1.5 pt-1">
<label
htmlFor={`budget-limit-input-${workspaceId}`}
className="text-[11px] text-zinc-400 block"
>
Budget limit (credits)
</label>
<input
id={`budget-limit-input-${workspaceId}`}
type="number"
min="0"
step="1"
value={limitInput}
onChange={(e) => 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"
/>
<p className="text-xs text-zinc-500">Leave blank for unlimited</p>
{saveError && (
<div
role="alert"
data-testid="budget-save-error"
className="px-3 py-1.5 rounded-lg bg-red-950/40 border border-red-800/50 text-xs text-red-400"
>
{saveError}
</div>
)}
<button
onClick={handleSave}
disabled={saving}
data-testid="budget-save-btn"
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
);
}

View File

@ -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<PeerData[]>([]);
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) {
<option value={4}>Tier 4 VM</option>
</select>
</Field>
<Field label="Budget limit (USD)">
<input
type="number"
min="0"
step="0.01"
value={budgetLimit}
onChange={(e) => 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"
/>
<p className="mt-0.5 text-xs text-zinc-500">Leave blank for unlimited</p>
</Field>
{saveError && (
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
{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) {
</div>
) : (
<div className="space-y-1.5">
{budgetExceeded && (
<div
role="status"
aria-label="Budget limit exceeded"
data-testid="budget-exceeded-badge"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-red-950/50 border border-red-800/50 text-red-400 text-[11px] font-medium"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M6 1L11 10H1L6 1Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M6 5v2.5M6 9h.01" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Budget limit exceeded
</div>
)}
<Row label="Name" value={data.name} />
<Row label="Role" value={data.role || "—"} />
<Row label="Tier" value={`T${data.tier}`} />
<Row label="Budget limit" value={
data.budgetLimit != null ? `$${data.budgetLimit.toFixed(2)}` : "Unlimited"
} />
{data.budgetUsed != null && (
<Row label="Budget used" value={`$${data.budgetUsed.toFixed(2)}`} />
)}
<Row label="Status" value={data.status} />
<Row label="URL" value={data.url || "—"} mono />
<Row label="Parent" value={data.parentId || "root"} mono />
@ -246,7 +202,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
)}
</Section>
{/* Token usage + spend (scaffold — wired to GET /workspaces/:id/metrics once #593 lands) */}
{/* Budget — dedicated section with live usage stats (#541) */}
<BudgetSection workspaceId={workspaceId} />
{/* Token usage + spend — wired to GET /workspaces/:id/metrics (#592) */}
<WorkspaceUsage workspaceId={workspaceId} />
{/* Agent Card / Skills */}