mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:14:46 +00:00
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:
committed by
GitHub
parent
0b03b902be
commit
8178a6c949
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {};
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
ui/src/ui/provider-quota-summary.ts
Normal file
55
ui/src/ui/provider-quota-summary.ts
Normal 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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user