fix: stabilize usage loading and session search

This commit is contained in:
shiyue
2026-08-05 10:41:50 +08:00
parent c61b7434d2
commit 3f00cfe7a4
22 changed files with 109 additions and 47 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View File

@@ -411,6 +411,29 @@ async function main() {
await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-panel').hidden"), 10000);
await waitFor(() => evaluate(cdp, "document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading'"), 30000);
await sleep(320);
const loadingLayout = await evaluate(cdp, `(() => {
const metricGrid = document.querySelector('.usage-dashboard__metric-grid');
const beforeTop = metricGrid.getBoundingClientRect().top;
document.querySelector('#usage-dashboard-refresh').click();
const afterTop = metricGrid.getBoundingClientRect().top;
return {
state: document.querySelector('#usage-dashboard-panel').dataset.state,
label: document.querySelector('#usage-dashboard-refresh-label').textContent,
busy: document.querySelector('#usage-dashboard-refresh').getAttribute('aria-busy'),
contentShift: Math.round(Math.abs(afterTop - beforeTop) * 10) / 10,
standaloneLoading: !!document.querySelector('#usage-dashboard-loading'),
};
})()`);
if (loadingLayout.state !== 'loading'
|| loadingLayout.label !== '整理中'
|| loadingLayout.busy !== 'true'
|| loadingLayout.contentShift > 0.5
|| loadingLayout.standaloneLoading) {
throw new Error(`加载状态仍引发布局变化:${JSON.stringify(loadingLayout)}`);
}
await waitFor(() => evaluate(cdp, `document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading'
&& document.querySelector('#usage-dashboard-refresh-label').textContent === '刷新'`), 30000);
await sleep(300);
await evaluate(cdp, `(() => {
const from = document.querySelector('#usage-dashboard-from');
const to = document.querySelector('#usage-dashboard-to');

View File

@@ -1,5 +1,5 @@
{
"generatedAt": "2026-08-03T14:15:13.710Z",
"generatedAt": "2026-08-05T01:06:45.134Z",
"chrome": "/home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome",
"views": [
{

View File

@@ -322,6 +322,17 @@ function updatedTime(doc) {
return Number.isFinite(time) ? time : 0;
}
// “最新”按本次命中的消息时间排序;没有消息时间时才回退到会话更新时间。
function resultTime(result) {
const matchTimes = (result?.matches || [])
.map((match) => new Date(match?.timestamp || 0).getTime())
.filter((time) => Number.isFinite(time) && time > 0);
if (matchTimes.length > 0) return Math.max(...matchTimes);
const directTime = new Date(result?.timestamp || 0).getTime();
if (Number.isFinite(directTime) && directTime > 0) return directTime;
return updatedTime(result);
}
function buildQuery(rawQuery, options) {
const maxQueryChars = clampInteger(
options.maxQueryChars,
@@ -735,6 +746,8 @@ function createSessionSearchIndex(options = {}) {
messageIndex: firstMatch ? firstMatch.messageIndex : null,
role: firstMatch ? firstMatch.role : null,
timestamp: firstMatch ? firstMatch.timestamp : null,
matchTimestamp: firstMatch ? firstMatch.timestamp : null,
sessionUpdated: doc.updated,
title: doc.title,
projectName: doc.projectName,
updated: doc.updated,
@@ -773,8 +786,8 @@ function createSessionSearchIndex(options = {}) {
}
results.sort((a, b) => {
if (sort === 'newest') return updatedTime(b) - updatedTime(a) || b.score - a.score || a.sessionId.localeCompare(b.sessionId);
return b.score - a.score || updatedTime(b) - updatedTime(a) || a.sessionId.localeCompare(b.sessionId);
if (sort === 'newest') return resultTime(b) - resultTime(a) || b.score - a.score || a.sessionId.localeCompare(b.sessionId);
return b.score - a.score || resultTime(b) - resultTime(a) || a.sessionId.localeCompare(b.sessionId);
});
return {

View File

@@ -349,13 +349,13 @@
const usageDashboardStatus = $('#usage-dashboard-status');
const usageDashboardGeneratedAt = $('#usage-dashboard-generated-at');
const usageDashboardRefresh = $('#usage-dashboard-refresh');
const usageDashboardRefreshLabel = $('#usage-dashboard-refresh-label');
const usageDashboardPeriodWeek = $('#usage-dashboard-period-week');
const usageDashboardPeriodMonth = $('#usage-dashboard-period-month');
const usageDashboardPeriodCustom = $('#usage-dashboard-period-custom');
const usageDashboardFrom = $('#usage-dashboard-from');
const usageDashboardTo = $('#usage-dashboard-to');
const usageDashboardApply = $('#usage-dashboard-apply');
const usageDashboardLoading = $('#usage-dashboard-loading');
const usageDashboardError = $('#usage-dashboard-error');
const usageDashboardTrend = $('#usage-dashboard-trend');
const usageDashboardMcpStatus = $('#usage-dashboard-mcp-status');
@@ -4142,7 +4142,8 @@
title: result?.title || match?.title || '未命名会话',
projectName: result?.projectName || match?.projectName || '',
cwd: result?.cwd || match?.cwd || '',
updated: match?.timestamp || result?.updated || null,
updated: match?.timestamp || result?.matchTimestamp || result?.updated || null,
sessionUpdated: result?.sessionUpdated || result?.updated || null,
snippet: match?.snippet || result?.snippet || '',
messageIndex: Number.isFinite(Number(match?.messageIndex)) ? Number(match.messageIndex) : null,
});
@@ -4459,19 +4460,23 @@
}
function setUsageDashboardState(state, message = '') {
usageDashboardState.loading = state === 'loading';
const loading = state === 'loading';
usageDashboardState.loading = loading;
usageDashboardState.error = state === 'error' ? String(message || '统计查询失败') : '';
if (usageDashboardPanel) usageDashboardPanel.dataset.state = state;
if (usageDashboardLoading) usageDashboardLoading.hidden = state !== 'loading';
if (usageDashboardError) {
usageDashboardError.hidden = state !== 'error';
usageDashboardError.textContent = state === 'error' ? usageDashboardState.error : '';
}
if (usageDashboardRefresh) {
usageDashboardRefresh.disabled = state === 'loading';
usageDashboardRefresh.setAttribute('aria-busy', state === 'loading' ? 'true' : 'false');
const refreshText = loading ? '整理中' : '刷新';
usageDashboardRefresh.disabled = loading;
usageDashboardRefresh.setAttribute('aria-busy', loading ? 'true' : 'false');
usageDashboardRefresh.setAttribute('aria-label', loading ? '正在整理统计数据' : '刷新统计');
usageDashboardRefresh.title = loading ? '正在整理统计数据' : '刷新统计';
if (usageDashboardRefreshLabel) usageDashboardRefreshLabel.textContent = refreshText;
}
if (usageDashboardApply) usageDashboardApply.disabled = state === 'loading';
if (usageDashboardApply) usageDashboardApply.disabled = loading;
}
function formatUsageNumber(value) {

View File

@@ -24,7 +24,7 @@
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260803-usage-statistics-echarts">
<link rel="stylesheet" href="style.css?v=20260805-usage-loading-inline">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -164,7 +164,7 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.12-6.6L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 15.12 6.6L21 16"></path><path d="M16 16h5v5"></path>
</svg>
<span>刷新</span>
<span id="usage-dashboard-refresh-label" aria-live="polite">刷新</span>
</button>
<button id="usage-dashboard-close" class="usage-dashboard__icon-button usage-dashboard__back" type="button" aria-label="返回聊天" title="返回聊天">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -196,7 +196,6 @@
</div>
<div class="usage-dashboard__body">
<div id="usage-dashboard-loading" class="usage-dashboard__loading" role="status" hidden>正在整理统计数据</div>
<div id="usage-dashboard-error" class="usage-dashboard__error" role="alert" hidden></div>
<section class="usage-dashboard__metric-grid" aria-label="统计概览">
@@ -387,6 +386,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script src="vendor/echarts.min.js?v=5.6.0"></script>
<script src="app.js?v=20260803-usage-statistics-echarts"></script>
<script src="app.js?v=20260805-usage-loading-inline"></script>
</body>
</html>

View File

@@ -10209,7 +10209,6 @@ html[data-theme='gilded'] {
scrollbar-gutter: stable;
}
.usage-dashboard__loading,
.usage-dashboard__error {
margin-bottom: 14px;
padding: 10px 12px;
@@ -10220,18 +10219,6 @@ html[data-theme='gilded'] {
font-size: 12px;
}
.usage-dashboard__loading::before {
content: '';
display: inline-block;
width: 9px;
height: 9px;
margin-right: 8px;
border: 2px solid var(--usage-dashboard-border);
border-top-color: var(--usage-dashboard-accent);
border-radius: 50%;
animation: usageDashboardSpin 800ms linear infinite;
}
.usage-dashboard__error {
color: var(--usage-dashboard-danger);
border-color: color-mix(in srgb, var(--usage-dashboard-danger) 30%, transparent);
@@ -10812,7 +10799,6 @@ html[data-theme='gilded'] {
padding: 8px 14px 30px;
}
.usage-dashboard__loading,
.usage-dashboard__error {
margin-bottom: 10px;
}
@@ -11950,7 +11936,6 @@ html[data-theme='wasteland'] :is(
.usage-dashboard__dates input,
.usage-dashboard__metric,
.usage-dashboard__panel,
.usage-dashboard__loading,
.usage-dashboard__error,
.usage-dashboard__badge,
.usage-dashboard__row-action
@@ -11973,7 +11958,6 @@ html[data-theme='wasteland'] .usage-dashboard__metric {
@media (prefers-reduced-motion: reduce) {
.usage-dashboard,
.usage-dashboard__loading::before,
.usage-dashboard__refresh svg,
.usage-dashboard-open,
.usage-dashboard__icon-button,
@@ -11984,8 +11968,4 @@ html[data-theme='wasteland'] .usage-dashboard__metric {
transition: none !important;
scroll-behavior: auto !important;
}
.usage-dashboard__loading::before {
border-color: var(--usage-dashboard-accent);
}
}

View File

@@ -622,8 +622,8 @@ function assertFrontendSidebarCollapseContract() {
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
}
@@ -946,8 +946,8 @@ function assertPlanListProgressContract() {
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Plan progress frontend logic should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Plan progress frontend logic should use the current cache-busted URL');
}
function assertFrontendGildedThemeContract() {
@@ -1062,8 +1062,8 @@ function assertFrontendGildedThemeContract() {
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Theme bundle app script should use the current cache-busted asset URL');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Theme bundle app script should use the current cache-busted asset URL');
}
function assertFrontendWastelandThemeContract() {
@@ -1316,8 +1316,8 @@ function assertFrontendWastelandThemeContract() {
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Wasteland registration should share the cache-busted theme bundle URL');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Wasteland registration should share the cache-busted theme bundle URL');
}
function assertFrontendCcwebPromptContract() {
@@ -4449,8 +4449,8 @@ function assertAdvancedSessionSearchContract() {
'Existing sidebar search clear control contract should remain unchanged');
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
'Advanced search workspace should expose stable panel and result hooks');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
@@ -4500,12 +4500,45 @@ function assertAdvancedSessionSearchContract() {
assert(searchIndexSource.includes("role !== 'user' && role !== 'assistant'")
&& searchIndexSource.includes("type === 'tool_use'")
&& searchIndexSource.includes('function resultTime(result)')
&& searchIndexSource.includes('resultTime(b) - resultTime(a)')
&& searchIndexSource.includes('MAX_QUERY_CHARS_HARD_LIMIT = 200')
&& searchIndexSource.includes('MAX_RESULTS_HARD_LIMIT = 50')
&& !searchIndexSource.includes('new RegExp('),
'Search index should constrain input and exclude system/tool/attachment content without dynamic regexes');
}
async function assertAdvancedSearchTimeOrderingContract() {
const { createSessionSearchIndex } = require(SESSION_SEARCH_INDEX_PATH);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-advanced-search-time-'));
try {
const sessionsDir = path.join(tempRoot, 'sessions');
mkdirp(sessionsDir);
fs.writeFileSync(path.join(sessionsDir, 'session-old-updated.json'), JSON.stringify({
id: 'session-old-updated',
title: '旧命中时间',
updated: '2026-08-05T12:00:00.000Z',
messages: [{ role: 'user', content: '同一关键词', timestamp: '2026-08-01T12:00:00.000Z' }],
}));
fs.writeFileSync(path.join(sessionsDir, 'session-new-message.json'), JSON.stringify({
id: 'session-new-message',
title: '新命中时间',
updated: '2026-08-01T12:00:00.000Z',
messages: [{ role: 'user', content: '同一关键词', timestamp: '2026-08-04T12:00:00.000Z' }],
}));
const index = createSessionSearchIndex({ sessionsDir, maxResults: 50 });
await index.initialize();
const result = index.search({ query: '同一关键词', sort: 'newest', matchMode: 'contains' });
assert(result.results[0]?.sessionId === 'session-new-message',
'最新优先应按命中消息时间排序,而不是按会话 updated 排序');
assert(result.results[0]?.matchTimestamp === '2026-08-04T12:00:00.000Z'
&& result.results[0]?.sessionUpdated === '2026-08-01T12:00:00.000Z',
'搜索结果应同时保留命中消息时间和会话更新时间');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
function assertUsageStatisticsContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
@@ -4533,10 +4566,13 @@ function assertUsageStatisticsContract() {
'Usage dashboard should be a chat-main-local workspace');
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
assert(indexSource.includes('id="usage-dashboard-refresh-label" aria-live="polite"')
&& !indexSource.includes('id="usage-dashboard-loading"'),
'Usage loading feedback should live inside the refresh action without inserting a temporary content row');
assert(metricGridIndex > usagePanelIndex
&& primaryGridIndex > metricGridIndex
&& trendPanelIndex > primaryGridIndex
@@ -4577,7 +4613,11 @@ function assertUsageStatisticsContract() {
assert(usageFunctions.includes("type: 'usage_stats_query'")
&& usageFunctions.includes('usageDashboardState.requestId')
&& usageFunctions.includes('usageDashboardDetail.dataset.motion')
&& usageFunctions.includes('setUsageMcpDetailOpen'),
&& usageFunctions.includes('setUsageMcpDetailOpen')
&& usageFunctions.includes("const refreshText = loading ? '整理中' : '刷新'")
&& usageFunctions.includes("setAttribute('aria-busy', loading ? 'true' : 'false')")
&& !frontendSource.includes('usageDashboardLoading')
&& !styleSource.includes('.usage-dashboard__loading'),
'Usage dashboard should use an independent request id and use an in-context detail drawer with reduced-motion handling');
assert(usageFunctions.includes('function getUsageEchart')
&& usageFunctions.includes('window.echarts')
@@ -5054,6 +5094,7 @@ async function main() {
}
if (regressionTarget === 'advanced-session-search') {
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
await runAdvancedSessionSearchRegression();
console.log('Advanced session search regression checks passed.');
return;
@@ -5100,6 +5141,7 @@ async function main() {
assertSessionSwitchResilienceContract();
assertSessionSwitchRaceContract();
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
assertUsageStatisticsUnitChecks();
assertUsageStatisticsContract();
assertCodexAppChildToolRoutingContract();