From 931e38c823816d3e8fdf9bb457baa066ae470f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 14:32:00 -0400 Subject: [PATCH] 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 --- .../dashboard/BudgetAlertBanner.tsx | 123 ++++++++++++++++++ .../components/dashboard/Dashboard.tsx | 4 + 2 files changed, 127 insertions(+) create mode 100644 src/renderer/components/dashboard/BudgetAlertBanner.tsx diff --git a/src/renderer/components/dashboard/BudgetAlertBanner.tsx b/src/renderer/components/dashboard/BudgetAlertBanner.tsx new file mode 100644 index 00000000..9f7d79ca --- /dev/null +++ b/src/renderer/components/dashboard/BudgetAlertBanner.tsx @@ -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(() => { + 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 = ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+ Budget Alert +
+
+ {agentsOverThreshold.length} agent(s) approaching budget limit +
+
+ {agentsOverThreshold.map((agent) => ( + = 100 + ? "text-[color:var(--danger)]" + : "text-[color:var(--warn)]" + }`} + > + {agent.name} ({agent.pct}%) + + ))} +
+ +
+ + {/* Dismiss button */} + +
+
+ ); + + if (dismissed) return null; + + if (reducedMotion) { + return content; + } + + return ( + + + {content} + + + ); +} diff --git a/src/renderer/components/dashboard/Dashboard.tsx b/src/renderer/components/dashboard/Dashboard.tsx index 76b9c106..29f95e5a 100644 --- a/src/renderer/components/dashboard/Dashboard.tsx +++ b/src/renderer/components/dashboard/Dashboard.tsx @@ -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 */} + {/* Budget alert banner for agents approaching budget limits */} + + {/* Health + Metrics */}