diff --git a/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png b/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png new file mode 100644 index 00000000000..dc2baee2596 Binary files /dev/null and b/devlog/_plan/260904_dashboard_minimal/assets/061_usage_after.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 365ead295a4..aaa290703ab 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -794,6 +794,7 @@ export const de: Record = { "debug.noLines.usage": "Nutzungserfassung ist an, aber es wurde noch nichts erfasst. Sende einen Chat/eine Anfrage über Codex, dann erscheint es hier.", "debug.noLines.injection": "Injektions-Log ist an, aber es wurde noch nichts erfasst. Es erfasst Multi-Agent-Guidance-Injektion und Effort-Cap-Entscheidungen bei Collab- und Sub-Agent-Turns.", "usage.title": "Nutzung", + "usage.subtitleAria": "So wird die Nutzung gezählt", "usage.subtitle": "Lokale Token-Buchhaltung deines Proxys. Fehlende Nutzung wird nie als Null angezeigt.", "usage.loading": "Lade Nutzungsdaten…", "usage.empty": "Noch keine Nutzung erfasst. Sende eine Anfrage über den Proxy, um Aktivität hier zu sehen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7be434a8727..61330d5abbb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -831,6 +831,7 @@ export const en = { // usage page "usage.title": "Usage", + "usage.subtitleAria": "How usage is counted", "usage.subtitle": "Local token accounting from your proxy. Missing usage is never shown as zero.", "usage.loading": "Loading usage data…", "usage.empty": "No usage recorded yet. Send a request through the proxy to see activity here.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 53ab6e56eb6..0445f919da5 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -808,6 +808,7 @@ export const fr: Record = { "debug.noLines.usage": "L’extraction de l’utilisation est activée, mais rien n’a encore été capturé. Envoyez une conversation ou une requête par Codex pour qu’elle apparaisse ici.", "debug.noLines.injection": "Le journal des injections est activé, mais rien n’a encore été capturé. Il consigne l’injection des directives multi-agents et les décisions de plafonnement du niveau lors des tours collab et des sous-agents.", "usage.title": "Utilisation", + "usage.subtitleAria": "Comment l’utilisation est comptée", "usage.subtitle": "Comptabilisation locale des jetons par votre proxy. Une utilisation manquante n’est jamais affichée comme nulle.", "usage.loading": "Chargement des données d’utilisation…", "usage.empty": "Aucune utilisation enregistrée pour le moment. Envoyez une requête par le proxy pour voir l’activité ici.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a7567a8296c..e7937cbd253 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -774,6 +774,7 @@ export const ja: Record = { // usage page "usage.title": "使用量", + "usage.subtitleAria": "使用量の集計方法", "usage.subtitle": "プロキシからのローカルトークン会計です。欠損した使用量はゼロとして表示されることはありません。", "usage.loading": "使用量データを読み込み中…", "usage.empty": "まだ使用量が記録されていません。プロキシ経由でリクエストを送信するとここにアクティビティが表示されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a69f3d493bf..62772ae783f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -816,6 +816,7 @@ export const ko: Record = { // usage page "usage.title": "사용량", + "usage.subtitleAria": "사용량 집계 방식", "usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.", "usage.loading": "사용량 데이터를 불러오는 중…", "usage.empty": "아직 기록된 사용량이 없습니다. 프록시로 요청을 보내면 여기에 표시됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c3e95dcbd07..08f2fbefd98 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -815,6 +815,7 @@ export const ru: Record = { // usage page "usage.title": "Использование", + "usage.subtitleAria": "Как учитывается использование", "usage.subtitle": "Локальный учёт токенов вашего прокси. Отсутствующие данные никогда не показываются как ноль.", "usage.loading": "Загрузка данных об использовании…", "usage.empty": "Данных об использовании пока нет. Отправьте запрос через прокси, чтобы увидеть здесь активность.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1c100651ac3..5fd1069ae95 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -822,6 +822,7 @@ export const tr: Record = { // usage page "usage.title": "Kullanım", + "usage.subtitleAria": "Kullanım nasıl sayılır", "usage.subtitle": "Proxy'nizden yerel jeton muhasebesi.", "usage.loading": "Kullanım verileri yükleniyor…", "usage.empty": "Henüz kullanım kaydedilmedi.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e378cf8a0d1..33800678651 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -643,6 +643,7 @@ export const zhTW: Record = { "debug.noLines.usage": "用量提取已開啟但尚未捕獲任何內容。請透過 Codex 傳送請求,隨後會顯示在此處。", "debug.noLines.injection": "注入日誌已開啟但尚未捕獲任何內容。它紀錄協作和子代理回合中的多代理指導注入與 effort-cap 決策。", "usage.title": "用量", + "usage.subtitleAria": "用量的統計方式", "usage.subtitle": "代理本地的 Token 用量統計。缺失的用量不會顯示為零。", "usage.loading": "正在載入用量資料…", "usage.empty": "尚無用量紀錄。透過代理傳送請求後將在此顯示。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b7d08a1fbcc..e67246157ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -809,6 +809,7 @@ export const zh: Record = { // usage page "usage.title": "用量", + "usage.subtitleAria": "用量的统计方式", "usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。", "usage.loading": "正在加载用量数据…", "usage.empty": "尚无用量记录。通过代理发送请求后将在此显示。", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index bd7537073ba..fd720f5b396 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -4,7 +4,8 @@ import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; -import { EmptyState, Notice } from "../ui"; +import { EmptyState, Notice, Tooltip } from "../ui"; +import { IconInfo } from "../icons"; import { modelLabel } from "../model-display"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -272,18 +273,21 @@ function UsageFilters({ function UsageSummaryCards({ summary, - activeDays, locale, t, }: { summary: UsageSummaryTotals; - activeDays: number; locale: Locale; t: TFn; }) { return ( <> -
+ {/* + Five cards. "Active days" was the sixth — on a 7/30-day range with a heatmap below it + said nothing the range did not. The page's counting caveat rides on the coverage + card as a focusable info button rather than a paragraph above everything. + */} +
{t("usage.card.requests")}
{summary.requests}
{t("usage.card.measured")}
{summary.measuredRequests}
{t("usage.card.totalTokens")}
{formatTokens(summary.totalTokens, locale)}
@@ -296,13 +300,21 @@ function UsageSummaryCards({
)}
-
{t("usage.card.coverage")}
{formatPct(summary.coverageRatio)}
-
{t("usage.card.activeDays")}
{activeDays}
+
+
+ {t("usage.card.coverage")} + + +
+
{formatPct(summary.coverageRatio)}
+
{summary.estimatedCostUsd !== undefined && (
{t("usage.cost.total")} - + {formatUsdEstimate(summary.estimatedCostUsd, locale)} {t("usage.cost.disclaimer")} @@ -387,22 +399,40 @@ function UsageHeatmapPanel({ const heatmapRef = useRef(null); const [hoverCell, setHoverCell] = useState<{ weekIndex: number; dayIndex: number; x: number; y: number } | null>(null); + // Component-scoped so both the effect and the details' onToggle can call it: a closed + // details has zero width, so the effect's run is a no-op until the user opens it. + const pinRight = useCallback(() => { + const element = heatmapRef.current; + if (element) element.scrollLeft = element.scrollWidth; + }, []); + useEffect(() => { const element = heatmapRef.current; if (!element) return; - const pinRight = () => { element.scrollLeft = element.scrollWidth; }; pinRight(); const observer = new ResizeObserver(pinRight); observer.observe(element); return () => observer.disconnect(); - }, [heatmap, range]); + }, [heatmap, range, pinRight]); - return ( -
-

{t("usage.section.heatmap")}

- {range === "7d" ? ( + if (range === "7d") { + return ( +
+

{t("usage.section.heatmap")}

- ) : ( +
+ ); + } + + /* + The year heatmap is GitHub-style chrome for a 30-day range: mostly empty and tall. It + stays available behind a closed disclosure; the seven-day bars above are small enough + to keep inline. + */ + return ( +
+ {t("usage.section.heatmap")} + {(
@@ -453,7 +483,7 @@ function UsageHeatmapPanel({
)} -
+ ); } @@ -654,7 +684,6 @@ function UsageWorkspaceBody({ data, heatmap, weekBars, - activeDays, filteredModels, modelQuery, onModelQuery, @@ -666,7 +695,6 @@ function UsageWorkspaceBody({ data: UsageResponse | null; heatmap: ReturnType; weekBars: UsageDay[]; - activeDays: number; filteredModels: UsageModel[]; modelQuery: string; onModelQuery: (query: string) => void; @@ -683,7 +711,7 @@ function UsageWorkspaceBody({ meta: data ? `${data.summary.requests}` : "—", body: data ? ( <> - + ) : null, @@ -788,7 +816,6 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas const heatmap = useMemo(() => buildHeatmap(data?.days ?? []), [data?.days]); const weekBars = useMemo(() => lastSevenDays(data?.days ?? []), [data?.days]); - const activeDays = useMemo(() => (data?.days ?? []).filter(d => d.requests > 0).length, [data?.days]); const filteredModels = useMemo(() => { const q = modelQuery.trim().toLowerCase(); const models = data?.models ?? []; @@ -812,7 +839,6 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas

{t("usage.title")}

-

{t("usage.subtitle")}

{/* Only shown when connected. Naming the source is a two-plane concept: it answers "which store served these numbers", and that question only exists once there are @@ -864,7 +890,6 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas data={data} heatmap={heatmap} weekBars={weekBars} - activeDays={activeDays} filteredModels={filteredModels} modelQuery={modelQuery} onModelQuery={setModelQuery} diff --git a/gui/src/styles.css b/gui/src/styles.css index 74c34bae2c3..2ba0bc7b4c5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2440,6 +2440,14 @@ button.prov-account-row.active { cursor: default; } } .usage-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } +.usage-cards-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } +@container (max-width: 1100px) { .usage-cards-5 { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +/* Year heatmap behind a disclosure for 30d; the summary reads as the panel title. */ +.usage-heatmap-details > summary { cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 8px; } +.usage-heatmap-details > summary::-webkit-details-marker { display: none; } +.usage-heatmap-details > summary::before { content: ""; width: 0; height: 0; border-left: 5px solid var(--muted); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--motion-fast); } +.usage-heatmap-details[open] > summary::before { transform: rotate(90deg); } +.usage-heatmap-details[open] > summary { margin-bottom: 10px; } .usage-cards .stat-value { font-size: var(--text-title); font-weight: var(--weight-semibold); margin-top: 4px; } /* Top-align with Storage: centering against the taller filter chips dropped the "Usage" title. */ .usage-head { flex-wrap: wrap; align-items: flex-start; } diff --git a/gui/tests/usage-coverage-tooltip.test.tsx b/gui/tests/usage-coverage-tooltip.test.tsx new file mode 100644 index 00000000000..1f2e87ef1fc --- /dev/null +++ b/gui/tests/usage-coverage-tooltip.test.tsx @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { en } from "../src/i18n/en"; +import { Tooltip } from "../src/ui"; +import { IconInfo } from "../src/icons"; + +/** + * The Usage coverage card's caveat is a Tooltip whose child is an icon plus an sr-only + * name (060). This renders that exact shape and pins what the plan promised: the trigger + * is a focusable button, it has the accessible name, and focusing it shows the caveat. + */ +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let root: Root | null = null; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#usage" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + jest.useFakeTimers(); +}); + +afterEach(async () => { + if (root) { await act(async () => { root!.unmount(); }); root = null; } + jest.useRealTimers(); + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +test("the coverage caveat tooltip is a focusable, named trigger that shows the caveat on focus", async () => { + const container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container); + root = createRoot(container as unknown as HTMLElement); + await act(async () => { + root!.render( + + + + , + ); + }); + const trigger = container.querySelector("button.ocx-tooltip")!; + expect(trigger).not.toBeNull(); + expect(trigger.tagName).toBe("BUTTON"); + expect(trigger.hasAttribute("disabled")).toBe(false); + // Accessible name comes from the visually-hidden span, not a title attribute. + expect(trigger.textContent).toContain(en["usage.subtitleAria"]); + expect(trigger.getAttribute("title")).toBeNull(); + expect(container.textContent).not.toContain(en["usage.subtitle"]); + + await act(async () => { + trigger.dispatchEvent(new testWindow.FocusEvent("focus", { bubbles: false })); + trigger.dispatchEvent(new testWindow.FocusEvent("focusin", { bubbles: true })); + }); + await act(async () => { jest.advanceTimersByTime(300); }); + const tipId = trigger.getAttribute("aria-describedby"); + expect(tipId).toBeTruthy(); + const tip = testWindow.document.getElementById(tipId!); + expect(tip?.textContent).toContain(en["usage.subtitle"]); +}); diff --git a/gui/tests/usage-minimal.test.ts b/gui/tests/usage-minimal.test.ts new file mode 100644 index 00000000000..105872c4784 --- /dev/null +++ b/gui/tests/usage-minimal.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const src = readFileSync(join(import.meta.dir, "../src/pages/Usage.tsx"), "utf8"); +const css = readFileSync(join(import.meta.dir, "../src/styles.css"), "utf8"); + +/** + * devlog/_plan/260904_dashboard_minimal/060_usage.md: five summary cards (the "active days" + * card said nothing a 7/30-day range with a heatmap did not), the counting caveat on the + * coverage card as a focusable info button (not a paragraph, not a title attribute), the + * cost figure at body weight beside its caveat, and the year heatmap behind a closed + * disclosure for 30d while the seven-day bars stay inline. + */ +describe("Usage page minimal shape", () => { + test("five cards, no active-days card, no subtitle paragraph", () => { + expect(src).toContain('className="usage-cards usage-cards-5"'); + expect(src).not.toContain("usage.card.activeDays"); + expect(src).not.toContain("activeDays"); + expect(src).not.toContain('

{t("usage.subtitle")}

'); + }); + + test("the counting caveat is a focusable tooltip on the coverage card with an accessible name", () => { + expect(src).toContain(' { + expect(src).toContain('className="mono text-control usage-cost-value"'); + expect(src).not.toContain('className="stat-value mono usage-cost-value"'); + }); + + test("30d heatmap sits in a closed details whose toggle re-pins the scroll; 7d bars stay inline", () => { + expect(src).toContain('
'); + expect(src).toContain("const pinRight = useCallback("); + expect(src).not.toMatch(/
]*\bopen\b/); + // Seven-day bars are returned before the details branch. + expect(src.indexOf('if (range === "7d") {')).toBeLessThan(src.indexOf('
summary::-webkit-details-marker { display: none; }"); + expect(css).toContain(".usage-cards-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }"); + }); +});