diff --git a/README.en.md b/README.en.md index c64b513..e73c875 100644 --- a/README.en.md +++ b/README.en.md @@ -24,7 +24,7 @@ Codex Mate is a local-first CLI + Web UI for unified management of: - Claude Code profiles (writes to `~/.claude/settings.json`) - OpenClaw JSON5 profiles and workspace `AGENTS.md` - Local skills market for Codex / Claude Code (target switching, local skills management, cross-app import, ZIP distribution) -- Local Codex/Claude sessions (list/filter/export/delete) +- Local Codex/Claude sessions (list/filter/export/delete) with Usage analytics overview It works on local files directly and does not require cloud hosting. The skills market is also local-first: it operates on local directories and does not depend on a remote marketplace. @@ -34,8 +34,9 @@ It works on local files directly and does not require cloud hosting. The skills | --- | --- | --- | | Multi-tool management | Codex + Claude Code + OpenClaw in one entry | Different files and folders per tool | | Operation mode | CLI + local Web UI | Manual TOML/JSON/JSON5 edits | -| Session handling | Browse/export/batch cleanup | Manual file location and processing | +| Session handling | Browse/filter/Usage analytics/export/batch cleanup | Manual file location and processing | | Skills reuse | Local skills market + cross-app import + ZIP distribution | Manual folder copy and reconciliation | +| Operational visibility | Unified view of config, sessions, and Usage summaries | Depends on manual file inspection and scattered commands | | Rollback readiness | Backup before first takeover | Easy to overwrite by mistake | | Automation integration | MCP stdio (read-only by default) | Requires custom scripting | @@ -51,6 +52,7 @@ It works on local files directly and does not require cloud hosting. The skills - Unified Codex + Claude session list - Local session pinning with persistent pinned state and pinned-first ordering - Keyword/source/cwd filters +- Usage subview with 7d / 30d session trends, message trends, source share, and top paths - Markdown export - Session-level and message-level delete (supports batch) @@ -82,7 +84,7 @@ flowchart TB API["Local HTTP API"] MCPS["MCP stdio Server"] PROXY["Built-in Proxy"] - SERVICES["Config / Sessions / Skills Market / Workflow"] + SERVICES["Config / Sessions & Usage / Skills Market / Workflow"] CORE["File IO / Network / Diff / Session Utils"] end @@ -91,7 +93,7 @@ flowchart TB CLAUDE["~/.claude/settings.json"] OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"] SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"] - STATE["sessions / trash / workflow runs / skill exports"] + STATE["sessions / usage aggregates / trash / workflow runs / skill exports"] end CLI --> ENTRY @@ -195,8 +197,10 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2" ### Sessions Mode - Unified Codex + Claude sessions +- Browser / Usage subview switching - Local pin/unpin with persistent storage and pinned-first ordering - Search, filter, export, delete, batch cleanup +- Usage view includes 7d / 30d session trends, message trends, source share, and top paths ### Skills Market Tab - Switch the skills install target between `Codex` and `Claude Code` diff --git a/README.md b/README.md index 0c505aa..14974a4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - Claude Code 配置方案(写入 `~/.claude/settings.json`) - OpenClaw JSON5 配置与 Workspace `AGENTS.md` - Codex / Claude Code Skills 市场(安装目标切换、本地 skills 管理、跨应用导入、ZIP 分发) -- Codex / Claude 本地会话浏览、筛选、导出、删除 +- Codex / Claude 本地会话浏览、筛选、导出、删除与 Usage 统计概览 项目不依赖云端托管,配置写入你的本地文件,便于审计和回滚。Skills 市场同样坚持本地优先,只操作本地目录,不依赖远程在线市场。 @@ -34,8 +34,9 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: | --- | --- | --- | | 多工具管理 | Codex + Claude Code + OpenClaw 统一入口 | 多文件、多目录分散修改 | | 使用方式 | CLI + 本地 Web UI | 纯手改 TOML / JSON / JSON5 | -| 会话处理 | 支持浏览、导出、批量清理 | 需要手动定位和处理文件 | +| 会话处理 | 支持浏览、筛选、Usage 统计、导出、批量清理 | 需要手动定位和处理文件 | | Skills 复用 | 本地 Skills 市场 + 跨应用导入 + ZIP 分发 | 目录手动复制,容易遗漏 | +| 使用可见性 | 统一查看配置、会话与 Usage 概览 | 依赖手工翻文件和零散命令 | | 可回滚性 | 首次接管前自动备份 | 易误覆盖、回滚成本高 | | 自动化接入 | 提供 MCP stdio(默认只读) | 需自行封装脚本 | @@ -51,6 +52,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - 同页查看 Codex 与 Claude 会话 - 支持本地会话置顶,置顶状态持久化保存并优先排序显示 - 关键词搜索、来源筛选、cwd 路径筛选 +- Usage 子页:近 7 天 / 近 30 天会话趋势、消息趋势、来源占比、高频路径 - 会话导出 Markdown - 会话与消息级删除(支持批量) @@ -78,7 +80,7 @@ flowchart TB ENTRY["cli.js Entry"] API["Local HTTP API"] MCPS["MCP stdio Server"] - SERVICES["Config / Sessions / Skills Market / Workflow"] + SERVICES["Config / Sessions & Usage / Skills Market / Workflow"] CORE["File IO / Network / Diff / Session Utils"] end @@ -87,7 +89,7 @@ flowchart TB CLAUDE["~/.claude/settings.json"] OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"] SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"] - STATE["sessions / trash / workflow runs / skill exports"] + STATE["sessions / usage aggregates / trash / workflow runs / skill exports"] end CLI --> ENTRY @@ -187,8 +189,10 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 ### 会话模式 - Codex + Claude 会话统一列表 +- Browser / Usage 双子视图切换 - 支持本地会话置顶、持久化保存与置顶优先排序 - 搜索、筛选、导出、删除、批量清理 +- Usage 视图提供近 7 天 / 近 30 天会话趋势、消息趋势、来源占比与高频路径统计 ### Skills 市场标签页 - 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标 diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 4707c1c..6381f0a 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -177,6 +177,13 @@ test('config template keeps expected config tabs in top and side navigation', () /:class="\['card', \{ active: currentOpenclawConfig === name \}\]"[\s\S]*@click="applyOpenclawConfig\(name\)"[\s\S]*@keydown\.enter\.self\.prevent="applyOpenclawConfig\(name\)"[\s\S]*@keydown\.space\.self\.prevent="applyOpenclawConfig\(name\)"[\s\S]*tabindex="0"[\s\S]*role="button"[\s\S]*:aria-current="currentOpenclawConfig === name \? 'true' : null"/ ); assert.match(html, /class="session-item-copy session-item-pin"/); + assert.match(sessionsPanel, /class="sessions-subtabs" role="tablist" aria-label="会话视图切换"/); + assert.match(sessionsPanel, /sessionsViewMode === 'browser'/); + assert.match(sessionsPanel, /sessionsViewMode === 'usage'/); + assert.match(sessionsPanel, /sessionsUsageTimeRange === '7d'/); + assert.match(sessionsPanel, /sessionsUsageTimeRange === '30d'/); + assert.match(sessionsPanel, /sessionUsageSummaryCards/); + assert.match(sessionsPanel, /sessionUsageCharts\.buckets/); assert.match(html, /class="pin-icon"/); assert.match(html, /:aria-selected="mainTab === 'sessions'"/); assert.match(html, /:aria-selected="mainTab === 'config' && configMode === 'codex'"/); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 8a60801..56a564f 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -29,6 +29,7 @@ await import(pathToFileURL(path.join(__dirname, 'openclaw-persist-regression.tes await import(pathToFileURL(path.join(__dirname, 'agents-modal-guards.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-browser-timeline-regression.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'session-usage.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'agents-diff-ui.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs'))); diff --git a/tests/unit/session-usage.test.mjs b/tests/unit/session-usage.test.mjs new file mode 100644 index 0000000..a52d73c --- /dev/null +++ b/tests/unit/session-usage.test.mjs @@ -0,0 +1,43 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const logic = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs'))); +const { buildUsageChartGroups } = logic; + +test('buildUsageChartGroups aggregates codex and claude sessions into day buckets', () => { + const now = Date.UTC(2026, 3, 6, 12, 0, 0); + const result = buildUsageChartGroups([ + { source: 'codex', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 5, cwd: '/a' }, + { source: 'claude', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 7, cwd: '/a' }, + { source: 'codex', updatedAt: '2026-04-05T09:00:00.000Z', messageCount: 3, cwd: '/b' } + ], { range: '7d', now }); + + assert.strictEqual(result.summary.totalSessions, 3); + assert.strictEqual(result.summary.totalMessages, 15); + assert.strictEqual(result.summary.codexTotal, 2); + assert.strictEqual(result.summary.claudeTotal, 1); + assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').percent, 67); + assert.strictEqual(result.topPaths[0].path, '/a'); + assert.strictEqual(result.topPaths[0].count, 2); + const lastBucket = result.buckets[result.buckets.length - 1]; + assert.strictEqual(lastBucket.codex, 1); + assert.strictEqual(lastBucket.claude, 1); + assert.strictEqual(lastBucket.totalMessages, 12); +}); + +test('buildUsageChartGroups ignores invalid sessions and keeps empty buckets stable', () => { + const now = Date.UTC(2026, 3, 6, 12, 0, 0); + const result = buildUsageChartGroups([ + null, + { source: 'other', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 9 }, + { source: 'codex', updatedAt: 'bad-date', messageCount: 2 } + ], { range: '7d', now }); + + assert.strictEqual(result.summary.totalSessions, 0); + assert.strictEqual(result.summary.totalMessages, 0); + assert.strictEqual(result.buckets.length, 7); + assert.ok(result.buckets.every((item) => item.totalSessions === 0)); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 9c3b0c7..a4e2d7a 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -319,7 +319,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus const headDataKeys = Object.keys(headAppOptions.data()).sort(); const extraCurrentKeys = currentDataKeys.filter((key) => !headDataKeys.includes(key)).sort(); const missingCurrentKeys = headDataKeys.filter((key) => !currentDataKeys.includes(key)).sort(); - const allowedExtraCurrentKeys = []; + const allowedExtraCurrentKeys = ['sessionsUsageTimeRange', 'sessionsViewMode']; const allowedMissingCurrentKeys = []; if (parityAgainstHead) { const allowedExtraKeySet = new Set(allowedExtraCurrentKeys); @@ -353,9 +353,23 @@ test('captured bundled app skeleton only exposes expected data key drift versus currentMethodKeys.filter((key) => !extraCurrentMethodKeys.includes(key)).sort(), headMethodKeys ); + const currentComputedKeys = Object.keys(currentComputed).sort(); + const headComputedKeys = Object.keys(headComputed).sort(); + const extraCurrentComputedKeys = currentComputedKeys.filter((key) => !headComputedKeys.includes(key)).sort(); + const missingCurrentComputedKeys = headComputedKeys.filter((key) => !currentComputedKeys.includes(key)).sort(); + const allowedExtraCurrentComputedKeys = ['sessionUsageCharts', 'sessionUsageSummaryCards']; + if (parityAgainstHead) { + const allowedExtraComputedKeySet = new Set(allowedExtraCurrentComputedKeys); + const unexpectedExtraCurrentComputedKeys = extraCurrentComputedKeys.filter((key) => !allowedExtraComputedKeySet.has(key)); + assert.deepStrictEqual(unexpectedExtraCurrentComputedKeys, [], `unexpected extra computed keys against ${parityBaseline.ref}`); + assert.deepStrictEqual(missingCurrentComputedKeys, [], `unexpected missing computed keys against ${parityBaseline.ref}`); + } else { + assert.deepStrictEqual(extraCurrentComputedKeys, allowedExtraCurrentComputedKeys); + assert.deepStrictEqual(missingCurrentComputedKeys, []); + } assert.deepStrictEqual( - Object.keys(currentComputed).sort(), - Object.keys(headComputed).sort() + currentComputedKeys.filter((key) => !extraCurrentComputedKeys.includes(key)).sort(), + headComputedKeys ); assert.strictEqual(typeof currentAppOptions.mounted, typeof headAppOptions.mounted); assert.strictEqual(typeof currentAppOptions.beforeUnmount, typeof headAppOptions.beforeUnmount); diff --git a/web-ui/app.js b/web-ui/app.js index cc13d0e..6c83612 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -117,6 +117,8 @@ document.addEventListener('DOMContentLoaded', () => { skillsMarketLocalLoadedOnce: false, skillsMarketImportLoadedOnce: false, sessionPinnedMap: {}, + sessionsViewMode: 'browser', + sessionsUsageTimeRange: '7d', sessionsList: [], sessionsLoadedOnce: false, sessionsLoading: false, diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 8e9b724..8d74fa6 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -88,6 +88,94 @@ export function formatSessionTimelineTimestamp(timestamp) { return value; } +export function buildUsageChartGroups(sessions = [], options = {}) { + const list = Array.isArray(sessions) ? sessions : []; + const range = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d'; + const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + const rangeDays = range === '30d' ? 30 : 7; + const buckets = []; + for (let i = rangeDays - 1; i >= 0; i -= 1) { + const stamp = new Date(now - (i * dayMs)); + const key = `${stamp.getUTCFullYear()}-${String(stamp.getUTCMonth() + 1).padStart(2, '0')}-${String(stamp.getUTCDate()).padStart(2, '0')}`; + buckets.push({ + key, + label: key.slice(5), + codex: 0, + claude: 0, + totalMessages: 0, + totalSessions: 0 + }); + } + const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket])); + let codexTotal = 0; + let claudeTotal = 0; + let messageTotal = 0; + const pathMap = new Map(); + + for (const session of list) { + if (!session || typeof session !== 'object') continue; + const source = normalizeSessionSource(session.source, ''); + if (source !== 'codex' && source !== 'claude') continue; + const updatedAtMs = Date.parse(session.updatedAt || ''); + if (!Number.isFinite(updatedAtMs)) continue; + const stamp = new Date(updatedAtMs); + const key = `${stamp.getUTCFullYear()}-${String(stamp.getUTCMonth() + 1).padStart(2, '0')}-${String(stamp.getUTCDate()).padStart(2, '0')}`; + const bucket = bucketMap.get(key); + if (!bucket) continue; + const messageCount = Number.isFinite(Number(session.messageCount)) + ? Math.max(0, Math.floor(Number(session.messageCount))) + : 0; + bucket.totalSessions += 1; + bucket.totalMessages += messageCount; + if (source === 'codex') { + bucket.codex += 1; + codexTotal += 1; + } else { + bucket.claude += 1; + claudeTotal += 1; + } + messageTotal += messageCount; + const cwd = normalizeSessionPathFilter(session.cwd); + if (cwd) { + pathMap.set(cwd, (Number(pathMap.get(cwd)) || 0) + 1); + } + } + + const totalSessions = codexTotal + claudeTotal; + const sourceShare = [ + { key: 'codex', label: 'Codex', value: codexTotal }, + { key: 'claude', label: 'Claude', value: claudeTotal } + ].map((item) => ({ + ...item, + percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0 + })); + + const topPaths = [...pathMap.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-Hans-CN')) + .slice(0, 5) + .map(([pathValue, count]) => ({ path: pathValue, count })); + + const maxSessionBucket = buckets.reduce((max, item) => Math.max(max, item.totalSessions), 0); + const maxMessageBucket = buckets.reduce((max, item) => Math.max(max, item.totalMessages), 0); + + return { + range, + buckets, + summary: { + totalSessions, + totalMessages: messageTotal, + codexTotal, + claudeTotal, + activeDays: buckets.filter((item) => item.totalSessions > 0).length + }, + sourceShare, + topPaths, + maxSessionBucket, + maxMessageBucket + }; +} + export function buildSessionTimelineNodes(messages = [], options = {}) { const list = Array.isArray(messages) ? messages : []; const getKey = typeof options.getKey === 'function' diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index d43fac6..5dcb1c2 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -1,5 +1,6 @@ import { buildSessionTimelineNodes, + buildUsageChartGroups, isSessionQueryEnabled } from '../logic.mjs'; import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs'; @@ -100,6 +101,21 @@ export function createSessionComputed() { } return '当前来源暂不支持关键词检索'; }, + sessionUsageCharts() { + return buildUsageChartGroups(this.sessionsList, { + range: this.sessionsUsageTimeRange + }); + }, + sessionUsageSummaryCards() { + const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary + ? this.sessionUsageCharts.summary + : { totalSessions: 0, totalMessages: 0, activeDays: 0 }; + return [ + { key: 'sessions', label: '总会话数', value: summary.totalSessions || 0 }, + { key: 'messages', label: '总消息数', value: summary.totalMessages || 0 }, + { key: 'days', label: '活跃天数', value: summary.activeDays || 0 } + ]; + }, visibleSessionTrashItems() { const items = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : []; const visibleCount = Number(this.sessionTrashVisibleCount); diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html index 048e472..0e280d3 100644 --- a/web-ui/partials/index/panel-sessions.html +++ b/web-ui/partials/index/panel-sessions.html @@ -22,6 +22,104 @@
+
+ + +
+ +
+
+
+ 本地使用概览 +
+
+ + +
+
+ +
暂无可用于统计的会话数据
+ +
+ +
diff --git a/web-ui/styles.css b/web-ui/styles.css index c0e4245..e425d57 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -6,6 +6,7 @@ @import url('./styles/sessions-toolbar-trash.css'); @import url('./styles/sessions-list.css'); @import url('./styles/sessions-preview.css'); +@import url('./styles/sessions-usage.css'); @import url('./styles/modals-core.css'); @import url('./styles/health-check-dialog.css'); @import url('./styles/openclaw-structured.css'); diff --git a/web-ui/styles/sessions-usage.css b/web-ui/styles/sessions-usage.css new file mode 100644 index 0000000..edd881c --- /dev/null +++ b/web-ui/styles/sessions-usage.css @@ -0,0 +1,276 @@ +.sessions-subtabs { + display: flex; + gap: 10px; + align-items: center; + margin: 0 0 16px; +} + +.sessions-subtab { + border: 1px solid var(--color-border); + background: var(--color-surface-alt); + color: var(--color-text-secondary); + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: + background var(--transition-fast) var(--ease-smooth), + color var(--transition-fast) var(--ease-smooth), + border-color var(--transition-fast) var(--ease-smooth), + box-shadow var(--transition-fast) var(--ease-smooth), + transform var(--transition-fast) var(--ease-smooth); +} + +.sessions-subtab:hover { + background: var(--color-surface); + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.sessions-subtab.active { + background: var(--color-brand-light); + color: var(--color-brand-dark); + border-color: var(--color-brand); + box-shadow: var(--shadow-subtle); +} + +.usage-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.usage-range-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.usage-range-btn { + border: 1px solid var(--color-border); + background: var(--color-surface-alt); + color: var(--color-text-secondary); + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: + background var(--transition-fast) var(--ease-smooth), + color var(--transition-fast) var(--ease-smooth), + border-color var(--transition-fast) var(--ease-smooth), + box-shadow var(--transition-fast) var(--ease-smooth); +} + +.usage-range-btn:hover { + background: var(--color-surface); + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.usage-range-btn.active { + background: var(--color-brand-light); + color: var(--color-brand-dark); + border-color: var(--color-brand); + box-shadow: var(--shadow-subtle); +} + +.usage-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.usage-summary-card { + padding: 14px 16px; + border-radius: 14px; + background: var(--color-surface-alt); + border: 1px solid var(--color-border-soft); + box-shadow: var(--shadow-subtle); +} + +.usage-summary-label { + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 6px; +} + +.usage-summary-value { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); +} + +.usage-chart-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; +} + +.usage-card { + padding: 16px; + border-radius: 16px; + background: var(--color-surface); + border: 1px solid var(--color-border-soft); + box-shadow: var(--shadow-subtle); + min-width: 0; + overflow: hidden; +} + +.usage-card-title { + font-size: 14px; + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 12px; +} + +.usage-bars { + display: flex; + align-items: flex-end; + gap: 8px; + min-height: 180px; + width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 4px; +} + +.usage-bar-group { + flex: 1 0 44px; + min-width: 44px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.usage-bar-stack { + width: 100%; + max-width: 36px; + height: 160px; + display: flex; + align-items: flex-end; + gap: 4px; +} + +.usage-bar { + flex: 1; + border-radius: 10px 10px 4px 4px; + min-height: 4px; +} + +.usage-bar.codex { + background: var(--color-brand); +} + +.usage-bar.claude { + background: #8b6bd6; +} + +.usage-bar-label { + font-size: 11px; + color: var(--color-text-secondary); + width: 100%; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.usage-legend { + display: flex; + gap: 14px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 10px; +} + +.usage-legend-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; + margin-right: 6px; +} + +.usage-list { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.usage-list-row { + display: grid; + grid-template-columns: minmax(56px, 72px) minmax(0, 1fr) minmax(48px, auto); + gap: 10px; + align-items: center; + min-width: 0; +} + +.usage-list-label, +.usage-list-value { + font-size: 12px; + color: var(--color-text-secondary); + min-width: 0; +} + +.usage-list-value { + word-break: break-word; + overflow-wrap: anywhere; + text-align: right; +} + +.usage-progress { + height: 8px; + border-radius: 999px; + background: rgba(71, 60, 52, 0.10); + overflow: hidden; +} + +.usage-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--color-brand), #8b6bd6); +} + +.usage-empty { + padding: 24px 16px; + border-radius: 16px; + background: var(--color-surface-alt); + border: 1px dashed var(--color-border); + color: var(--color-text-secondary); +} + +@media (max-width: 960px) { + .usage-chart-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .usage-list-row { + grid-template-columns: 1fr; + gap: 6px; + } + + .usage-list-value { + text-align: left; + } + + .usage-bar-group { + flex-basis: 36px; + min-width: 36px; + } + + .usage-bar-stack { + max-width: 28px; + } +}