From db0e95d8489b0b60b1621d5d1829c31250c496f5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:14:13 +0200 Subject: [PATCH 1/3] feat(gui): add Usage workspace wired to global view toggle Add a section rail (Overview/Models/Providers/Coverage) driven by the sidebar Classic/Workspace preference, with container-query stacking and no per-page toggle. --- gui/src/App.tsx | 2 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Usage.tsx | 336 +++++++++++++++++++++++------ gui/src/styles-usage-workspace.css | 178 +++++++++++++++ gui/src/styles.css | 1 + gui/tests/usage-workspace.test.ts | 38 ++++ 11 files changed, 492 insertions(+), 69 deletions(-) create mode 100644 gui/src/styles-usage-workspace.css create mode 100644 gui/tests/usage-workspace.test.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index e205469cd..c1fd196b2 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -281,7 +281,7 @@ export default function App() { {page === "combos" && } {page === "subagents" && } {page === "logs" && } - {page === "usage" && } + {page === "usage" && } {page === "storage" && } {page === "codex-auth" && } {page === "api" && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6c69b33ee..f0d6a6042 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -548,6 +548,7 @@ export const de = { "usage.card.coverage": "Abdeckung", "usage.card.activeDays": "Aktive Tage", "usage.section.heatmap": "Tägliche Aktivität", + "usage.section.overview": "Übersicht", "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ed890ecfd..af40c446b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -572,6 +572,7 @@ export const en = { "usage.card.coverage": "Coverage", "usage.card.activeDays": "Active days", "usage.section.heatmap": "Daily activity", + "usage.section.overview": "Overview", "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 114cd7b22..3f1f2059b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -537,6 +537,7 @@ export const ja: Record = { "usage.card.coverage": "カバレッジ", "usage.card.activeDays": "アクティブ日数", "usage.section.heatmap": "日のアクティビティ", + "usage.section.overview": "概要", "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7d8754059..aeb04bff6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -565,6 +565,7 @@ export const ko: Record = { "usage.card.coverage": "커버리지", "usage.card.activeDays": "활동일", "usage.section.heatmap": "일별 활동", + "usage.section.overview": "개요", "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a74b75a9e..52950deb2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -572,6 +572,7 @@ export const ru: Record = { "usage.card.coverage": "Покрытие", "usage.card.activeDays": "Активные дни", "usage.section.heatmap": "Активность по дням", + "usage.section.overview": "Обзор", "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 81a2c961e..5e03ceac5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -565,6 +565,7 @@ export const zh: Record = { "usage.card.coverage": "覆盖率", "usage.card.activeDays": "活跃天数", "usage.section.heatmap": "每日活动", + "usage.section.overview": "概览", "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 2274ea269..b5c8f4797 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; import { EmptyState, Notice } from "../ui"; import { modelLabel } from "../model-display"; +import { readViewMode, type ViewMode } from "../view-mode"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude"; @@ -449,93 +450,145 @@ function UsageModelsTable({ onModelQuery, locale, t, + workspace = false, }: { models: UsageModel[]; modelQuery: string; onModelQuery: (query: string) => void; locale: Locale; t: TFn; + workspace?: boolean; }) { const searchLabel = t("usage.search.models"); + const sectionLabel = t("usage.section.models"); + const titleId = "usage-models-title"; + const searchInput = ( + onModelQuery(event.target.value)} + /> + ); + const table = ( +
+ + + + + + + + + + + + + {models.map(model => ( + + + + + + + + + ))} + +
{t("logs.col.model")}{t("logs.col.provider")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.tokens")}{t("usage.col.share")}
{modelLabel(model.model)}{model.provider}{model.requests}{model.measuredRequests}{formatTokens(model.totalTokens, locale)}
+
+ ); + + if (workspace) { + return ( + +
{searchInput}
+ {table} +
+ ); + } return ( -
+
-

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

- onModelQuery(event.target.value)} - /> -
-
- - - - - - - - - - - - - {models.map(model => ( - - - - - - - - - ))} - -
{t("logs.col.model")}{t("logs.col.provider")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.tokens")}{t("usage.col.share")}
{modelLabel(model.model)}{model.provider}{model.requests}{model.measuredRequests}{formatTokens(model.totalTokens, locale)}
+

{sectionLabel}

+ {searchInput}
+ {table}
); } -function UsageProvidersTable({ providers, locale, t }: { providers: UsageProvider[]; locale: Locale; t: TFn }) { - return ( -
-

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

-
- - - - - - - - +function UsageProvidersTable({ + providers, + locale, + t, + workspace = false, +}: { + providers: UsageProvider[]; + locale: Locale; + t: TFn; + workspace?: boolean; +}) { + const sectionLabel = t("usage.section.providers"); + const titleId = "usage-providers-title"; + const table = ( +
+
{t("logs.col.provider")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.tokens")}{t("usage.col.share")}
+ + + + + + + + + + + {providers.map(provider => ( + + + + + + - - - {providers.map(provider => ( - - - - - - - - ))} - -
{t("logs.col.provider")}{t("usage.col.requests")}{t("usage.col.measured")}{t("usage.col.tokens")}{t("usage.col.share")}
{provider.provider}{provider.requests}{provider.measuredRequests}{formatTokens(provider.totalTokens, locale)}
{provider.provider}{provider.requests}{provider.measuredRequests}{formatTokens(provider.totalTokens, locale)}
-
+ ))} + + + + ); + + if (workspace) { + return ( + + {table} + + ); + } + + return ( +
+

{sectionLabel}

+ {table}
); } -function UsageCoveragePanel({ summary, t }: { summary: UsageSummaryTotals; t: TFn }) { - return ( -
-

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

+function UsageCoveragePanel({ + summary, + t, + workspace = false, +}: { + summary: UsageSummaryTotals; + t: TFn; + workspace?: boolean; +}) { + const sectionLabel = t("usage.section.coverage"); + const titleId = "usage-coverage-title"; + const body = ( + <>
{t("usage.coverage.measured")}
{summary.measuredRequests}
{t("usage.coverage.reported")}
{summary.reportedRequests}
@@ -544,11 +597,140 @@ function UsageCoveragePanel({ summary, t }: { summary: UsageSummaryTotals; t: TF
{t("logs.tokens.unsupported")}
{summary.unsupportedRequests}

{t("usage.coverage.note")}

+ + ); + + if (workspace) { + return ( + + {body} + + ); + } + + return ( +
+

{sectionLabel}

+ {body}
); } -export default function Usage({ apiBase }: { apiBase: string }) { +function UsageWorkspaceSection({ + title, + titleId, + children, +}: { + title: string; + titleId: string; + children: ReactNode; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +/** + * Workspace layout for the Usage tab: a left rail lists the report sections + * (Overview, Models, Providers, Coverage) and the main pane renders the selected + * one. Section bodies reuse the same panel components as the classic stacked view. + */ +function UsageWorkspaceBody({ + data, + heatmap, + weekBars, + activeDays, + filteredModels, + modelQuery, + onModelQuery, + sortedProviders, + range, + selectedSection, + onSelectSection, + locale, + t, +}: { + data: UsageResponse; + heatmap: ReturnType; + weekBars: UsageDay[]; + activeDays: number; + filteredModels: UsageModel[]; + modelQuery: string; + onModelQuery: (query: string) => void; + sortedProviders: UsageProvider[]; + range: Range; + selectedSection: string; + onSelectSection: (id: string) => void; + locale: Locale; + t: TFn; +}) { + const sections = [ + { + id: "overview", + label: t("usage.section.overview"), + meta: `${data.summary.requests}`, + body: ( + <> + + + + ), + }, + { + id: "models", + label: t("usage.section.models"), + meta: `${data.models.length}`, + body: , + }, + { + id: "providers", + label: t("usage.section.providers"), + meta: `${data.providers.length}`, + body: , + }, + { + id: "coverage", + label: t("usage.section.coverage"), + meta: formatPct(data.summary.coverageRatio), + body: , + }, + ]; + const selected = sections.find(s => s.id === selectedSection) ?? sections[0]; + + return ( +
+
+ +
+
{selected.body}
+
+
+
+ ); +} + +export default function Usage({ apiBase, viewMode }: { apiBase: string; viewMode?: ViewMode }) { const { t, locale } = useI18n(); const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); @@ -556,6 +738,8 @@ export default function Usage({ apiBase }: { apiBase: string }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modelQuery, setModelQuery] = useState(""); + const [selectedSection, setSelectedSection] = useState("overview"); + const workspaceView = (viewMode ?? readViewMode()) === "workspace"; const fetchUsage = useCallback(async (nextRange: Range, nextSurface: UsageSurface, signal: AbortSignal) => { setLoading(true); @@ -631,6 +815,22 @@ export default function Usage({ apiBase }: { apiBase: string }) { ) : data?.summary.requests === 0 ? ( + ) : data && workspaceView ? ( + ) : data ? ( <> diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css new file mode 100644 index 000000000..b4d9cf77d --- /dev/null +++ b/gui/src/styles-usage-workspace.css @@ -0,0 +1,178 @@ +/* ============================================================================ + usage-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) + Namespace: usage-workspace- | widgets: usw- + Uses only design tokens from styles.css. No gradients. + ============================================================================ */ + +.main-inner:has(.usage-workspace-shell) { + max-width: 1200px; +} + +.usage-workspace-shell { + width: 100%; + min-width: 0; + container-type: inline-size; + container-name: usage-workspace; +} + +.usage-workspace-root { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: var(--space-4); + width: 100%; + max-width: 100%; + min-height: 480px; +} + +/* ── Rail ─────────────────────────────────────────────── */ + +.usage-workspace-rail { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-right: 1px solid var(--border); + padding-right: var(--space-3); + min-width: 0; +} + +.usage-workspace-rail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.usage-workspace-rail-title { + font-size: var(--text-body); + font-weight: var(--weight-semibold); + color: var(--text); +} + +.usage-workspace-rail-list { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.usage-workspace-rail-row { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + max-width: 100%; + min-width: 0; + min-height: 46px; + appearance: none; + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-2); + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + overflow: hidden; +} + +.usage-workspace-rail-row:hover { + background: var(--raised); +} + +.usage-workspace-rail-row--selected { + background: var(--raised); + box-shadow: inset 0 0 0 1px var(--border); +} + +.usage-workspace-rail-name { + font-size: var(--text-body); + font-weight: var(--weight-medium); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.usage-workspace-rail-meta { + font-size: var(--text-caption); + color: var(--faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +/* ── Main pane ────────────────────────────────────────── */ + +.usage-workspace-main { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.usw-body { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.usw-section { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; +} + +.usw-section .h-section { + margin: 0 0 var(--space-3); + font-size: var(--text-title); + font-weight: var(--weight-semibold); + line-height: 1.2; + color: var(--text); +} + +.usw-section-toolbar { + margin: 0 0 var(--space-5); + max-width: 220px; +} + +.usw-section .tbl-wrap { + min-width: 0; +} + +.usw-section .usage-cards { + margin-top: 0; +} + +.usw-section .usage-scroll { + max-height: 520px; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@container usage-workspace (max-width: 720px) { + .usage-workspace-root { + grid-template-columns: 1fr; + } + + .usage-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } +} + +@media (max-width: 768px) { + .usage-workspace-root { + grid-template-columns: 1fr; + } + + .usage-workspace-rail { + border-right: none; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: var(--space-3); + } +} diff --git a/gui/src/styles.css b/gui/src/styles.css index c57f9ce13..dc4b7bff6 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -16,6 +16,7 @@ @import "./styles-models-workspace.css"; @import "./styles-dashboard-workspace.css"; @import "./styles-subagents-workspace.css"; +@import "./styles-usage-workspace.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ diff --git a/gui/tests/usage-workspace.test.ts b/gui/tests/usage-workspace.test.ts new file mode 100644 index 000000000..a2e2506f1 --- /dev/null +++ b/gui/tests/usage-workspace.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +test("Usage uses global viewMode (no per-page toggle) and workspace shell", async () => { + const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(page).toContain("viewMode"); + expect(page).toContain("readViewMode"); + expect(page).toContain("UsageWorkspaceBody"); + expect(page).toContain('workspaceView = (viewMode ?? readViewMode()) === "workspace"'); + expect(page).not.toContain("ocx-usage-view"); + expect(page).not.toContain("pws.workspaceToggle"); + expect(page).not.toContain("pws.classicToggle"); + + expect(app).toContain(""); + expect(css).toContain('@import "./styles-usage-workspace.css"'); +}); + +test("Usage workspace uses section landmark and section rail", async () => { + const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + expect(src).toContain("usage-workspace-shell"); + expect(src).toContain("usage-workspace-rail"); + expect(src).toContain('t("usage.section.overview")'); + expect(src).toContain('t("usage.section.models")'); + expect(src).toContain('t("usage.section.providers")'); + expect(src).toContain('t("usage.section.coverage")'); + expect(src).toContain('
{ + const css = await Bun.file(new URL("../src/styles-usage-workspace.css", import.meta.url)).text(); + expect(css).toContain("container-name: usage-workspace"); + expect(css).toContain("container-type: inline-size"); + expect(css).toContain("@container usage-workspace (max-width: 720px)"); + expect(css).toContain("@media (max-width: 768px)"); +}); From 545d9cd6087bf10d4b4194a4df099131c7f36a30 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:19:06 +0200 Subject: [PATCH 2/3] fix(gui): stop Logs auto-refresh from flashing Loading above the table Keep polling every 2s, but silent background fetches no longer toggle loading or insert a status row that shifts the table. --- gui/src/pages/Logs.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index d1ddd5099..3d91ae258 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -277,8 +277,8 @@ export default function Logs({ apiBase }: { apiBase: string }) { else if (e.key === "ArrowRight" || e.key === "End") { e.preventDefault(); selectTab("debug"); document.getElementById("logs-tab-debug")?.focus(); } }; - const fetchLogs = useCallback(async () => { - setLoading(true); + const fetchLogs = useCallback(async (opts?: { silent?: boolean }) => { + if (!opts?.silent) setLoading(true); setError(null); try { const res = await fetch(`${apiBase}/api/logs`); @@ -288,7 +288,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const detail = cause instanceof Error ? cause.message : ""; setError(detail ? `${t("logs.loadError")} ${detail}` : t("logs.loadError")); } finally { - setLoading(false); + if (!opts?.silent) setLoading(false); } }, [apiBase, t]); @@ -296,7 +296,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { if (tab !== "logs") return; void fetchLogs(); if (!autoRefresh) return; - const interval = setInterval(() => void fetchLogs(), 2000); + const interval = setInterval(() => void fetchLogs({ silent: true }), 2000); return () => clearInterval(interval); }, [autoRefresh, fetchLogs, tab]); @@ -402,7 +402,6 @@ export default function Logs({ apiBase }: { apiBase: string }) { ) : ( <> - {loading &&
{t("common.loading")}
}
From df59b246678e1a0383e3f5f27e33b2733c95fec1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:13:01 +0200 Subject: [PATCH 3/3] fix(gui): stabilize Logs silent refresh and Usage workspace a11y Keep silent log polls from clearing errors or flashing empty/loading UI, restore Usage workspace headings and distinct landmark labels, mount the workspace shell while loading, and hide Codex Auth from the sidebar in workspace mode (Providers deep links stay). --- gui/src/App.tsx | 2 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Logs.tsx | 10 +- gui/src/pages/Usage.tsx | 53 ++- gui/src/styles-usage-workspace.css | 1 + gui/tests/logs-auto-refresh.test.tsx | 334 ++++++++++++++++++ gui/tests/usage-workspace.test.ts | 43 +++ .../workspace-sidebar-codex-auth.test.ts | 9 + 13 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 gui/tests/logs-auto-refresh.test.tsx create mode 100644 gui/tests/workspace-sidebar-codex-auth.test.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c1fd196b2..657d67994 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -214,7 +214,7 @@ export default function App() {