Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
} catch (error) {
console.log("codex account/read failed", error);
}
this.fetchAndEmitRateLimits(context);

const normalizedModel = resolveCodexModelForAccount(
normalizeCodexModelSlug(input.model),
Expand Down Expand Up @@ -1168,6 +1169,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
activeTurnId: undefined,
lastError: errorMessage ?? context.session.lastError,
});
this.fetchAndEmitRateLimits(context);
return;
}

Expand Down Expand Up @@ -1305,6 +1307,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
context.child.stdin.write(`${encoded}\n`);
}

private fetchAndEmitRateLimits(context: CodexSessionContext): void {
this.sendRequest(context, "account/rateLimits/read", {})
.then((rateLimitsResponse) => {
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()),
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1737,7 +1738,9 @@ export default function Sidebar() {
</SidebarContent>

<SidebarSeparator />
<SidebarFooter className="p-2">
<SidebarFooter className="gap-0 p-2">
<WeeklyLimitPill />
<SidebarSeparator className="my-1.5" />
<SidebarMenu>
<SidebarMenuItem>
{isOnSettings ? (
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/components/WeeklyLimitPill.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group/bar flex flex-col gap-1">
<span className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground/70">
<OpenAI className="size-3 shrink-0" />
{label}
</span>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-border/40">
<div
className="h-full rounded-full bg-ring/50 transition-all duration-500"
style={{ width: `${usedPercent}%` }}
/>
</div>
<div className="flex items-center">
<span className="text-[10px] tabular-nums text-muted-foreground/50">
{remainingPercent}% left
</span>
{resetsIn && (
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/40 opacity-0 transition-opacity group-hover/bar:opacity-100">
resets in {resetsIn}
</span>
)}
</div>
</div>
);
}

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 (
<div className="flex flex-col rounded-lg border border-border/50 px-2.5 py-1.5">
{expandableWindow && (
<div
className="grid transition-[grid-template-rows,opacity] duration-300 ease-in-out"
style={{
gridTemplateRows: expanded ? "1fr" : "0fr",
opacity: expanded ? 1 : 0,
}}
>
<div className="min-h-0 overflow-hidden">
<div className="mb-2 border-b border-border/30 pb-2">
<LimitBar label={expandableWindow.label} window={expandableWindow.window} />
</div>
</div>
</div>
)}

<div className="flex items-start gap-1">
<div className="min-w-0 flex-1">
<LimitBar label={mainWindow.label} window={mainWindow.window} />
</div>
{expandableWindow && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-0.5 shrink-0 rounded p-0.5 text-muted-foreground/40 transition-colors hover:text-muted-foreground/70"
title={
expanded
? `Hide ${expandableWindow.label.toLowerCase()}`
: `Show ${expandableWindow.label.toLowerCase()}`
}
>
<ChevronUpIcon
className={`size-3 transition-transform duration-300 ease-in-out ${expanded ? "" : "rotate-180"}`}
/>
</button>
)}
</div>
</div>
);
});
37 changes: 37 additions & 0 deletions apps/web/src/rateLimitsStore.ts
Original file line number Diff line number Diff line change
@@ -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);
}
54 changes: 54 additions & 0 deletions apps/web/src/wsNativeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -214,6 +221,7 @@ export const WsPush = Schema.Union([
WsPushServerConfigUpdated,
WsPushTerminalEvent,
WsPushOrchestrationDomainEvent,
WsPushProviderRateLimitsUpdated,
]);
export type WsPush = typeof WsPush.Type;

Expand Down