diff --git a/apps/desktop/perf/usage-tab-entry.spec.ts b/apps/desktop/perf/usage-tab-entry.spec.ts
new file mode 100644
index 00000000000..c572afebd0d
--- /dev/null
+++ b/apps/desktop/perf/usage-tab-entry.spec.ts
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import type { ElectronApplication, Page } from '@playwright/test';
+import { expect, test as base, withE2eWindow } from '../e2e/fixtures';
+
+// #4677 item 4: weigh leaving and re-entering the Usage settings section with
+// a ledger that would once have mounted every activity row. The table is a
+// native
(BaseTable), the section tabs render as buttons inside a
+// plain (no ARIA tabs pattern), and the nav items carry stable
+// assistant targets — so the measurement never leans on the tabs' roles.
+const ACTIVITY_TABLE = '[aria-label="使用统计活动记录表"]';
+const ACTIVITY_TABLE_LOCATOR = `${ACTIVITY_TABLE} tbody tr`;
+const USAGE_NAV = '[data-maka-assistant-target="settings.usage"]';
+const GENERAL_NAV = '[data-maka-assistant-target="settings.general"]';
+const TABS_NAV = '[aria-label="使用统计视图"]';
+const REQUESTS_TAB = /活动记录/;
+/** #4677 item 4: the fixture pads the handcrafted turns to exactly this many activity rows. */
+const EXPECTED_ACTIVITY_ROWS = 409;
+/** usage-settings-view's USAGE_REQUESTS_PAGE_SIZE. */
+const PAGE_SIZE = 50;
+const ROUNDS = 12;
+/**
+ * Frames the in-renderer poll may wait per phase before giving up. Playwright
+ * serializes page.evaluate callbacks without their Node-side closure, so this
+ * must be passed into each callback as an argument.
+ */
+const MOUNT_POLL_FRAMES = 600;
+
+interface UsageWindow {
+ page: Page;
+ app: ElectronApplication;
+}
+
+const test = base.extend<{ usageWindow: UsageWindow }>({
+ usageWindow: async ({}, use, testInfo) => {
+ await withE2eWindow(
+ {
+ testInfo,
+ seed: false,
+ readinessSelector: '[data-maka-contract="settings-sidebar"]',
+ readinessTimeoutMs: 60_000,
+ e2eFixtureScenario: 'settings-usage',
+ locale: 'zh-CN',
+ // rAF is throttled in hidden windows, and the entry sample ends on a
+ // painted frame, so the window must be visible.
+ showWindow: true,
+ },
+ async (page, { app }) => use({ page, app }),
+ );
+ },
+});
+
+test('usage activity tab entry mounts one bounded page of 409 total rows', async ({
+ usageWindow: { page },
+}) => {
+ await page.setViewportSize({ width: 1_400, height: 900 });
+
+ // The fixture's settings.json enables usage details and the 'all' range, so
+ // the activity table renders as soon as the Usage section is open. Navigate
+ // there explicitly anyway: the section can drift after mount, and every
+ // measured entry below re-clicks the same nav item regardless of where the
+ // shell currently sits.
+ await page.locator(USAGE_NAV).first().click();
+ const table = page.locator(ACTIVITY_TABLE);
+ await expect(table).toBeVisible({ timeout: 30_000 });
+
+ // Bounded mount: paginateData puts only the current page's rows in the DOM,
+ // however many rows the ledger holds; the tab badge carries the total.
+ const domRows = await page.locator(ACTIVITY_TABLE_LOCATOR).count();
+ console.log(JSON.stringify({ stage: 'bounded mount', domRows, expectedTotal: EXPECTED_ACTIVITY_ROWS }));
+ expect(domRows, 'the activity table must not mount more than one page').toBeLessThanOrEqual(
+ PAGE_SIZE,
+ );
+ const badge = page.locator(TABS_NAV).getByRole('button', { name: REQUESTS_TAB });
+ const badgeDigits = ((await badge.textContent({ timeout: 10_000 })) ?? '').replace(/\D/g, '');
+ expect(badgeDigits, 'the tab badge must still report every activity row').toBe(
+ String(EXPECTED_ACTIVITY_ROWS),
+ );
+
+ // #4531's repro: leave the Usage section and come back. Each round runs
+ // entirely inside the renderer — the clock starts at the synthetic click and
+ // stops at the mounted table's next painted frame — so it excludes
+ // Playwright round-trips and waits only for what the user would wait for.
+ // Clicking the same nav items every round keeps the measurement immune to
+ // whatever section the shell drifted to between rounds.
+ const entries: number[] = [];
+ for (let round = 1; round <= ROUNDS; round += 1) {
+ const leftMs = await page.evaluate(
+ ([tableSelector, navSelector, pollBudgetFrames]) =>
+ new Promise((resolve, reject) => {
+ const t0 = performance.now();
+ const nav = document.querySelector(navSelector);
+ if (!(nav instanceof HTMLElement)) {
+ reject(new Error('general nav item not found'));
+ return;
+ }
+ nav.click();
+ let frames = 0;
+ const poll = () => {
+ if (!document.querySelector(tableSelector)) {
+ requestAnimationFrame(() => resolve(performance.now() - t0));
+ return;
+ }
+ frames += 1;
+ if (frames > pollBudgetFrames) {
+ reject(new Error('activity table never unmounted'));
+ return;
+ }
+ requestAnimationFrame(poll);
+ };
+ requestAnimationFrame(poll);
+ }),
+ [ACTIVITY_TABLE, GENERAL_NAV, MOUNT_POLL_FRAMES],
+ );
+ const entryMs = await page.evaluate(
+ ([tableSelector, navSelector, pollBudgetFrames]) =>
+ new Promise((resolve, reject) => {
+ const t0 = performance.now();
+ const nav = document.querySelector(navSelector);
+ if (!(nav instanceof HTMLElement)) {
+ reject(new Error('usage nav item not found'));
+ return;
+ }
+ nav.click();
+ let frames = 0;
+ const poll = () => {
+ if (document.querySelector(tableSelector)) {
+ requestAnimationFrame(() => resolve(performance.now() - t0));
+ return;
+ }
+ frames += 1;
+ if (frames > pollBudgetFrames) {
+ reject(new Error('activity table did not mount within the poll budget'));
+ return;
+ }
+ requestAnimationFrame(poll);
+ };
+ requestAnimationFrame(poll);
+ }),
+ [ACTIVITY_TABLE, USAGE_NAV, MOUNT_POLL_FRAMES],
+ );
+ entries.push(entryMs);
+ console.log(JSON.stringify({
+ stage: `re-entry ${round}`,
+ leaveMs: Number(leftMs.toFixed(1)),
+ entryMs: Number(entryMs.toFixed(1)),
+ }));
+ }
+
+ const sorted = [...entries].sort((left, right) => left - right);
+ const mid = Math.floor(sorted.length / 2);
+ // Even sample: the median is the mean of the two middle values, not the
+ // upper one.
+ const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+ const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)];
+ const summary = {
+ condition:
+ 'Real Electron, real Host, real storage. Warm re-entry: click off the Usage settings section (wait for the table to unmount), click back in; the clock runs from the click to the activity table’s next painted frame, sampled inside the renderer. Fixture: 409 activity rows (399 model + 10 tool) on the fixed clock; table page size 50.',
+ rounds: ROUNDS,
+ entriesMs: entries.map((value) => Number(value.toFixed(1))),
+ medianMs: Number(median.toFixed(1)),
+ p95Ms: Number(p95.toFixed(1)),
+ domRowsAfterFirstEntry: domRows,
+ expectedActivityRows: EXPECTED_ACTIVITY_ROWS,
+ };
+ const output = path.join(process.env.TEMP ?? 'perf-results', 'usage-tab-entry-results.json');
+ await mkdir(path.dirname(output), { recursive: true });
+ await writeFile(output, JSON.stringify(summary, null, 2));
+ console.log(JSON.stringify(summary));
+});
diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts
index bf99d7975c0..9a9661efa8c 100644
--- a/apps/desktop/src/main/e2e-fixture.ts
+++ b/apps/desktop/src/main/e2e-fixture.ts
@@ -302,7 +302,12 @@ export async function seedE2eFixture(input: {
await runStore.close?.();
// Fold the appended attempts into the read model so the page's first read
// sees canonical usage (production's readCanonicalUsage also repairs).
- await usage.modelCalls.catchUpModelCallProjection();
+ // One pass bounds how many lagging runs it processes, and the padded
+ // fixture creates hundreds of runs, so fold until nothing is pending.
+ for (;;) {
+ const projection = await usage.modelCalls.catchUpModelCallProjection();
+ if (projection.pendingRuns === 0) break;
+ }
await usage.flush();
} finally {
await usage.close();
diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
index c51430eb6e0..7da6a0364ab 100644
--- a/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
+++ b/apps/desktop/src/main/e2e-fixture/scenarios-usage.ts
@@ -45,6 +45,33 @@ type PersistedToolInvocationRecord = Parameters<
// - 5 models (glm / claude / gpt families) → 模型统计
// - 6 tools with 2 failures → 工具统计 (exercises the error column)
// - a dozen activity rows mixing model + tool + success/error → 活动记录
+//
+// The handcrafted turns are padded to exactly 409 activity rows — the
+// unpaginated mount the #4677 tab-entry measurement weighs. Activity rows are
+// model rows plus tool rows, so the pad is 394 zero-tool turns (one token_usage
+// each, shared 132/131/131 across the three sessions): 394 + 5 handcrafted
+// model turns = 399 model rows, plus the 10 handcrafted tool rows = 409.
+// Synthetic turns reuse the handcrafted generators and stay on the fixed clock
+// minus a minutes offset, so every table stays deterministic.
+
+/**
+ * One synthetic turn contributes exactly one activity row (its token_usage),
+ * so the pad's arithmetic stays checkable: share out the turns across the
+ * three seeded sessions, models follow their session's connection.
+ */
+function syntheticUsageTurns(count: number, offset: number, model: string): UsageTurnSpec[] {
+ return Array.from({ length: count }, (_, index) => ({
+ turnId: `usage-synthetic-${String(offset + index).padStart(3, '0')}`,
+ minutesAgo: 60 + offset + index,
+ model,
+ usage: {
+ input: 1200 + ((offset + index) % 13) * 320,
+ output: 180 + ((offset + index) % 7) * 90,
+ costUsd: 0.0021,
+ },
+ tools: [],
+ }));
+}
interface UsageTurnSpec {
turnId: string;
@@ -173,6 +200,7 @@ export function usageStatsSessions(
{ id: 'usage-glm-2-write', toolName: 'Write', displayName: '写入文件', durationMs: 1_460, isError: true },
],
}),
+ ...syntheticUsageTurns(132, 0, 'glm-5.1').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
{
@@ -203,6 +231,7 @@ export function usageStatsSessions(
{ id: 'usage-claude-2-bash', toolName: 'Bash', displayName: '构建 renderer', durationMs: 5_200 },
],
}),
+ ...syntheticUsageTurns(131, 132, 'claude-sonnet-4.5').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
{
@@ -224,6 +253,7 @@ export function usageStatsSessions(
{ id: 'usage-gpt-1-grep', toolName: 'Grep', displayName: '扫描目录', durationMs: 720, isError: true },
],
}),
+ ...syntheticUsageTurns(131, 263, 'gpt-5.1-mini').flatMap((spec) => usageTurnMessages(now, spec)),
],
},
];