Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -4257,7 +4257,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../../shared/runtime-host-identity.js": 1,
"../features/usage-activity": 1,
"../locales/settings-usage-copy": 1,
"./settings-error-copy": 1,
"./settings-metric-card": 1,
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/features/usage-activity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.
*/

export {
shortUsageActivitySessionId,
UsageActivityPagination,
type UsageActivityPage,
} from './ui/usage-activity-pagination.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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 { Pagination, paginateData } from '@astryxdesign/core';
import { HStack } from '@maka/ui';
import { useState, type ReactNode } from 'react';
import { parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js';

export interface UsageActivityPage<Item> {
items: Item[];
rowIndexStart: number;
rowCount: number;
reset(): void;
}

export function UsageActivityPagination<Item>(props: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: @astryxdesign/core@0.5.2 already ships the seam this reimplements. useTablePagination is the Table plugin for exactly this shape (consumer owns the page state, the plugin renders Pagination around the table, with position, align, size and label). The PR uses paginateData from the same module but routes around the plugin with a render-prop component, a new feature directory and a barrel.

The smaller equivalent: hold page in UsageRequestsPanel (above the showDetails early return), call paginateData directly, and pass the plugin through UsageStatsTable. That deletes features/usage-activity/ entirely, the UsageActivityPage type, and the renderer-architecture.json plus astryx-surface-file-inventory churn, and turns the reset into a plain setPage(1). I have not rendered the plugin's chrome to confirm it matches the current HStack + Pagination layout; if it does not, the fallback is still to move the state into UsageRequestsPanel and drop the wrapper while keeping the hand-placed Pagination.

items: Item[];
pageSize: number;
children(page: UsageActivityPage<Item>): ReactNode;
}) {
const [page, setPage] = useState(1);
const pageCount = Math.max(1, Math.ceil(props.items.length / props.pageSize));
const currentPage = Math.min(page, pageCount);

return (
<>
{props.children({
items: paginateData(props.items, currentPage, props.pageSize),
rowIndexStart: (currentPage - 1) * props.pageSize + 1,
rowCount: props.items.length,
reset: () => setPage(1),
})}
{pageCount > 1 ? (
<HStack hAlign="center">
<Pagination
page={currentPage}
onChange={setPage}
totalItems={props.items.length}
pageSize={props.pageSize}
size="sm"
/>
</HStack>
) : null}
</>
);
}

export function shortUsageActivitySessionId(sessionKey: string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: this helper is about session ids, not pagination, and its only caller is still usageSessionDisplayLabel in usage-settings-page.tsx. Keeping it there (along with the parseDesktopSessionKey import) avoids a new public export, a barrel entry and a dependency edge in renderer-architecture.json.

try {
return shortenSessionId(parseDesktopSessionKey(sessionKey).sessionId);
} catch {
return shortenSessionId(sessionKey);
}
}

function shortenSessionId(sessionId: string) {
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
}
64 changes: 42 additions & 22 deletions apps/desktop/src/renderer/settings/usage-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
proportional,
} from '@astryxdesign/core';
import { uiLocaleToIntlLocale } from '@maka/core/ui-locale';
import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js';
import {
type AppSettings,
type UpdateAppSettingsResult,
Expand All @@ -51,6 +50,11 @@ import {
Banner,
} from '@maka/ui';
import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons';
import {
shortUsageActivitySessionId,
UsageActivityPagination,
type UsageActivityPage,
} from '../features/usage-activity';
import {
getUsageSettingsCopy,
type UsageSettingsCopy,
Expand All @@ -63,6 +67,8 @@ import { useOptimisticSettingsDraft } from './use-optimistic-settings-draft';

type UsageActiveTab = AppSettings['usage']['activeTab'];

const USAGE_REQUESTS_PAGE_SIZE = 50;

export function UsageSettingsPage(props: {
settings: AppSettings;
stats: UsageStats | null;
Expand Down Expand Up @@ -303,13 +309,25 @@ function UsageRequestsPanel(props: {
endContent={<Button variant="secondary" size="sm" onClick={props.onEnableDetails} label={props.copy.showDetails} />} />
);
}
return (
<>
function renderPage({
items,
rowIndexStart,
rowCount,
reset,
}: UsageActivityPage<UsageStats['logs'][number]>) {
const clearFilters = () => {
reset();
props.onClearFilters();
};
return <>
<div className="settingsUsageFilters" role="group" aria-label={props.copy.filtersAria}>
<div className="settingsUsageModelFilter">
<TextInput
value={props.modelFilter}
onChange={(value) => props.onModelFilterChange(value)}
onChange={(value) => {
reset();
props.onModelFilterChange(value);
}}
placeholder={props.copy.filterPlaceholder}
label={props.copy.filterAria}
isLabelHidden
Expand All @@ -327,7 +345,10 @@ function UsageRequestsPanel(props: {
{ value: 'aborted', label: props.copy.statuses[3] },
]}
width={320}
onChange={(value) => props.onStatusChange(value as AppSettings['usage']['status'])}
onChange={(value) => {
reset();
props.onStatusChange(value as AppSettings['usage']['status']);
}}
/>
<div className="settingsUsageDetailToggle">
<span>{props.copy.details}</span>
Expand All @@ -346,12 +367,14 @@ 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 ? clearFilters : undefined}
label={props.copy.clearFilters}
/>
</div>
<UsageStatsTable
ariaLabel={props.copy.tables.requestsAria}
rowIndexStart={rowIndexStart}
rowCount={rowCount}
columns={[
{ header: props.copy.tables.requestHeaders[0], width: 168 },
{ header: props.copy.tables.requestHeaders[1], width: 72 },
Expand All @@ -362,7 +385,7 @@ function UsageRequestsPanel(props: {
{ header: props.copy.tables.requestHeaders[6], numeric: true },
{ header: props.copy.tables.requestHeaders[7], width: 72 },
]}
rows={props.logs.map((row) => [
rows={items.map((row) => [
new Date(row.ts).toLocaleString(uiLocaleToIntlLocale(props.locale)),
usageRequestKindLabel(row.kind, props.copy),
usageRequestTarget(row),
Expand All @@ -383,12 +406,17 @@ function UsageRequestsPanel(props: {
variant="ghost"
size="sm"
label={props.copy.clearFilters}
onClick={props.onClearFilters}
onClick={clearFilters}
/>
) : undefined,
}}
/>
</>
</>;
}
return (
<UsageActivityPagination items={props.logs} pageSize={USAGE_REQUESTS_PAGE_SIZE}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: changing the time range does not return the table to page 1. setRange (line 120) only persists, the surface then refetches, and UsageActivityPagination keeps whatever page number it held. A user sitting on page 5 of a 90d history switches to 7d and lands on page 5 of a completely different dataset, or gets silently clamped to its last page. The same applies to Refresh: new records are prepended, so the page 2 window shifts under the reader and boundary rows repeat. The reset wiring added for search, status and clear filters covers the filter paths but not the dataset paths.

Smallest fix: <UsageActivityPagination key={usageDraft.range} items={props.logs} pageSize={USAGE_REQUESTS_PAGE_SIZE}>, so a range change remounts and resets. To cover the refetch shift too, also reset when the items identity changes.

{renderPage}
</UsageActivityPagination>
);
}

Expand Down Expand Up @@ -501,19 +529,7 @@ function usageRequestSessionCell(row: UsageStats['logs'][number], copy: UsageSet
function usageSessionDisplayLabel(row: UsageStats['logs'][number], copy: UsageSettingsCopy) {
const name = row.sessionName?.trim();
if (name) return name;
return `${copy.tables.untitledSession} · ${shortRealSessionId(row.sessionId ?? '')}`;
}

function shortRealSessionId(sessionKey: string) {
try {
return shortUsageSessionId(parseDesktopSessionKey(sessionKey).sessionId);
} catch {
return shortUsageSessionId(sessionKey);
}
}

function shortUsageSessionId(sessionId: string) {
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
return `${copy.tables.untitledSession} · ${shortUsageActivitySessionId(row.sessionId ?? '')}`;
}

function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], copy: UsageSettingsCopy) {
Expand Down Expand Up @@ -574,6 +590,8 @@ interface UsageEmpty {

function UsageStatsTable(props: {
ariaLabel: string;
rowIndexStart?: number;
rowCount?: number;
columns: UsageColumn[];
rows: Array<Array<ReactNode>>;
empty: UsageEmpty;
Expand Down Expand Up @@ -613,6 +631,8 @@ function UsageStatsTable(props: {
<Card className="settingsUsageTable" padding={3}>
<Table
aria-label={props.ariaLabel}
rowIndexStart={props.rowIndexStart}
rowCount={props.rowCount}
data={data}
columns={columns}
idKey="id"
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/stories/settings/settings-pages.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ function makeUsageLog(input: {
};
}

const USAGE_PAGINATION_SENTINEL = 'Usage pagination page two sentinel';

const usageLogs: UsageStats['logs'] = [
makeUsageLog({
id: '1',
Expand All @@ -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
Expand Down Expand Up @@ -2093,6 +2112,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<HTMLTableCellElement>('tbody tr td:first-child');
expect(timeCell).not.toBeNull();
const timeText = timeCell?.firstElementChild;
Expand All @@ -2117,6 +2138,53 @@ 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() {
await waitFor(() => {
const firstPageTable = canvas.getByRole('table', {
name: usageCopy.tables.requestsAria,
});
expect(firstPageTable.querySelectorAll('tbody tr')).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();

await goToPageTwo();
const statusFilter = canvas.getByRole('combobox', { name: usageCopy.statusAria });
await userEvent.click(canvas.getByRole('button', { name: usageCopy.clearFilters }));
await expectFirstPage();
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();
},
};
// Real path: the same long-content Usage page at the minimum supported window width.
Expand Down
3 changes: 2 additions & 1 deletion docs/astryx-surface-file-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).

Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.

**Totals:** 240 files — blocker 0, reimplementation 0, polish 1, aligned 239.
**Totals:** 241 files — blocker 0, reimplementation 0, polish 1, aligned 240.

## Exclusions (explicit)

Expand Down Expand Up @@ -66,6 +66,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/usage-activity/ui/usage-activity-pagination.tsx` | other | HStack, Pagination | aligned — uses Astryx (HStack, Pagination) | aligned |
| `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState, MoreMenu | aligned — uses Astryx (Badge, Banner, Button, EmptyState, MoreMenu) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned |
Expand Down
1 change: 1 addition & 0 deletions docs/astryx-surface-file-inventory.paths
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-prov
apps/desktop/src/renderer/features/session-settings/services-context.tsx
apps/desktop/src/renderer/features/task-entry/services-context.tsx
apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx
apps/desktop/src/renderer/features/usage-activity/ui/usage-activity-pagination.tsx
apps/desktop/src/renderer/features/workbar/services-context.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx
Expand Down