feat(canvas): budget_limit input in workspace creation and settings UI (#541)

- Adds optional Budget limit (USD) numeric field to CreateWorkspaceDialog;
  blank = null (unlimited), populated = parsed float sent as budget_limit in
  POST /workspaces body
- Adds budget_limit field to DetailsTab edit form; saves via
  PATCH /workspaces/:id; pre-fills from current WorkspaceNodeData
- Shows 'Budget limit exceeded' warning badge when budgetUsed > budgetLimit
  (forward-compatible — badge hidden when budgetUsed is absent)
- Extends WorkspaceData, WorkspaceNodeData, and buildNodesAndEdges to carry
  budgetLimit / budgetUsed fields ready for backend hydration (issue #541 BE PR)
- Ships 22 new tests across CreateWorkspaceDialog and BudgetLimit.DetailsTab
  suites (575 total, all passing); npm run build clean; 'use client' grep empty

API shape confirmed from workspace.go and CreateWorkspacePayload struct:
  field name: budget_limit | type: number | null | units: USD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 01:10:36 +00:00
parent 652019afcc
commit 07240e7095
7 changed files with 444 additions and 5 deletions

View File

@ -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,6 +365,8 @@ function InputField({
placeholder?: string;
required?: boolean;
mono?: boolean;
type?: string;
helper?: string;
}) {
return (
<div>
@ -363,11 +382,17 @@ function InputField({
)}
</label>
<input
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>
);
}

View File

@ -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<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();
});
// ── Read view ─────────────────────────────────────────────────────────────────
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();
});
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();
});
});
// ── Budget exceeded badge ─────────────────────────────────────────────────────
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 })} />);
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(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 150 })} />);
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);
});
it("sends budget_limit as null when field is cleared", async () => {
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: 100 })} />);
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();
});
it("calls updateNodeData with the new budgetLimit on successful save", async () => {
render(<DetailsTab workspaceId="ws-1" data={makeData({ budgetLimit: null })} />);
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<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");
});
});

View File

@ -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("");
});
});

View File

@ -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<PeerData[]>([]);
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) {
<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}
@ -146,7 +174,14 @@ 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);
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"
>
Cancel
@ -155,9 +190,29 @@ 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 />

View File

@ -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,

View File

@ -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";

View File

@ -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;