From f4f6c9c4bf19d056c049ac051d8fffd46b60b9d2 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 09:00:08 +0000 Subject: [PATCH 1/6] feat(web-ui): add sessions usage tab --- tests/unit/config-tabs-ui.test.mjs | 7 ++ tests/unit/run.mjs | 1 + tests/unit/session-usage.test.mjs | 43 ++++++++++ tests/unit/web-ui-behavior-parity.test.mjs | 20 ++++- web-ui/app.js | 2 + web-ui/logic.sessions.mjs | 88 +++++++++++++++++++ web-ui/modules/app.computed.session.mjs | 16 ++++ web-ui/partials/index/panel-sessions.html | 99 ++++++++++++++++++++++ web-ui/styles.css | 1 + web-ui/styles/sessions-usage.css | 1 + 10 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 tests/unit/session-usage.test.mjs create mode 100644 web-ui/styles/sessions-usage.css 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..1b29e19 --- /dev/null +++ b/web-ui/styles/sessions-usage.css @@ -0,0 +1 @@ +.sessions-subtabs{display:flex;gap:10px;align-items:center;margin:0 0 16px}.sessions-subtab{border:1px solid var(--border-color,#2a3342);background:var(--panel-muted-bg,#11161f);color:var(--text-secondary,#9aa4b2);padding:8px 14px;border-radius:999px;cursor:pointer;font-size:13px;font-weight:600}.sessions-subtab.active{background:var(--accent-soft,#1c2d4a);color:var(--text-primary,#fff);border-color:var(--accent-color,#4f8cff)}.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(--border-color,#2a3342);background:var(--panel-muted-bg,#11161f);color:var(--text-secondary,#9aa4b2);padding:6px 12px;border-radius:999px;cursor:pointer;font-size:12px;font-weight:600}.usage-range-btn.active{background:var(--accent-soft,#1c2d4a);color:var(--text-primary,#fff);border-color:var(--accent-color,#4f8cff)}.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(--panel-muted-bg,#11161f);border:1px solid var(--border-color,#2a3342)}.usage-summary-label{font-size:12px;color:var(--text-secondary,#9aa4b2);margin-bottom:6px}.usage-summary-value{font-size:24px;font-weight:700;color:var(--text-primary,#fff)}.usage-chart-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.usage-card{padding:16px;border-radius:16px;background:var(--panel-bg,#0d1117);border:1px solid var(--border-color,#2a3342)}.usage-card-title{font-size:14px;font-weight:700;color:var(--text-primary,#fff);margin-bottom:12px}.usage-bars{display:flex;align-items:flex-end;gap:8px;min-height:180px}.usage-bar-group{flex:1;min-width:0;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:#4f8cff}.usage-bar.claude{background:#b277ff}.usage-bar-label{font-size:11px;color:var(--text-secondary,#9aa4b2)}.usage-legend{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--text-secondary,#9aa4b2);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}.usage-list-row{display:grid;grid-template-columns:72px 1fr 48px;gap:10px;align-items:center}.usage-list-label,.usage-list-value{font-size:12px;color:var(--text-secondary,#9aa4b2)}.usage-progress{height:8px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}.usage-progress-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#4f8cff,#b277ff)}.usage-empty{padding:24px 16px;border-radius:16px;background:var(--panel-muted-bg,#11161f);border:1px dashed var(--border-color,#2a3342);color:var(--text-secondary,#9aa4b2)}@media (max-width: 960px){.usage-chart-grid{grid-template-columns:1fr}} \ No newline at end of file From 6c0c3451a0e1f2cec3969ff8b03f4bed11726be4 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 09:46:36 +0000 Subject: [PATCH 2/6] fix(web-ui): align sessions usage tab with existing theme --- web-ui/styles/sessions-usage.css | 236 ++++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/web-ui/styles/sessions-usage.css b/web-ui/styles/sessions-usage.css index 1b29e19..25b8cac 100644 --- a/web-ui/styles/sessions-usage.css +++ b/web-ui/styles/sessions-usage.css @@ -1 +1,235 @@ -.sessions-subtabs{display:flex;gap:10px;align-items:center;margin:0 0 16px}.sessions-subtab{border:1px solid var(--border-color,#2a3342);background:var(--panel-muted-bg,#11161f);color:var(--text-secondary,#9aa4b2);padding:8px 14px;border-radius:999px;cursor:pointer;font-size:13px;font-weight:600}.sessions-subtab.active{background:var(--accent-soft,#1c2d4a);color:var(--text-primary,#fff);border-color:var(--accent-color,#4f8cff)}.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(--border-color,#2a3342);background:var(--panel-muted-bg,#11161f);color:var(--text-secondary,#9aa4b2);padding:6px 12px;border-radius:999px;cursor:pointer;font-size:12px;font-weight:600}.usage-range-btn.active{background:var(--accent-soft,#1c2d4a);color:var(--text-primary,#fff);border-color:var(--accent-color,#4f8cff)}.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(--panel-muted-bg,#11161f);border:1px solid var(--border-color,#2a3342)}.usage-summary-label{font-size:12px;color:var(--text-secondary,#9aa4b2);margin-bottom:6px}.usage-summary-value{font-size:24px;font-weight:700;color:var(--text-primary,#fff)}.usage-chart-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.usage-card{padding:16px;border-radius:16px;background:var(--panel-bg,#0d1117);border:1px solid var(--border-color,#2a3342)}.usage-card-title{font-size:14px;font-weight:700;color:var(--text-primary,#fff);margin-bottom:12px}.usage-bars{display:flex;align-items:flex-end;gap:8px;min-height:180px}.usage-bar-group{flex:1;min-width:0;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:#4f8cff}.usage-bar.claude{background:#b277ff}.usage-bar-label{font-size:11px;color:var(--text-secondary,#9aa4b2)}.usage-legend{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--text-secondary,#9aa4b2);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}.usage-list-row{display:grid;grid-template-columns:72px 1fr 48px;gap:10px;align-items:center}.usage-list-label,.usage-list-value{font-size:12px;color:var(--text-secondary,#9aa4b2)}.usage-progress{height:8px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}.usage-progress-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#4f8cff,#b277ff)}.usage-empty{padding:24px 16px;border-radius:16px;background:var(--panel-muted-bg,#11161f);border:1px dashed var(--border-color,#2a3342);color:var(--text-secondary,#9aa4b2)}@media (max-width: 960px){.usage-chart-grid{grid-template-columns:1fr}} \ No newline at end of file +.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); +} + +.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; +} + +.usage-bar-group { + flex: 1; + min-width: 0; + 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); +} + +.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; +} + +.usage-list-row { + display: grid; + grid-template-columns: 72px 1fr 48px; + gap: 10px; + align-items: center; +} + +.usage-list-label, +.usage-list-value { + font-size: 12px; + color: var(--color-text-secondary); +} + +.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; + } +} From 590a8eec7b008609c19690c9e0813a2c334f7c05 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 09:54:55 +0000 Subject: [PATCH 3/6] fix(web-ui): prevent sessions usage charts and paths overflow --- web-ui/styles/sessions-usage.css | 47 ++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/web-ui/styles/sessions-usage.css b/web-ui/styles/sessions-usage.css index 25b8cac..edd881c 100644 --- a/web-ui/styles/sessions-usage.css +++ b/web-ui/styles/sessions-usage.css @@ -118,6 +118,8 @@ background: var(--color-surface); border: 1px solid var(--color-border-soft); box-shadow: var(--shadow-subtle); + min-width: 0; + overflow: hidden; } .usage-card-title { @@ -132,11 +134,16 @@ 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; - min-width: 0; + flex: 1 0 44px; + min-width: 44px; display: flex; flex-direction: column; align-items: center; @@ -169,6 +176,11 @@ .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 { @@ -192,19 +204,28 @@ display: flex; flex-direction: column; gap: 10px; + min-width: 0; } .usage-list-row { display: grid; - grid-template-columns: 72px 1fr 48px; + 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 { @@ -233,3 +254,23 @@ 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; + } +} From 3f3f01a46ee823f92e1080aac2d89109aedadfd9 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 10:10:37 +0000 Subject: [PATCH 4/6] docs(readme): document sessions usage analytics view --- README.en.md | 7 ++++++- README.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.en.md b/README.en.md index c64b513..f80f977 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. @@ -51,6 +51,8 @@ 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 +- Overflow-safe Usage layout for long paths and narrow containers - Markdown export - Session-level and message-level delete (supports batch) @@ -195,8 +197,11 @@ 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 +- Usage charts and long-path rows are tuned to avoid overflow on narrow screens and small containers ### Skills Market Tab - Switch the skills install target between `Codex` and `Claude Code` diff --git a/README.md b/README.md index 0c505aa..4354aa8 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 市场同样坚持本地优先,只操作本地目录,不依赖远程在线市场。 @@ -51,6 +51,8 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - 同页查看 Codex 与 Claude 会话 - 支持本地会话置顶,置顶状态持久化保存并优先排序显示 - 关键词搜索、来源筛选、cwd 路径筛选 +- Usage 子页:近 7 天 / 近 30 天会话趋势、消息趋势、来源占比、高频路径 +- Usage 视图针对长路径与窄屏做了防溢出布局优化 - 会话导出 Markdown - 会话与消息级删除(支持批量) @@ -187,8 +189,11 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 ### 会话模式 - Codex + Claude 会话统一列表 +- Browser / Usage 双子视图切换 - 支持本地会话置顶、持久化保存与置顶优先排序 - 搜索、筛选、导出、删除、批量清理 +- Usage 视图提供近 7 天 / 近 30 天会话趋势、消息趋势、来源占比与高频路径统计 +- Usage 图表与长路径列表已做窄屏与小容器防溢出优化 ### Skills 市场标签页 - 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标 From c9cb504da194cab90b514c8a7a28c7a337c231da Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 10:20:56 +0000 Subject: [PATCH 5/6] docs(readme): keep sessions usage docs feature-focused --- README.en.md | 2 -- README.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/README.en.md b/README.en.md index f80f977..ebb4799 100644 --- a/README.en.md +++ b/README.en.md @@ -52,7 +52,6 @@ It works on local files directly and does not require cloud hosting. The skills - 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 -- Overflow-safe Usage layout for long paths and narrow containers - Markdown export - Session-level and message-level delete (supports batch) @@ -201,7 +200,6 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2" - 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 -- Usage charts and long-path rows are tuned to avoid overflow on narrow screens and small containers ### Skills Market Tab - Switch the skills install target between `Codex` and `Claude Code` diff --git a/README.md b/README.md index 4354aa8..ca5e919 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理: - 支持本地会话置顶,置顶状态持久化保存并优先排序显示 - 关键词搜索、来源筛选、cwd 路径筛选 - Usage 子页:近 7 天 / 近 30 天会话趋势、消息趋势、来源占比、高频路径 -- Usage 视图针对长路径与窄屏做了防溢出布局优化 - 会话导出 Markdown - 会话与消息级删除(支持批量) @@ -193,7 +192,6 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2 - 支持本地会话置顶、持久化保存与置顶优先排序 - 搜索、筛选、导出、删除、批量清理 - Usage 视图提供近 7 天 / 近 30 天会话趋势、消息趋势、来源占比与高频路径统计 -- Usage 图表与长路径列表已做窄屏与小容器防溢出优化 ### Skills 市场标签页 - 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标 From 13f5556f96bbdd5e04657fc3c1e199588c9825cc Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Mon, 6 Apr 2026 10:29:19 +0000 Subject: [PATCH 6/6] docs(readme): expand comparison and architecture for sessions usage --- README.en.md | 7 ++++--- README.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.en.md b/README.en.md index ebb4799..e73c875 100644 --- a/README.en.md +++ b/README.en.md @@ -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 | @@ -83,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 @@ -92,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 diff --git a/README.md b/README.md index ca5e919..14974a4 100644 --- a/README.md +++ b/README.md @@ -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(默认只读) | 需自行封装脚本 | @@ -79,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 @@ -88,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