From 411952573e5e0154224d714ee9038a595fe39276 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 08:12:38 -0500 Subject: [PATCH] Add budget tabs and sidebar budget indicators --- ui/src/App.tsx | 1 + ui/src/components/BudgetPolicyCard.tsx | 222 ++++++++++++++-------- ui/src/components/BudgetSidebarMarker.tsx | 13 ++ ui/src/components/SidebarAgents.tsx | 24 ++- ui/src/components/SidebarProjects.tsx | 2 + ui/src/pages/AgentDetail.tsx | 24 ++- ui/src/pages/ProjectDetail.tsx | 50 +++-- 7 files changed, 224 insertions(+), 112 deletions(-) create mode 100644 ui/src/components/BudgetSidebarMarker.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b8d77f4..d00f809 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -133,6 +133,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/BudgetPolicyCard.tsx b/ui/src/components/BudgetPolicyCard.tsx index 4ff7209..7834e8c 100644 --- a/ui/src/components/BudgetPolicyCard.tsx +++ b/ui/src/components/BudgetPolicyCard.tsx @@ -33,11 +33,13 @@ export function BudgetPolicyCard({ onSave, isSaving, compact = false, + variant = "card", }: { summary: BudgetPolicySummary; onSave?: (amountCents: number) => void; isSaving?: boolean; compact?: boolean; + variant?: "card" | "plain"; }) { const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount)); @@ -49,6 +51,142 @@ export function BudgetPolicyCard({ const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave); const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0; const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet; + const isPlain = variant === "plain"; + + const observedBudgetGrid = isPlain ? ( +
+
+
Observed
+
{formatCents(summary.observedAmount)}
+
+ {summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"} +
+
+
+
Budget
+
+ {summary.amount > 0 ? formatCents(summary.amount) : "Disabled"} +
+
+ Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""} +
+
+
+ ) : ( +
+
+
Observed
+
{formatCents(summary.observedAmount)}
+
+ {summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"} +
+
+
+
Budget
+
+ {summary.amount > 0 ? formatCents(summary.amount) : "Disabled"} +
+
+ Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""} +
+
+
+ ); + + const progressSection = ( +
+
+ Remaining + {summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"} +
+
+
+
+
+ ); + + const pausedPane = summary.paused ? ( +
+ +
+ {summary.scopeType === "project" + ? "Execution is paused for this project until the budget is raised or the incident is dismissed." + : "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."} +
+
+ ) : null; + + const saveSection = onSave ? ( +
+
+ + setDraftBudget(event.target.value)} + className="mt-2" + inputMode="decimal" + placeholder="0.00" + /> +
+ +
+ ) : null; + + if (isPlain) { + return ( +
+
+
+
+ {summary.scopeType} +
+
{summary.scopeName}
+
{windowLabel(summary.windowKind)}
+
+
+ + {summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"} +
+
+ + {observedBudgetGrid} + {progressSection} + {pausedPane} + {saveSection} + {parsedDraft === null ? ( +

Enter a valid non-negative dollar amount.

+ ) : null} +
+ ); + } return ( @@ -68,84 +206,12 @@ export function BudgetPolicyCard({
-
-
-
Observed
-
{formatCents(summary.observedAmount)}
-
- {summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"} -
-
-
-
Budget
-
- {summary.amount > 0 ? formatCents(summary.amount) : "Disabled"} -
-
- Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""} -
-
-
- -
-
- Remaining - {summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"} -
-
-
-
-
- - {summary.paused ? ( -
- -
- {summary.scopeType === "project" - ? "Execution is paused for this project until the budget is raised or the incident is dismissed." - : "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."} -
-
- ) : null} - - {onSave ? ( -
-
-
- - setDraftBudget(event.target.value)} - className="mt-2" - inputMode="decimal" - placeholder="0.00" - /> -
- -
- {parsedDraft === null ? ( -

Enter a valid non-negative dollar amount.

- ) : null} -
+ {observedBudgetGrid} + {progressSection} + {pausedPane} + {saveSection} + {parsedDraft === null ? ( +

Enter a valid non-negative dollar amount.

) : null} diff --git a/ui/src/components/BudgetSidebarMarker.tsx b/ui/src/components/BudgetSidebarMarker.tsx new file mode 100644 index 0000000..43f10b9 --- /dev/null +++ b/ui/src/components/BudgetSidebarMarker.tsx @@ -0,0 +1,13 @@ +import { DollarSign } from "lucide-react"; + +export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) { + return ( + + + + ); +} diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index b94ccfe..5b438f0 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { AgentIcon } from "./AgentIconPicker"; +import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { Collapsible, CollapsibleContent, @@ -124,15 +125,22 @@ export function SidebarAgents() { > {agent.name} - {runCount > 0 && ( + {(agent.pauseReason === "budget" || runCount > 0) && ( - - - - - - {runCount} live - + {agent.pauseReason === "budget" ? ( + + ) : null} + {runCount > 0 ? ( + + + + + ) : null} + {runCount > 0 ? ( + + {runCount} live + + ) : null} )} diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index 72d0a21..cc6f417 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -20,6 +20,7 @@ import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; import { cn, projectRouteRef } from "../lib/utils"; import { useProjectOrder } from "../hooks/useProjectOrder"; +import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { Collapsible, CollapsibleContent, @@ -88,6 +89,7 @@ function SortableProjectItem({ style={{ backgroundColor: project.color ?? "#6366f1" }} /> {project.name} + {project.pauseReason === "budget" ? : null} {projectSidebarSlots.length > 0 && (
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c1c9ebf..3203935 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -185,10 +185,11 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "runs"; +type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; + if (value === "budget") return "budget"; if (value === "runs") return value; return "dashboard"; } @@ -638,6 +639,7 @@ export function AgentDetail() { { value: "dashboard", label: "Dashboard" }, { value: "configuration", label: "Configuration" }, { value: "runs", label: "Runs" }, + { value: "budget", label: "Budget" }, ]} value={activeView} onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)} @@ -645,15 +647,6 @@ export function AgentDetail() { )} - {!urlRunId && resolvedCompanyId ? ( - budgetMutation.mutate(amount)} - /> - ) : null} - {actionError &&

{actionError}

} {isPendingApproval && (

@@ -752,6 +745,17 @@ export function AgentDetail() { adapterType={agent.adapterType} /> )} + + {activeView === "budget" && resolvedCompanyId ? ( +

+ budgetMutation.mutate(amount)} + variant="plain" + /> +
+ ) : null}
); } diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index b74eb9d..4de4078 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -26,7 +26,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "configuration"; +type ProjectBaseTab = "overview" | "list" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -41,6 +41,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu const tab = segments[projectsIdx + 2]; if (tab === "overview") return "overview"; if (tab === "configuration") return "configuration"; + if (tab === "budget") return "budget"; if (tab === "issues") return "list"; return null; } @@ -328,6 +329,10 @@ export function ProjectDetail() { navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true }); return; } + if (activeTab === "budget") { + navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true }); + return; + } if (activeTab === "list") { if (filter) { navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); @@ -454,6 +459,8 @@ export function ProjectDetail() { } if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); + } else if (tab === "budget") { + navigate(`/projects/${canonicalProjectRef}/budget`); } else if (tab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`); } else { @@ -470,12 +477,20 @@ export function ProjectDetail() { onSelect={(color) => updateProject.mutate({ color })} />
- updateProject.mutate({ name })} - as="h2" - className="text-xl font-bold" - /> +
+ updateProject.mutate({ name })} + as="h2" + className="text-xl font-bold" + /> + {project.pauseReason === "budget" ? ( +
+ + Paused by budget hard stop +
+ ) : null} +
({ value: item.value, label: item.label, @@ -526,15 +542,6 @@ export function ProjectDetail() { /> - {resolvedCompanyId ? ( - budgetMutation.mutate(amount)} - /> - ) : null} - {activeTab === "overview" && ( )} + {activeTab === "budget" && resolvedCompanyId ? ( +
+ budgetMutation.mutate(amount)} + /> +
+ ) : null} + {activePluginTab && (