feat: show provider quota in control ui overview (#82647)

* feat: show provider quota in control ui overview

* feat: show provider quota in chat header

* fix: recover stale control ui chat runs

* fix: polish control ui quota refresh
This commit is contained in:
Peter Steinberger
2026-05-16 19:24:02 +01:00
committed by GitHub
parent 0b03b902be
commit 8178a6c949
14 changed files with 511 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647)
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
- CLI/cron: add `openclaw cron run --wait` with timeout and poll interval controls, plus exact `cron.runs --run-id` filtering so automation can block on one queued manual run. (#81929) Thanks @ificator.
- Maintainer tooling: route Crabbox skill defaults through the repo brokered AWS config, leaving Blacksmith Testbox as an explicit opt-in instead of the broad-proof default.

View File

@@ -1217,6 +1217,21 @@
grid-template-areas: "session model thinking";
}
.chat-controls__session-row--has-quota {
grid-template-columns:
minmax(116px, 5fr) minmax(132px, 7fr) minmax(132px, 5fr)
minmax(128px, 4fr) minmax(104px, auto);
grid-template-areas: "agent session model thinking quota";
}
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-columns: minmax(132px, 7fr) minmax(132px, 5fr) minmax(128px, 4fr) minmax(
104px,
auto
);
grid-template-areas: "session model thinking quota";
}
.chat-controls__session-picker {
grid-area: session;
}
@@ -1270,6 +1285,49 @@
max-width: none;
}
.chat-controls__quota {
grid-area: quota;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
min-width: 104px;
height: 36px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--card) 86%, var(--bg-elevated) 14%);
color: var(--fg);
font-size: 12px;
font-weight: 650;
line-height: 1;
text-decoration: none;
white-space: nowrap;
}
.chat-controls__quota:hover {
border-color: color-mix(in srgb, var(--accent) 44%, var(--border));
background: color-mix(in srgb, var(--accent-subtle) 28%, var(--card));
}
.chat-controls__quota-label {
color: var(--muted);
font-weight: 600;
}
.chat-controls__quota-value {
font-variant-numeric: tabular-nums;
}
.chat-controls__quota--warn .chat-controls__quota-value {
color: var(--warn);
}
.chat-controls__quota--danger .chat-controls__quota-value {
color: var(--danger);
}
.chat-controls__thinking {
display: flex;
align-items: center;

View File

@@ -5870,6 +5870,14 @@ td.data-table-key-col {
color: var(--text-strong);
}
.ov-card__value .warn {
color: var(--warning, #b7791f);
}
.ov-card__value .danger {
color: var(--danger);
}
.ov-card__hint {
font-size: 12px;
color: var(--muted);
@@ -5897,6 +5905,10 @@ td.data-table-key-col {
animation-delay: 150ms;
}
.ov-cards .ov-card:nth-child(5) {
animation-delay: 200ms;
}
/* ── Attention items ── */
.ov-attention-list {
display: flex;

View File

@@ -47,6 +47,21 @@
grid-template-areas: "session model thinking";
}
.chat-controls__session-row--has-quota {
grid-template-columns:
minmax(96px, 5fr) minmax(112px, 7fr) minmax(116px, 5fr)
minmax(112px, 4fr) minmax(96px, auto);
grid-template-areas: "agent session model thinking quota";
}
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-columns: minmax(112px, 7fr) minmax(116px, 5fr) minmax(112px, 4fr) minmax(
96px,
auto
);
grid-template-areas: "session model thinking quota";
}
.chat-controls__agent {
grid-area: agent;
}
@@ -63,6 +78,11 @@
grid-area: thinking;
}
.chat-controls__quota {
grid-area: quota;
min-width: 96px;
}
.chat-controls__session,
.chat-controls__agent,
.chat-controls__model,
@@ -460,6 +480,22 @@
"model model";
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row--has-quota {
grid-template-areas:
"agent session"
"model thinking"
"quota quota";
}
.chat-mobile-controls-wrapper
.chat-controls-dropdown
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-areas:
"session thinking"
"model model"
"quota quota";
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session {
min-width: unset;
max-width: unset;
@@ -488,6 +524,12 @@
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__quota {
width: 100%;
min-width: 0;
min-height: 40px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select-full {
display: block;
width: 100%;

View File

@@ -49,6 +49,9 @@ vi.mock("./gateway.ts", async (importOriginal) => {
if (method === "models.authStatus") {
return { ts: 0, providers: [] };
}
if (method === "sessions.list") {
return { count: 0, sessions: [] };
}
return {};
});

View File

@@ -376,8 +376,9 @@ describe("handleGatewayEvent session.message", () => {
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
});
it("skips history reload while a chat run is active", () => {
it("refreshes sessions instead of reloading history while a chat run is active", async () => {
loadChatHistoryMock.mockReset();
loadSessionsMock.mockReset().mockResolvedValue(undefined);
const host = createHost();
host.sessionKey = "agent:qa:main";
host.chatRunId = "run-123";
@@ -390,10 +391,73 @@ describe("handleGatewayEvent session.message", () => {
});
expect(loadChatHistoryMock).not.toHaveBeenCalled();
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
activeMinutes: 10,
agentId: "qa",
limit: 25,
});
await Promise.resolve();
expect(loadChatHistoryMock).not.toHaveBeenCalled();
});
it("replays deferred history reload after session refresh clears a stale active run", async () => {
loadChatHistoryMock.mockReset();
loadSessionsMock.mockReset().mockImplementation(async (state) => {
state.chatRunId = null;
});
const host = createHost();
host.sessionKey = "agent:qa:main";
host.chatRunId = "run-stale";
handleGatewayEvent(host, {
type: "event",
event: "session.message",
payload: { sessionKey: "agent:qa:main" },
seq: 1,
});
await Promise.resolve();
await Promise.resolve();
expect(host.chatRunId).toBeNull();
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
});
it("waits for an in-flight sessions refresh before replaying deferred history", async () => {
vi.useFakeTimers();
try {
loadChatHistoryMock.mockReset();
loadSessionsMock.mockReset().mockResolvedValue(undefined);
const host = createHost();
host.sessionKey = "agent:qa:main";
host.chatRunId = "run-stale";
host.sessionsLoading = true;
handleGatewayEvent(host, {
type: "event",
event: "session.message",
payload: { sessionKey: "agent:qa:main" },
seq: 1,
});
await Promise.resolve();
expect(loadChatHistoryMock).not.toHaveBeenCalled();
host.chatRunId = null;
host.sessionsLoading = false;
await vi.advanceTimersByTimeAsync(250);
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
} finally {
vi.useRealTimers();
}
});
it("ignores transcript updates for other sessions", () => {
loadChatHistoryMock.mockReset();
loadSessionsMock.mockReset();
const host = createHost();
host.sessionKey = "agent:qa:main";

View File

@@ -113,6 +113,7 @@ type GatewayHost = {
chatRunId: string | null;
pendingAbort?: { runId?: string | null; sessionKey: string } | null;
refreshSessionsAfterChat: Set<string>;
sessionsLoading?: boolean;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
updateAvailable: UpdateAvailable | null;
@@ -142,6 +143,8 @@ type GatewayHostWithSideResults = GatewayHost & {
};
const SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS = 5_000;
const DEFERRED_SESSION_MESSAGE_REPLAY_POLL_MS = 250;
const DEFERRED_SESSION_MESSAGE_REPLAY_TIMEOUT_MS = 10_000;
function enqueueApprovalRequest(host: GatewayHost, entry: ExecApprovalRequest | null) {
if (!entry) {
@@ -721,7 +724,13 @@ function resolveChatEventSessionListAgentId(
host: GatewayHost,
payload: ChatEventPayload | undefined,
): string {
const sessionKey = payload?.sessionKey?.trim() || host.sessionKey;
return resolveSessionListAgentIdForSessionKey(
host,
payload?.sessionKey?.trim() || host.sessionKey,
);
}
function resolveSessionListAgentIdForSessionKey(host: GatewayHost, sessionKey: string): string {
const parsed = parseAgentSessionKey(sessionKey);
if (parsed?.agentId) {
return parsed.agentId;
@@ -801,6 +810,41 @@ function handleSessionMessageGatewayEvent(
// first LLM delta arrives.
if (host.chatRunId) {
deferredReloadHost.pendingSessionMessageReloadSessionKey = sessionKey;
void loadSessions(host as unknown as SessionsState, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
agentId: resolveSessionListAgentIdForSessionKey(host, sessionKey),
limit: CHAT_SESSIONS_REFRESH_LIMIT,
}).finally(() =>
replayDeferredSessionMessageReloadAfterSessionsRefresh(host, sessionKey, Date.now()),
);
return;
}
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
void loadChatHistory(host as unknown as ChatState);
}
function replayDeferredSessionMessageReloadAfterSessionsRefresh(
host: GatewayHost,
sessionKey: string,
startedAt: number,
) {
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
if (
deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim() !== sessionKey ||
host.sessionKey !== sessionKey
) {
return;
}
if (host.chatRunId) {
if (
host.sessionsLoading === true &&
Date.now() - startedAt < DEFERRED_SESSION_MESSAGE_REPLAY_TIMEOUT_MS
) {
globalThis.setTimeout(
() => replayDeferredSessionMessageReloadAfterSessionsRefresh(host, sessionKey, startedAt),
DEFERRED_SESSION_MESSAGE_REPLAY_POLL_MS,
);
}
return;
}
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;

View File

@@ -402,6 +402,56 @@ describe("refreshActiveTab", () => {
expect(mocks.loadCronRunsMock).toHaveBeenCalledOnce();
});
it("refreshes model auth status on the chat tab for the quota pill", async () => {
const host = createHost();
host.tab = "chat";
await refreshActiveTab(host as never);
expect(mocks.refreshChatMock).toHaveBeenCalledOnce();
expect(mocks.loadModelAuthStatusStateMock).toHaveBeenCalledWith(host);
expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce();
});
it("does not wait for quota status before scrolling the chat tab", async () => {
const host = createHost();
host.tab = "chat";
const quotaRefresh = createDeferred();
mocks.loadModelAuthStatusStateMock.mockReturnValueOnce(quotaRefresh.promise);
const refresh = refreshActiveTab(host as never);
const outcome = await raceWithNextMacrotask(refresh);
expect(outcome).toBe("resolved");
expect(mocks.refreshChatMock).toHaveBeenCalledOnce();
expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce();
quotaRefresh.resolve();
await quotaRefresh.promise;
});
it("preserves chat refresh failures while loading quota status", async () => {
const host = createHost();
host.tab = "chat";
mocks.refreshChatMock.mockRejectedValueOnce(new Error("chat refresh failed"));
await expect(refreshActiveTab(host as never)).rejects.toThrow("chat refresh failed");
expect(mocks.loadModelAuthStatusStateMock).toHaveBeenCalledWith(host);
expect(mocks.scheduleChatScrollMock).not.toHaveBeenCalled();
});
it("contains quota status failures on the chat tab", async () => {
const host = createHost();
host.tab = "chat";
mocks.loadModelAuthStatusStateMock.mockRejectedValueOnce(new Error("quota failed"));
await expect(refreshActiveTab(host as never)).resolves.toBeUndefined();
expect(mocks.refreshChatMock).toHaveBeenCalledOnce();
expect(mocks.scheduleChatScrollMock).toHaveBeenCalledOnce();
});
it("records failed cron runs status from the controller outcome", async () => {
const host = createHost();
host.tab = "cron";

View File

@@ -403,13 +403,16 @@ export async function refreshActiveTab(host: SettingsHost) {
loadWikiMemoryPalace(app),
]);
break;
case "chat":
case "chat": {
const modelAuthRefresh = loadModelAuthStatusState(app).catch(() => undefined);
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[0],
!host.chatHasAutoScrolled,
);
void modelAuthRefresh;
break;
}
case "debug":
await loadDebug(app);
host.eventLog = host.eventLogBuffer;

View File

@@ -10,6 +10,9 @@ import {
} from "../chat-model-select-state.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts";
import { loadSessions } from "../controllers/sessions.ts";
import { isMonitoredAuthProvider } from "../model-auth-helpers.ts";
import { pathForTab } from "../navigation.ts";
import { collectQuotaWindowsFromAuthStatus, formatQuotaReset } from "../provider-quota-summary.ts";
import { pushUniqueTrimmedSelectOption } from "../select-options.ts";
import { isCronSessionKey, resolveSessionDisplayName } from "../session-display.ts";
import {
@@ -43,6 +46,7 @@ export function renderChatSessionSelect(
const agentSelect = renderChatAgentSelect(state, onSwitchSession, agentOptions);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const quotaPill = renderChatQuotaPill(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
@@ -50,6 +54,7 @@ export function renderChatSessionSelect(
const rowClass = [
"chat-controls__session-row",
hasAgentSelect ? "" : "chat-controls__session-row--single-agent",
quotaPill ? "chat-controls__session-row--has-quota" : "",
flashSession ? "chat-controls__session-row--flash" : "",
]
.filter(Boolean)
@@ -93,7 +98,7 @@ export function renderChatSessionSelect(
)}
</select>
</label>
${modelSelect} ${thinkingSelect}
${modelSelect} ${thinkingSelect} ${quotaPill}
</div>
<div class="chat-controls__session-notice" role="status" aria-live="polite">
${state.sessionSwitchNotice?.text ?? ""}
@@ -101,6 +106,56 @@ export function renderChatSessionSelect(
`;
}
function renderChatQuotaPill(state: AppViewState) {
const windows = collectQuotaWindowsFromAuthStatus(
state.modelAuthStatusResult,
isMonitoredAuthProvider,
);
const primary = windows[0];
if (!primary) {
return "";
}
const secondary = windows.find(
(entry) => entry.displayName !== primary.displayName || entry.label !== primary.label,
);
const reset = formatQuotaReset(primary.resetAt);
const detail = [primary.displayName, primary.label, reset ? `resets ${reset}` : null]
.filter(Boolean)
.join(" · ");
const secondaryDetail = secondary
? `${secondary.displayName}${secondary.label ? ` ${secondary.label}` : ""} ${secondary.remaining}% left`
: null;
const title = [detail, secondaryDetail].filter(Boolean).join(" · ");
const severity = primary.remaining <= 10 ? "danger" : primary.remaining <= 25 ? "warn" : "ok";
return html`
<a
class="chat-controls__quota chat-controls__quota--${severity}"
href=${pathForTab("usage", state.basePath)}
title=${title}
aria-label=${`Provider usage: ${title}`}
data-chat-provider-usage="true"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
state.setTab("usage");
}}
>
<span class="chat-controls__quota-label">Usage</span>
<span class="chat-controls__quota-value">${primary.remaining}%</span>
</a>
`;
}
function renderChatAgentSelect(
state: AppViewState,
onSwitchSession: ChatSessionSwitchHandler,

View File

@@ -0,0 +1,55 @@
import type { ModelAuthStatusProvider, ModelAuthStatusResult } from "./types.ts";
export type QuotaWindowSummary = {
displayName: string;
label: string;
remaining: number;
resetAt?: number;
};
export function formatQuotaReset(resetAt?: number): string | null {
if (!resetAt || !Number.isFinite(resetAt)) {
return null;
}
const diffMs = resetAt - Date.now();
if (diffMs <= 0) {
return "now";
}
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours < 24) {
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
const days = Math.floor(hours / 24);
if (days < 7) {
const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
}
return new Date(resetAt).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function collectQuotaWindows(
providers: ReadonlyArray<ModelAuthStatusProvider>,
): QuotaWindowSummary[] {
return providers
.flatMap((provider) =>
(provider.usage?.windows ?? []).map((window) => ({
displayName: provider.displayName,
label: (window.label || "").trim(),
remaining: Math.max(0, Math.min(100, Math.round(100 - window.usedPercent))),
resetAt: window.resetAt,
})),
)
.toSorted((a, b) => a.remaining - b.remaining || a.displayName.localeCompare(b.displayName));
}
export function collectQuotaWindowsFromAuthStatus(
status: ModelAuthStatusResult | null,
filter: (provider: ModelAuthStatusProvider) => boolean,
): QuotaWindowSummary[] {
return collectQuotaWindows((status?.providers ?? []).filter(filter));
}

View File

@@ -334,6 +334,7 @@ function createChatHeaderState(
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
setTab: vi.fn(),
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
@@ -1058,6 +1059,38 @@ describe("chat session controls", () => {
]);
});
it("shows provider quota in the chat header when usage data is loaded", () => {
const { state } = createChatHeaderState();
state.modelAuthStatusResult = {
ts: Date.now(),
providers: [
{
provider: "openai-codex",
displayName: "Codex",
status: "ok",
profiles: [{ profileId: "codex", type: "oauth", status: "ok" }],
usage: {
windows: [
{ label: "3h", usedPercent: 18 },
{ label: "Week", usedPercent: 72 },
],
},
},
],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const quota = container.querySelector<HTMLAnchorElement>('[data-chat-provider-usage="true"]');
expect(quota?.textContent?.replace(/\s+/g, " ").trim()).toBe("Usage 28%");
expect(quota?.getAttribute("href")).toBe("/usage");
expect(quota?.getAttribute("title")).toContain("Codex · Week");
quota?.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
expect(state.setTab).toHaveBeenCalledWith("usage");
});
it("falls back to the selected agent's main session when no sessions exist yet", () => {
const { state } = createChatHeaderState();
const onSwitchSession = vi.fn();

View File

@@ -4,6 +4,11 @@ import { t } from "../../i18n/index.ts";
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
import { isMonitoredAuthProvider } from "../model-auth-helpers.ts";
import { formatNextRun } from "../presenter.ts";
import {
collectQuotaWindows,
formatQuotaReset,
type QuotaWindowSummary,
} from "../provider-quota-summary.ts";
import { resolveSessionDisplayName } from "../session-display.ts";
import type {
SessionsUsageResult,
@@ -51,6 +56,39 @@ function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
`;
}
function renderProviderQuotaCard(windows: QuotaWindowSummary[]): StatCard | null {
const primary = windows[0];
if (!primary) {
return null;
}
const reset = formatQuotaReset(primary.resetAt);
const primaryHint = [primary.displayName, primary.label, reset ? `reset ${reset}` : null].filter(
Boolean,
);
const secondary = windows.find(
(entry) => entry.displayName !== primary.displayName || entry.label !== primary.label,
);
const secondaryHint = secondary
? `${[secondary.displayName, secondary.label].filter(Boolean).join(" · ")} ${t(
"overview.cards.modelAuthUsageLeft",
{
pct: String(secondary.remaining),
},
)}`
: null;
const valueClass = primary.remaining <= 10 ? "danger" : primary.remaining <= 25 ? "warn" : "";
return {
kind: "quota",
tab: "usage",
label: t("tabs.usage"),
value: html`<span class=${valueClass}
>${t("overview.cards.modelAuthUsageLeft", { pct: String(primary.remaining) })}</span
>`,
hint: [primaryHint.join(" · "), secondaryHint].filter(Boolean).join(" · "),
};
}
function renderSkeletonCards() {
// Render 4 skeletons — matching the always-present cards (cost, sessions,
// skills, cron). The Model Auth card is conditional on OAuth providers
@@ -94,6 +132,10 @@ export function renderOverviewCards(props: OverviewCardsProps) {
const cronNext = props.cronStatus?.nextWakeAtMs ?? null;
const cronJobCount = props.cronJobs.length;
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length;
const authLoading = props.modelAuthStatus === null;
const authProviders = props.modelAuthStatus?.providers ?? [];
const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);
const quotaCard = renderProviderQuotaCard(collectQuotaWindows(monitoredProviders));
const cronValue =
cronEnabled == null
@@ -139,6 +181,9 @@ export function renderOverviewCards(props: OverviewCardsProps) {
hint: cronHint,
},
];
if (quotaCard) {
cards.splice(1, 0, quotaCard);
}
// Model auth card — show providers whose auth needs monitoring.
// See isMonitoredAuthProvider for the exact predicate.
@@ -148,9 +193,6 @@ export function renderOverviewCards(props: OverviewCardsProps) {
// card's N/A-placeholder pattern. Still hidden entirely for api-key-only
// setups post-load (nothing to monitor), which accepts a one-time hide
// rather than the recurring load-time layout shift.
const authLoading = props.modelAuthStatus === null;
const authProviders = props.modelAuthStatus?.providers ?? [];
const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);
if (authLoading) {
cards.push({
kind: "auth",

View File

@@ -180,4 +180,46 @@ describe("overview view rendering", () => {
expect(recentNames).toEqual(["Ops Room", "Telegram Session", "Main Project"]);
expect(recentNames).not.toContain("telegram:123:456");
});
it("promotes provider quota into a dedicated overview card", async () => {
const container = document.createElement("div");
const props = createOverviewProps({
usageResult: {
totals: { totalCost: 0, totalTokens: 0 },
aggregates: { messages: { total: 0 } },
} as OverviewProps["usageResult"],
modelAuthStatus: {
ts: Date.now(),
providers: [
{
provider: "openai-codex",
displayName: "Codex",
status: "ok",
profiles: [{ profileId: "codex", type: "oauth", status: "ok" }],
usage: {
windows: [
{ label: "3h", usedPercent: 18 },
{ label: "Week", usedPercent: 72 },
],
},
},
{
provider: "anthropic",
displayName: "Claude",
status: "ok",
profiles: [{ profileId: "anthropic", type: "token", status: "ok" }],
usage: {
windows: [{ label: "5h", usedPercent: 60 }],
},
},
],
},
});
render(renderOverview(props), container);
await Promise.resolve();
const quota = container.querySelector('[data-kind="quota"]');
expect(compactText(quota)).toBe("Usage 28% left Codex · Week · Claude · 5h 40% left");
});
});