feat(05-03): policy-level BudgetSummary, incident-based BudgetAlertBanner, AgentEditor policy wiring

- BudgetSummary shows policy-level utilization bars when companyId provided, falls back to agent-level
- BudgetAlertBanner loads open incidents for policy-based alerts (warn_threshold/hard_stop), falls back to agent thresholds
- AgentEditor reads budget from budget_policies and writes budget as policy on save (D-11)
- Dashboard passes companyId through to BudgetAlertBanner
This commit is contained in:
ZacharyZhang-NY committed 2026-03-26 21:17:02 -04:00
1 parent 3a8e4c40ad
commit 1c462d571c
5 files changed
+217 -46

No files matched your search

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { Warning, X } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "framer-motion";
import type { AgentRecord, SectionId } from "@shared/types";
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
import { useReducedMotion } from "../ui";
export interface BudgetAlertBannerProps {
agents: AgentRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
companyId?: string;
}
interface AgentOverThreshold {
@@ -15,10 +16,21 @@ interface AgentOverThreshold {
pct: number;
}
export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) {
export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
// Load policy-based incidents when companyId is provided
useEffect(() => {
if (!companyId) return;
void (async () => {
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" });
if (result.ok) setIncidents(result.data);
})();
}, [companyId]);
// Fallback: agent-level threshold detection (existing logic)
const agentsOverThreshold = useMemo<AgentOverThreshold[]>(() => {
return agents
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
@@ -30,34 +42,102 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
.sort((a, b) => b.pct - a.pct);
}, [agents]);
// Reset dismissed state when the set of agents over threshold changes
const overThresholdKey = useMemo(
() => agentsOverThreshold.map((a) => a.id).sort().join(","),
[agentsOverThreshold],
);
// Determine whether to use policy-based or agent-based alerts
const usePolicyAlerts = companyId && incidents.length > 0;
// Reset dismissed state when the set of alerts changes
const alertKey = useMemo(() => {
if (usePolicyAlerts) {
return incidents.map((i) => i.id).sort().join(",");
}
return agentsOverThreshold.map((a) => a.id).sort().join(",");
}, [usePolicyAlerts, incidents, agentsOverThreshold]);
useEffect(() => {
setDismissed(false);
}, [overThresholdKey]);
}, [alertKey]);
const handleDismiss = useCallback(() => setDismissed(true), []);
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
if (agentsOverThreshold.length === 0) return null;
// Nothing to show
if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null;
if (dismissed) return null;
const content = (
function getScopeName(scopeType: string, scopeId: string): string {
if (scopeType === "company") return "Company";
if (scopeType === "agent") {
const agent = agents.find((a) => a.id === scopeId);
return agent ? agent.name : scopeId.slice(0, 8);
}
return scopeId.slice(0, 8);
}
// Policy-based alert content
const policyContent = usePolicyAlerts ? (
<div className="space-y-2">
{incidents.map((incident) => {
const isHardStop = incident.thresholdType === "hard_stop";
const borderColor = isHardStop ? "border-[color:var(--danger)]" : "border-[color:var(--warn)]";
const bgColor = isHardStop ? "bg-[color:var(--danger-soft)]" : "bg-[color:var(--warn-soft)]";
const textColor = isHardStop ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]";
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
return (
<div
key={incident.id}
role="alert"
aria-live="assertive"
className={`rounded-[8px] border ${borderColor} border-opacity-30 ${bgColor} px-4 py-3`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className={textColor} />
</div>
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold ${textColor}`}>
{isHardStop ? "Budget Hard Stop" : "Budget Warning"}
</div>
<div className="mt-0.5 text-[13px] text-[color:var(--muted-strong)]">
{getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%)
</div>
<button
type="button"
onClick={handleViewCosts}
className="mt-1.5 text-[11px] font-medium text-[color:var(--accent)] hover:underline cursor-pointer"
>
View Costs
</button>
</div>
</div>
</div>
);
})}
<div className="flex justify-end">
<button
type="button"
onClick={handleDismiss}
title="Dismiss budget alerts"
aria-label="Dismiss budget alerts"
className="shrink-0 rounded p-0.5 text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
<X size={14} />
</button>
</div>
</div>
) : null;
// Fallback agent-level alert content (existing logic)
const agentContent = !usePolicyAlerts ? (
<div
role="alert"
aria-live="assertive"
className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className="text-[color:var(--warn)]" />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-[color:var(--warn)]">
Budget Alert
@@ -88,8 +168,6 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
View Costs
</button>
</div>
{/* Dismiss button */}
<button
type="button"
onClick={handleDismiss}
@@ -101,9 +179,9 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
</button>
</div>
</div>
);
) : null;
if (dismissed) return null;
const content = usePolicyAlerts ? policyContent : agentContent;
if (reducedMotion) {
return content;
@@ -30,6 +30,7 @@ import { BudgetAlertBanner } from "./BudgetAlertBanner";
export interface DashboardProps {
companyName?: string;
companyId?: string;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
@@ -44,7 +45,7 @@ export interface DashboardProps {
export function Dashboard(props: DashboardProps) {
const t = useT();
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
@@ -95,7 +96,7 @@ export function Dashboard(props: DashboardProps) {
{/* Conditional banners */}
<RecoveryBanner />
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} />
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
{/* Key Metrics — 4 cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
@@ -1,11 +1,31 @@
import { useEffect, useState } from "react"
import { Warning } from "@phosphor-icons/react"
import type { ProfileSnapshot } from "@shared/types"
import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types"
import { formatMoney } from "../../lib/formatters"
import { ListFrame, ListRow } from "../ui"
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
interface BudgetSummaryProps {
agents: ProfileSnapshot["agents"]
costs: ProfileSnapshot["costs"]
companyId?: string
}
export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) {
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null)
useEffect(() => {
if (!companyId) return
void (async () => {
const result = await window.agentCompany.getBudgetStatus(companyId)
if (result.ok) setBudgetStatus(result.data)
})()
}, [companyId])
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0)
const unattributedCount = costs.filter((entry) => entry.unattributed).length
const hasPolicies = budgetStatus && budgetStatus.policies.length > 0
return (
<div className="space-y-4">
<div className="border-y border-[color:var(--line)]">
@@ -18,30 +38,68 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
<div className="mono text-[18px] text-[color:var(--text)]">{String(unattributedCount)}</div>
</div>
</div>
<ListFrame>
{agents.map((agent) => {
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
return (
<ListRow key={agent.id}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
{hasPolicies ? (
<ListFrame>
{budgetStatus.policies.map((status) => {
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
const isWarning = pct > 80 && pct <= 100
const isDanger = pct > 100
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
const scopeLabel = status.scopeType === "company"
? "Company"
: status.scopeType === "agent"
? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8))
: status.scopeId.slice(0, 8)
return (
<ListRow key={status.policyId}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-[color:var(--panel-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-[color:var(--muted-strong)]">
{status.scopeType}
</span>
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
</ListRow>
)
})}
</ListFrame>
) : (
<ListFrame>
{agents.map((agent) => {
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
const barColor = isDanger ? "bg-[color:var(--danger)]" : isWarning ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"
const nameColor = isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--text)]"
return (
<ListRow key={agent.id}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
{isDanger ? <Warning size={14} weight="fill" className="text-[color:var(--danger)]" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-[color:var(--warn)]" /> : null}
</div>
<div className="flex items-center gap-2">
<div className="mono text-[12px] text-[color:var(--text)]">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
<span className={`mono text-[11px] font-semibold ${isDanger ? "text-[color:var(--danger)]" : isWarning ? "text-[color:var(--warn)]" : "text-[color:var(--muted)]"}`}>{Math.round(pct)}%</span>
</div>
</div>
</div>
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
</ListRow>
)
})}
</ListFrame>
<div className="h-2 rounded-full bg-[color:var(--line)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
</ListRow>
)
})}
</ListFrame>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Trash } from "@phosphor-icons/react";
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import type { AgentRecord, BudgetPolicyRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
import { unwrap } from "../../lib/desktop";
import { useAppStore } from "../../state/store";
@@ -87,6 +87,23 @@ export function AgentEditor({
}
}, [companyId, skills.length, setSkills]);
// Load agent's budget policy (D-11: read from budget_policies)
const [agentPolicy, setAgentPolicy] = useState<BudgetPolicyRecord | null>(null);
useEffect(() => {
void (async () => {
const result = await window.agentCompany.listBudgetPolicies(companyId);
if (result.ok) {
const policy = result.data.find(
(p) => p.scopeType === "agent" && p.scopeId === agent.id
);
if (policy) {
setAgentPolicy(policy);
setBudget(String((policy.amount / 100).toFixed(2)));
}
}
})();
}, [companyId, agent.id]);
const managerCandidates = agents.filter((a) => a.id !== agent.id);
return (
@@ -194,6 +211,7 @@ export function AgentEditor({
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
else delete meta.thinkingEffort;
try {
const budgetDollars = Number(budget || 0);
unwrap(await window.agentCompany.saveAgent({
id: agent.id,
companyId,
@@ -206,9 +224,24 @@ export function AgentEditor({
reportsTo: reportsTo || null,
model: model || null,
metadataJson: JSON.stringify(meta),
budgetMonthlyUsd: Number(budget || 0),
budgetMonthlyUsd: budgetDollars,
capabilities,
}));
// D-11: Also write to budget_policies for policy-based enforcement
if (budgetDollars > 0 && agent.id) {
await window.agentCompany.saveBudgetPolicy({
companyId,
scopeType: "agent",
scopeId: agent.id,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: Math.round(budgetDollars * 100),
warnPercent: agentPolicy?.warnPercent ?? 80,
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
isActive: true,
});
}
await onSaved();
} catch (error) {
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");