From a42a3efc002df4ddf4d164b4c275cf58ada2c856 Mon Sep 17 00:00:00 2001 From: sherlock Date: Tue, 5 May 2026 22:42:43 +0530 Subject: [PATCH 1/2] restore(usage): bring back OpenUsage rate-limit feature files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restored from commit 97f179fd (pre-PR #77 state), with one adaptation for upstream's new SPI: ProviderKind → ProviderDriverKind (now branded; uses .make()) Adapted files: - lib/openUsageRateLimits.ts (provider id → driver kind mapping) - lib/openUsageReactQuery.ts (query key driver kind) Restored verbatim: - components/RateLimitsPanel.tsx (sidebar collapsible) - components/RateLimitsPanel.test.tsx - components/RateLimitSummaryList.tsx - components/chat/RateLimitBanner.tsx (in-chat banner) - lib/openUsageRateLimits.test.ts - lib/rateLimits.ts (snapshot parsing core) - lib/icons.tsx (lucide aliases — distinct from components/Icons.tsx which is the app's custom monochrome icon set) Not yet wired: panel + banner are not mounted anywhere. Sidebar integration follows in a subsequent commit. Local: `bunx tsc --noEmit` clean from apps/web. --- .../src/components/RateLimitSummaryList.tsx | 40 ++ .../src/components/RateLimitsPanel.test.tsx | 231 +++++++++ apps/web/src/components/RateLimitsPanel.tsx | 68 +++ .../src/components/chat/RateLimitBanner.tsx | 72 +++ apps/web/src/lib/icons.tsx | 144 ++++++ apps/web/src/lib/openUsageRateLimits.test.ts | 142 ++++++ apps/web/src/lib/openUsageRateLimits.ts | 155 ++++++ apps/web/src/lib/openUsageReactQuery.ts | 41 ++ apps/web/src/lib/rateLimits.ts | 451 ++++++++++++++++++ 9 files changed, 1344 insertions(+) create mode 100644 apps/web/src/components/RateLimitSummaryList.tsx create mode 100644 apps/web/src/components/RateLimitsPanel.test.tsx create mode 100644 apps/web/src/components/RateLimitsPanel.tsx create mode 100644 apps/web/src/components/chat/RateLimitBanner.tsx create mode 100644 apps/web/src/lib/icons.tsx create mode 100644 apps/web/src/lib/openUsageRateLimits.test.ts create mode 100644 apps/web/src/lib/openUsageRateLimits.ts create mode 100644 apps/web/src/lib/openUsageReactQuery.ts create mode 100644 apps/web/src/lib/rateLimits.ts diff --git a/apps/web/src/components/RateLimitSummaryList.tsx b/apps/web/src/components/RateLimitSummaryList.tsx new file mode 100644 index 00000000000..c3caf5d7d37 --- /dev/null +++ b/apps/web/src/components/RateLimitSummaryList.tsx @@ -0,0 +1,40 @@ +// FILE: RateLimitSummaryList.tsx +// Purpose: Renders the compact rate-limit rows shared by the local popover and +// the dedicated rate-limit panel. + +import { useMemo } from "react"; + +import type { ProviderRateLimit } from "~/lib/rateLimits"; +import { + deriveVisibleRateLimitRows, + formatRateLimitRemainingPercent, + formatRateLimitResetTime, +} from "~/lib/rateLimits"; + +export function RateLimitSummaryList({ + rateLimits, +}: { + rateLimits: ReadonlyArray; +}) { + const rows = useMemo(() => deriveVisibleRateLimitRows(rateLimits), [rateLimits]); + + if (rows.length === 0) { + return

No rate limit data yet.

; + } + + return ( + <> + {rows.map((row) => ( +
+ {row.label} + + + {formatRateLimitRemainingPercent(row.remainingPercent)} + + {row.resetsAt ? {formatRateLimitResetTime(row.resetsAt)} : null} + +
+ ))} + + ); +} diff --git a/apps/web/src/components/RateLimitsPanel.test.tsx b/apps/web/src/components/RateLimitsPanel.test.tsx new file mode 100644 index 00000000000..d1cea2f7ac0 --- /dev/null +++ b/apps/web/src/components/RateLimitsPanel.test.tsx @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; + +import { + deriveAccountRateLimits, + deriveVisibleRateLimitRows, + formatRateLimitRemainingPercent, +} from "~/lib/rateLimits"; + +function makeActivity( + id: string, + kind: string, + payload: unknown, + createdAt = "2099-04-08T18:00:00.000Z", +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "info", + kind, + summary: kind, + payload, + turnId: TurnId.make("turn-1"), + createdAt, + }; +} + +describe("RateLimitsPanel helpers", () => { + it("normalizes direct rate-limit snapshots into visible 5h and Weekly rows", () => { + const rateLimits = deriveAccountRateLimits([ + { + activities: [ + makeActivity("activity-1", "account.rate-limits.updated", { + provider: "codex", + rateLimitsByLimitId: { + short: { + primary: { + usedPercent: 12, + windowDurationMins: 300, + resetsAt: "2099-04-08T20:43:00.000Z", + }, + }, + weekly: { + primary: { + usedPercent: 8, + windowDurationMins: 10_080, + resetsAt: "2099-04-15T00:00:00.000Z", + }, + }, + }, + }), + ], + }, + ]); + + const rows = deriveVisibleRateLimitRows(rateLimits); + + expect(rows).toEqual([ + { + id: "codex-5h", + label: "5h", + remainingPercent: 88, + resetsAt: "2099-04-08T20:43:00.000Z", + windowDurationMins: 300, + }, + { + id: "codex-Weekly", + label: "Weekly", + remainingPercent: 92, + resetsAt: "2099-04-15T00:00:00.000Z", + windowDurationMins: 10080, + }, + ]); + expect(formatRateLimitRemainingPercent(rows[0]?.remainingPercent)).toBe("88%"); + }); + + it("keeps the most constrained row when multiple providers report the same window", () => { + const rows = deriveVisibleRateLimitRows([ + { + provider: "codex", + updatedAt: "2099-04-08T18:00:00.000Z", + limits: [ + { + window: "Weekly", + usedPercent: 8, + resetsAt: "2099-04-15T00:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + { + provider: "claudeAgent", + updatedAt: "2099-04-08T18:05:00.000Z", + limits: [ + { + window: "Weekly", + usedPercent: 20, + resetsAt: "2099-04-14T20:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + ]); + + expect(rows).toEqual([ + { + id: "claudeAgent-Weekly", + label: "Weekly", + remainingPercent: 80, + resetsAt: "2099-04-14T20:00:00.000Z", + windowDurationMins: 10080, + }, + ]); + }); + + it("reads nested codex runtime payloads like the app-server notifications", () => { + const rateLimits = deriveAccountRateLimits([ + { + activities: [ + makeActivity("activity-1", "account.rate-limits.updated", { + provider: "codex", + rateLimits: { + limitId: "codex", + primary: { + usedPercent: 12, + windowDurationMins: 300, + resetsAt: "2099-04-08T20:43:00.000Z", + }, + secondary: { + usedPercent: 8, + windowDurationMins: 10_080, + resetsAt: "2099-04-15T00:00:00.000Z", + }, + }, + }), + ], + }, + ]); + + const rows = deriveVisibleRateLimitRows(rateLimits); + + expect(rows).toEqual([ + { + id: "codex-5h", + label: "5h", + remainingPercent: 88, + resetsAt: "2099-04-08T20:43:00.000Z", + windowDurationMins: 300, + }, + { + id: "codex-Weekly", + label: "Weekly", + remainingPercent: 92, + resetsAt: "2099-04-15T00:00:00.000Z", + windowDurationMins: 10080, + }, + ]); + }); + + it("reads doubly nested codex runtime payloads from provider logs", () => { + const rateLimits = deriveAccountRateLimits([ + { + activities: [ + makeActivity("activity-1", "account.rate-limits.updated", { + provider: "codex", + rateLimits: { + rateLimits: { + primary: { + usedPercent: 20, + windowDurationMins: 300, + resetsAt: 4_079_388_780, + }, + secondary: { + usedPercent: 10, + windowDurationMins: 10_080, + resetsAt: 4_079_880_000, + }, + }, + }, + }), + ], + }, + ]); + + expect(deriveVisibleRateLimitRows(rateLimits)).toEqual([ + { + id: "codex-5h", + label: "5h", + remainingPercent: 80, + resetsAt: "2099-04-09T03:33:00.000Z", + windowDurationMins: 300, + }, + { + id: "codex-Weekly", + label: "Weekly", + remainingPercent: 90, + resetsAt: "2099-04-14T20:00:00.000Z", + windowDurationMins: 10080, + }, + ]); + }); + + it("reads claude rate_limit_info payloads from runtime telemetry", () => { + const rateLimits = deriveAccountRateLimits([ + { + activities: [ + makeActivity("activity-1", "account.rate-limits.updated", { + provider: "claudeAgent", + rate_limit_info: { + status: "allowed_warning", + rateLimitType: "five_hour", + utilization: 0.9, + resetsAt: 4_078_972_980, + }, + }), + ], + }, + ]); + + const rows = deriveVisibleRateLimitRows(rateLimits); + + expect(rows).toEqual([ + { + id: "claudeAgent-5h", + label: "5h", + remainingPercent: 10, + resetsAt: "2099-04-04T08:03:00.000Z", + windowDurationMins: 300, + }, + ]); + }); +}); diff --git a/apps/web/src/components/RateLimitsPanel.tsx b/apps/web/src/components/RateLimitsPanel.tsx new file mode 100644 index 00000000000..8b157a9c766 --- /dev/null +++ b/apps/web/src/components/RateLimitsPanel.tsx @@ -0,0 +1,68 @@ +// FILE: RateLimitsPanel.tsx +// Purpose: Wraps the shared rate-limit summary UI in a collapsible panel fed by +// orchestration thread activities. + +import { useMemo, useState } from "react"; +import type { OrchestrationThread } from "@t3tools/contracts"; +import { ChevronDownIcon, ExternalLinkIcon } from "lucide-react"; +import { deriveAccountRateLimits, deriveRateLimitLearnMoreHref } from "~/lib/rateLimits"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; +import { cn } from "~/lib/utils"; +import { RateLimitSummaryList } from "./RateLimitSummaryList"; + +export default function RateLimitsPanel({ + threads, +}: { + threads: ReadonlyArray>; +}) { + const [open, setOpen] = useState(false); + const rateLimits = useMemo(() => deriveAccountRateLimits(threads), [threads]); + const learnMoreHref = useMemo(() => deriveRateLimitLearnMoreHref(rateLimits), [rateLimits]); + + if (rateLimits.length === 0) return null; + + return ( + +
+
+ + + + + + + Rate limits remaining + + + + +
+ + {learnMoreHref ? ( + + Learn more + + + ) : null} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/RateLimitBanner.tsx b/apps/web/src/components/chat/RateLimitBanner.tsx new file mode 100644 index 00000000000..53a29875395 --- /dev/null +++ b/apps/web/src/components/chat/RateLimitBanner.tsx @@ -0,0 +1,72 @@ +import { memo } from "react"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { Alert, AlertDescription } from "../ui/alert"; +import { CircleAlertIcon } from "lucide-react"; + +export type RateLimitStatus = { + status: "rejected" | "allowed_warning"; + resetsAt?: string; + utilization?: number; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +export function deriveLatestRateLimitStatus( + activities: ReadonlyArray, +): RateLimitStatus | null { + const now = Date.now(); + for (let i = activities.length - 1; i >= 0; i--) { + const activity = activities[i]; + if (!activity || activity.kind !== "account.rate-limited") continue; + const payload = asRecord(activity.payload); + if (!payload) continue; + const status = payload.status; + if (status !== "rejected" && status !== "allowed_warning") continue; + // If resetsAt is in the past, the limit has expired — skip + if (typeof payload.resetsAt === "string") { + const resetsAtMs = Date.parse(payload.resetsAt); + if (!Number.isNaN(resetsAtMs) && resetsAtMs < now) continue; + } + return { + status, + ...(typeof payload.resetsAt === "string" ? { resetsAt: payload.resetsAt } : {}), + ...(typeof payload.utilization === "number" ? { utilization: payload.utilization } : {}), + }; + } + return null; +} + +function formatResetsAt(resetsAt: string): string { + const ms = Date.parse(resetsAt); + if (Number.isNaN(ms)) return ""; + const secondsLeft = Math.max(0, Math.ceil((ms - Date.now()) / 1000)); + if (secondsLeft < 60) return ` Resets in ${secondsLeft}s.`; + const minutesLeft = Math.ceil(secondsLeft / 60); + return ` Resets in ${minutesLeft}m.`; +} + +export const RateLimitBanner = memo(function RateLimitBanner({ + rateLimitStatus, +}: { + rateLimitStatus: RateLimitStatus | null; +}) { + if (!rateLimitStatus) return null; + + const { status, resetsAt, utilization } = rateLimitStatus; + const isRejected = status === "rejected"; + + const message = isRejected + ? `Rate limit reached.${resetsAt ? formatResetsAt(resetsAt) : ""}` + : `Approaching rate limit${utilization !== undefined ? ` (${Math.round(utilization * 100)}% used)` : ""}.${resetsAt ? formatResetsAt(resetsAt) : ""}`; + + return ( +
+ + + {message} + +
+ ); +}); diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx new file mode 100644 index 00000000000..0e80f1f2ae5 --- /dev/null +++ b/apps/web/src/lib/icons.tsx @@ -0,0 +1,144 @@ +// Re-export icons from lucide-react under the names used across the app. +import type { LucideIcon } from "lucide-react"; +import { + AlertCircleIcon, + AlertTriangleIcon, + AppWindowIcon, + ArrowDownIcon as LuArrowDown, + ArrowLeftIcon as LuArrowLeft, + ArrowLeftRightIcon, + ArrowRightIcon as LuArrowRight, + ArrowUpDownIcon as LuArrowUpDown, + BoltIcon, + BotIcon as LuBot, + BugIcon as LuBug, + CheckIcon as LuCheck, + ChevronDownIcon as LuChevronDown, + ChevronLeftIcon as LuChevronLeft, + ChevronRightIcon as LuChevronRight, + ChevronUpIcon as LuChevronUp, + ChevronsUpDownIcon as LuChevronsUpDown, + CircleAlertIcon as LuCircleAlert, + CircleCheckIcon as LuCircleCheck, + CloudUploadIcon as LuCloudUpload, + Columns2Icon as LuColumns2, + CopyIcon as LuCopy, + EllipsisIcon as LuEllipsis, + ExternalLinkIcon as LuExternalLink, + EyeIcon as LuEye, + FileIcon as LuFile, + FlaskConicalIcon as LuFlaskConical, + FolderIcon as LuFolder, + FolderOpenIcon as LuFolderOpen, + GitCompareIcon, + GitCommitHorizontalIcon, + GitForkIcon as LuGitFork, + GitPullRequestIcon as LuGitPullRequest, + GlobeIcon as LuGlobe, + HammerIcon as LuHammer, + InfoIcon as LuInfo, + ListChecksIcon as LuListChecks, + ListTodoIcon as LuListTodo, + Loader2Icon as LuLoader2, + LockIcon as LuLock, + LockOpenIcon as LuLockOpen, + Maximize2Icon, + Minimize2Icon, + PanelLeftCloseIcon as LuPanelLeftClose, + PanelLeftIcon as LuPanelLeft, + PanelRightCloseIcon as LuPanelRightClose, + PinIcon as LuPin, + PlayIcon as LuPlay, + PlugIcon as LuPlug, + PlusIcon as LuPlus, + RefreshCwIcon as LuRefreshCw, + RocketIcon as LuRocket, + RotateCcwIcon as LuRotateCcw, + Rows3Icon as LuRows3, + SearchIcon as LuSearch, + SettingsIcon as LuSettings, + SplitIcon, + SquarePenIcon as LuSquarePen, + TerminalIcon as LuTerminal, + TerminalSquareIcon as LuTerminalSquare, + WrapTextIcon, + Trash2Icon, + Undo2Icon as LuUndo2, + WrenchIcon as LuWrench, + XIcon as LuX, + ZapIcon as LuZap, +} from "lucide-react"; + +export type { LucideIcon }; + +export const AppsIcon = AppWindowIcon; +export const ArrowLeftIcon = LuArrowLeft; +export const ArrowRightIcon = LuArrowRight; +export const ArrowDownIcon = LuArrowDown; +export const ArrowUpDownIcon = LuArrowUpDown; +export const BotIcon = LuBot; +export const BugIcon = LuBug; +export const CheckIcon = LuCheck; +export const ChevronDownIcon = LuChevronDown; +export const ChevronLeftIcon = LuChevronLeft; +export const ChevronRightIcon = LuChevronRight; +export const ChevronUpIcon = LuChevronUp; +export const ChevronsUpDownIcon = LuChevronsUpDown; +export const CircleAlertIcon = LuCircleAlert; +export const CircleCheckIcon = LuCircleCheck; +export const CloudUploadIcon = LuCloudUpload; +export const Columns2Icon = LuColumns2; +export const CopyIcon = LuCopy; +export const DiffIcon = GitCompareIcon; +export const EllipsisIcon = LuEllipsis; +export const ExternalLinkIcon = LuExternalLink; +export const EyeIcon = LuEye; +export const FileIcon = LuFile; +export const FlaskConicalIcon = LuFlaskConical; +export const FolderClosedIcon = LuFolder; +export const FolderIcon = LuFolder; +export const FolderOpenIcon = LuFolderOpen; +export const GitCommitIcon = GitCommitHorizontalIcon; +export const GitForkIcon = LuGitFork; +export const GitPullRequestIcon = LuGitPullRequest; +export const GlobeIcon = LuGlobe; +export const PlugIcon = LuPlug; +export const HammerIcon = LuHammer; +export const HandoffIcon = ArrowLeftRightIcon; +export const InfoIcon = LuInfo; +export const ListChecksIcon = LuListChecks; +export const ListTodoIcon = LuListTodo; +export const Loader2Icon = LuLoader2; +export const LoaderCircleIcon = LuLoader2; +export const LoaderIcon = LuLoader2; +export const LockIcon = LuLock; +export const LockOpenIcon = LuLockOpen; +export const Maximize2 = Maximize2Icon; +export const Minimize2 = Minimize2Icon; +export const PanelLeftCloseIcon = LuPanelLeftClose; +export const PanelLeftIcon = LuPanelLeft; +export const PanelRightCloseIcon = LuPanelRightClose; +export const PinIcon = LuPin; +export const PinnedFilledIcon = LuPin; +export const PlayIcon = LuPlay; +export const Plus = LuPlus; +export const PlusIcon = LuPlus; +export const RefreshCwIcon = LuRefreshCw; +export const RocketIcon = LuRocket; +export const RotateCcwIcon = LuRotateCcw; +export const Rows3Icon = LuRows3; +export const SearchIcon = LuSearch; +export const SettingsIcon = LuSettings; +export const SquarePenIcon = LuSquarePen; +export const SquareSplitHorizontal = SplitIcon; +export const SquareSplitVertical = SplitIcon; +export const TerminalIcon = LuTerminal; +export const TerminalSquare = LuTerminalSquare; +export const TerminalSquareIcon = LuTerminalSquare; +export const TextWrapIcon = WrapTextIcon; +export const Trash2 = Trash2Icon; +export const TriangleAlertIcon = AlertTriangleIcon; +export const Undo2Icon = LuUndo2; +export const WrenchIcon = LuWrench; +export const XIcon = LuX; +export const ZapIcon = LuZap; diff --git a/apps/web/src/lib/openUsageRateLimits.test.ts b/apps/web/src/lib/openUsageRateLimits.test.ts new file mode 100644 index 00000000000..b12f01665e4 --- /dev/null +++ b/apps/web/src/lib/openUsageRateLimits.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeOpenUsageSnapshot, normalizeOpenUsageUsageLines } from "./openUsageRateLimits"; +import { mergeProviderRateLimits } from "./rateLimits"; + +describe("openUsageRateLimits", () => { + it("normalizes OpenUsage progress lines into shared provider rate limits", () => { + expect( + normalizeOpenUsageSnapshot({ + providerId: "codex", + fetchedAt: "2099-04-08T18:00:00.000Z", + lines: [ + { + type: "progress", + label: "Session", + used: 20, + limit: 100, + resetsAt: "2099-04-08T21:18:00.000Z", + periodDurationMs: 18_000_000, + }, + { + type: "progress", + label: "Weekly", + used: 10, + limit: 100, + resetsAt: "2099-04-14T18:00:00.000Z", + periodDurationMs: 604_800_000, + }, + ], + }), + ).toEqual({ + provider: "codex", + updatedAt: "2099-04-08T18:00:00.000Z", + limits: [ + { + window: "5h", + usedPercent: 20, + resetsAt: "2099-04-08T21:18:00.000Z", + windowDurationMins: 300, + }, + { + window: "Weekly", + usedPercent: 10, + resetsAt: "2099-04-14T18:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }); + }); + + it("merges runtime and OpenUsage windows for the same provider", () => { + expect( + mergeProviderRateLimits( + [ + { + provider: "codex", + updatedAt: "2099-04-08T18:05:00.000Z", + limits: [ + { + window: "5h", + usedPercent: 22, + resetsAt: "2099-04-08T21:18:00.000Z", + windowDurationMins: 300, + }, + ], + }, + ], + [ + { + provider: "codex", + updatedAt: "2099-04-08T18:00:00.000Z", + limits: [ + { + window: "Weekly", + usedPercent: 10, + resetsAt: "2099-04-14T18:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + ], + ), + ).toEqual([ + { + provider: "codex", + updatedAt: "2099-04-08T18:05:00.000Z", + limits: [ + { + window: "5h", + usedPercent: 22, + resetsAt: "2099-04-08T21:18:00.000Z", + windowDurationMins: 300, + }, + { + window: "Weekly", + usedPercent: 10, + resetsAt: "2099-04-14T18:00:00.000Z", + windowDurationMins: 10080, + }, + ], + }, + ]); + }); + + it("preserves OpenUsage text lines for daily token usage summaries", () => { + expect( + normalizeOpenUsageUsageLines({ + providerId: "codex", + fetchedAt: "2099-04-08T18:00:00.000Z", + lines: [ + { + type: "progress", + label: "Session", + used: 20, + limit: 100, + }, + { + type: "text", + label: "Today", + value: "$5.17 · 9.2M tokens", + }, + { + type: "text", + label: "Yesterday", + value: "$2.04 · 3.1M tokens", + subtitle: "via ccusage", + }, + ], + }), + ).toEqual([ + { + label: "Today", + value: "$5.17 · 9.2M tokens", + }, + { + label: "Yesterday", + value: "$2.04 · 3.1M tokens", + subtitle: "via ccusage", + }, + ]); + }); +}); diff --git a/apps/web/src/lib/openUsageRateLimits.ts b/apps/web/src/lib/openUsageRateLimits.ts new file mode 100644 index 00000000000..af72866da54 --- /dev/null +++ b/apps/web/src/lib/openUsageRateLimits.ts @@ -0,0 +1,155 @@ +// FILE: openUsageRateLimits.ts +// Purpose: Normalizes OpenUsage local HTTP snapshots into the shared rate-limit +// model consumed by the local toolbar popover. + +import { ProviderDriverKind } from "@t3tools/contracts"; + +import type { ProviderRateLimit, RateLimitWindow } from "~/lib/rateLimits"; +import { normalizeRateLimitLabel } from "~/lib/rateLimits"; + +interface OpenUsageProgressLine { + type?: unknown; + label?: unknown; + used?: unknown; + limit?: unknown; + resetsAt?: unknown; + periodDurationMs?: unknown; +} + +interface OpenUsageTextLine { + type?: unknown; + label?: unknown; + value?: unknown; + subtitle?: unknown; +} + +interface OpenUsageSnapshot { + providerId?: unknown; + fetchedAt?: unknown; + lines?: unknown; +} + +export interface OpenUsageUsageLine { + label: string; + value: string; + subtitle?: string; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function toWindowDurationMins(periodDurationMs: number | undefined): number | undefined { + if (periodDurationMs === undefined) return undefined; + return Math.round(periodDurationMs / 60_000); +} + +function toUsedPercent(line: OpenUsageProgressLine): number | undefined { + const used = asFiniteNumber(line.used); + const limit = asFiniteNumber(line.limit); + if (used === undefined || limit === undefined || limit <= 0) return undefined; + return Math.min(100, Math.max(0, (used / limit) * 100)); +} + +function toProviderDriverKind(providerId: string | undefined): ProviderDriverKind | null { + if (providerId === "codex") return ProviderDriverKind.make("codex"); + if (providerId === "claude") return ProviderDriverKind.make("claudeAgent"); + if (providerId === "copilot") return ProviderDriverKind.make("copilot"); + if (providerId === "cursor") return ProviderDriverKind.make("cursor"); + if (providerId === "opencode") return ProviderDriverKind.make("opencode"); + if (providerId === "gemini") return ProviderDriverKind.make("geminiCli"); + if (providerId === "amp") return ProviderDriverKind.make("amp"); + if (providerId === "kilo") return ProviderDriverKind.make("kilo"); + return null; +} + +export function openUsageProviderIdForProvider( + provider: ProviderDriverKind | null | undefined, +): string | null { + if (provider === "codex") return "codex"; + if (provider === "claudeAgent") return "claude"; + if (provider === "copilot") return "copilot"; + if (provider === "cursor") return "cursor"; + if (provider === "opencode") return "opencode"; + if (provider === "geminiCli") return "gemini"; + if (provider === "amp") return "amp"; + if (provider === "kilo") return "kilo"; + return null; +} + +function normalizeProgressLine(line: OpenUsageProgressLine): RateLimitWindow | null { + if (line.type !== "progress") return null; + + const label = asString(line.label); + const usedPercent = toUsedPercent(line); + const resetsAt = asString(line.resetsAt); + const windowDurationMins = toWindowDurationMins(asFiniteNumber(line.periodDurationMs)); + + if (usedPercent === undefined && !resetsAt) return null; + + return { + window: normalizeRateLimitLabel(label, windowDurationMins), + ...(usedPercent !== undefined ? { usedPercent } : {}), + ...(resetsAt ? { resetsAt } : {}), + ...(windowDurationMins !== undefined ? { windowDurationMins } : {}), + }; +} + +function normalizeTextLine(line: OpenUsageTextLine): OpenUsageUsageLine | null { + if (line.type !== "text") return null; + + const label = asString(line.label); + const value = asString(line.value); + const subtitle = asString(line.subtitle); + if (!label || !value) return null; + + return { + label, + value, + ...(subtitle ? { subtitle } : {}), + }; +} + +export function normalizeOpenUsageSnapshot( + snapshot: unknown, + preferredProvider?: ProviderDriverKind | null, +): ProviderRateLimit | null { + const parsed = asRecord(snapshot) as OpenUsageSnapshot | null; + if (!parsed) return null; + + const provider = + toProviderDriverKind(asString(parsed.providerId)) ?? + (preferredProvider !== undefined ? preferredProvider : null); + if (!provider) return null; + + const lines = Array.isArray(parsed.lines) ? parsed.lines : []; + const limits = lines + .map((line) => normalizeProgressLine(asRecord(line) ?? {})) + .filter((line): line is RateLimitWindow => line !== null); + + if (limits.length === 0) return null; + + return { + provider, + updatedAt: asString(parsed.fetchedAt) ?? new Date().toISOString(), + limits, + }; +} + +export function normalizeOpenUsageUsageLines(snapshot: unknown): OpenUsageUsageLine[] { + const parsed = asRecord(snapshot) as OpenUsageSnapshot | null; + if (!parsed) return []; + + const lines = Array.isArray(parsed.lines) ? parsed.lines : []; + return lines + .map((line) => normalizeTextLine(asRecord(line) ?? {})) + .filter((line): line is OpenUsageUsageLine => line !== null); +} diff --git a/apps/web/src/lib/openUsageReactQuery.ts b/apps/web/src/lib/openUsageReactQuery.ts new file mode 100644 index 00000000000..0803928981d --- /dev/null +++ b/apps/web/src/lib/openUsageReactQuery.ts @@ -0,0 +1,41 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; + +import { openUsageProviderIdForProvider } from "./openUsageRateLimits"; + +const OPEN_USAGE_BASE_URL = "http://127.0.0.1:6736"; + +export const openUsageQueryKeys = { + all: ["openUsage"] as const, + provider: (provider: ProviderDriverKind | null | undefined) => + ["openUsage", "provider", provider ?? null] as const, +}; + +export function openUsageProviderSnapshotQueryOptions(provider: ProviderDriverKind | null | undefined) { + const providerId = openUsageProviderIdForProvider(provider); + + return queryOptions({ + queryKey: openUsageQueryKeys.provider(provider), + enabled: providerId !== null, + staleTime: 15_000, + refetchInterval: 15_000, + refetchOnWindowFocus: false, + retry: false, + queryFn: async (): Promise => { + if (!providerId) return null; + + try { + const response = await fetch(`${OPEN_USAGE_BASE_URL}/v1/usage/${providerId}`); + if (response.status === 204 || response.status === 404) { + return null; + } + if (!response.ok) { + return null; + } + return await response.json(); + } catch { + return null; + } + }, + }); +} diff --git a/apps/web/src/lib/rateLimits.ts b/apps/web/src/lib/rateLimits.ts new file mode 100644 index 00000000000..4699867e3ad --- /dev/null +++ b/apps/web/src/lib/rateLimits.ts @@ -0,0 +1,451 @@ +// FILE: rateLimits.ts +// Purpose: Centralizes rate-limit parsing, normalization, formatting, and row derivation +// for provider runtime events so UI components can stay presentation-only. + +import type { OrchestrationThread } from "@t3tools/contracts"; + +export interface RateLimitWindow { + window: string; + usedPercent?: number; + utilization?: number; + resetsAt?: string; + windowDurationMins?: number; +} + +export interface ProviderRateLimit { + provider: string; + updatedAt: string; + limits?: RateLimitWindow[]; + usedPercent?: number; + utilization?: number; + resetsAt?: string; + windowDurationMins?: number; + status?: string; +} + +export interface VisibleRateLimitRow { + id: string; + label: string; + remainingPercent: number; + resetsAt?: string; + windowDurationMins?: number; +} + +const WINDOW_ORDER = new Map([ + ["5h", 0], + ["Weekly", 1], + ["Sonnet", 2], + ["Current", 3], +]); + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function clampPercent(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return Math.min(100, Math.max(0, value)); +} + +function toUsedPercent(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return clampPercent(value <= 1 ? value * 100 : value); +} + +function resolveUsedPercent(values: { + usedPercent?: unknown; + utilization?: unknown; +}): number | undefined { + if (typeof values.usedPercent === "number") return clampPercent(values.usedPercent); + if (typeof values.utilization === "number") return toUsedPercent(values.utilization); + return undefined; +} + +function isUpcomingReset(resetsAt: string | undefined, nowMs: number): boolean { + if (!resetsAt) return true; + const resetMs = Date.parse(resetsAt); + return Number.isNaN(resetMs) || resetMs >= nowMs; +} + +function toResetString(value: unknown): string | undefined { + if (typeof value === "string") return value; + return undefined; +} + +function toIsoReset(value: unknown): string | undefined { + if (typeof value === "number") return new Date(value * 1000).toISOString(); + return toResetString(value); +} + +function windowLabelFromDuration(windowDurationMins: number | undefined): string | undefined { + if (windowDurationMins === 300) return "5h"; + if (windowDurationMins === 10_080) return "Weekly"; + return undefined; +} + +export function normalizeRateLimitLabel( + label: string | undefined, + windowDurationMins?: number, +): string { + const durationLabel = windowLabelFromDuration(windowDurationMins); + if (durationLabel) return durationLabel; + if (!label) return "Current"; + + const normalized = label + .trim() + .toLowerCase() + .replace(/[_\s-]+/g, "_"); + if (normalized === "session" || normalized === "five_hour" || normalized === "5h") { + return "5h"; + } + if (normalized === "weekly" || normalized === "seven_day" || normalized === "7d") { + return "Weekly"; + } + if ( + normalized === "seven_day_sonnet" || + normalized === "weekly_sonnet" || + normalized === "sonnet" + ) { + return "Sonnet"; + } + return label; +} + +function compareWindowLabels(a: string, b: string): number { + return (WINDOW_ORDER.get(a) ?? 99) - (WINDOW_ORDER.get(b) ?? 99); +} + +function normalizeLimitWindow( + label: string, + rawWindow: Record, +): RateLimitWindow | null { + const usedPercent = resolveUsedPercent(rawWindow); + const windowDurationMins = + typeof rawWindow.windowDurationMins === "number" ? rawWindow.windowDurationMins : undefined; + const resetsAt = toIsoReset(rawWindow.resetsAt); + + if (usedPercent === undefined && !resetsAt) return null; + + const window: RateLimitWindow = { + window: normalizeRateLimitLabel(label, windowDurationMins), + }; + if (usedPercent !== undefined) { + window.usedPercent = usedPercent; + } + if (resetsAt) { + window.resetsAt = resetsAt; + } + if (windowDurationMins !== undefined) { + window.windowDurationMins = windowDurationMins; + } + return window; +} + +function extractLimitsFromById(payload: Record): RateLimitWindow[] | undefined { + const rateLimitsByLimitId = asRecord(payload.rateLimitsByLimitId); + if (!rateLimitsByLimitId) return undefined; + + const limits = Object.values(rateLimitsByLimitId) + .map((entry) => asRecord(entry)) + .flatMap((entry) => { + if (!entry) return []; + const primary = asRecord(entry.primary); + if (!primary) return []; + const label = + typeof entry.label === "string" + ? entry.label + : typeof entry.window === "string" + ? entry.window + : ""; + const normalized = normalizeLimitWindow(label, primary); + return normalized ? [normalized] : []; + }); + + return limits.length > 0 ? limits : undefined; +} + +function extractLimitsFromArray(payload: Record): RateLimitWindow[] | undefined { + if (!Array.isArray(payload.limits)) return undefined; + + const limits = payload.limits + .map((entry) => asRecord(entry)) + .flatMap((entry) => { + if (!entry || typeof entry.window !== "string") return []; + const normalized = normalizeLimitWindow(entry.window, entry); + return normalized ? [normalized] : []; + }); + + return limits.length > 0 ? limits : undefined; +} + +function extractLimitsFromCodexPayload( + payload: Record, +): RateLimitWindow[] | undefined { + const rateLimitsRoot = asRecord(payload.rateLimits); + const nestedRateLimits = + rateLimitsRoot && asRecord(rateLimitsRoot.rateLimits) + ? asRecord(rateLimitsRoot.rateLimits) + : (rateLimitsRoot ?? payload); + if (!nestedRateLimits) return undefined; + + const primary = asRecord(nestedRateLimits.primary); + const secondary = asRecord(nestedRateLimits.secondary); + const limits: RateLimitWindow[] = []; + + if (primary) { + const normalized = normalizeLimitWindow("Session", { + usedPercent: primary.usedPercent, + resetsAt: primary.resetsAt, + windowDurationMins: primary.windowDurationMins, + }); + if (normalized) limits.push(normalized); + } + + if (secondary) { + const normalized = normalizeLimitWindow("Weekly", { + usedPercent: secondary.usedPercent, + resetsAt: secondary.resetsAt, + windowDurationMins: secondary.windowDurationMins, + }); + if (normalized) limits.push(normalized); + } + + return limits.length > 0 ? limits : undefined; +} + +function extractLimitsFromClaudePayload( + payload: Record, +): { limits?: RateLimitWindow[]; status?: string } | undefined { + const info = asRecord(payload.rate_limit_info); + if (!info) return undefined; + + const rateLimitType = typeof info.rateLimitType === "string" ? info.rateLimitType : undefined; + const windowDurationMins = + rateLimitType === "five_hour" ? 300 : rateLimitType === "seven_day" ? 10_080 : undefined; + const normalized = normalizeLimitWindow(rateLimitType ?? "Current", { + utilization: info.utilization, + resetsAt: info.resetsAt, + windowDurationMins, + }); + + return { + ...(normalized ? { limits: [normalized] } : {}), + ...(typeof info.status === "string" ? { status: info.status } : {}), + }; +} + +function extractFallbackLimits(payload: Record): RateLimitWindow[] | undefined { + const usedPercent = resolveUsedPercent(payload); + const resetsAt = toIsoReset(payload.resetsAt); + const windowDurationMins = + typeof payload.windowDurationMins === "number" ? payload.windowDurationMins : undefined; + + if (usedPercent === undefined && !resetsAt) return undefined; + + return [ + { + window: normalizeRateLimitLabel(undefined, windowDurationMins), + ...(usedPercent !== undefined ? { usedPercent } : {}), + ...(resetsAt ? { resetsAt } : {}), + ...(windowDurationMins !== undefined ? { windowDurationMins } : {}), + }, + ]; +} + +export function deriveAccountRateLimits( + threads: ReadonlyArray>, +): ProviderRateLimit[] { + const byProvider = new Map(); + const nowMs = Date.now(); + + for (const thread of threads) { + for (const activity of thread.activities) { + if ( + activity.kind !== "account.rate-limits.updated" && + activity.kind !== "account.rate-limited" + ) { + continue; + } + + const payload = asRecord(activity.payload); + if (!payload) continue; + + const provider = typeof payload.provider === "string" ? payload.provider : "unknown"; + const existing = byProvider.get(provider); + if (existing && existing.updatedAt > activity.createdAt) continue; + + const claudePayload = extractLimitsFromClaudePayload(payload); + const limits = ( + extractLimitsFromById(payload) ?? + extractLimitsFromArray(payload) ?? + extractLimitsFromCodexPayload(payload) ?? + claudePayload?.limits ?? + extractFallbackLimits(payload) + ) + ?.filter((limit) => isUpcomingReset(limit.resetsAt, nowMs)) + .toSorted((a, b) => compareWindowLabels(a.window, b.window)); + + if (!limits || limits.length === 0) continue; + + byProvider.set(provider, { + provider, + updatedAt: activity.createdAt, + limits, + ...(claudePayload?.status ? { status: claudePayload.status } : {}), + }); + } + } + + return Array.from(byProvider.values()); +} + +export function deriveVisibleRateLimitRows( + rateLimits: ReadonlyArray, +): VisibleRateLimitRow[] { + const rowsByLabel = new Map(); + + for (const rateLimit of rateLimits) { + const limits = + rateLimit.limits && rateLimit.limits.length > 0 + ? rateLimit.limits + : [ + { + window: normalizeRateLimitLabel(undefined, rateLimit.windowDurationMins), + ...(() => { + const usedPercent = resolveUsedPercent(rateLimit); + return usedPercent !== undefined ? { usedPercent } : {}; + })(), + ...(rateLimit.resetsAt ? { resetsAt: rateLimit.resetsAt } : {}), + ...(typeof rateLimit.windowDurationMins === "number" + ? { windowDurationMins: rateLimit.windowDurationMins } + : {}), + }, + ]; + + for (const limit of limits) { + const usedPercent = resolveUsedPercent(limit); + if (usedPercent === undefined) continue; + + const label = normalizeRateLimitLabel(limit.window, limit.windowDurationMins); + const row = { + id: `${rateLimit.provider}-${label}`, + label, + remainingPercent: Math.round(100 - usedPercent), + ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}), + ...(typeof limit.windowDurationMins === "number" + ? { windowDurationMins: limit.windowDurationMins } + : {}), + usedPercent, + }; + + const existing = rowsByLabel.get(label); + if (!existing || usedPercent > existing.usedPercent) { + rowsByLabel.set(label, row); + } + } + } + + return Array.from(rowsByLabel.values()) + .toSorted((a, b) => compareWindowLabels(a.label, b.label)) + .map(({ usedPercent: _usedPercent, ...row }) => row); +} + +export function formatRateLimitRemainingPercent(remainingPercent: number | undefined): string { + if (remainingPercent === undefined) return "—"; + return `${Math.round(Math.min(100, Math.max(0, remainingPercent)))}%`; +} + +export function formatRateLimitResetTime(resetsAt: string): string { + const resetMs = Date.parse(resetsAt); + if (Number.isNaN(resetMs)) return ""; + const diffMs = resetMs - Date.now(); + + if (diffMs > 0 && diffMs < 24 * 60 * 60 * 1000) { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + }).format(resetMs); + } + + return new Intl.DateTimeFormat(undefined, { + day: "numeric", + month: "short", + }).format(resetMs); +} + +export function deriveRateLimitLearnMoreHref( + rateLimits: ReadonlyArray, +): string | null { + const providers = new Set(rateLimits.map((rateLimit) => rateLimit.provider)); + if (providers.size !== 1) return null; + + const [provider] = providers; + if (provider === "codex") return "https://platform.openai.com/usage"; + if (provider === "claudeAgent") { + return "https://docs.anthropic.com/en/docs/about-claude/models#rate-limits"; + } + return null; +} + +function mergeRateLimitWindowSets( + preferred: ReadonlyArray, + fallback: ReadonlyArray, +): RateLimitWindow[] { + const merged = new Map(); + + for (const limit of fallback) { + const label = normalizeRateLimitLabel(limit.window, limit.windowDurationMins); + merged.set(label, { + ...limit, + window: label, + }); + } + + for (const limit of preferred) { + const label = normalizeRateLimitLabel(limit.window, limit.windowDurationMins); + const existing = merged.get(label); + merged.set(label, { + ...existing, + ...limit, + window: label, + }); + } + + return Array.from(merged.values()).toSorted((a, b) => compareWindowLabels(a.window, b.window)); +} + +function mergeProviderRateLimit( + preferred: ProviderRateLimit, + fallback: ProviderRateLimit | undefined, +): ProviderRateLimit { + if (!fallback) return preferred; + + return { + provider: preferred.provider, + updatedAt: preferred.updatedAt, + limits: mergeRateLimitWindowSets(preferred.limits ?? [], fallback.limits ?? []), + ...((preferred.status ?? fallback.status) + ? { status: preferred.status ?? fallback.status } + : {}), + }; +} + +export function mergeProviderRateLimits( + preferred: ReadonlyArray, + fallback: ReadonlyArray, +): ProviderRateLimit[] { + const merged = new Map(); + + for (const rateLimit of fallback) { + merged.set(rateLimit.provider, rateLimit); + } + + for (const rateLimit of preferred) { + merged.set( + rateLimit.provider, + mergeProviderRateLimit(rateLimit, merged.get(rateLimit.provider)), + ); + } + + return Array.from(merged.values()); +} From ce828fac966ee9ed697bf74b116aaa70b4ce0933 Mon Sep 17 00:00:00 2001 From: sherlock Date: Tue, 5 May 2026 23:02:31 +0530 Subject: [PATCH 2/2] feat(sidebar): wire RateLimitsPanel into the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the restored OpenUsage rate-limits panel as a SidebarGroup between the search row and the projects section. Conditional render — the panel auto-hides itself when no rate-limit activities have arrived (returns null), and the wrapper SidebarGroup also hides when no threads-with-activities are subscribed, so the sidebar stays clean for users who never hit a provider rate limit. Plumbing: - New SidebarProjectsContent prop `threadsWithActivities` typed as `readonly Pick[]`. - Parent computes it via `selectThreadsAcrossEnvironments` mapped to just the activities slice, wrapped in `useShallow` so the reference stays stable when activity slices are unchanged. Tests: 995/995 web, including the restored 8 OpenUsage tests (2 panel + 6 derivation). --- apps/web/src/components/Sidebar.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f617472c99e..f3ca0354a27 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -68,6 +68,7 @@ import { selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, + selectThreadsAcrossEnvironments, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -166,6 +167,7 @@ import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; +import RateLimitsPanel from "./RateLimitsPanel"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; @@ -178,7 +180,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary, Thread } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, @@ -2486,6 +2488,7 @@ interface SidebarProjectsContentProps { suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; + threadsWithActivities: readonly Pick[]; } const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -2526,6 +2529,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, + threadsWithActivities, } = props; const handleProjectSortOrderChange = useCallback( @@ -2572,6 +2576,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( + {threadsWithActivities.length > 0 ? ( + + + + ) : null} {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -2714,6 +2723,15 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + // Threads (with activities) are subscribed to here only for the rate-limits + // panel, which derives `account.rate-limited` events from each thread's + // activity stream. Activity arrays change frequently — using `useShallow` + // keeps the reference stable when the activity slices haven't changed. + const threadsWithActivities = useStore( + useShallow((state) => + selectThreadsAcrossEnvironments(state).map((thread) => ({ activities: thread.activities })), + ), + ); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -3385,6 +3403,7 @@ export default function Sidebar() { suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef} attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} projectsLength={projects.length} + threadsWithActivities={threadsWithActivities} />