diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 9c5f4dd0..ad9e6fde 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -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(null); const [workspaces, setWorkspaces] = useState([]); @@ -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" /> + @@ -363,11 +382,17 @@ function InputField({ )} 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 && ( +

{helper}

+ )} ); } diff --git a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx new file mode 100644 index 00000000..67be41cd --- /dev/null +++ b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx @@ -0,0 +1,267 @@ +// @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. + */ +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 })); + +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(); + +// ── Base workspace data ──────────────────────────────────────────────────────── + +function makeData(overrides: Record = {}) { + 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(); +}); + +// ── Read view ───────────────────────────────────────────────────────────────── + +describe("DetailsTab — budget_limit read view", () => { + it("shows 'Unlimited' when budgetLimit is null", () => { + render(); + expect(screen.getByText("Unlimited")).toBeTruthy(); + }); + + 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(); + }); +}); + +// ── Budget exceeded badge ───────────────────────────────────────────────────── + +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(); + await openEdit(); + const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe(""); + }); + + it("pre-fills input with existing budgetLimit value", 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); + }); + + it("sends budget_limit as null when field is cleared", 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(); + }); + + it("calls updateNodeData with the new budgetLimit on successful save", async () => { + render(); + await openEdit(); + + fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { + target: { value: "500" }, + }); + + 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"); + }); +}); diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index cdd50255..dd207743 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -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; + 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; + 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; + 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(""); + }); +}); diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 8891fee1..6ca9efa1 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -24,6 +24,9 @@ export function DetailsTab({ workspaceId, data }: Props) { const [name, setName] = useState(data.name); const [role, setRole] = useState(data.role || ""); const [tier, setTier] = useState(data.tier); + const [budgetLimit, setBudgetLimit] = useState( + data.budgetLimit != null ? String(data.budgetLimit) : "" + ); const [peers, setPeers] = useState([]); const [saving, setSaving] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -40,7 +43,8 @@ export function DetailsTab({ workspaceId, data }: Props) { setName(data.name); setRole(data.role || ""); setTier(data.tier); - }, [data.name, data.role, data.tier]); + setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : ""); + }, [data.name, data.role, data.tier, data.budgetLimit]); const loadPeers = useCallback(async () => { setPeersError(null); @@ -59,9 +63,17 @@ 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 }); - updateNodeData(workspaceId, { name, role: role || "", tier }); + await api.patch(`/workspaces/${workspaceId}`, { + name, + role: role || null, + tier, + budget_limit: parsedBudget, + }); + updateNodeData(workspaceId, { name, role: role || "", tier, budgetLimit: parsedBudget }); setEditing(false); } catch (e) { setSaveError(e instanceof Error ? e.message : "Failed to save"); @@ -95,6 +107,10 @@ 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); @@ -132,6 +148,18 @@ export function DetailsTab({ workspaceId, data }: Props) { + + setBudgetLimit(e.target.value)} + placeholder="Leave blank for unlimited" + className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20" + /> +

Leave blank for unlimited

+
{saveError && (
{saveError} @@ -146,7 +174,14 @@ export function DetailsTab({ workspaceId, data }: Props) { {saving ? "Saving..." : "Save"}
) : (
+ {budgetExceeded && ( +
+ + Budget limit exceeded +
+ )} + + {data.budgetUsed != null && ( + + )} diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index 687b215e..d28434ad 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -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, diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 387c71e6..d10da178 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -29,6 +29,10 @@ export interface WorkspaceNodeData extends Record { 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"; diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index 5689791e..f350c4d7 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -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;