diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce460..fd474eca9 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -601,6 +601,7 @@ export class CodexAppServerManager extends EventEmitter { + this.emitEvent({ + id: EventId.makeUnsafe(randomUUID()), + kind: "notification", + provider: "codex", + threadId: context.session.threadId, + createdAt: new Date().toISOString(), + method: "account/rateLimits/updated", + payload: rateLimitsResponse, + }); + }) + .catch(() => { + // Rate limits may not be available for all auth modes. + }); + } + private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..4abc691fb 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -618,6 +618,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ).pipe(Effect.forkIn(subscriptionsScope)); + const providerService = yield* ProviderService; + yield* Stream.runForEach( + Stream.filter( + providerService.streamEvents, + (event) => event.type === "account.rate-limits.updated", + ), + (event) => { + const payload = (event as { payload?: { rateLimits?: unknown } }).payload; + return pushBus.publishAll(WS_CHANNELS.providerRateLimitsUpdated, payload?.rateLimits ?? {}); + }, + ).pipe(Effect.forkIn(subscriptionsScope)); + yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); yield* readiness.markOrchestrationSubscriptionsReady; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..1725ad384 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -47,6 +47,7 @@ import { derivePendingApprovals, derivePendingUserInputs } from "../session-logi import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; +import { WeeklyLimitPill } from "./WeeklyLimitPill"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; @@ -1737,7 +1738,9 @@ export default function Sidebar() { - + + + {isOnSettings ? ( diff --git a/apps/web/src/components/WeeklyLimitPill.tsx b/apps/web/src/components/WeeklyLimitPill.tsx new file mode 100644 index 000000000..552139ee0 --- /dev/null +++ b/apps/web/src/components/WeeklyLimitPill.tsx @@ -0,0 +1,148 @@ +import { ChevronUpIcon } from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import type { RateLimitWindow } from "../wsNativeApi"; +import { useRateLimits } from "../rateLimitsStore"; +import { OpenAI } from "./Icons"; + +function formatResetsAt(resetsAt: number | undefined): string | null { + if (!resetsAt) return null; + const now = Date.now() / 1000; + const diff = resetsAt - now; + if (diff <= 0) return null; + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) return `${hours}h`; + return `${Math.ceil(diff / 60)}m`; +} + +function classifyWindow(w: RateLimitWindow): "weekly" | "session" { + const mins = w.windowDurationMins; + if (mins != null && mins >= 1440) return "weekly"; + if (mins != null && mins < 1440) return "session"; + if (w.resetsAt) { + const hoursLeft = (w.resetsAt - Date.now() / 1000) / 3600; + if (hoursLeft > 48) return "weekly"; + } + return "session"; +} + +function windowLabel(kind: "weekly" | "session"): string { + return kind === "weekly" ? "Weekly Usage" : "Session Usage"; +} + +function LimitBar({ label, window }: { label: string; window: RateLimitWindow }) { + const usedPercent = Math.min(100, Math.max(0, window.usedPercent ?? 0)); + const remainingPercent = 100 - usedPercent; + const resetsIn = formatResetsAt(window.resetsAt); + + return ( +
+ + + {label} + +
+
+
+
+ + {remainingPercent}% left + + {resetsIn && ( + + resets in {resetsIn} + + )} +
+
+ ); +} + +interface ClassifiedWindow { + kind: "weekly" | "session"; + label: string; + window: RateLimitWindow; +} + +function useClassifiedWindows( + primary: RateLimitWindow | null | undefined, + secondary: RateLimitWindow | null | undefined, +): { session: ClassifiedWindow | null; weekly: ClassifiedWindow | null } { + return useMemo(() => { + const windows: ClassifiedWindow[] = []; + if (primary && primary.usedPercent !== undefined) { + const kind = classifyWindow(primary); + windows.push({ kind, label: windowLabel(kind), window: primary }); + } + if (secondary && secondary.usedPercent !== undefined) { + const kind = classifyWindow(secondary); + windows.push({ kind, label: windowLabel(kind), window: secondary }); + } + return { + session: windows.find((w) => w.kind === "session") ?? null, + weekly: windows.find((w) => w.kind === "weekly") ?? null, + }; + }, [primary, secondary]); +} + +export const WeeklyLimitPill = memo(function WeeklyLimitPill() { + const rateLimits = useRateLimits(); + const [expanded, setExpanded] = useState(false); + + const primary = rateLimits?.rateLimits?.primary; + const secondary = rateLimits?.rateLimits?.secondary; + const { session, weekly } = useClassifiedWindows(primary, secondary); + + if (!session && !weekly) return null; + + const mainWindow = session ?? weekly; + const expandableWindow = session && weekly ? weekly : null; + + if (!mainWindow) return null; + + return ( +
+ {expandableWindow && ( +
+
+
+ +
+
+
+ )} + +
+
+ +
+ {expandableWindow && ( + + )} +
+
+ ); +}); diff --git a/apps/web/src/rateLimitsStore.ts b/apps/web/src/rateLimitsStore.ts new file mode 100644 index 000000000..935b22841 --- /dev/null +++ b/apps/web/src/rateLimitsStore.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from "react"; +import { onRateLimitsUpdated, type RateLimitsPayload } from "./wsNativeApi"; + +let snapshot: RateLimitsPayload | null = null; +let listeners: Array<() => void> = []; + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +let unsubWs: (() => void) | null = null; + +function ensureSubscription(): void { + if (unsubWs) return; + unsubWs = onRateLimitsUpdated((payload) => { + snapshot = payload; + emitChange(); + }); +} + +function subscribe(listener: () => void): () => void { + ensureSubscription(); + listeners.push(listener); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + }; +} + +function getSnapshot(): RateLimitsPayload | null { + return snapshot; +} + +export function useRateLimits(): RateLimitsPayload | null { + return useSyncExternalStore(subscribe, getSnapshot, () => null); +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..dc7cf0d38 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -12,9 +12,34 @@ import { import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; +export interface RateLimitWindow { + readonly usedPercent?: number; + readonly windowDurationMins?: number; + readonly resetsAt?: number; +} + +export interface RateLimitsPayload { + readonly rateLimits?: { + readonly limitId?: string; + readonly limitName?: string | null; + readonly primary?: RateLimitWindow | null; + readonly secondary?: RateLimitWindow | null; + }; + readonly rateLimitsByLimitId?: Record< + string, + { + readonly limitId?: string; + readonly limitName?: string | null; + readonly primary?: RateLimitWindow | null; + readonly secondary?: RateLimitWindow | null; + } + >; +} + let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); +const rateLimitsListeners = new Set<(payload: RateLimitsPayload) => void>(); /** * Subscribe to the server welcome message. If a welcome was already received @@ -62,6 +87,24 @@ export function onServerConfigUpdated( }; } +export function onRateLimitsUpdated(listener: (payload: RateLimitsPayload) => void): () => void { + rateLimitsListeners.add(listener); + + const latest = + instance?.transport.getLatestPush(WS_CHANNELS.providerRateLimitsUpdated)?.data ?? null; + if (latest && typeof latest === "object") { + try { + listener(latest as RateLimitsPayload); + } catch { + // Swallow listener errors + } + } + + return () => { + rateLimitsListeners.delete(listener); + }; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; @@ -87,6 +130,17 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.providerRateLimitsUpdated, (message) => { + if (!message.data || typeof message.data !== "object") return; + const payload = message.data as RateLimitsPayload; + for (const listener of rateLimitsListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors + } + } + }); const api: NativeApi = { dialogs: { diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..eee95b9dd 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -83,6 +83,7 @@ export const WS_CHANNELS = { terminalEvent: "terminal.event", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", + providerRateLimitsUpdated: "provider.rateLimitsUpdated", } as const; // -- Tagged Union of all request body schemas ───────────────────────── @@ -173,6 +174,7 @@ export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; + readonly [WS_CHANNELS.providerRateLimitsUpdated]: unknown; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; } @@ -200,11 +202,16 @@ export const WsPushOrchestrationDomainEvent = makeWsPushSchema( ORCHESTRATION_WS_CHANNELS.domainEvent, OrchestrationEvent, ); +export const WsPushProviderRateLimitsUpdated = makeWsPushSchema( + WS_CHANNELS.providerRateLimitsUpdated, + Schema.Unknown, +); export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, WS_CHANNELS.terminalEvent, + WS_CHANNELS.providerRateLimitsUpdated, ORCHESTRATION_WS_CHANNELS.domainEvent, ]); export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; @@ -214,6 +221,7 @@ export const WsPush = Schema.Union([ WsPushServerConfigUpdated, WsPushTerminalEvent, WsPushOrchestrationDomainEvent, + WsPushProviderRateLimitsUpdated, ]); export type WsPush = typeof WsPush.Type;