feat(03-03): add BudgetAlertBanner component and integrate into Dashboard

- Create BudgetAlertBanner with threshold detection (>=80% budget utilization)
- Display agent names with percentages, warn color for 80-99%, danger for 100%+
- Dismissible per session with auto-reset on new threshold crossings
- View Costs link navigates to costs section
- Accessible with role=alert, aria-live=assertive, keyboard-dismissible
- Framer Motion fade-in/slide-down animation, respects reduced motion
- Positioned after RecoveryBanner in Dashboard layout
This commit is contained in:
ZacharyZhang-NY committed 2026-03-22 14:32:00 -04:00
1 parent e0ee5bb5ad
commit 931e38c823
2 files changed
+127

No files matched your search

@@ -0,0 +1,123 @@
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 { useReducedMotion } from "../ui";
export interface BudgetAlertBannerProps {
agents: AgentRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}
interface AgentOverThreshold {
id: string;
name: string;
pct: number;
}
export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const agentsOverThreshold = useMemo<AgentOverThreshold[]>(() => {
return agents
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
.map((a) => ({
id: a.id,
name: a.name,
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
}))
.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],
);
useEffect(() => {
setDismissed(false);
}, [overThresholdKey]);
const handleDismiss = useCallback(() => setDismissed(true), []);
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
if (agentsOverThreshold.length === 0) return null;
const content = (
<div
role="alert"
aria-live="assertive"
className="rounded-[10px] 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
</div>
<div className="mt-0.5 text-[13px] text-[color:var(--muted-strong)]">
{agentsOverThreshold.length} agent(s) approaching budget limit
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{agentsOverThreshold.map((agent) => (
<span
key={agent.id}
className={`text-[11px] font-[family-name:var(--font-mono)] ${
agent.pct >= 100
? "text-[color:var(--danger)]"
: "text-[color:var(--warn)]"
}`}
>
{agent.name} ({agent.pct}%)
</span>
))}
</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>
{/* Dismiss button */}
<button
type="button"
onClick={handleDismiss}
title="Dismiss budget alert"
aria-label="Dismiss budget alert"
className="shrink-0 rounded p-0.5 text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
<X size={14} />
</button>
</div>
</div>
);
if (dismissed) return null;
if (reducedMotion) {
return content;
}
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{content}
</motion.div>
</AnimatePresence>
);
}
@@ -43,6 +43,7 @@ import { SprintProgress } from "./SprintProgress";
import { GoalProgress } from "./GoalProgress";
import { NeedsAttention } from "./NeedsAttention";
import { RecoveryBanner } from "./RecoveryBanner";
import { BudgetAlertBanner } from "./BudgetAlertBanner";
export interface DashboardProps {
agents: AgentRecord[];
@@ -90,6 +91,9 @@ export function Dashboard(props: DashboardProps) {
{/* Recovery banner for interrupted runs */}
<RecoveryBanner />
{/* Budget alert banner for agents approaching budget limits */}
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} />
{/* Health + Metrics */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">