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: {
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) {
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}>
{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