Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'"/);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/session-usage.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});
20 changes: 17 additions & 3 deletions tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ document.addEventListener('DOMContentLoaded', () => {
skillsMarketLocalLoadedOnce: false,
skillsMarketImportLoadedOnce: false,
sessionPinnedMap: {},
sessionsViewMode: 'browser',
sessionsUsageTimeRange: '7d',
sessionsList: [],
sessionsLoadedOnce: false,
sessionsLoading: false,
Expand Down
88 changes: 88 additions & 0 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Comment on lines +93 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize unsupported range values to a canonical output ('7d'/'30d').

At Line [93] and Line [163], invalid inputs currently produce 7-day buckets but return the original invalid range string. This can desync metadata from actual aggregation.

💡 Proposed fix
-    const range = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d';
+    const rawRange = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d';
+    const range = rawRange === '30d' ? '30d' : '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 rangeDays = range === '30d' ? 30 : 7;

Also applies to: 163-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/logic.sessions.mjs` around lines 93 - 97, The code currently accepts
arbitrary strings into the variable range and later computes buckets using
rangeDays but returns the original invalid range string; normalize unsupported
values so range becomes only the canonical '7d' or '30d' before any further use.
In practice, validate and map options.range (the variable named range) to either
'7d' or '30d' (e.g., treat anything not equal to '30d' as '7d'), update derived
rangeDays accordingly, and ensure any metadata or return values that reference
range (including the place around the later usage at the location tied to line
163) use this normalized range value so buckets and metadata stay in sync.
Ensure you adjust both the initial assignment of range and all subsequent
usages/returns that relied on the original unnormalized range.

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'
Expand Down
16 changes: 16 additions & 0 deletions web-ui/modules/app.computed.session.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildSessionTimelineNodes,
buildUsageChartGroups,
isSessionQueryEnabled
} from '../logic.mjs';
import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs';
Expand Down Expand Up @@ -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);
Expand Down
99 changes: 99 additions & 0 deletions web-ui/partials/index/panel-sessions.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,104 @@
</div>

<div v-else>
<div class="sessions-subtabs" role="tablist" aria-label="会话视图切换">
<button
class="sessions-subtab"
:class="{ active: sessionsViewMode === 'browser' }"
type="button"
role="tab"
:aria-selected="sessionsViewMode === 'browser'"
@click="sessionsViewMode = 'browser'">
Sessions
</button>
<button
class="sessions-subtab"
:class="{ active: sessionsViewMode === 'usage' }"
type="button"
role="tab"
:aria-selected="sessionsViewMode === 'usage'"
@click="sessionsViewMode = 'usage'">
Usage
</button>
</div>

<div v-if="sessionsViewMode === 'usage'">
<div class="usage-toolbar">
<div class="selector-header" style="padding:0;border:0;background:none;">
<span class="selector-title">本地使用概览</span>
</div>
<div class="usage-range-group" role="group" aria-label="Usage 时间范围">
<button type="button" class="usage-range-btn" :class="{ active: sessionsUsageTimeRange === '7d' }" @click="sessionsUsageTimeRange = '7d'">近 7 天</button>
<button type="button" class="usage-range-btn" :class="{ active: sessionsUsageTimeRange === '30d' }" @click="sessionsUsageTimeRange = '30d'">近 30 天</button>
</div>
</div>

<div v-if="!sessionsList.length" class="usage-empty">暂无可用于统计的会话数据</div>
<template v-else>
<div class="usage-summary-grid">
<div v-for="card in sessionUsageSummaryCards" :key="card.key" class="usage-summary-card">
<div class="usage-summary-label">{{ card.label }}</div>
<div class="usage-summary-value">{{ card.value }}</div>
</div>
</div>

<div class="usage-chart-grid">
<section class="usage-card">
<div class="usage-card-title">会话趋势</div>
<div class="usage-legend">
<span><span class="usage-legend-dot" style="background:#4f8cff"></span>Codex</span>
<span><span class="usage-legend-dot" style="background:#b277ff"></span>Claude</span>
</div>
<div class="usage-bars">
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key" class="usage-bar-group">
<div class="usage-bar-stack">
<div class="usage-bar codex" :style="{ height: ((bucket.codex / Math.max(sessionUsageCharts.maxSessionBucket, 1)) * 100) + '%' }" :title="`Codex ${bucket.codex}`"></div>
<div class="usage-bar claude" :style="{ height: ((bucket.claude / Math.max(sessionUsageCharts.maxSessionBucket, 1)) * 100) + '%' }" :title="`Claude ${bucket.claude}`"></div>
</div>
<div class="usage-bar-label">{{ bucket.label }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">来源占比</div>
<div class="usage-list">
<div v-for="item in sessionUsageCharts.sourceShare" :key="item.key" class="usage-list-row">
<div class="usage-list-label">{{ item.label }}</div>
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: item.percent + '%' }"></div></div>
<div class="usage-list-value">{{ item.percent }}%</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">消息趋势</div>
<div class="usage-bars">
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key + '-messages'" class="usage-bar-group">
<div class="usage-bar-stack">
<div class="usage-bar codex" style="width:100%" :style="{ height: ((bucket.totalMessages / Math.max(sessionUsageCharts.maxMessageBucket, 1)) * 100) + '%' }" :title="`${bucket.totalMessages} messages`"></div>
</div>
<div class="usage-bar-label">{{ bucket.label }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">高频路径</div>
<div v-if="!sessionUsageCharts.topPaths.length" class="usage-list-value">暂无路径数据</div>
<div v-else class="usage-list">
<div v-for="item in sessionUsageCharts.topPaths" :key="item.path" class="usage-list-row">
<div class="usage-list-label">{{ item.count }} 次</div>
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: ((item.count / Math.max(sessionUsageCharts.topPaths[0]?.count || 1, 1)) * 100) + '%' }"></div></div>
<div class="usage-list-value" :title="item.path">{{ item.path }}</div>
</div>
</div>
</section>
</div>
</template>
</div>

<template v-else>
<div class="selector-section">
<div class="selector-header">
<span class="selector-title">会话来源</span>
Expand Down Expand Up @@ -284,5 +382,6 @@
</div>
</div>
</div>
</template>
</div>
</div>
1 change: 1 addition & 0 deletions web-ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading