diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
index 61df3dcdd8..9db5db48da 100644
--- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
+++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
@@ -19,11 +19,13 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
+ paginateData,
SegmentedControl,
SegmentedControlItem,
Tab,
TabList,
Tooltip,
+ useTablePagination,
} from '@astryxdesign/core';
import { uiLocaleToIntlLocale } from '@maka/core/ui-locale';
import { parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js';
@@ -36,13 +38,15 @@ import {
type UsageSettingsCopy,
} from '../../../locales/settings-usage-copy.js';
import { MetricCard } from './metric-card.js';
-import { UsageStatsTable } from './usage-stats-table.js';
+import { UsageStatsTable, type UsageTableRow } from './usage-stats-table.js';
import { useActionGuard } from '../controller/action-guard.js';
import { useOptimisticSettingsDraft } from '../controller/optimistic-settings-draft.js';
import { useUsageServices, useUsageStats } from '../services-context.js';
type UsageActiveTab = UsageSettings['activeTab'];
+const USAGE_REQUESTS_PAGE_SIZE = 50;
+
/**
* The Usage settings surface (issue #4425). A disposable view: it unmounts when
* the user leaves the Usage section. The loaded stats snapshot lives in the
@@ -69,6 +73,7 @@ export function UsageSettingsView(props: {
// panels read `null` (loading/empty) rather than the previous range's numbers.
const { stats, reload, targetKey } = useUsageStats(persistedUsage.range);
const [refreshing, setRefreshing] = useState(false);
+ const [usageRefreshRevision, setUsageRefreshRevision] = useState(0);
const usageRefreshGuard = useActionGuard<'refresh'>();
const {
draft: usageDraft,
@@ -127,6 +132,7 @@ export function UsageSettingsView(props: {
async function refresh() {
if (!usageRefreshGuard.begin('refresh')) return;
+ setUsageRefreshRevision((revision) => revision + 1);
setRefreshing(true);
try {
await reload(usageDraftRef.current.range);
@@ -210,6 +216,7 @@ export function UsageSettingsView(props: {
{usageDraft.activeTab === 'requests' ? (
({
+ page: currentPage,
+ onPageChange: setPage,
+ totalItems: props.logs.length,
+ pageSize: USAGE_REQUESTS_PAGE_SIZE,
+ size: 'sm',
+ });
+
+ useEffect(() => {
+ setPage(1);
+ }, [props.logs]);
+
if (!props.showDetails) {
return (
props.onModelFilterChange(value)}
+ onChange={(value) => {
+ setPage(1);
+ props.onModelFilterChange(value);
+ }}
placeholder={props.copy.filterPlaceholder}
label={props.copy.filterAria}
isLabelHidden
@@ -308,7 +333,10 @@ function UsageRequestsPanel(props: {
{ value: 'aborted', label: props.copy.statuses[3] },
]}
width={320}
- onChange={(value) => props.onStatusChange(value as UsageSettings['status'])}
+ onChange={(value) => {
+ setPage(1);
+ props.onStatusChange(value as UsageSettings['status']);
+ }}
/>
{props.copy.details}
@@ -327,12 +355,22 @@ function UsageRequestsPanel(props: {
isDisabled={!props.hasRequestFilters}
aria-hidden={!props.hasRequestFilters ? 'true' : undefined}
tabIndex={!props.hasRequestFilters ? -1 : undefined}
- onClick={props.hasRequestFilters ? props.onClearFilters : undefined}
+ onClick={
+ props.hasRequestFilters
+ ? () => {
+ setPage(1);
+ props.onClearFilters();
+ }
+ : undefined
+ }
label={props.copy.clearFilters}
/>
[
+ rows={paginateData(props.logs, currentPage, USAGE_REQUESTS_PAGE_SIZE).map((row) => [
new Date(row.ts).toLocaleString(uiLocaleToIntlLocale(props.locale)),
usageRequestKindLabel(row.kind, props.copy),
usageRequestTarget(row),
@@ -362,7 +400,10 @@ function UsageRequestsPanel(props: {
variant="ghost"
size="sm"
label={props.copy.clearFilters}
- onClick={props.onClearFilters}
+ onClick={() => {
+ setPage(1);
+ props.onClearFilters();
+ }}
/>
) : undefined,
}}
diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
index beb6a1830b..ec3af123fe 100644
--- a/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
+++ b/apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
@@ -41,7 +41,7 @@ export interface UsageColumn {
width?: number;
}
-type UsageTableRow = Record & { id: number };
+export type UsageTableRow = Record & { id: number };
function usageCellNeedsCustomRenderer(value: ReactNode) {
return (
@@ -81,6 +81,9 @@ export interface UsageEmpty {
export function UsageStatsTable(props: {
ariaLabel: string;
+ rowIndexStart?: number;
+ rowCount?: number;
+ plugins?: Record>;
columns: UsageColumn[];
rows: Array>;
empty: UsageEmpty;
@@ -121,13 +124,17 @@ export function UsageStatsTable(props: {
);
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index e3be3bb49e..4ac4c67390 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -239,6 +239,8 @@ function makeUsageLog(input: {
};
}
+const USAGE_PAGINATION_SENTINEL = 'Usage pagination page two sentinel';
+
const usageLogs: UsageStats['logs'] = [
makeUsageLog({
id: '1',
@@ -265,6 +267,23 @@ const usageLogs: UsageStats['logs'] = [
turnId: undefined,
costUsd: undefined,
},
+ ...Array.from({ length: 47 }, (_, index) => {
+ const id = String(index + 6);
+ return makeUsageLog({
+ id,
+ kind: 'model',
+ model: 'gpt-5',
+ sessionName: `Usage pagination fixture ${id}`,
+ minutesAgo: index + 40,
+ });
+ }),
+ makeUsageLog({
+ id: '53',
+ kind: 'model',
+ model: 'gpt-5',
+ sessionName: USAGE_PAGINATION_SENTINEL,
+ minutesAgo: 90,
+ }),
];
// Priced provenance so the fixtures' costs read as authoritative
@@ -1472,7 +1491,7 @@ function withUsageStoryBridge(
): Promise => ({
settings: mergeSettings(settings, patch),
}),
- usageStats: async (): Promise => stats,
+ usageStats: async (): Promise => ({ ...stats, logs: [...stats.logs] }),
},
} satisfies Record);
}
@@ -2106,6 +2125,8 @@ export const UsageLongTail: Story = {
if (showDetails) await userEvent.click(showDetails);
const table = await canvas.findByRole('table', { name: usageCopy.tables.requestsAria });
+ expect(table.querySelectorAll('tbody tr')).toHaveLength(50);
+ expect(within(table).queryByText(USAGE_PAGINATION_SENTINEL)).not.toBeInTheDocument();
const timeCell = table.querySelector('tbody tr td:first-child');
expect(timeCell).not.toBeNull();
const timeText = timeCell?.firstElementChild;
@@ -2130,6 +2151,66 @@ export const UsageLongTail: Story = {
expect(tooltip).toHaveTextContent(longTarget);
});
await userEvent.unhover(targetCellText);
+
+ async function goToPageTwo() {
+ const pageTwo = canvas
+ .getAllByRole('button')
+ .find((button) => button.textContent?.trim() === '2');
+ expect(pageTwo).toBeDefined();
+ await userEvent.click(pageTwo!);
+ await waitFor(() => {
+ const secondPageTable = canvas.getByRole('table', {
+ name: usageCopy.tables.requestsAria,
+ });
+ expect(secondPageTable.querySelectorAll('tbody tr')).toHaveLength(3);
+ expect(secondPageTable).toHaveAttribute('aria-rowcount', String(usageLogs.length));
+ expect(secondPageTable.querySelector('tbody tr')).toHaveAttribute('aria-rowindex', '51');
+ expect(within(secondPageTable).getByText(USAGE_PAGINATION_SENTINEL)).toBeInTheDocument();
+ });
+ }
+
+ async function expectFirstPage(reason: string) {
+ await waitFor(() => {
+ const firstPageTable = canvas.getByRole('table', {
+ name: usageCopy.tables.requestsAria,
+ });
+ expect(firstPageTable.querySelectorAll('tbody tr'), reason).toHaveLength(50);
+ expect(within(firstPageTable).getByText(longTarget)).toBeInTheDocument();
+ expect(within(firstPageTable).queryByText(USAGE_PAGINATION_SENTINEL)).not.toBeInTheDocument();
+ });
+ }
+
+ await goToPageTwo();
+ const modelFilter = canvas.getByRole('textbox', { name: usageCopy.filterAria });
+ await userEvent.type(modelFilter, 'zai');
+ await expectFirstPage('model filter should reset pagination');
+
+ await goToPageTwo();
+ const statusFilter = canvas.getByRole('combobox', { name: usageCopy.statusAria });
+ await userEvent.click(canvas.getByRole('button', { name: usageCopy.clearFilters }));
+ await expectFirstPage('clearing filters should reset pagination');
+ expect(modelFilter).toHaveValue('');
+ expect(statusFilter).toHaveTextContent(usageCopy.statuses[0]);
+
+ await goToPageTwo();
+ await userEvent.click(statusFilter);
+ await userEvent.click(
+ await within(document.body).findByRole('option', { name: usageCopy.statuses[1] }),
+ );
+ await expectFirstPage('status filter should reset pagination');
+
+ await userEvent.click(await canvas.findByRole('button', { name: usageCopy.clearFilters }));
+ await goToPageTwo();
+ const nextRange = canvas
+ .getAllByRole('radio')
+ .find((radio) => radio.getAttribute('aria-checked') === 'false');
+ expect(nextRange).toBeDefined();
+ await userEvent.click(nextRange!);
+ await expectFirstPage('changing range should reset pagination');
+
+ await goToPageTwo();
+ await userEvent.click(canvas.getByRole('button', { name: usageCopy.refreshAria }));
+ await expectFirstPage('refreshing should reset pagination');
},
};
// Real path: the same long-content Usage page at the minimum supported window width.