forked from molecule-ai/molecule-core
Merge pull request #606 from Molecule-AI/feat/issue-541-budget-limit-frontend
Merge gate passed (all 7 gates). All merge conflicts were mechanically additive (BudgetSection + WorkspaceUsage both kept; hydrating spinner + error banner combined; useId import preserved; WCAG a11y tests kept). UNSTABLE = known GitHub App token scope gap, not a test failure.
This commit is contained in:
commit
deecd01a8d
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useId } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
@ -42,6 +42,7 @@ export function CreateWorkspaceButton() {
|
||||
const [tier, setTier] = useState(1);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
@ -87,6 +88,7 @@ export function CreateWorkspaceButton() {
|
||||
setTier(1);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setHermesProvider("anthropic");
|
||||
setHermesApiKey("");
|
||||
@ -113,12 +115,17 @@ export function CreateWorkspaceButton() {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
|
||||
await api.post("/workspaces", {
|
||||
name: name.trim(),
|
||||
role: role.trim() || undefined,
|
||||
template: template.trim() || undefined,
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
...(isHermes && provider
|
||||
? { secrets: { [provider.envVar]: hermesApiKey.trim() } }
|
||||
@ -182,6 +189,14 @@ export function CreateWorkspaceButton() {
|
||||
onChange={setRole}
|
||||
placeholder="e.g. SEO Specialist"
|
||||
/>
|
||||
<InputField
|
||||
label="Budget limit (USD)"
|
||||
value={budgetLimit}
|
||||
onChange={setBudgetLimit}
|
||||
placeholder="e.g. 100"
|
||||
type="number"
|
||||
helper="Leave blank for unlimited"
|
||||
/>
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
@ -341,6 +356,8 @@ function InputField({
|
||||
placeholder,
|
||||
required,
|
||||
mono,
|
||||
type = "text",
|
||||
helper,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
@ -348,10 +365,16 @@ function InputField({
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
helper?: string;
|
||||
}) {
|
||||
// useId() generates a stable, unique ID for the label↔input association,
|
||||
// satisfying WCAG 2.1 SC 1.3.1 (Info and Relationships, Level A).
|
||||
const inputId = useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
<label htmlFor={inputId} className="text-[11px] text-zinc-400 block mb-1">
|
||||
{label}{" "}
|
||||
{required && (
|
||||
<>
|
||||
@ -363,11 +386,18 @@ function InputField({
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
min={type === "number" ? "0" : undefined}
|
||||
step={type === "number" ? "0.01" : undefined}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
/>
|
||||
{helper && (
|
||||
<p className="mt-1 text-xs text-zinc-500">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
221
canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx
Normal file
221
canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
removeNode: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
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";
|
||||
|
||||
const mockPatch = vi.mocked(api.patch);
|
||||
const mockGet = vi.mocked(api.get);
|
||||
const mockUpdateNodeData = vi.fn();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeData(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "Test Agent",
|
||||
role: "Researcher",
|
||||
tier: 1,
|
||||
status: "online",
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:8080",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "langgraph",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
budgetUsed: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockPatch.mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// ── BudgetSection mounting ────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Workspace edit form (no budget_limit) ──────────────────────────────────────
|
||||
|
||||
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();
|
||||
// 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("PATCH /workspaces/:id body does NOT include budget_limit", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={makeData({ name: "My Agent" })} />);
|
||||
await openEdit();
|
||||
|
||||
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(Object.prototype.hasOwnProperty.call(body, "budget_limit")).toBe(false);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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.name).toBe("Alpha");
|
||||
expect(body.role).toBe("Writer");
|
||||
expect(body.tier).toBe(2);
|
||||
});
|
||||
|
||||
it("Cancel reverts name, role, tier without touching budget state", async () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={makeData({ name: "Original", role: "Dev" })}
|
||||
/>
|
||||
);
|
||||
await openEdit();
|
||||
|
||||
// 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.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();
|
||||
});
|
||||
});
|
||||
389
canvas/src/components/__tests__/BudgetSection.test.tsx
Normal file
389
canvas/src/components/__tests__/BudgetSection.test.tsx
Normal file
@ -0,0 +1,389 @@
|
||||
// @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: 0 (not null) when input is '0' — zero-credit budget", async () => {
|
||||
// Regression for QA bug report: `parseInt("0") || null` would yield null.
|
||||
// The correct form `raw !== "" ? parseInt(raw, 10) : null` must return 0.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockPatch.mockResolvedValueOnce(budgetResponse({ budget_limit: 0, budget_used: 0, budget_remaining: 0 }) as any);
|
||||
await renderLoaded(budgetResponse({ budget_limit: 1000 }));
|
||||
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), {
|
||||
target: { value: "0" },
|
||||
});
|
||||
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).toBe(0);
|
||||
expect(body.budget_limit).not.toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -161,3 +161,72 @@ describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
});
|
||||
|
||||
// ── WCAG 2.1 SC 1.3.1 — Programmatic label association (Issue #558) ──────────
|
||||
//
|
||||
// Every <input> rendered by the InputField helper must have a matching <label>
|
||||
// via htmlFor/id so screen readers announce the field name, not just the
|
||||
// placeholder. useId() in InputField generates stable unique IDs per render.
|
||||
|
||||
describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () => {
|
||||
it("Name input has a <label> whose htmlFor matches the input id", async () => {
|
||||
await openDialog();
|
||||
const nameInput = screen.getByPlaceholderText("e.g. SEO Agent") as HTMLInputElement;
|
||||
expect(nameInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${nameInput.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Name");
|
||||
});
|
||||
|
||||
it("Role input has a <label> whose htmlFor matches the input id", async () => {
|
||||
await openDialog();
|
||||
const roleInput = screen.getByPlaceholderText("e.g. SEO Specialist") as HTMLInputElement;
|
||||
expect(roleInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${roleInput.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Role");
|
||||
});
|
||||
|
||||
it("Budget limit input has a <label> whose htmlFor matches the input id", async () => {
|
||||
await openDialog();
|
||||
const budgetInput = screen.getByPlaceholderText("e.g. 100") as HTMLInputElement;
|
||||
expect(budgetInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${budgetInput.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Budget limit");
|
||||
});
|
||||
|
||||
it("Template input has a <label> whose htmlFor matches the input id", async () => {
|
||||
await openDialog();
|
||||
const templateInput = screen.getByPlaceholderText(
|
||||
"e.g. seo-agent (from workspace-configs-templates/)"
|
||||
) as HTMLInputElement;
|
||||
expect(templateInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateInput.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Template");
|
||||
});
|
||||
|
||||
it("each InputField generates a distinct id (no id collisions)", async () => {
|
||||
await openDialog();
|
||||
const inputs = [
|
||||
screen.getByPlaceholderText("e.g. SEO Agent"),
|
||||
screen.getByPlaceholderText("e.g. SEO Specialist"),
|
||||
screen.getByPlaceholderText("e.g. 100"),
|
||||
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
|
||||
] as HTMLInputElement[];
|
||||
|
||||
const ids = inputs.map((i) => i.id).filter(Boolean);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length); // no duplicates
|
||||
expect(ids.length).toBe(4);
|
||||
});
|
||||
|
||||
it("Name label text contains the required asterisk indicator", async () => {
|
||||
await openDialog();
|
||||
const nameInput = screen.getByPlaceholderText("e.g. SEO Agent") as HTMLInputElement;
|
||||
const label = document.querySelector(`label[for="${nameInput.id}"]`);
|
||||
// aria-hidden asterisk * is present for visual required indicator
|
||||
expect(label?.querySelector("[aria-hidden='true']")?.textContent).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
@ -299,3 +299,85 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// budget_limit field tests (#541)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CreateWorkspaceDialog — budget_limit field", () => {
|
||||
it("renders a Budget limit (USD) input", async () => {
|
||||
await openDialog();
|
||||
const budgetInput = screen.getByPlaceholderText("e.g. 100");
|
||||
expect(budgetInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders helper text 'Leave blank for unlimited'", async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByText("Leave blank for unlimited")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends budget_limit as a number when a value is entered", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Budget Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "250" },
|
||||
});
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBe(250);
|
||||
});
|
||||
|
||||
it("sends budget_limit as null when the field is left blank", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Unlimited Agent" },
|
||||
});
|
||||
// Leave budget_limit empty
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBeNull();
|
||||
});
|
||||
|
||||
it("sends budget_limit as a float when a decimal value is entered", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Float Budget Agent" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "49.99" },
|
||||
});
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.budget_limit).toBeCloseTo(49.99);
|
||||
});
|
||||
|
||||
it("resets budget_limit to empty when dialog is reopened", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. 100"), {
|
||||
target: { value: "500" },
|
||||
});
|
||||
|
||||
// Close dialog
|
||||
const cancelBtn = screen.getAllByRole("button").find((b) =>
|
||||
b.textContent === "Cancel"
|
||||
);
|
||||
fireEvent.click(cancelBtn!);
|
||||
cleanup();
|
||||
|
||||
// Re-open
|
||||
await openDialog();
|
||||
const budgetInput = screen.getByPlaceholderText("e.g. 100") as HTMLInputElement;
|
||||
expect(budgetInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
253
canvas/src/components/tabs/BudgetSection.tsx
Normal file
253
canvas/src/components/tabs/BudgetSection.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
'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();
|
||||
// Use explicit empty-string check (not falsy check) so that a
|
||||
// user-entered "0" is sent as budget_limit: 0, not null (unlimited).
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
@ -60,7 +61,11 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier });
|
||||
await api.patch(`/workspaces/${workspaceId}`, {
|
||||
name,
|
||||
role: role || null,
|
||||
tier,
|
||||
});
|
||||
updateNodeData(workspaceId, { name, role: role || "", tier });
|
||||
setEditing(false);
|
||||
} catch (e) {
|
||||
@ -146,7 +151,13 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setSaveError(null); setName(data.name); setRole(data.role || ""); setTier(data.tier); }}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
setName(data.name);
|
||||
setRole(data.role || "");
|
||||
setTier(data.tier);
|
||||
}}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
@ -191,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 */}
|
||||
|
||||
@ -142,6 +142,8 @@ export function buildNodesAndEdges(
|
||||
currentTask: ws.current_task || "",
|
||||
runtime: ws.runtime || "",
|
||||
needsRestart: false,
|
||||
budgetLimit: ws.budget_limit ?? null,
|
||||
budgetUsed: ws.budget_used ?? null,
|
||||
},
|
||||
// Hide child nodes from canvas — they render inside the parent WorkspaceNode
|
||||
hidden: !!ws.parent_id,
|
||||
|
||||
@ -29,6 +29,10 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
needsRestart: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budgetLimit: number | null;
|
||||
/** Cumulative USD spend. Present when the platform tracks spend (issue #541). */
|
||||
budgetUsed?: number | null;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity";
|
||||
|
||||
@ -118,6 +118,10 @@ export interface WorkspaceData {
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
|
||||
budget_limit: number | null;
|
||||
/** Cumulative USD spend for this workspace. Present when the platform tracks spend. */
|
||||
budget_used?: number | null;
|
||||
}
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user