Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
25 changes: 18 additions & 7 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
const templateAgentModals = readProjectFile('web-ui/partials/index/modal-config-template-agents.html');
const openclawModal = readProjectFile('web-ui/partials/index/modal-openclaw-config.html');
const sessionsPanel = readProjectFile('web-ui/partials/index/panel-sessions.html');
const usagePanel = readProjectFile('web-ui/partials/index/panel-usage.html');
const baseTheme = readProjectFile('web-ui/styles/base-theme.css');
const controlsForms = readProjectFile('web-ui/styles/controls-forms.css');
const sideRail = html.match(/<aside class="side-rail"[\s\S]*?<\/aside>/)?.[0] || '';
Expand Down Expand Up @@ -56,6 +57,15 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /sessionTrashCount/);
assert.match(html, /id="side-tab-market"/);
assert.match(html, /id="tab-market"/);
assert.match(html, /id="side-tab-usage"/);
assert.match(html, /id="tab-usage"/);
assert.match(html, /data-main-tab="usage"/);
assert.match(html, /onMainTabPointerDown\('usage', \$event\)/);
assert.match(html, /onMainTabClick\('usage', \$event\)/);
assert.match(html, /aria-controls="panel-usage"/);
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
assert.match(html, /id="panel-usage"/);
assert.match(html, /v-show="mainTab === 'usage'"/);
assert.match(html, /data-main-tab="market"/);
assert.match(html, /onMainTabPointerDown\('market', \$event\)/);
assert.match(html, /onMainTabClick\('market', \$event\)/);
Expand Down Expand Up @@ -177,15 +187,15 @@ 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.doesNotMatch(sessionsPanel, /sessionsViewMode/);
assert.doesNotMatch(sessionsPanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /sessionsUsageTimeRange === '7d'/);
assert.match(usagePanel, /sessionsUsageTimeRange === '30d'/);
assert.match(usagePanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /sessionUsageCharts\.buckets/);
assert.match(html, /class="pin-icon"/);
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
assert.match(html, /:aria-selected="mainTab === 'config' && configMode === 'codex'"/);
assert.match(html, /v-memo="\[activeSessionExportKey === getSessionExportKey\(session\)/);
assert.match(html, /v-memo="\[msg\.text,\s*msg\.timestamp,\s*msg\.roleLabel,\s*msg\.normalizedRole\]"/);
Expand Down Expand Up @@ -284,6 +294,7 @@ test('web ui script defines provider mode metadata for codex only', () => {
assert.match(appScript, /isMainTabNavActive\(tab\)/);
assert.match(appScript, /isConfigModeNavActive\(mode\)/);
assert.match(appScript, /const isLeavingSessions = previousTab === 'sessions' && targetTab !== 'sessions';/);
assert.match(appScript, /const enteringSessionDataTab = nextTab === 'sessions' \|\| nextTab === 'usage';/);
assert.match(appScript, /if \(targetTab === previousTab\) {/);
assert.match(appScript, /const shouldDeferApply = isLeavingSessions;/);
assert.match(appScript, /if \(isLeavingSessions && !this\.isSessionPanelFastHidden\(\)\) {/);
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/session-tab-switch-performance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ test('switchMainTab prepares session render and loads sessions only when not loa
assert.strictEqual(calls.loadSessions, 1);
});

test('switchMainTab loads sessions for usage tab without preparing session render', () => {
const calls = {
teardown: 0,
prepare: 0,
loadSessions: 0
};
const vm = {
mainTab: 'config',
configMode: 'codex',
sessionsLoadedOnce: false,
teardownSessionTabRender() {
calls.teardown += 1;
},
prepareSessionTabRender() {
calls.prepare += 1;
},
loadSessions() {
calls.loadSessions += 1;
this.sessionsLoadedOnce = true;
},
refreshClaudeModelContext() {}
};

switchMainTab.call(vm, 'usage');
assert.strictEqual(vm.mainTab, 'usage');
assert.strictEqual(calls.prepare, 0);
assert.strictEqual(calls.loadSessions, 1);

switchMainTab.call(vm, 'usage');
assert.strictEqual(calls.prepare, 0);
assert.strictEqual(calls.loadSessions, 1);
});

test('switchMainTab keeps claude model context refresh behavior', () => {
let refreshCount = 0;
const vm = {
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ test('captured bundled app skeleton only exposes expected data key drift versus
const extraCurrentKeys = currentDataKeys.filter((key) => !headDataKeys.includes(key)).sort();
const missingCurrentKeys = headDataKeys.filter((key) => !currentDataKeys.includes(key)).sort();
const allowedExtraCurrentKeys = [];
const allowedMissingCurrentKeys = [];
const allowedMissingCurrentKeys = [
'sessionsViewMode'
];
if (parityAgainstHead) {
const allowedExtraKeySet = new Set(allowedExtraCurrentKeys);
const allowedMissingKeySet = new Set(allowedMissingCurrentKeys);
Expand Down
1 change: 0 additions & 1 deletion web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ document.addEventListener('DOMContentLoaded', () => {
skillsMarketLocalLoadedOnce: false,
skillsMarketImportLoadedOnce: false,
sessionPinnedMap: {},
sessionsViewMode: 'browser',
sessionsUsageTimeRange: '7d',
sessionsList: [],
sessionsLoadedOnce: false,
Expand Down
1 change: 1 addition & 0 deletions web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<!-- @include ./partials/index/panel-config-claude.html -->
<!-- @include ./partials/index/panel-config-openclaw.html -->
<!-- @include ./partials/index/panel-sessions.html -->
<!-- @include ./partials/index/panel-usage.html -->
<!-- @include ./partials/index/panel-settings.html -->
<!-- @include ./partials/index/panel-market.html -->
<!-- @include ./partials/index/layout-footer.html -->
Expand Down
1 change: 1 addition & 0 deletions web-ui/modules/config-mode.computed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function createConfigModeComputed() {
inspectorMainTabLabel() {
if (this.mainTab === 'config') return '配置中心';
if (this.mainTab === 'sessions') return '会话浏览';
if (this.mainTab === 'usage') return 'Usage';
if (this.mainTab === 'market') return '技能市场';
if (this.mainTab === 'settings') return '设置';
return '未知';
Expand Down
42 changes: 41 additions & 1 deletion web-ui/partials/index/layout-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@
:class="{ active: isMainTabNavActive('sessions') }"
@pointerdown="onMainTabPointerDown('sessions', $event)"
@click="onMainTabClick('sessions', $event)">会话浏览</button>
<button class="top-tab"
id="tab-usage"
role="tab"
data-main-tab="usage"
:tabindex="mainTab === 'usage' ? 0 : -1"
:aria-selected="mainTab === 'usage'"
aria-controls="panel-usage"
:class="{ active: isMainTabNavActive('usage') }"
@pointerdown="onMainTabPointerDown('usage', $event)"
@click="onMainTabClick('usage', $event)">Usage</button>
<button class="top-tab"
id="tab-market"
role="tab"
Expand Down Expand Up @@ -198,6 +208,19 @@
<span>来源:{{ sessionFilterSource === 'all' ? '全部' : (sessionFilterSource === 'codex' ? 'Codex' : 'Claude') }}</span>
</div>
</button>
<button
id="side-tab-usage"
data-main-tab="usage"
:aria-current="mainTab === 'usage' ? 'page' : null"
:class="['side-item', { active: isMainTabNavActive('usage') }]"
@pointerdown="onMainTabPointerDown('usage', $event)"
@click="onMainTabClick('usage', $event)">
<div class="side-item-title">Usage</div>
<div class="side-item-meta">
<span>本地统计 / 趋势</span>
<span>范围:{{ sessionsUsageTimeRange === '30d' ? '近 30 天' : '近 7 天' }}</span>
</div>
</button>
</div>

<div class="side-section" role="navigation" aria-label="技能市场">
Expand Down Expand Up @@ -236,7 +259,7 @@
<main class="main-panel">
<div class="panel-header" v-if="!sessionStandalone">
<h1 class="main-title">
{{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : (mainTab === 'market' ? '技能市场' : '设置')) }}
{{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : (mainTab === 'usage' ? 'Usage' : (mainTab === 'market' ? '技能市场' : '设置'))) }}
</h1>
<p class="subtitle" v-if="mainTab === 'config'">
配置中枢:管理 Codex / Claude / OpenClaw
Expand All @@ -245,6 +268,9 @@ <h1 class="main-title">
<p class="subtitle" v-else-if="mainTab === 'sessions'">
统一查看与导出 Codex / Claude 会话。
</p>
<p class="subtitle" v-else-if="mainTab === 'usage'">
单独查看本地会话用量、趋势与高频路径。
</p>
<p class="subtitle" v-else-if="mainTab === 'market'">
统一管理 Codex / Claude Skills,并聚焦本地导入与分发。
</p>
Expand Down Expand Up @@ -300,6 +326,20 @@ <h1 class="main-title">
<span class="value">{{ sessionsList.length }}</span>
</div>
</div>
<div class="status-strip" v-else-if="!sessionStandalone && mainTab === 'usage'">
<div class="status-chip">
<span class="label">统计范围</span>
<span class="value">{{ sessionsUsageTimeRange === '30d' ? '近 30 天' : '近 7 天' }}</span>
</div>
<div class="status-chip">
<span class="label">总会话数</span>
<span class="value">{{ sessionUsageSummaryCards[0]?.value ?? 0 }}</span>
</div>
<div class="status-chip">
<span class="label">总消息数</span>
<span class="value">{{ sessionUsageSummaryCards[1]?.value ?? 0 }}</span>
</div>
</div>
<div class="status-strip" v-else-if="!sessionStandalone && mainTab === 'market'">
<div class="status-chip">
<span class="label">当前目标</span>
Expand Down
100 changes: 0 additions & 100 deletions web-ui/partials/index/panel-sessions.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,106 +20,7 @@
<pre class="session-standalone-text">{{ sessionStandaloneText }}</pre>
</div>
</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 @@ -382,6 +283,5 @@
</div>
</div>
</div>
</template>
</div>
</div>
Loading
Loading