Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta
2026-03-16 17:02:39 -05:00
125 changed files with 38085 additions and 683 deletions

View File

@@ -0,0 +1,69 @@
import { Database, Gauge, ReceiptText } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
const SURFACES = [
{
title: "Inference ledger",
description: "Request-scoped usage and billed runs from cost_events.",
icon: Database,
points: ["tokens + billed dollars", "provider, biller, model", "subscription and overage aware"],
tone: "from-sky-500/12 via-sky-500/6 to-transparent",
},
{
title: "Finance ledger",
description: "Account-level charges that are not one prompt-response pair.",
icon: ReceiptText,
points: ["top-ups, refunds, fees", "Bedrock provisioned or training charges", "credit expiries and adjustments"],
tone: "from-amber-500/14 via-amber-500/6 to-transparent",
},
{
title: "Live quotas",
description: "Provider or biller windows that can stop traffic in real time.",
icon: Gauge,
points: ["provider quota windows", "biller credit systems", "errors surfaced directly"],
tone: "from-emerald-500/14 via-emerald-500/6 to-transparent",
},
] as const;
export function AccountingModelCard() {
return (
<Card className="relative overflow-hidden border-border/70">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]" />
<CardHeader className="relative px-5 pt-5 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Accounting model
</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-6">
Paperclip now separates request-level inference usage from account-level finance events.
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
</CardDescription>
</CardHeader>
<CardContent className="relative grid gap-3 px-5 pb-5 md:grid-cols-3">
{SURFACES.map((surface) => {
const Icon = surface.icon;
return (
<div
key={surface.title}
className={`rounded-2xl border border-border/70 bg-gradient-to-br ${surface.tone} p-4 shadow-sm`}
>
<div className="mb-3 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-background/80">
<Icon className="h-4 w-4 text-foreground" />
</div>
<div>
<div className="text-sm font-semibold">{surface.title}</div>
<div className="text-xs text-muted-foreground">{surface.description}</div>
</div>
</div>
<div className="space-y-1.5 text-xs text-muted-foreground">
{surface.points.map((point) => (
<div key={point}>{point}</div>
))}
</div>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -33,6 +33,9 @@ export function ApprovalCard({
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type;
const showResolutionButtons =
approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested");
return (
<div className="border border-border rounded-lg p-4 space-y-0">
@@ -67,7 +70,7 @@ export function ApprovalCard({
)}
{/* Actions */}
{(approval.status === "pending" || approval.status === "revision_requested") && (
{showResolutionButtons && (
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
<Button
size="sm"

View File

@@ -1,13 +1,16 @@
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
import { formatCents } from "../lib/utils";
export const typeLabel: Record<string, string> = {
hire_agent: "Hire Agent",
approve_ceo_strategy: "CEO Strategy",
budget_override_required: "Budget Override",
};
export const typeIcon: Record<string, typeof UserPlus> = {
hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb,
budget_override_required: ShieldAlert,
};
export const defaultTypeIcon = ShieldCheck;
@@ -69,7 +72,28 @@ export function CeoStrategyPayload({ payload }: { payload: Record<string, unknow
);
}
export function BudgetOverridePayload({ payload }: { payload: Record<string, unknown> }) {
const budgetAmount = typeof payload.budgetAmount === "number" ? payload.budgetAmount : null;
const observedAmount = typeof payload.observedAmount === "number" ? payload.observedAmount : null;
return (
<div className="mt-3 space-y-1.5 text-sm">
<PayloadField label="Scope" value={payload.scopeName ?? payload.scopeType} />
<PayloadField label="Window" value={payload.windowKind} />
<PayloadField label="Metric" value={payload.metric} />
{(budgetAmount !== null || observedAmount !== null) ? (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Limit {budgetAmount !== null ? formatCents(budgetAmount) : "—"} · Observed {observedAmount !== null ? formatCents(observedAmount) : "—"}
</div>
) : null}
{!!payload.guidance && (
<p className="text-muted-foreground">{String(payload.guidance)}</p>
)}
</div>
);
}
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
return <CeoStrategyPayload payload={payload} />;
}

View File

@@ -0,0 +1,145 @@
import { useMemo } from "react";
import type { CostByBiller, CostByProviderModel } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { QuotaBar } from "./QuotaBar";
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
interface BillerSpendCardProps {
row: CostByBiller;
weekSpendCents: number;
budgetMonthlyCents: number;
totalCompanySpendCents: number;
providerRows: CostByProviderModel[];
}
export function BillerSpendCard({
row,
weekSpendCents,
budgetMonthlyCents,
totalCompanySpendCents,
providerRows,
}: BillerSpendCardProps) {
const providerBreakdown = useMemo(() => {
const map = new Map<string, { provider: string; costCents: number; inputTokens: number; outputTokens: number }>();
for (const entry of providerRows) {
const current = map.get(entry.provider) ?? {
provider: entry.provider,
costCents: 0,
inputTokens: 0,
outputTokens: 0,
};
current.costCents += entry.costCents;
current.inputTokens += entry.inputTokens + entry.cachedInputTokens;
current.outputTokens += entry.outputTokens;
map.set(entry.provider, current);
}
return Array.from(map.values()).sort((a, b) => b.costCents - a.costCents);
}, [providerRows]);
const billingTypeBreakdown = useMemo(() => {
const map = new Map<string, number>();
for (const entry of providerRows) {
map.set(entry.billingType, (map.get(entry.billingType) ?? 0) + entry.costCents);
}
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
}, [providerRows]);
const providerBudgetShare =
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
: budgetMonthlyCents;
const budgetPct =
providerBudgetShare > 0
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
: 0;
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0 gap-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-sm font-semibold">
{providerDisplayName(row.biller)}
</CardTitle>
<CardDescription className="text-xs mt-0.5">
<span className="font-mono">{formatTokens(row.inputTokens + row.cachedInputTokens)}</span> in
{" · "}
<span className="font-mono">{formatTokens(row.outputTokens)}</span> out
{" · "}
{row.providerCount} provider{row.providerCount === 1 ? "" : "s"}
{" · "}
{row.modelCount} model{row.modelCount === 1 ? "" : "s"}
</CardDescription>
</div>
<span className="text-xl font-bold tabular-nums shrink-0">
{formatCents(row.costCents)}
</span>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-3 space-y-4">
{budgetMonthlyCents > 0 && (
<QuotaBar
label="Period spend"
percentUsed={budgetPct}
leftLabel={formatCents(row.costCents)}
rightLabel={`${Math.round(budgetPct)}% of allocation`}
/>
)}
<div className="text-xs text-muted-foreground">
{row.apiRunCount > 0 ? `${row.apiRunCount} metered run${row.apiRunCount === 1 ? "" : "s"}` : "0 metered runs"}
{" · "}
{row.subscriptionRunCount > 0
? `${row.subscriptionRunCount} subscription run${row.subscriptionRunCount === 1 ? "" : "s"}`
: "0 subscription runs"}
{" · "}
{formatCents(weekSpendCents)} this week
</div>
{billingTypeBreakdown.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Billing types
</p>
<div className="space-y-1.5">
{billingTypeBreakdown.map(([billingType, costCents]) => (
<div key={billingType} className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">{billingTypeDisplayName(billingType as any)}</span>
<span className="font-medium tabular-nums">{formatCents(costCents)}</span>
</div>
))}
</div>
</div>
</>
)}
{providerBreakdown.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Upstream providers
</p>
<div className="space-y-1.5">
{providerBreakdown.map((entry) => (
<div key={entry.provider} className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">{providerDisplayName(entry.provider)}</span>
<div className="text-right tabular-nums">
<div className="font-medium">{formatCents(entry.costCents)}</div>
<div className="text-muted-foreground">
{formatTokens(entry.inputTokens + entry.outputTokens)} tok
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,100 @@
import { useState } from "react";
import type { BudgetIncident } from "@paperclipai/shared";
import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react";
import { formatCents } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function centsInputValue(value: number) {
return (value / 100).toFixed(2);
}
function parseDollarInput(value: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
export function BudgetIncidentCard({
incident,
onRaiseAndResume,
onKeepPaused,
isMutating,
}: {
incident: BudgetIncident;
onRaiseAndResume: (amountCents: number) => void;
onKeepPaused: () => void;
isMutating?: boolean;
}) {
const [draftAmount, setDraftAmount] = useState(
centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)),
);
const parsed = parseDollarInput(draftAmount);
return (
<Card className="overflow-hidden border-red-500/20 bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]">
<CardHeader className="px-5 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-red-200/80">
{incident.scopeType} hard stop
</div>
<CardTitle className="mt-1 text-base text-red-50">{incident.scopeName}</CardTitle>
<CardDescription className="mt-1 text-red-100/70">
Spending reached {formatCents(incident.amountObserved)} against a limit of {formatCents(incident.amountLimit)}.
</CardDescription>
</div>
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-200">
<AlertOctagon className="h-4 w-4" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5 pt-0">
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-50/90">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{incident.scopeType === "project"
? "Project execution is paused. New work in this project will not start until you resolve the budget incident."
: "This scope is paused. New heartbeats will not start until you resolve the budget incident."}
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
New budget (USD)
</label>
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
<Input
value={draftAmount}
onChange={(event) => setDraftAmount(event.target.value)}
inputMode="decimal"
placeholder="0.00"
/>
<Button
className="gap-2"
disabled={isMutating || parsed === null || parsed <= incident.amountObserved}
onClick={() => {
if (typeof parsed === "number") onRaiseAndResume(parsed);
}}
>
<ArrowUpRight className="h-4 w-4" />
{isMutating ? "Applying..." : "Raise budget & resume"}
</Button>
</div>
{parsed !== null && parsed <= incident.amountObserved ? (
<p className="mt-2 text-xs text-red-200/80">
The new budget must exceed current observed spend.
</p>
) : null}
</div>
<div className="flex justify-end">
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
Keep paused
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useState } from "react";
import type { BudgetPolicySummary } from "@paperclipai/shared";
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
import { cn, formatCents } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function centsInputValue(value: number) {
return (value / 100).toFixed(2);
}
function parseDollarInput(value: string) {
const normalized = value.trim();
if (normalized.length === 0) return 0;
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) {
return windowKind === "lifetime" ? "Lifetime budget" : "Monthly UTC budget";
}
function statusTone(status: BudgetPolicySummary["status"]) {
if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10";
if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10";
return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10";
}
export function BudgetPolicyCard({
summary,
onSave,
isSaving,
compact = false,
variant = "card",
}: {
summary: BudgetPolicySummary;
onSave?: (amountCents: number) => void;
isSaving?: boolean;
compact?: boolean;
variant?: "card" | "plain";
}) {
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
useEffect(() => {
setDraftBudget(centsInputValue(summary.amount));
}, [summary.amount]);
const parsedDraft = parseDollarInput(draftBudget);
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
const isPlain = variant === "plain";
const observedBudgetGrid = isPlain ? (
<div className="grid gap-6 sm:grid-cols-2">
<div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
<div className="mt-2 text-xl font-semibold tabular-nums">
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
</div>
</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
</div>
</div>
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
<div className="mt-2 text-xl font-semibold tabular-nums">
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
</div>
</div>
</div>
);
const progressSection = (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Remaining</span>
<span>{summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"}</span>
</div>
<div className={cn("h-2 overflow-hidden rounded-full", isPlain ? "bg-border/70" : "bg-muted/70")}>
<div
className={cn(
"h-full rounded-full transition-[width,background-color] duration-200",
summary.status === "hard_stop"
? "bg-red-400"
: summary.status === "warning"
? "bg-amber-300"
: "bg-emerald-300",
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
const pausedPane = summary.paused ? (
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{summary.scopeType === "project"
? "Execution is paused for this project until the budget is raised or the incident is dismissed."
: "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."}
</div>
</div>
) : null;
const saveSection = onSave ? (
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-end", isPlain ? "" : "rounded-xl border border-border/70 bg-background/50 p-3")}>
<div className="min-w-0 flex-1">
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Budget (USD)
</label>
<Input
value={draftBudget}
onChange={(event) => setDraftBudget(event.target.value)}
className="mt-2"
inputMode="decimal"
placeholder="0.00"
/>
</div>
<Button
onClick={() => {
if (typeof parsedDraft === "number" && onSave) onSave(parsedDraft);
}}
disabled={!canSave || isSaving || parsedDraft === null}
>
{isSaving ? "Saving..." : summary.amount > 0 ? "Update budget" : "Set budget"}
</Button>
</div>
) : null;
if (isPlain) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-6">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
{summary.scopeType}
</div>
<div className="mt-2 text-xl font-semibold">{summary.scopeName}</div>
<div className="mt-2 text-sm text-muted-foreground">{windowLabel(summary.windowKind)}</div>
</div>
<div
className={cn(
"inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.18em]",
summary.status === "hard_stop"
? "text-red-300"
: summary.status === "warning"
? "text-amber-200"
: "text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" />
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
</div>
</div>
{observedBudgetGrid}
{progressSection}
{pausedPane}
{saveSection}
{parsedDraft === null ? (
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
) : null}
</div>
);
}
return (
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
<CardHeader className={cn("gap-3", compact ? "px-4 pt-4 pb-2" : "px-5 pt-5 pb-3")}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
{summary.scopeType}
</div>
<CardTitle className="mt-1 text-base">{summary.scopeName}</CardTitle>
<CardDescription className="mt-1">{windowLabel(summary.windowKind)}</CardDescription>
</div>
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.18em]", statusTone(summary.status))}>
<StatusIcon className="h-3.5 w-3.5" />
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
</div>
</div>
</CardHeader>
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
{observedBudgetGrid}
{progressSection}
{pausedPane}
{saveSection}
{parsedDraft === null ? (
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,13 @@
import { DollarSign } from "lucide-react";
export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) {
return (
<span
title={title}
aria-label={title}
className="ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-500/90 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
>
<DollarSign className="h-3 w-3" />
</span>
);
}

View File

@@ -0,0 +1,140 @@
import type { QuotaWindow } from "@paperclipai/shared";
import { cn, quotaSourceDisplayName } from "@/lib/utils";
interface ClaudeSubscriptionPanelProps {
windows: QuotaWindow[];
source?: string | null;
error?: string | null;
}
const WINDOW_ORDER = [
"currentsession",
"currentweekallmodels",
"currentweeksonnetonly",
"currentweeksonnet",
"currentweekopusonly",
"currentweekopus",
"extrausage",
] as const;
function normalizeLabel(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function detailText(window: QuotaWindow): string | null {
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
if (window.resetsAt) {
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `Resets ${formatted}`;
}
return null;
}
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
return [...windows].sort((a, b) => {
const aIndex = WINDOW_ORDER.indexOf(normalizeLabel(a.label) as (typeof WINDOW_ORDER)[number]);
const bIndex = WINDOW_ORDER.indexOf(normalizeLabel(b.label) as (typeof WINDOW_ORDER)[number]);
return (aIndex === -1 ? WINDOW_ORDER.length : aIndex) - (bIndex === -1 ? WINDOW_ORDER.length : bIndex);
});
}
function fillClass(usedPercent: number | null): string {
if (usedPercent == null) return "bg-zinc-700";
if (usedPercent >= 90) return "bg-red-400";
if (usedPercent >= 70) return "bg-amber-400";
return "bg-primary/70";
}
export function ClaudeSubscriptionPanel({
windows,
source = null,
error = null,
}: ClaudeSubscriptionPanelProps) {
const ordered = orderedWindows(windows);
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Anthropic subscription
</div>
<div className="mt-1 text-sm text-muted-foreground">
Live Claude quota windows.
</div>
</div>
{source ? (
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(source)}
</span>
) : null}
</div>
{error ? (
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="mt-4 space-y-4">
{ordered.map((window) => {
const normalized = normalizeLabel(window.label);
const detail = detailText(window);
if (normalized === "extrausage") {
return (
<div
key={window.label}
className="border border-border px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{window.valueLabel ? (
<div className="text-sm font-medium text-foreground">{window.valueLabel}</div>
) : null}
</div>
{detail ? (
<div className="mt-2 text-sm text-muted-foreground">{detail}</div>
) : null}
</div>
);
}
const width = Math.min(100, Math.max(0, window.usedPercent ?? 0));
return (
<div
key={window.label}
className="border border-border px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{detail ? (
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
{window.usedPercent != null ? (
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
{window.usedPercent}% used
</div>
) : null}
</div>
<div className="mt-3 h-2 overflow-hidden bg-muted">
<div
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import type { QuotaWindow } from "@paperclipai/shared";
import { cn, quotaSourceDisplayName } from "@/lib/utils";
interface CodexSubscriptionPanelProps {
windows: QuotaWindow[];
source?: string | null;
error?: string | null;
}
const WINDOW_PRIORITY = [
"5hlimit",
"weeklylimit",
"credits",
] as const;
function normalizeLabel(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
return [...windows].sort((a, b) => {
const aBase = normalizeLabel(a.label).replace(/^gpt53codexspark/, "");
const bBase = normalizeLabel(b.label).replace(/^gpt53codexspark/, "");
const aIndex = WINDOW_PRIORITY.indexOf(aBase as (typeof WINDOW_PRIORITY)[number]);
const bIndex = WINDOW_PRIORITY.indexOf(bBase as (typeof WINDOW_PRIORITY)[number]);
return (aIndex === -1 ? WINDOW_PRIORITY.length : aIndex) - (bIndex === -1 ? WINDOW_PRIORITY.length : bIndex);
});
}
function detailText(window: QuotaWindow): string | null {
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
if (!window.resetsAt) return null;
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `Resets ${formatted}`;
}
function fillClass(usedPercent: number | null): string {
if (usedPercent == null) return "bg-zinc-700";
if (usedPercent >= 90) return "bg-red-400";
if (usedPercent >= 70) return "bg-amber-400";
return "bg-primary/70";
}
function isModelSpecific(label: string): boolean {
const normalized = normalizeLabel(label);
return normalized.includes("gpt53codexspark") || normalized.includes("gpt5");
}
export function CodexSubscriptionPanel({
windows,
source = null,
error = null,
}: CodexSubscriptionPanelProps) {
const ordered = orderedWindows(windows);
const accountWindows = ordered.filter((window) => !isModelSpecific(window.label));
const modelWindows = ordered.filter((window) => isModelSpecific(window.label));
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Codex subscription
</div>
<div className="mt-1 text-sm text-muted-foreground">
Live Codex quota windows.
</div>
</div>
{source ? (
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(source)}
</span>
) : null}
</div>
{error ? (
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="mt-4 space-y-5">
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Account windows
</div>
<div className="space-y-3">
{accountWindows.map((window) => (
<QuotaWindowRow key={window.label} window={window} />
))}
</div>
</div>
{modelWindows.length > 0 ? (
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model windows
</div>
<div className="space-y-3">
{modelWindows.map((window) => (
<QuotaWindowRow key={window.label} window={window} />
))}
</div>
</div>
) : null}
</div>
</div>
);
}
function QuotaWindowRow({ window }: { window: QuotaWindow }) {
const detail = detailText(window);
if (window.usedPercent == null) {
return (
<div className="border border-border px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{window.valueLabel ? (
<div className="text-sm font-semibold tabular-nums text-foreground">{window.valueLabel}</div>
) : null}
</div>
{detail ? (
<div className="mt-2 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
);
}
return (
<div className="border border-border px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{detail ? (
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
{window.usedPercent}% used
</div>
</div>
<div className="mt-3 h-2 overflow-hidden bg-muted">
<div
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent))}%` }}
/>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "../lib/utils";
const BAYER_4X4 = [
@@ -10,6 +10,7 @@ const BAYER_4X4 = [
interface CompanyPatternIconProps {
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
}
@@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
return canvas.toDataURL("image/png");
}
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
export function CompanyPatternIcon({
companyName,
logoUrl,
brandColor,
className,
}: CompanyPatternIconProps) {
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
const [imageError, setImageError] = useState(false);
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
useEffect(() => {
setImageError(false);
}, [logoUrl]);
const patternDataUrl = useMemo(
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
[companyName, brandColor],
@@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
className,
)}
>
{patternDataUrl ? (
{logo ? (
<img
src={logo}
alt={`${companyName} logo`}
onError={() => setImageError(true)}
className="absolute inset-0 h-full w-full object-cover"
/>
) : patternDataUrl ? (
<img
src={patternDataUrl}
alt=""
@@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
) : (
<div className="absolute inset-0 bg-muted" />
)}
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
{!logo && (
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
)}
</div>
);
}

View File

@@ -122,6 +122,7 @@ function SortableCompanyItem({
>
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className={cn(
isSelected

View File

@@ -0,0 +1,44 @@
import type { FinanceByBiller } from "@paperclipai/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCents, providerDisplayName } from "@/lib/utils";
interface FinanceBillerCardProps {
row: FinanceByBiller;
}
export function FinanceBillerCard({ row }: FinanceBillerCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{providerDisplayName(row.biller)}</CardTitle>
<CardDescription className="mt-1 text-xs">
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} across {row.kindCount} kind{row.kindCount === 1 ? "" : "s"}
</CardDescription>
</div>
<div className="text-right">
<div className="text-lg font-semibold tabular-nums">{formatCents(row.netCents)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">net</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-3">
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.debitCents)}</div>
</div>
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">credits</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.creditCents)}</div>
</div>
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">estimated</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.estimatedDebitCents)}</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import type { FinanceByKind } from "@paperclipai/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { financeEventKindDisplayName, formatCents } from "@/lib/utils";
interface FinanceKindCardProps {
rows: FinanceByKind[];
}
export function FinanceKindCard({ rows }: FinanceKindCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<CardTitle className="text-base">Financial event mix</CardTitle>
<CardDescription>Account-level charges grouped by event kind.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4 pt-3">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">No finance events in this period.</p>
) : (
rows.map((row) => (
<div
key={row.eventKind}
className="flex items-center justify-between gap-3 border border-border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{financeEventKindDisplayName(row.eventKind)}</div>
<div className="text-xs text-muted-foreground">
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} · {row.billerCount} biller{row.billerCount === 1 ? "" : "s"}
</div>
</div>
<div className="text-right tabular-nums">
<div className="text-sm font-medium">{formatCents(row.netCents)}</div>
<div className="text-xs text-muted-foreground">
{formatCents(row.debitCents)} debits
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,71 @@
import type { FinanceEvent } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
financeDirectionDisplayName,
financeEventKindDisplayName,
formatCents,
formatDateTime,
providerDisplayName,
} from "@/lib/utils";
interface FinanceTimelineCardProps {
rows: FinanceEvent[];
emptyMessage?: string;
}
export function FinanceTimelineCard({
rows,
emptyMessage = "No financial events in this period.",
}: FinanceTimelineCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<CardTitle className="text-base">Recent financial events</CardTitle>
<CardDescription>Top-ups, fees, credits, commitments, and other non-request charges.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-3">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
) : (
rows.map((row) => (
<div
key={row.id}
className="border border-border p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{financeEventKindDisplayName(row.eventKind)}</Badge>
<Badge variant={row.direction === "credit" ? "outline" : "secondary"}>
{financeDirectionDisplayName(row.direction)}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(row.occurredAt)}</span>
</div>
<div className="text-sm font-medium">
{providerDisplayName(row.biller)}
{row.provider ? ` -> ${providerDisplayName(row.provider)}` : ""}
{row.model ? <span className="ml-1 font-mono text-xs text-muted-foreground">{row.model}</span> : null}
</div>
{(row.description || row.externalInvoiceId || row.region || row.pricingTier) && (
<div className="space-y-1 text-xs text-muted-foreground">
{row.description ? <div>{row.description}</div> : null}
{row.externalInvoiceId ? <div>invoice {row.externalInvoiceId}</div> : null}
{row.region ? <div>region {row.region}</div> : null}
{row.pricingTier ? <div>tier {row.pricingTier}</div> : null}
</div>
)}
</div>
<div className="text-right tabular-nums">
<div className="text-sm font-semibold">{formatCents(row.amountCents)}</div>
<div className="text-xs text-muted-foreground">{row.currency}</div>
{row.estimated ? <div className="text-[11px] uppercase tracking-[0.12em] text-amber-600">estimated</div> : null}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,416 @@
import { useMemo } from "react";
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QuotaBar } from "./QuotaBar";
import { ClaudeSubscriptionPanel } from "./ClaudeSubscriptionPanel";
import { CodexSubscriptionPanel } from "./CodexSubscriptionPanel";
import {
billingTypeDisplayName,
formatCents,
formatTokens,
providerDisplayName,
quotaSourceDisplayName,
} from "@/lib/utils";
// ordered display labels for rolling-window rows
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
interface ProviderQuotaCardProps {
provider: string;
rows: CostByProviderModel[];
/** company monthly budget in cents (0 means unlimited) */
budgetMonthlyCents: number;
/** total company spend in this period in cents, all providers */
totalCompanySpendCents: number;
/** spend in the current calendar week in cents, this provider only */
weekSpendCents: number;
/** rolling window rows for this provider: 5h, 24h, 7d */
windowRows: CostWindowSpendRow[];
showDeficitNotch: boolean;
/** live subscription quota windows from the provider's own api */
quotaWindows?: QuotaWindow[];
quotaError?: string | null;
quotaSource?: string | null;
quotaLoading?: boolean;
}
export function ProviderQuotaCard({
provider,
rows,
budgetMonthlyCents,
totalCompanySpendCents,
weekSpendCents,
windowRows,
showDeficitNotch,
quotaWindows = [],
quotaError = null,
quotaSource = null,
quotaLoading = false,
}: ProviderQuotaCardProps) {
// single-pass aggregation over rows — memoized so the 8 derived values are not
// recomputed on every parent render tick (providers tab polls every 30s, and each
// card is mounted twice: once in the "all" tab grid and once in its per-provider tab).
const totals = useMemo(() => {
let inputTokens = 0, outputTokens = 0, costCents = 0;
let apiRunCount = 0, subRunCount = 0, subInputTokens = 0, subOutputTokens = 0;
for (const r of rows) {
inputTokens += r.inputTokens;
outputTokens += r.outputTokens;
costCents += r.costCents;
apiRunCount += r.apiRunCount;
subRunCount += r.subscriptionRunCount;
subInputTokens += r.subscriptionInputTokens;
subOutputTokens += r.subscriptionOutputTokens;
}
const totalTokens = inputTokens + outputTokens;
const subTokens = subInputTokens + subOutputTokens;
// denominator: api-billed tokens (from cost_events) + subscription tokens (from heartbeat_runs)
const allTokens = totalTokens + subTokens;
return {
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
totalTokens,
totalCostCents: costCents,
totalApiRuns: apiRunCount,
totalSubRuns: subRunCount,
totalSubInputTokens: subInputTokens,
totalSubOutputTokens: subOutputTokens,
totalSubTokens: subTokens,
subSharePct: allTokens > 0 ? (subTokens / allTokens) * 100 : 0,
};
}, [rows]);
const {
totalInputTokens,
totalOutputTokens,
totalTokens,
totalCostCents,
totalApiRuns,
totalSubRuns,
totalSubInputTokens,
totalSubOutputTokens,
totalSubTokens,
subSharePct,
} = totals;
// budget bars: use this provider's own spend vs its pro-rata share of budget
// pro-rata: if a provider is 40% of total spend, it gets 40% of the budget allocated.
// falls back to raw provider spend vs total budget when totalCompanySpend is 0.
const providerBudgetShare =
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
? (totalCostCents / totalCompanySpendCents) * budgetMonthlyCents
: budgetMonthlyCents;
const budgetPct =
providerBudgetShare > 0
? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
: 0;
// 4.33 = average weeks per calendar month (52 / 12)
const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
const weekPct =
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
const hasBudget = budgetMonthlyCents > 0;
// memoized so the Map and max are not reconstructed on every parent render tick
const windowMap = useMemo(
() => new Map(windowRows.map((r) => [r.window, r])),
[windowRows],
);
const maxWindowCents = useMemo(
() => Math.max(...windowRows.map((r) => r.costCents), 0),
[windowRows],
);
const isClaudeQuotaPanel = provider === "anthropic";
const isCodexQuotaPanel = provider === "openai" && quotaSource?.startsWith("codex-");
const supportsSubscriptionQuota = provider === "anthropic" || provider === "openai";
const showSubscriptionQuotaSection =
supportsSubscriptionQuota && (quotaLoading || quotaWindows.length > 0 || quotaError != null);
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0 gap-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-sm font-semibold">
{providerDisplayName(provider)}
</CardTitle>
<CardDescription className="text-xs mt-0.5">
<span className="font-mono">{formatTokens(totalInputTokens)}</span> in
{" · "}
<span className="font-mono">{formatTokens(totalOutputTokens)}</span> out
{(totalApiRuns > 0 || totalSubRuns > 0) && (
<span className="ml-1.5">
·{" "}
{totalApiRuns > 0 && `~${totalApiRuns} api`}
{totalApiRuns > 0 && totalSubRuns > 0 && " / "}
{totalSubRuns > 0 && `~${totalSubRuns} sub`}
{" runs"}
</span>
)}
</CardDescription>
</div>
<span className="text-xl font-bold tabular-nums shrink-0">
{formatCents(totalCostCents)}
</span>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-3 space-y-4">
{hasBudget && (
<div className="space-y-3">
<QuotaBar
label="Period spend"
percentUsed={budgetPct}
leftLabel={formatCents(totalCostCents)}
rightLabel={`${Math.round(budgetPct)}% of allocation`}
showDeficitNotch={showDeficitNotch}
/>
<QuotaBar
label="This week"
percentUsed={weekPct}
leftLabel={formatCents(weekSpendCents)}
rightLabel={`~${formatCents(Math.round(weeklyBudgetShare))} / wk`}
showDeficitNotch={weekPct >= 100}
/>
</div>
)}
{/* rolling window consumption — always shown when data is available */}
{windowRows.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Rolling windows
</p>
<div className="space-y-2.5">
{ROLLING_WINDOWS.map((w) => {
const row = windowMap.get(w);
// omit windows with no data rather than showing false $0.00 zeros
if (!row) return null;
const cents = row.costCents;
const tokens = row.inputTokens + row.outputTokens;
const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
return (
<div key={w} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span>
<span className="text-muted-foreground font-mono flex-1">
{formatTokens(tokens)} tok
</span>
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
</div>
<div className="h-2 w-full border border-border overflow-hidden">
<div
className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</>
)}
{/* subscription usage — shown when any subscription-billed runs exist */}
{totalSubRuns > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Subscription
</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
{" · "}
{totalSubTokens > 0 && (
<>
<span className="font-mono text-foreground">{formatTokens(totalSubTokens)}</span> total
{" · "}
</>
)}
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
{" · "}
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
</p>
{subSharePct > 0 && (
<>
<div className="h-1.5 w-full border border-border overflow-hidden">
<div
className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${subSharePct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{Math.round(subSharePct)}% of token usage via subscription
</p>
</>
)}
</div>
</>
)}
{/* model breakdown — always shown, with token-share bars */}
{rows.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-3">
{rows.map((row) => {
const rowTokens = row.inputTokens + row.outputTokens;
const tokenPct = totalTokens > 0 ? (rowTokens / totalTokens) * 100 : 0;
const costPct = totalCostCents > 0 ? (row.costCents / totalCostCents) * 100 : 0;
return (
<div key={`${row.provider}:${row.model}`} className="space-y-1.5">
{/* model name and cost */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground truncate font-mono block">
{row.model}
</span>
<span className="text-[11px] text-muted-foreground truncate block">
{providerDisplayName(row.biller)} · {billingTypeDisplayName(row.billingType)}
</span>
</div>
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
<span className="text-muted-foreground">
{formatTokens(rowTokens)} tok
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
</div>
</div>
{/* token share bar */}
<div className="relative h-2 w-full border border-border overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-primary/60 transition-[width] duration-150"
style={{ width: `${tokenPct}%` }}
title={`${Math.round(tokenPct)}% of provider tokens`}
/>
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
<div
className="absolute inset-y-0 left-0 bg-primary/85 transition-[width] duration-150"
style={{ width: `${costPct}%` }}
title={`${Math.round(costPct)}% of provider cost`}
/>
</div>
</div>
);
})}
</div>
</>
)}
{/* subscription quota windows from provider api — shown when data is available */}
{showSubscriptionQuotaSection && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Subscription quota
</p>
{quotaSource && !isClaudeQuotaPanel && !isCodexQuotaPanel ? (
<span className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(quotaSource)}
</span>
) : null}
</div>
{quotaLoading ? (
<QuotaPanelSkeleton />
) : isClaudeQuotaPanel ? (
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
) : isCodexQuotaPanel ? (
<CodexSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
) : (
<>
{quotaError ? (
<p className="text-xs text-destructive">
{quotaError}
</p>
) : null}
<div className="space-y-2.5">
{quotaWindows.map((qw) => {
const fillColor =
qw.usedPercent == null
? null
: qw.usedPercent >= 90
? "bg-red-400"
: qw.usedPercent >= 70
? "bg-yellow-400"
: "bg-green-400";
return (
<div key={qw.label} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
<span className="flex-1" />
{qw.valueLabel != null ? (
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
) : qw.usedPercent != null ? (
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
) : null}
</div>
{qw.usedPercent != null && fillColor != null && (
<div className="h-2 w-full border border-border overflow-hidden">
<div
className={`h-full transition-[width] duration-150 ${fillColor}`}
style={{ width: `${qw.usedPercent}%` }}
/>
</div>
)}
{qw.detail ? (
<p className="text-xs text-muted-foreground">
{qw.detail}
</p>
) : qw.resetsAt ? (
<p className="text-xs text-muted-foreground">
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</p>
) : null}
</div>
);
})}
</div>
</>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
function QuotaPanelSkeleton() {
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-4 w-64 max-w-full" />
</div>
<Skeleton className="h-7 w-28" />
</div>
<div className="mt-4 space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="border border-border px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-44 max-w-full" />
</div>
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="mt-3 h-2 w-full" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { cn } from "@/lib/utils";
interface QuotaBarProps {
label: string;
// value between 0 and 100
percentUsed: number;
leftLabel: string;
rightLabel?: string;
// shows a 2px destructive notch at the fill tip when true
showDeficitNotch?: boolean;
className?: string;
}
function fillColor(pct: number): string {
if (pct > 90) return "bg-red-400";
if (pct > 70) return "bg-yellow-400";
return "bg-green-400";
}
export function QuotaBar({
label,
percentUsed,
leftLabel,
rightLabel,
showDeficitNotch = false,
className,
}: QuotaBarProps) {
const clampedPct = Math.min(100, Math.max(0, percentUsed));
// keep the notch visible even near the edges
const notchLeft = Math.min(clampedPct, 97);
return (
<div className={cn("space-y-1.5", className)}>
{/* row header */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium tabular-nums">{leftLabel}</span>
{rightLabel && (
<span className="text-xs text-muted-foreground tabular-nums">{rightLabel}</span>
)}
</div>
</div>
{/* track — boxed border, square corners to match the theme */}
<div className="relative h-2 w-full border border-border overflow-hidden">
{/* fill */}
<div
className={cn(
"absolute inset-y-0 left-0 transition-[width,background-color] duration-150",
fillColor(clampedPct),
)}
style={{ width: `${clampedPct}%` }}
/>
{/* deficit notch — 2px wide, sits at the fill tip */}
{showDeficitNotch && clampedPct > 0 && (
<div
className="absolute inset-y-0 w-[2px] bg-destructive z-10"
style={{ left: `${notchLeft}%` }}
/>
)}
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
Collapsible,
CollapsibleContent,
@@ -124,15 +125,22 @@ export function SidebarAgents() {
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{agent.name}</span>
{runCount > 0 && (
{(agent.pauseReason === "budget" || runCount > 0) && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
{agent.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Agent paused by budget" />
) : null}
{runCount > 0 ? (
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : null}
{runCount > 0 ? (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
) : null}
</span>
)}
</NavLink>

View File

@@ -20,6 +20,7 @@ import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
Collapsible,
CollapsibleContent,
@@ -88,6 +89,7 @@ function SortableProjectItem({
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
{projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">