diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bab6e74b58..1de296a512b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e598a146591..99d568d781c 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index e8b5c8ae4e7..458e6de9136 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 0e2384a3620..431df3ba092 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -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%; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 1885af3ae9e..cb7ddac52ce 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -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 {}; }); diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 3e4e6bd5867..aae6a4fe8e6 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -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"; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index eadf89d10e0..93bc0f701fa 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -113,6 +113,7 @@ type GatewayHost = { chatRunId: string | null; pendingAbort?: { runId?: string | null; sessionKey: string } | null; refreshSessionsAfterChat: Set; + 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; diff --git a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts index eed494d8ed3..43f2284ec6a 100644 --- a/ui/src/ui/app-settings.refresh-active-tab.node.test.ts +++ b/ui/src/ui/app-settings.refresh-active-tab.node.test.ts @@ -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"; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 40a6cd6fd84..a2d048aad84 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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[0]); scheduleChatScroll( host as unknown as Parameters[0], !host.chatHasAutoScrolled, ); + void modelAuthRefresh; break; + } case "debug": await loadDebug(app); host.eventLog = host.eventLogBuffer; diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index 40774511c6c..a834a7d5aab 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -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( )} - ${modelSelect} ${thinkingSelect} + ${modelSelect} ${thinkingSelect} ${quotaPill}
${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` + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + state.setTab("usage"); + }} + > + Usage + ${primary.remaining}% + + `; +} + function renderChatAgentSelect( state: AppViewState, onSwitchSession: ChatSessionSwitchHandler, diff --git a/ui/src/ui/provider-quota-summary.ts b/ui/src/ui/provider-quota-summary.ts new file mode 100644 index 00000000000..92fdf0d15c5 --- /dev/null +++ b/ui/src/ui/provider-quota-summary.ts @@ -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, +): 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)); +} diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 76ab11c22d1..ec3687c6d7d 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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('[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(); diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts index aa0ab835ba8..81214fcb539 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/ui/views/overview-cards.ts @@ -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`${t("overview.cards.modelAuthUsageLeft", { pct: String(primary.remaining) })}`, + 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", diff --git a/ui/src/ui/views/overview.render.test.ts b/ui/src/ui/views/overview.render.test.ts index 12e71268f55..aa3d3b895dc 100644 --- a/ui/src/ui/views/overview.render.test.ts +++ b/ui/src/ui/views/overview.render.test.ts @@ -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"); + }); });