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.