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 */}