stagent 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +35 -4
  2. package/package.json +3 -2
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -20
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/playbook/[slug]/page.tsx +76 -0
  18. package/src/app/playbook/page.tsx +54 -0
  19. package/src/app/profiles/page.tsx +7 -4
  20. package/src/app/settings/page.tsx +2 -2
  21. package/src/components/costs/cost-dashboard.tsx +226 -320
  22. package/src/components/dashboard/activity-feed.tsx +6 -2
  23. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  24. package/src/components/notifications/notification-item.tsx +6 -3
  25. package/src/components/notifications/pending-approval-host.tsx +57 -11
  26. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  27. package/src/components/playbook/journey-card.tsx +110 -0
  28. package/src/components/playbook/playbook-action-button.tsx +22 -0
  29. package/src/components/playbook/playbook-browser.tsx +143 -0
  30. package/src/components/playbook/playbook-card.tsx +102 -0
  31. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  32. package/src/components/playbook/playbook-homepage.tsx +142 -0
  33. package/src/components/playbook/playbook-toc.tsx +90 -0
  34. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  35. package/src/components/playbook/related-docs.tsx +30 -0
  36. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  37. package/src/components/profiles/context-proposal-review.tsx +7 -3
  38. package/src/components/profiles/learned-context-panel.tsx +116 -8
  39. package/src/components/profiles/profile-detail-view.tsx +6 -3
  40. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  41. package/src/components/settings/api-key-form.tsx +5 -43
  42. package/src/components/settings/auth-config-section.tsx +10 -6
  43. package/src/components/settings/auth-status-badge.tsx +8 -0
  44. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  45. package/src/components/settings/connection-test-control.tsx +63 -0
  46. package/src/components/settings/permissions-section.tsx +85 -75
  47. package/src/components/settings/permissions-sections.tsx +24 -0
  48. package/src/components/settings/presets-section.tsx +159 -0
  49. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  50. package/src/components/shared/app-sidebar.tsx +2 -0
  51. package/src/components/shared/command-palette.tsx +30 -0
  52. package/src/components/shared/light-markdown.tsx +134 -0
  53. package/src/components/workflows/loop-status-view.tsx +8 -4
  54. package/src/components/workflows/workflow-status-view.tsx +16 -9
  55. package/src/lib/agents/learned-context.ts +27 -15
  56. package/src/lib/agents/learning-session.ts +234 -0
  57. package/src/lib/agents/pattern-extractor.ts +19 -0
  58. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  59. package/src/lib/agents/profiles/sort.ts +7 -0
  60. package/src/lib/constants/settings.ts +1 -0
  61. package/src/lib/db/schema.ts +3 -0
  62. package/src/lib/docs/adoption.ts +105 -0
  63. package/src/lib/docs/journey-tracker.ts +21 -0
  64. package/src/lib/docs/reader.ts +102 -0
  65. package/src/lib/docs/types.ts +54 -0
  66. package/src/lib/docs/usage-stage.ts +60 -0
  67. package/src/lib/notifications/actionable.ts +18 -10
  68. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  69. package/src/lib/settings/budget-guardrails.ts +213 -85
  70. package/src/lib/settings/permission-presets.ts +150 -0
  71. package/src/lib/settings/runtime-setup.ts +71 -0
  72. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  73. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  74. package/src/lib/usage/ledger.ts +1 -1
  75. package/src/lib/usage/pricing-registry.ts +570 -0
  76. package/src/lib/usage/pricing.ts +15 -95
  77. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  78. package/src/lib/utils/learned-context-history.ts +150 -0
  79. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  80. package/src/lib/validators/settings.ts +3 -9
  81. package/src/lib/workflows/engine.ts +18 -0
@@ -1,75 +1,38 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState, type ComponentType } from "react";
3
+ import { useEffect, useMemo, useState, type ComponentType } from "react";
4
+ import { AlertTriangle, ArrowRight, CalendarClock, ShieldAlert, Wallet } from "lucide-react";
5
+ import { toast } from "sonner";
4
6
  import { Badge } from "@/components/ui/badge";
5
7
  import { Button } from "@/components/ui/button";
6
8
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
9
  import { Input } from "@/components/ui/input";
8
- import { Separator } from "@/components/ui/separator";
9
- import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
10
- import type { BudgetPolicy } from "@/lib/validators/settings";
10
+ import { Slider } from "@/components/ui/slider";
11
11
  import {
12
- AlertTriangle,
13
- ArrowRight,
14
- ChevronDown,
15
- ChevronUp,
16
- Coins,
17
- Landmark,
18
- RotateCcw,
19
- ShieldAlert,
20
- ShieldCheck,
21
- Wallet,
22
- } from "lucide-react";
23
- import { toast } from "sonner";
24
-
25
- type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
26
- type BudgetMetric = "spend" | "tokens";
27
- type BudgetWindow = "daily" | "monthly";
28
-
29
- interface BudgetStatus {
30
- id: string;
31
- scopeId: string;
32
- scopeLabel: string;
33
- runtimeId: string | null;
34
- metric: BudgetMetric;
35
- window: BudgetWindow;
36
- currentValue: number;
37
- limitValue: number | null;
38
- ratio: number | null;
39
- health: BudgetHealth;
40
- resetAtIso: string;
41
- }
42
-
43
- interface BudgetSnapshot {
44
- policy: BudgetPolicy;
45
- statuses: BudgetStatus[];
46
- dailyResetAtIso: string;
47
- monthlyResetAtIso: string;
48
- }
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@/components/ui/select";
18
+ import type { AgentRuntimeId } from "@/lib/agents/runtime/catalog";
19
+ import type { BudgetPolicy, ClaudeOAuthPlan } from "@/lib/validators/settings";
20
+ import type { BudgetSnapshot, BudgetWindowStatus } from "@/lib/settings/budget-guardrails";
21
+ import type { RuntimeSetupState } from "@/lib/settings/runtime-setup";
22
+ import { PricingRegistryPanel } from "./pricing-registry-panel";
49
23
 
50
24
  interface BudgetFormState {
51
- overallDailySpendCapUsd: string;
52
25
  overallMonthlySpendCapUsd: string;
53
26
  runtimes: Record<
54
- string,
27
+ AgentRuntimeId,
55
28
  {
56
- dailySpendCapUsd: string;
57
29
  monthlySpendCapUsd: string;
58
- dailyTokenCap: string;
59
- monthlyTokenCap: string;
30
+ claudeOAuthPlan?: ClaudeOAuthPlan;
60
31
  }
61
32
  >;
62
33
  }
63
34
 
64
- const runtimes = listRuntimeCatalog();
65
-
66
- interface DerivedTokenEstimate {
67
- estimatedBudgetTokens: number | null;
68
- estimatedRemainingTokens: number | null;
69
- sourceLabel: string | null;
70
- }
71
-
72
- function toInputValue(value: number | null) {
35
+ function toInputValue(value: number | null | undefined) {
73
36
  return value == null ? "" : String(value);
74
37
  }
75
38
 
@@ -80,101 +43,71 @@ function toNullableNumber(value: string) {
80
43
 
81
44
  function buildFormState(policy: BudgetPolicy): BudgetFormState {
82
45
  return {
83
- overallDailySpendCapUsd: toInputValue(policy.overall.dailySpendCapUsd),
84
46
  overallMonthlySpendCapUsd: toInputValue(policy.overall.monthlySpendCapUsd),
85
- runtimes: Object.fromEntries(
86
- runtimes.map((runtime) => [
87
- runtime.id,
88
- {
89
- dailySpendCapUsd: toInputValue(
90
- policy.runtimes[runtime.id].dailySpendCapUsd
91
- ),
92
- monthlySpendCapUsd: toInputValue(
93
- policy.runtimes[runtime.id].monthlySpendCapUsd
94
- ),
95
- dailyTokenCap: toInputValue(policy.runtimes[runtime.id].dailyTokenCap),
96
- monthlyTokenCap: toInputValue(
97
- policy.runtimes[runtime.id].monthlyTokenCap
98
- ),
99
- },
100
- ])
101
- ),
47
+ runtimes: {
48
+ "claude-code": {
49
+ monthlySpendCapUsd: toInputValue(
50
+ policy.runtimes["claude-code"].monthlySpendCapUsd
51
+ ),
52
+ claudeOAuthPlan: policy.runtimes["claude-code"].claudeOAuthPlan,
53
+ },
54
+ "openai-codex-app-server": {
55
+ monthlySpendCapUsd: toInputValue(
56
+ policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd
57
+ ),
58
+ },
59
+ },
102
60
  };
103
61
  }
104
62
 
105
63
  function buildPayload(form: BudgetFormState): BudgetPolicy {
106
64
  return {
107
65
  overall: {
108
- dailySpendCapUsd: toNullableNumber(form.overallDailySpendCapUsd),
109
66
  monthlySpendCapUsd: toNullableNumber(form.overallMonthlySpendCapUsd),
110
67
  },
111
- runtimes: Object.fromEntries(
112
- runtimes.map((runtime) => [
113
- runtime.id,
114
- {
115
- dailySpendCapUsd: toNullableNumber(
116
- form.runtimes[runtime.id].dailySpendCapUsd
117
- ),
118
- monthlySpendCapUsd: toNullableNumber(
119
- form.runtimes[runtime.id].monthlySpendCapUsd
120
- ),
121
- dailyTokenCap: toNullableNumber(form.runtimes[runtime.id].dailyTokenCap),
122
- monthlyTokenCap: toNullableNumber(
123
- form.runtimes[runtime.id].monthlyTokenCap
124
- ),
125
- },
126
- ])
127
- ) as BudgetPolicy["runtimes"],
68
+ runtimes: {
69
+ "claude-code": {
70
+ monthlySpendCapUsd: toNullableNumber(
71
+ form.runtimes["claude-code"].monthlySpendCapUsd
72
+ ),
73
+ claudeOAuthPlan: form.runtimes["claude-code"].claudeOAuthPlan,
74
+ },
75
+ "openai-codex-app-server": {
76
+ monthlySpendCapUsd: toNullableNumber(
77
+ form.runtimes["openai-codex-app-server"].monthlySpendCapUsd
78
+ ),
79
+ },
80
+ },
128
81
  };
129
82
  }
130
83
 
131
- function formatResetAt(value: string) {
132
- return new Date(value).toLocaleString(undefined, {
133
- dateStyle: "medium",
134
- timeStyle: "short",
135
- });
136
- }
137
-
138
- function formatStatusValue(status: BudgetStatus) {
139
- if (status.metric === "tokens") {
140
- return new Intl.NumberFormat("en-US").format(status.currentValue);
84
+ function formatCurrencyUsd(value: number | null) {
85
+ if (value == null) {
86
+ return "Unlimited";
141
87
  }
142
88
 
143
89
  return new Intl.NumberFormat("en-US", {
144
90
  style: "currency",
145
91
  currency: "USD",
146
- minimumFractionDigits: 2,
147
- maximumFractionDigits: 4,
148
- }).format(status.currentValue / 1_000_000);
92
+ minimumFractionDigits: value >= 100 ? 0 : 2,
93
+ maximumFractionDigits: value >= 100 ? 0 : 2,
94
+ }).format(value);
149
95
  }
150
96
 
151
- function formatStatusLimit(status: BudgetStatus) {
152
- if (status.limitValue == null) {
153
- return "Unlimited";
154
- }
155
-
156
- if (status.metric === "tokens") {
157
- return new Intl.NumberFormat("en-US").format(status.limitValue);
158
- }
159
-
97
+ function formatMicrosAsUsd(value: number) {
160
98
  return new Intl.NumberFormat("en-US", {
161
99
  style: "currency",
162
100
  currency: "USD",
163
101
  minimumFractionDigits: 2,
164
- maximumFractionDigits: 4,
165
- }).format(status.limitValue / 1_000_000);
102
+ maximumFractionDigits: value >= 1_000_000 ? 2 : 4,
103
+ }).format(value / 1_000_000);
166
104
  }
167
105
 
168
- function formatEstimatedTokens(value: number | null) {
169
- if (value == null) {
170
- return "Unavailable";
171
- }
172
-
173
- const rounded = Math.max(0, Math.round(value));
174
- return new Intl.NumberFormat("en-US", {
175
- notation: rounded >= 100_000 ? "compact" : "standard",
176
- maximumFractionDigits: rounded >= 100_000 ? 1 : 0,
177
- }).format(rounded);
106
+ function formatResetAt(value: string) {
107
+ return new Date(value).toLocaleString(undefined, {
108
+ dateStyle: "medium",
109
+ timeStyle: "short",
110
+ });
178
111
  }
179
112
 
180
113
  function SectionEyebrow({
@@ -192,104 +125,91 @@ function SectionEyebrow({
192
125
  );
193
126
  }
194
127
 
195
- function healthBadge(status: BudgetStatus) {
196
- if (status.health === "blocked") {
197
- return <Badge variant="destructive">Blocked</Badge>;
198
- }
199
- if (status.health === "warning") {
200
- return (
201
- <Badge
202
- variant="outline"
203
- className="border-status-warning/30 bg-status-warning/10 text-status-warning"
204
- >
205
- Warning
206
- </Badge>
207
- );
208
- }
209
- if (status.health === "ok") {
210
- return <Badge variant="success">Healthy</Badge>;
211
- }
212
- return <Badge variant="secondary">Unlimited</Badge>;
213
- }
214
-
215
128
  function getStatus(
216
- statuses: BudgetStatus[],
129
+ statuses: BudgetWindowStatus[],
217
130
  scopeId: string,
218
- window: BudgetWindow,
219
- metric: BudgetMetric
131
+ window: "daily" | "monthly"
220
132
  ) {
221
133
  return statuses.find(
222
- (status) =>
223
- status.scopeId === scopeId &&
224
- status.window === window &&
225
- status.metric === metric
134
+ (status) => status.scopeId === scopeId && status.window === window
226
135
  );
227
136
  }
228
137
 
229
- function deriveTokenEstimate(input: {
230
- spendCapUsd: string;
231
- primarySpendStatus?: BudgetStatus;
232
- primaryTokenStatus?: BudgetStatus;
233
- fallbackSpendStatus?: BudgetStatus;
234
- fallbackTokenStatus?: BudgetStatus;
235
- }): DerivedTokenEstimate {
236
- const spendCapUsd = toNullableNumber(input.spendCapUsd);
237
- if (spendCapUsd == null) {
238
- return {
239
- estimatedBudgetTokens: null,
240
- estimatedRemainingTokens: null,
241
- sourceLabel: null,
242
- };
138
+ function roundUsd(value: number) {
139
+ return Math.round(value * 100) / 100;
140
+ }
141
+
142
+ function deriveClaudeAllocation(form: BudgetFormState) {
143
+ const overall = toNullableNumber(form.overallMonthlySpendCapUsd);
144
+ const claude = toNullableNumber(form.runtimes["claude-code"].monthlySpendCapUsd);
145
+ const openai = toNullableNumber(
146
+ form.runtimes["openai-codex-app-server"].monthlySpendCapUsd
147
+ );
148
+
149
+ if (overall == null || overall <= 0) {
150
+ return 50;
243
151
  }
244
152
 
245
- const spendCapMicros = spendCapUsd * 1_000_000;
246
- const primaryHasRate =
247
- (input.primarySpendStatus?.currentValue ?? 0) > 0 &&
248
- (input.primaryTokenStatus?.currentValue ?? 0) > 0;
249
- const fallbackHasRate =
250
- (input.fallbackSpendStatus?.currentValue ?? 0) > 0 &&
251
- (input.fallbackTokenStatus?.currentValue ?? 0) > 0;
252
-
253
- const spendStatus = primaryHasRate
254
- ? input.primarySpendStatus
255
- : fallbackHasRate
256
- ? input.fallbackSpendStatus
257
- : undefined;
258
- const tokenStatus = primaryHasRate
259
- ? input.primaryTokenStatus
260
- : fallbackHasRate
261
- ? input.fallbackTokenStatus
262
- : undefined;
263
-
264
- if (!spendStatus || !tokenStatus) {
265
- return {
266
- estimatedBudgetTokens: null,
267
- estimatedRemainingTokens: null,
268
- sourceLabel: null,
269
- };
153
+ const total = (claude ?? 0) + (openai ?? 0);
154
+ if (total <= 0) {
155
+ return 50;
270
156
  }
271
157
 
272
- const tokensPerMicro = tokenStatus.currentValue / spendStatus.currentValue;
273
- const estimatedBudgetTokens = spendCapMicros * tokensPerMicro;
274
- const estimatedRemainingTokens = Math.max(
275
- 0,
276
- (spendCapMicros - spendStatus.currentValue) * tokensPerMicro
277
- );
158
+ return Math.round(((claude ?? 0) / total) * 100);
159
+ }
278
160
 
279
- return {
280
- estimatedBudgetTokens,
281
- estimatedRemainingTokens,
282
- sourceLabel:
283
- spendStatus.window === input.primarySpendStatus?.window
284
- ? `${spendStatus.window} blended pricing`
285
- : `${spendStatus.window} blended pricing fallback`,
161
+ function applyBudgetSplit(
162
+ current: BudgetFormState,
163
+ overallMonthlySpendCapUsd: string,
164
+ activeRuntimeIds: AgentRuntimeId[],
165
+ claudePercent = deriveClaudeAllocation(current)
166
+ ): BudgetFormState {
167
+ const overall = toNullableNumber(overallMonthlySpendCapUsd);
168
+ const next: BudgetFormState = {
169
+ overallMonthlySpendCapUsd,
170
+ runtimes: {
171
+ ...current.runtimes,
172
+ "claude-code": { ...current.runtimes["claude-code"] },
173
+ "openai-codex-app-server": {
174
+ ...current.runtimes["openai-codex-app-server"],
175
+ },
176
+ },
286
177
  };
178
+
179
+ if (overall == null || activeRuntimeIds.length === 0) {
180
+ next.runtimes["claude-code"].monthlySpendCapUsd = "";
181
+ next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = "";
182
+ return next;
183
+ }
184
+
185
+ if (activeRuntimeIds.length === 1) {
186
+ const runtimeId = activeRuntimeIds[0];
187
+ next.runtimes[runtimeId].monthlySpendCapUsd = String(overall);
188
+ for (const candidate of ["claude-code", "openai-codex-app-server"] as const) {
189
+ if (candidate !== runtimeId) {
190
+ next.runtimes[candidate].monthlySpendCapUsd = "";
191
+ }
192
+ }
193
+ return next;
194
+ }
195
+
196
+ const claudeCap = roundUsd(overall * (claudePercent / 100));
197
+ const openAICap = roundUsd(Math.max(overall - claudeCap, 0));
198
+
199
+ next.runtimes["claude-code"].monthlySpendCapUsd = String(claudeCap);
200
+ next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = String(openAICap);
201
+ return next;
287
202
  }
288
203
 
204
+ const CLAUDE_PLAN_LABELS: Record<ClaudeOAuthPlan, string> = {
205
+ pro: "Pro",
206
+ max_5x: "Max 5x",
207
+ max_20x: "Max 20x",
208
+ };
209
+
289
210
  export function BudgetGuardrailsSection() {
290
211
  const [snapshot, setSnapshot] = useState<BudgetSnapshot | null>(null);
291
212
  const [form, setForm] = useState<BudgetFormState | null>(null);
292
- const [advancedOpen, setAdvancedOpen] = useState<Record<string, boolean>>({});
293
213
  const [loading, setLoading] = useState(true);
294
214
  const [saving, setSaving] = useState(false);
295
215
  const [error, setError] = useState<string | null>(null);
@@ -309,17 +229,6 @@ export function BudgetGuardrailsSection() {
309
229
  const parsed = data as BudgetSnapshot;
310
230
  setSnapshot(parsed);
311
231
  setForm(buildFormState(parsed.policy));
312
- setAdvancedOpen(
313
- Object.fromEntries(
314
- runtimes.map((runtime) => [
315
- runtime.id,
316
- Boolean(
317
- parsed.policy.runtimes[runtime.id].dailyTokenCap ||
318
- parsed.policy.runtimes[runtime.id].monthlyTokenCap
319
- ),
320
- ])
321
- )
322
- );
323
232
  } catch (fetchError) {
324
233
  setError(
325
234
  fetchError instanceof Error
@@ -371,471 +280,345 @@ export function BudgetGuardrailsSection() {
371
280
  }
372
281
  }
373
282
 
283
+ const activeRuntimes = useMemo(() => {
284
+ if (!snapshot) {
285
+ return [] as RuntimeSetupState[];
286
+ }
287
+ return Object.values(snapshot.runtimeStates).filter((runtime) => runtime.configured);
288
+ }, [snapshot]);
289
+
374
290
  if (loading || !snapshot || !form) {
375
291
  return (
376
292
  <Card className="surface-card">
377
293
  <CardHeader>
378
294
  <CardTitle>Cost &amp; Usage Guardrails</CardTitle>
379
- <CardDescription>Loading budget policy and current usage windows.</CardDescription>
295
+ <CardDescription>Loading budget policy, runtime setup, and pricing data.</CardDescription>
380
296
  </CardHeader>
381
297
  </Card>
382
298
  );
383
299
  }
384
300
 
385
- const blockedStatuses = snapshot.statuses.filter((status) => status.health === "blocked");
386
- const warningStatuses = snapshot.statuses.filter((status) => status.health === "warning");
387
- const groupedStatuses = snapshot.statuses.reduce<Record<string, BudgetStatus[]>>(
388
- (acc, status) => {
389
- const key = status.scopeId;
390
- acc[key] ??= [];
391
- acc[key].push(status);
392
- return acc;
393
- },
394
- {}
395
- );
301
+ const overallDaily = getStatus(snapshot.statuses, "overall", "daily");
302
+ const overallMonthly = getStatus(snapshot.statuses, "overall", "monthly");
303
+ const blocked = snapshot.statuses.filter((status) => status.health === "blocked");
304
+ const warnings = snapshot.statuses.filter((status) => status.health === "warning");
305
+ const claudeAllocation = deriveClaudeAllocation(form);
306
+ const claudeRuntime = snapshot.runtimeStates["claude-code"];
307
+ const showSplitSlider = activeRuntimes.length === 2;
396
308
 
397
309
  return (
398
310
  <Card className="surface-card">
399
- <CardHeader>
400
- <div className="space-y-4">
401
- <div className="space-y-2">
402
- <CardTitle className="flex items-center gap-2">
403
- <Wallet className="h-5 w-5" />
404
- Cost &amp; Usage Guardrails
405
- </CardTitle>
406
- <CardDescription>
407
- Set optional daily and monthly spend caps for all runtime activity.
408
- Runtime sections keep spend as the primary control, show derived
409
- token guidance from recent blended pricing, and tuck hard token
410
- ceilings into an advanced section.
411
- </CardDescription>
412
- </div>
413
- <div className="grid gap-3 md:grid-cols-3">
414
- <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
415
- <div className="rounded-lg bg-destructive/10 p-2 text-destructive">
416
- <ShieldAlert className="h-4 w-4" />
417
- </div>
418
- <div className="min-w-0">
419
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
420
- Blocked now
421
- </p>
422
- <p className="mt-1 text-lg font-semibold">{blockedStatuses.length}</p>
423
- </div>
424
- </div>
425
- <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
426
- <div className="rounded-lg bg-status-warning/10 p-2 text-status-warning">
427
- <AlertTriangle className="h-4 w-4" />
428
- </div>
429
- <div className="min-w-0">
430
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
431
- Near cap
311
+ <CardHeader className="space-y-4">
312
+ <div className="space-y-2">
313
+ <CardTitle className="flex items-center gap-2">
314
+ <Wallet className="h-5 w-5" />
315
+ Cost &amp; Usage Guardrails
316
+ </CardTitle>
317
+ <CardDescription>
318
+ Set one monthly budget, let Stagent derive daily pacing, and keep provider spend
319
+ splits aligned with the runtimes you actually have configured.
320
+ </CardDescription>
321
+ </div>
322
+
323
+ {blocked.length > 0 ? (
324
+ <div className="surface-card-muted rounded-2xl border border-destructive/20 bg-destructive/8 p-4">
325
+ <div className="flex items-start gap-3">
326
+ <ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
327
+ <div className="space-y-1">
328
+ <p className="text-sm font-semibold">Spend is currently blocked</p>
329
+ <p className="text-sm text-muted-foreground">
330
+ {blocked[0]?.scopeLabel} hit its {blocked[0]?.window} cap. New paid work stays
331
+ blocked until the next reset.
432
332
  </p>
433
- <p className="mt-1 text-lg font-semibold">{warningStatuses.length}</p>
434
333
  </div>
435
334
  </div>
436
- <div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
437
- <div className="rounded-lg bg-info/10 p-2 text-info">
438
- <RotateCcw className="h-4 w-4" />
439
- </div>
440
- <div className="min-w-0">
441
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
442
- Reset windows
443
- </p>
444
- <p className="mt-1 text-xs text-muted-foreground">
445
- Day: {formatResetAt(snapshot.dailyResetAtIso)}
446
- </p>
447
- <p className="text-xs text-muted-foreground">
448
- Month: {formatResetAt(snapshot.monthlyResetAtIso)}
335
+ </div>
336
+ ) : warnings.length > 0 ? (
337
+ <div className="surface-card-muted rounded-2xl border border-status-warning/25 bg-status-warning/8 p-4">
338
+ <div className="flex items-start gap-3">
339
+ <AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
340
+ <div className="space-y-1">
341
+ <p className="text-sm font-semibold">Spend is approaching a cap</p>
342
+ <p className="text-sm text-muted-foreground">
343
+ {warnings[0]?.scopeLabel} is close to its {warnings[0]?.window} spend limit.
449
344
  </p>
450
345
  </div>
451
346
  </div>
452
347
  </div>
453
- </div>
348
+ ) : null}
454
349
  </CardHeader>
455
- <CardContent className="space-y-6">
456
- <div className="surface-panel rounded-xl p-4">
457
- <div className="flex items-start gap-3">
458
- {blockedStatuses.length > 0 ? (
459
- <ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
460
- ) : (
461
- <AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
462
- )}
463
- <div className="space-y-1 text-sm text-muted-foreground">
464
- <p>
465
- Warning notifications are emitted once per window when usage
466
- reaches 80% of a configured cap.
467
- </p>
468
- <p>
469
- Blocked attempts are recorded in the usage ledger with zero cost
470
- so later audit views can explain why work did not start.
471
- </p>
472
- </div>
473
- </div>
474
- </div>
475
350
 
476
- <div className="space-y-4">
477
- <div className="space-y-3">
478
- <div>
479
- <SectionEyebrow icon={Landmark} label="Global Guardrails" />
480
- <h3 className="text-sm font-semibold">Overall spend caps</h3>
481
- <p className="text-xs text-muted-foreground">
482
- Leave an input blank to keep that window unlimited.
483
- </p>
484
- </div>
485
- <div className="grid gap-3 md:grid-cols-2">
486
- <label className="space-y-2">
487
- <span className="text-sm font-medium">Daily spend cap (USD)</span>
488
- <Input
489
- className="surface-control"
490
- inputMode="decimal"
491
- placeholder="Unlimited"
492
- value={form.overallDailySpendCapUsd}
493
- onChange={(event) =>
494
- setForm((current) =>
495
- current
496
- ? {
497
- ...current,
498
- overallDailySpendCapUsd: event.target.value,
499
- }
500
- : current
501
- )
502
- }
503
- />
504
- </label>
505
- <label className="space-y-2">
506
- <span className="text-sm font-medium">Monthly spend cap (USD)</span>
507
- <Input
508
- className="surface-control"
509
- inputMode="decimal"
510
- placeholder="Unlimited"
511
- value={form.overallMonthlySpendCapUsd}
512
- onChange={(event) =>
513
- setForm((current) =>
514
- current
515
- ? {
516
- ...current,
517
- overallMonthlySpendCapUsd: event.target.value,
518
- }
519
- : current
520
- )
521
- }
522
- />
523
- </label>
524
- </div>
351
+ <CardContent className="space-y-6">
352
+ {activeRuntimes.length === 0 ? (
353
+ <div className="surface-panel rounded-2xl p-4 text-sm text-muted-foreground">
354
+ Configure Claude and/or OpenAI first. Guardrails adapt to the runtimes that are
355
+ currently set up.
525
356
  </div>
526
-
527
- <Separator />
528
-
529
- <div className="space-y-4">
530
- {runtimes.map((runtime) => (
531
- <div key={runtime.id} className="surface-card-muted rounded-xl p-4">
532
- <div className="mb-3">
533
- <SectionEyebrow icon={Wallet} label="Runtime Budget" />
534
- <h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
357
+ ) : (
358
+ <>
359
+ <div className="surface-panel rounded-2xl p-4">
360
+ <div className="space-y-3">
361
+ <div>
362
+ <SectionEyebrow icon={Wallet} label="Monthly Budget" />
363
+ <h3 className="mt-1 text-sm font-semibold">Overall spend cap</h3>
535
364
  <p className="text-xs text-muted-foreground">
536
- {runtime.description}
365
+ Leave blank to keep spend unlimited. Daily pacing is derived automatically.
537
366
  </p>
538
367
  </div>
539
- <div className="grid gap-3 md:grid-cols-2">
540
- <label className="space-y-2">
541
- <span className="text-sm font-medium">Daily spend cap (USD)</span>
542
- <Input
543
- className="surface-control"
544
- inputMode="decimal"
545
- placeholder="Unlimited"
546
- value={form.runtimes[runtime.id].dailySpendCapUsd}
547
- onChange={(event) =>
368
+
369
+ <label className="space-y-2">
370
+ <span className="text-sm font-medium">Monthly spend cap (USD)</span>
371
+ <Input
372
+ className="surface-control"
373
+ inputMode="decimal"
374
+ placeholder="Unlimited"
375
+ value={form.overallMonthlySpendCapUsd}
376
+ onChange={(event) =>
377
+ setForm((current) =>
378
+ current
379
+ ? applyBudgetSplit(
380
+ current,
381
+ event.target.value,
382
+ activeRuntimes.map((runtime) => runtime.runtimeId)
383
+ )
384
+ : current
385
+ )
386
+ }
387
+ />
388
+ </label>
389
+ </div>
390
+ </div>
391
+
392
+ {showSplitSlider ? (
393
+ <div className="surface-panel rounded-2xl p-4">
394
+ <div className="space-y-4">
395
+ <div>
396
+ <SectionEyebrow icon={ArrowRight} label="Provider Allocation" />
397
+ <h3 className="mt-1 text-sm font-semibold">Split the monthly cap</h3>
398
+ <p className="text-xs text-muted-foreground">
399
+ Adjust one slider. Stagent writes the provider cap figures for you.
400
+ </p>
401
+ </div>
402
+
403
+ <div className="space-y-3">
404
+ <div className="flex items-center justify-between gap-3 text-sm">
405
+ <span className="font-medium">Claude</span>
406
+ <span className="text-muted-foreground">{claudeAllocation}%</span>
407
+ </div>
408
+ <Slider
409
+ value={[claudeAllocation]}
410
+ min={0}
411
+ max={100}
412
+ step={1}
413
+ onValueChange={(value) =>
548
414
  setForm((current) =>
549
415
  current
550
- ? {
551
- ...current,
552
- runtimes: {
553
- ...current.runtimes,
554
- [runtime.id]: {
555
- ...current.runtimes[runtime.id],
556
- dailySpendCapUsd: event.target.value,
557
- },
558
- },
559
- }
416
+ ? applyBudgetSplit(
417
+ current,
418
+ current.overallMonthlySpendCapUsd,
419
+ activeRuntimes.map((runtime) => runtime.runtimeId),
420
+ value[0] ?? 50
421
+ )
560
422
  : current
561
423
  )
562
424
  }
563
425
  />
564
- </label>
565
- <label className="space-y-2">
566
- <span className="text-sm font-medium">Monthly spend cap (USD)</span>
567
- <Input
568
- className="surface-control"
569
- inputMode="decimal"
570
- placeholder="Unlimited"
571
- value={form.runtimes[runtime.id].monthlySpendCapUsd}
572
- onChange={(event) =>
426
+ <div className="grid gap-3 md:grid-cols-2">
427
+ {activeRuntimes.map((runtime) => (
428
+ <div key={runtime.runtimeId} className="rounded-xl border border-border/60 bg-background/40 p-3">
429
+ <p className="text-sm font-medium">{runtime.label}</p>
430
+ <p className="mt-1 text-lg font-semibold">
431
+ {formatCurrencyUsd(
432
+ toNullableNumber(form.runtimes[runtime.runtimeId].monthlySpendCapUsd)
433
+ )}
434
+ </p>
435
+ <p className="text-xs text-muted-foreground">
436
+ {runtime.runtimeId === "claude-code"
437
+ ? `${claudeAllocation}% of the monthly cap`
438
+ : `${100 - claudeAllocation}% of the monthly cap`}
439
+ </p>
440
+ </div>
441
+ ))}
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+ ) : (
447
+ <div className="grid gap-3 md:grid-cols-2">
448
+ {activeRuntimes.map((runtime) => (
449
+ <div key={runtime.runtimeId} className="surface-panel rounded-2xl p-4">
450
+ <SectionEyebrow icon={Wallet} label="Provider Cap" />
451
+ <h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
452
+ <p className="mt-2 text-lg font-semibold">
453
+ {formatCurrencyUsd(
454
+ toNullableNumber(form.runtimes[runtime.runtimeId].monthlySpendCapUsd)
455
+ )}
456
+ </p>
457
+ <p className="text-xs text-muted-foreground">
458
+ {runtime.label} receives the full monthly cap because it is the only
459
+ configured paid provider.
460
+ </p>
461
+ </div>
462
+ ))}
463
+ </div>
464
+ )}
465
+
466
+ {claudeRuntime.configured && claudeRuntime.billingMode === "subscription" ? (
467
+ <div className="surface-panel rounded-2xl p-4">
468
+ <div className="space-y-3">
469
+ <div>
470
+ <SectionEyebrow icon={CalendarClock} label="Claude Plan" />
471
+ <h3 className="mt-1 text-sm font-semibold">Claude OAuth billing</h3>
472
+ <p className="text-xs text-muted-foreground">
473
+ Claude OAuth uses fixed monthly subscription pricing for budgeting instead
474
+ of token-priced usage.
475
+ </p>
476
+ </div>
477
+
478
+ <div className="grid gap-3 md:grid-cols-[minmax(0,240px)_1fr]">
479
+ <Select
480
+ value={form.runtimes["claude-code"].claudeOAuthPlan ?? "pro"}
481
+ onValueChange={(value: ClaudeOAuthPlan) =>
573
482
  setForm((current) =>
574
483
  current
575
484
  ? {
576
485
  ...current,
577
486
  runtimes: {
578
487
  ...current.runtimes,
579
- [runtime.id]: {
580
- ...current.runtimes[runtime.id],
581
- monthlySpendCapUsd: event.target.value,
488
+ "claude-code": {
489
+ ...current.runtimes["claude-code"],
490
+ claudeOAuthPlan: value,
582
491
  },
583
492
  },
584
493
  }
585
494
  : current
586
495
  )
587
496
  }
588
- />
589
- </label>
497
+ >
498
+ <SelectTrigger className="surface-control">
499
+ <SelectValue placeholder="Select Claude plan" />
500
+ </SelectTrigger>
501
+ <SelectContent>
502
+ {Object.entries(CLAUDE_PLAN_LABELS).map(([value, label]) => (
503
+ <SelectItem key={value} value={value}>
504
+ {label}
505
+ </SelectItem>
506
+ ))}
507
+ </SelectContent>
508
+ </Select>
509
+
510
+ <div className="rounded-xl border border-border/60 bg-background/40 p-3">
511
+ <p className="text-sm font-medium">Budget basis</p>
512
+ <p className="mt-1 text-xs text-muted-foreground">
513
+ Dashboard pacing and guardrail enforcement use the selected plan price as
514
+ Claude&apos;s monthly cost basis. Activity and tokens still appear in audit
515
+ views.
516
+ </p>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ ) : null}
522
+
523
+ <PricingRegistryPanel
524
+ initialSnapshot={snapshot.pricing}
525
+ showClaudePlans={claudeRuntime.billingMode === "subscription"}
526
+ />
527
+
528
+ <div className="surface-panel rounded-2xl p-4">
529
+ <div className="space-y-3">
530
+ <div>
531
+ <SectionEyebrow icon={ArrowRight} label="Live Status" />
532
+ <h3 className="mt-1 text-sm font-semibold">Current spend pacing</h3>
533
+ <p className="text-xs text-muted-foreground">
534
+ Derived daily pacing is recalculated from the monthly cap using the current
535
+ calendar month.
536
+ </p>
590
537
  </div>
591
- {(() => {
592
- const dailySpendStatus = getStatus(
593
- snapshot.statuses,
594
- runtime.id,
595
- "daily",
596
- "spend"
597
- );
598
- const dailyTokenStatus = getStatus(
599
- snapshot.statuses,
600
- runtime.id,
601
- "daily",
602
- "tokens"
603
- );
604
- const monthlySpendStatus = getStatus(
605
- snapshot.statuses,
606
- runtime.id,
607
- "monthly",
608
- "spend"
609
- );
610
- const monthlyTokenStatus = getStatus(
611
- snapshot.statuses,
612
- runtime.id,
613
- "monthly",
614
- "tokens"
615
- );
616
- const dailyEstimate = deriveTokenEstimate({
617
- spendCapUsd: form.runtimes[runtime.id].dailySpendCapUsd,
618
- primarySpendStatus: dailySpendStatus,
619
- primaryTokenStatus: dailyTokenStatus,
620
- fallbackSpendStatus: monthlySpendStatus,
621
- fallbackTokenStatus: monthlyTokenStatus,
622
- });
623
- const monthlyEstimate = deriveTokenEstimate({
624
- spendCapUsd: form.runtimes[runtime.id].monthlySpendCapUsd,
625
- primarySpendStatus: monthlySpendStatus,
626
- primaryTokenStatus: monthlyTokenStatus,
627
- fallbackSpendStatus: dailySpendStatus,
628
- fallbackTokenStatus: dailyTokenStatus,
629
- });
630
- const hasAdvancedTokenCaps =
631
- Boolean(form.runtimes[runtime.id].dailyTokenCap) ||
632
- Boolean(form.runtimes[runtime.id].monthlyTokenCap);
633
-
634
- return (
635
- <>
636
- <div className="mt-4 grid gap-3 md:grid-cols-2">
637
- <div className="surface-panel rounded-lg px-3 py-3">
638
- <SectionEyebrow icon={Coins} label="Derived Guidance" />
639
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
640
- Estimated Daily Token Budget
641
- </p>
642
- {dailyEstimate.sourceLabel ? (
643
- <>
644
- <p className="mt-1 text-sm font-semibold">
645
- ~{formatEstimatedTokens(dailyEstimate.estimatedBudgetTokens)}
646
- </p>
647
- <p className="mt-1 text-xs text-muted-foreground">
648
- ~{formatEstimatedTokens(dailyEstimate.estimatedRemainingTokens)} remaining headroom based on {dailyEstimate.sourceLabel}.
649
- </p>
650
- </>
651
- ) : (
652
- <p className="mt-1 text-xs text-muted-foreground">
653
- Set a spend cap and accumulate priced usage to see a token estimate.
654
- </p>
655
- )}
656
- </div>
657
- <div className="surface-panel rounded-lg px-3 py-3">
658
- <SectionEyebrow icon={Coins} label="Derived Guidance" />
659
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
660
- Estimated Monthly Token Budget
661
- </p>
662
- {monthlyEstimate.sourceLabel ? (
663
- <>
664
- <p className="mt-1 text-sm font-semibold">
665
- ~{formatEstimatedTokens(monthlyEstimate.estimatedBudgetTokens)}
666
- </p>
667
- <p className="mt-1 text-xs text-muted-foreground">
668
- ~{formatEstimatedTokens(monthlyEstimate.estimatedRemainingTokens)} remaining headroom based on {monthlyEstimate.sourceLabel}.
669
- </p>
670
- </>
671
- ) : (
672
- <p className="mt-1 text-xs text-muted-foreground">
673
- Set a spend cap and accumulate priced usage to see a token estimate.
674
- </p>
675
- )}
676
- </div>
677
- </div>
678
538
 
679
- <div className="mt-4 rounded-lg border border-border/60 bg-background/40">
680
- <button
681
- type="button"
682
- className="flex w-full items-center justify-between gap-3 cursor-pointer px-3 py-2 text-left text-sm font-medium"
683
- onClick={() =>
684
- setAdvancedOpen((current) => ({
685
- ...current,
686
- [runtime.id]: !(current[runtime.id] ?? hasAdvancedTokenCaps),
687
- }))
688
- }
689
- >
690
- <span className="flex items-center gap-2">
691
- <ShieldCheck className="h-4 w-4 text-muted-foreground" />
692
- {advancedOpen[runtime.id] ?? hasAdvancedTokenCaps
693
- ? "Hide advanced token guardrails"
694
- : "Show advanced token guardrails"}
695
- </span>
696
- {advancedOpen[runtime.id] ?? hasAdvancedTokenCaps ? (
697
- <ChevronUp className="h-4 w-4 text-muted-foreground" />
698
- ) : (
699
- <ChevronDown className="h-4 w-4 text-muted-foreground" />
700
- )}
701
- </button>
702
- {(advancedOpen[runtime.id] ?? hasAdvancedTokenCaps) && (
703
- <div className="border-t border-border/60 px-3 py-3">
704
- <SectionEyebrow icon={ShieldCheck} label="Advanced Override" />
705
- <p className="mb-3 text-xs text-muted-foreground">
706
- Use hard token caps only when you need a strict technical ceiling. Spend caps remain the primary operator control.
707
- </p>
708
- <div className="grid gap-3 md:grid-cols-2">
709
- <label className="space-y-2">
710
- <span className="text-sm font-medium">Daily token cap</span>
711
- <Input
712
- className="surface-control"
713
- inputMode="numeric"
714
- placeholder="Unlimited"
715
- value={form.runtimes[runtime.id].dailyTokenCap}
716
- onChange={(event) =>
717
- setForm((current) =>
718
- current
719
- ? {
720
- ...current,
721
- runtimes: {
722
- ...current.runtimes,
723
- [runtime.id]: {
724
- ...current.runtimes[runtime.id],
725
- dailyTokenCap: event.target.value,
726
- },
727
- },
728
- }
729
- : current
730
- )
731
- }
732
- />
733
- </label>
734
- <label className="space-y-2">
735
- <span className="text-sm font-medium">Monthly token cap</span>
736
- <Input
737
- className="surface-control"
738
- inputMode="numeric"
739
- placeholder="Unlimited"
740
- value={form.runtimes[runtime.id].monthlyTokenCap}
741
- onChange={(event) =>
742
- setForm((current) =>
743
- current
744
- ? {
745
- ...current,
746
- runtimes: {
747
- ...current.runtimes,
748
- [runtime.id]: {
749
- ...current.runtimes[runtime.id],
750
- monthlyTokenCap: event.target.value,
751
- },
752
- },
753
- }
754
- : current
755
- )
756
- }
757
- />
758
- </label>
759
- </div>
539
+ <div className="grid gap-3 lg:grid-cols-2">
540
+ <div className="rounded-xl border border-border/60 bg-background/40 p-3">
541
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
542
+ Overall monthly spend
543
+ </p>
544
+ <p className="mt-1 text-lg font-semibold">
545
+ {overallMonthly
546
+ ? `${formatMicrosAsUsd(overallMonthly.currentValue)} / ${overallMonthly.limitValue == null ? "Unlimited" : formatMicrosAsUsd(overallMonthly.limitValue)}`
547
+ : "Unavailable"}
548
+ </p>
549
+ <p className="text-xs text-muted-foreground">
550
+ Resets {formatResetAt(snapshot.monthlyResetAtIso)}
551
+ </p>
552
+ </div>
553
+
554
+ <div className="rounded-xl border border-border/60 bg-background/40 p-3">
555
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
556
+ Today pace vs derived daily cap
557
+ </p>
558
+ <p className="mt-1 text-lg font-semibold">
559
+ {overallDaily
560
+ ? `${formatMicrosAsUsd(overallDaily.currentValue)} / ${overallDaily.limitValue == null ? "Unlimited" : formatMicrosAsUsd(overallDaily.limitValue)}`
561
+ : "Unavailable"}
562
+ </p>
563
+ <p className="text-xs text-muted-foreground">
564
+ Resets {formatResetAt(snapshot.dailyResetAtIso)}
565
+ </p>
566
+ </div>
567
+ </div>
568
+
569
+ <div className="space-y-2">
570
+ {activeRuntimes.map((runtime) => {
571
+ const monthlyStatus = getStatus(snapshot.statuses, runtime.runtimeId, "monthly");
572
+ const dailyStatus = getStatus(snapshot.statuses, runtime.runtimeId, "daily");
573
+
574
+ return (
575
+ <div
576
+ key={runtime.runtimeId}
577
+ className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/40 px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
578
+ >
579
+ <div>
580
+ <div className="flex items-center gap-2">
581
+ <p className="text-sm font-medium">{runtime.label}</p>
582
+ <Badge variant="outline">
583
+ {runtime.billingMode === "subscription" ? "Plan priced" : "Usage priced"}
584
+ </Badge>
760
585
  </div>
761
- )}
586
+ <p className="text-xs text-muted-foreground">
587
+ Monthly {monthlyStatus ? formatMicrosAsUsd(monthlyStatus.currentValue) : "Unavailable"}
588
+ {monthlyStatus?.limitValue != null
589
+ ? ` of ${formatMicrosAsUsd(monthlyStatus.limitValue)}`
590
+ : " of Unlimited"}
591
+ </p>
592
+ </div>
593
+ <p className="text-xs text-muted-foreground">
594
+ Today {dailyStatus ? formatMicrosAsUsd(dailyStatus.currentValue) : "Unavailable"}
595
+ {dailyStatus?.limitValue != null
596
+ ? ` / ${formatMicrosAsUsd(dailyStatus.limitValue)}`
597
+ : " / Unlimited"}
598
+ </p>
762
599
  </div>
763
- </>
764
- );
765
- })()}
600
+ );
601
+ })}
602
+ </div>
766
603
  </div>
767
- ))}
768
- </div>
769
- </div>
604
+ </div>
605
+ </>
606
+ )}
770
607
 
771
608
  <div className="flex items-center justify-between gap-3">
772
609
  <div className="text-xs text-muted-foreground">
773
- Blank fields are treated as unlimited. Changes reset warning dedupe for the current windows.
610
+ Warning notifications fire once per window after 80% of a configured cap is reached.
774
611
  </div>
775
612
  <Button onClick={handleSave} disabled={saving}>
776
613
  {saving ? "Saving..." : "Save guardrails"}
777
614
  </Button>
778
615
  </div>
779
616
 
780
- {error && (
617
+ {error ? (
781
618
  <div className="rounded-xl border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
782
619
  {error}
783
620
  </div>
784
- )}
785
-
786
- <Separator />
787
-
788
- <div className="space-y-3">
789
- <div>
790
- <SectionEyebrow icon={ArrowRight} label="Live Status" />
791
- <h3 className="text-sm font-semibold">Current window status</h3>
792
- <p className="text-xs text-muted-foreground">
793
- Live usage is derived from the normalized usage ledger in the
794
- machine&apos;s local timezone.
795
- </p>
796
- </div>
797
- <div className="grid gap-4 xl:grid-cols-3">
798
- {Object.entries(groupedStatuses).map(([scopeId, statuses]) => (
799
- <div key={scopeId} className="surface-card-muted rounded-xl p-4">
800
- <div className="mb-3 flex items-center justify-between gap-2">
801
- <h4 className="text-sm font-semibold">{statuses[0]?.scopeLabel}</h4>
802
- {statuses.some((status) => status.health === "blocked")
803
- ? <Badge variant="destructive">Blocked</Badge>
804
- : statuses.some((status) => status.health === "warning")
805
- ? (
806
- <Badge
807
- variant="outline"
808
- className="border-status-warning/30 bg-status-warning/10 text-status-warning"
809
- >
810
- Warning
811
- </Badge>
812
- )
813
- : <Badge variant="success">Healthy</Badge>}
814
- </div>
815
- <div className="space-y-2">
816
- {statuses.map((status) => (
817
- <div key={status.id} className="surface-panel rounded-lg px-3 py-2">
818
- <div className="flex items-center justify-between gap-2">
819
- <div>
820
- <p className="text-sm font-medium capitalize">
821
- {status.window} {status.metric}
822
- </p>
823
- <p className="text-xs text-muted-foreground">
824
- {formatStatusValue(status)} / {formatStatusLimit(status)}
825
- </p>
826
- </div>
827
- {healthBadge(status)}
828
- </div>
829
- <p className="mt-2 text-xs text-muted-foreground">
830
- Resets {formatResetAt(status.resetAtIso)}
831
- </p>
832
- </div>
833
- ))}
834
- </div>
835
- </div>
836
- ))}
837
- </div>
838
- </div>
621
+ ) : null}
839
622
  </CardContent>
840
623
  </Card>
841
624
  );