Skip to content
Merged
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
3 changes: 2 additions & 1 deletion web/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
triggerLabel?: string;
suffix?: ReactNode;
suffixAriaLabel?: string;
disabled?: boolean;
Expand Down Expand Up @@ -198,7 +199,7 @@ export function Select({
? selectedIndex
: firstEnabledIndex;
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
const displayText = selected?.label ?? placeholder ?? '';
const displayText = selected?.triggerLabel ?? selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;

const commitSelection = useCallback(
Expand Down
66 changes: 45 additions & 21 deletions web/src/features/ranking/RankingPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -892,13 +892,12 @@

@mixin ranking-header-stacked {
.leaderboardHeader {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'title'
'toolbar'
'profile';
'title title'
'toolbar profile';
align-items: stretch;
column-gap: 0;
column-gap: 12px;
row-gap: 14px;
padding: 0;
}
Expand All @@ -921,36 +920,43 @@
}

.leaderboardHeaderActions {
justify-content: center;
justify-self: center;
align-self: center;
justify-content: flex-end;
justify-self: end;
max-width: 100%;
width: auto;
margin-right: 0;
}

.profileActionShell,
.profileActionShellActive {
max-width: 100%;
}

.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr);
display: flex;
justify-content: flex-start;
width: 100%;
max-width: 100%;
padding: 4px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-inline: contain;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;

&::-webkit-scrollbar {
display: none;
}
}

.periods {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
width: 100%;
}

.periodButton {
padding-inline: 6px;
grid-template-columns: repeat(4, max-content);
flex: 0 0 auto;
width: max-content;
margin-left: auto;
}

.metricControl {
width: 100%;
flex: 0 0 168px;
width: 168px;
margin-right: auto;
}
}

Expand All @@ -961,6 +967,24 @@
@include mobile {
@include ranking-header-stacked;

.profileActionShell {
max-width: 160px;
}

.profileActionShellActive {
flex: 0 0 42px;
width: 42px;
}

.profileActionShellActive :global(.main-action-button) {
width: 32px;
padding-inline: 5px;
}

.profileActionName {
display: none;
}

.participantColumn,
.participantCell {
min-width: 0;
Expand Down
11 changes: 9 additions & 2 deletions web/src/features/ranking/RankingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,12 @@ function LeaderboardCard({
const podium = rows.slice(0, 3);
const tableRows = rows;
const scoreExplanation = resolveScoreExplanation(board, metric, language);
const hasRankingProfile = status?.status === 'active' || status?.status === 'paused';
const profileActionAriaLabel = hasRankingProfile && status.display_name
? `${status.display_name} · ${t('ranking.profile_action')}`
: hasRankingProfile
? t('ranking.profile_action')
: undefined;
return (
<article className={`card ${styles.leaderboardCard}`.trim()} aria-busy={loading}>
<header className={styles.leaderboardHeader}>
Expand Down Expand Up @@ -644,12 +650,13 @@ function LeaderboardCard({
data-ranking-profile-action-shell
>
<MainActionButton
shellClassName={`${styles.profileActionShell} ${status?.status === 'active' || status?.status === 'paused' ? styles.profileActionShellActive : ''}`.trim()}
shellClassName={`${styles.profileActionShell} ${hasRankingProfile ? styles.profileActionShellActive : ''}`.trim()}
onClick={onOpenProfile}
disabled={statusLoading && !status}
aria-label={profileActionAriaLabel}
data-ranking-profile-action
>
{status?.status === 'active' || status?.status === 'paused' ? (
{hasRankingProfile ? (
<>
<RankingAvatar avatarID={status.avatar_id ?? 1} name={status.display_name ?? ''} className={styles.profileActionAvatar} decorative />
<span className={styles.profileActionName} data-ranking-profile-name>{status.display_name || t('ranking.profile_action')}</span>
Expand Down
27 changes: 16 additions & 11 deletions web/src/features/ranking/components/RankingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ const PERIODS: ReadonlyArray<{ value: RankingPeriod; labelKey: string }> = [
{ value: 'previous_month', labelKey: 'ranking.period_previous_month' },
];

const METRICS: ReadonlyArray<{ value: RankingMetric; labelKey: string }> = [
{ value: 'overall', labelKey: 'ranking.metric_overall' },
{ value: 'total_tokens', labelKey: 'ranking.metric_total_tokens' },
{ value: 'request_count', labelKey: 'ranking.metric_request_count' },
{ value: 'cache_read_rate', labelKey: 'ranking.metric_cache_read_rate' },
{ value: 'ttft_average', labelKey: 'ranking.metric_ttft_average' },
{ value: 'latency_average', labelKey: 'ranking.metric_latency_average' },
{ value: 'peak_tpm', labelKey: 'ranking.metric_peak_tpm' },
{ value: 'peak_rpm', labelKey: 'ranking.metric_peak_rpm' },
// 触发器保持短名称,展开菜单保留完整指标语义。
const METRICS: ReadonlyArray<{ value: RankingMetric; labelKey: string; triggerLabelKey: string }> = [
{ value: 'overall', labelKey: 'ranking.metric_overall', triggerLabelKey: 'ranking.metric_overall' },
{ value: 'total_tokens', labelKey: 'ranking.metric_total_tokens', triggerLabelKey: 'ranking.metric_short_total_tokens' },
{ value: 'request_count', labelKey: 'ranking.metric_request_count', triggerLabelKey: 'ranking.metric_short_request_count' },
{ value: 'cache_read_rate', labelKey: 'ranking.metric_cache_read_rate', triggerLabelKey: 'ranking.metric_short_cache_read_rate' },
{ value: 'ttft_average', labelKey: 'ranking.metric_ttft_average', triggerLabelKey: 'ranking.metric_short_ttft_average' },
{ value: 'latency_average', labelKey: 'ranking.metric_latency_average', triggerLabelKey: 'ranking.metric_short_latency_average' },
{ value: 'peak_tpm', labelKey: 'ranking.metric_peak_tpm', triggerLabelKey: 'ranking.metric_short_peak_tpm' },
{ value: 'peak_rpm', labelKey: 'ranking.metric_peak_rpm', triggerLabelKey: 'ranking.metric_short_peak_rpm' },
];

export interface RankingToolbarProps {
Expand All @@ -37,7 +38,11 @@ export function RankingToolbar({
}: RankingToolbarProps) {
const { t } = useTranslation();
const metricOptions = useMemo(
() => METRICS.map((option) => ({ value: option.value, label: t(option.labelKey) })),
() => METRICS.map((option) => ({
value: option.value,
label: t(option.labelKey),
triggerLabel: t(option.triggerLabelKey),
})),
[t],
);

Expand All @@ -64,7 +69,7 @@ export function RankingToolbar({
</div>
<div className={styles.metricControl} data-ranking-metric>
<span className={styles.metricWidthSizer} aria-hidden="true" data-ranking-metric-sizer>
{metricOptions.map((option) => <span key={option.value}>{option.label}</span>)}
{metricOptions.map((option) => <span key={option.value}>{option.triggerLabel}</span>)}
</span>
<Select
value={metric}
Expand Down
22 changes: 15 additions & 7 deletions web/src/features/ranking/components/test/RankingToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key === 'ranking.metric_ttft_average'
? 'This is the longest translated ranking metric'
: key,
t: (key: string) => {
if (key === 'ranking.metric_ttft_average') {
return 'This is the longest translated ranking metric';
}
if (key === 'ranking.metric_short_ttft_average') return 'TTFT';
return key;
},
}),
}));

Expand All @@ -35,7 +39,7 @@ describe('RankingToolbar', () => {
act(() => root.render(
<RankingToolbar
period="today"
metric="overall"
metric="ttft_average"
onPeriodChange={onPeriodChange}
onMetricChange={vi.fn()}
/>,
Expand All @@ -49,12 +53,16 @@ describe('RankingToolbar', () => {

const metricTrigger = container.querySelector<HTMLButtonElement>('[data-ranking-metric] button');
expect(metricTrigger).not.toBeNull();
expect(metricTrigger?.textContent).toContain('ranking.metric_overall');
expect(metricTrigger?.textContent).toContain('TTFT');
expect(metricTrigger?.textContent).not.toContain('This is the longest translated ranking metric');
const metricSizer = container.querySelector('[data-ranking-metric-sizer]');
expect(metricSizer?.querySelectorAll('span')).toHaveLength(8);
expect(metricSizer?.textContent).toContain('This is the longest translated ranking metric');
expect(metricSizer?.textContent).toContain('TTFT');
expect(metricSizer?.textContent).not.toContain('This is the longest translated ranking metric');
act(() => metricTrigger?.click());
expect(document.querySelector<HTMLElement>('[role="listbox"]')?.style.width).toBe('260px');
const listbox = document.querySelector<HTMLElement>('[role="listbox"]');
expect(listbox?.textContent).toContain('This is the longest translated ranking metric');
expect(listbox?.style.width).toBe('260px');
expect(periodGroup?.compareDocumentPosition(container.querySelector('[data-ranking-metric]') as Node) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
});
Expand Down
23 changes: 19 additions & 4 deletions web/src/features/ranking/test/RankingPage.styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('Ranking table context styles', () => {
expect(rule('.toolbar')).toContain('row-gap: 10px;');
});

it('keeps long titles on one line and reflows the header when the card narrows', () => {
it('keeps long titles on one line and reflows narrow-card filters into one scrollable row', () => {
const card = rule('.leaderboardCard:global(.card)');
const title = rule('.leaderboardTitle :global(.keeper-card-title)');
const stackedStart = styles.indexOf('@mixin ranking-header-stacked');
Expand All @@ -158,9 +158,22 @@ describe('Ranking table context styles', () => {
expect(title).toContain('white-space: nowrap;');
expect(title).toContain('overflow: hidden;');
expect(title).toContain('text-overflow: ellipsis;');
expect(stacked).toContain("grid-template-areas:\n 'title'\n 'toolbar'\n 'profile';");
expect(stacked).toMatch(
/\.leaderboardHeader\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto;[\s\S]*?'title title'[\s\S]*?'toolbar profile';/,
);
expect(stacked).toMatch(/\.leaderboardHeaderToolbar\s*\{[\s\S]*?justify-self:\s*stretch;/);
expect(stacked).toMatch(/\.toolbar\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\);/);
expect(stacked).toMatch(
/\.leaderboardHeaderActions\s*\{[\s\S]*?justify-content:\s*flex-end;[\s\S]*?justify-self:\s*end;[\s\S]*?margin-right:\s*0;/,
);
expect(stacked).toMatch(
/\.toolbar\s*\{[\s\S]*?display:\s*flex;[\s\S]*?padding:\s*4px;[\s\S]*?overflow-x:\s*auto;[\s\S]*?scrollbar-width:\s*none;/,
);
expect(stacked).toMatch(
/\.periods\s*\{[\s\S]*?grid-template-columns:\s*repeat\(4, max-content\);[\s\S]*?width:\s*max-content;/,
);
expect(stacked).toMatch(
/\.metricControl\s*\{[\s\S]*?flex:\s*0 0 168px;[\s\S]*?width:\s*168px;/,
);
expect(container).toContain('@include ranking-header-stacked;');
});

Expand All @@ -182,12 +195,14 @@ describe('Ranking table context styles', () => {
expect(rule('.profileActionAvatar')).toContain('flex: 0 0 22px;');
});

it('stacks the header in scan order with compact time text and a centered profile entry on mobile', () => {
it('keeps only the active avatar visible on mobile while reusing the narrow-card header layout', () => {
const mobileStart = styles.indexOf('@include mobile');
const mobileEnd = styles.indexOf('@media (prefers-reduced-motion', mobileStart);
const mobile = styles.slice(mobileStart, mobileEnd);

expect(mobile).toContain('@include ranking-header-stacked;');
expect(mobile).toMatch(/\.profileActionShellActive\s*\{[\s\S]*?flex:\s*0 0 42px;[\s\S]*?width:\s*42px;/);
expect(mobile).toMatch(/\.profileActionName\s*\{[\s\S]*?display:\s*none;/);
});

it('lets the sticky participant column follow the display name width on mobile', () => {
Expand Down
10 changes: 10 additions & 0 deletions web/src/features/ranking/test/RankingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ describe('RankingPage', () => {
expect(toolbar?.parentElement).toBe(header);
expect(profile?.parentElement).toBe(header);
expect(header?.children).toHaveLength(3);
expect(title?.compareDocumentPosition(toolbar as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(toolbar?.compareDocumentPosition(profile as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(toolbar?.querySelector('[data-ranking-toolbar]')).not.toBeNull();
expect(header?.querySelector('[data-ranking-periods]')).not.toBeNull();
expect(header?.querySelector('[data-ranking-metric]')).not.toBeNull();
Expand Down Expand Up @@ -226,6 +228,14 @@ describe('RankingPage', () => {
expect(dialog?.querySelectorAll('[data-ranking-avatar-option]')).toHaveLength(66);
});

it('keeps an accessible profile action name when mobile styling leaves only the active avatar visible', async () => {
await renderPage({ status: { status: 'active', display_name: 'Owner', avatar_id: 7 } });

const profileAction = container.querySelector<HTMLButtonElement>('[data-ranking-profile-action]');
expect(profileAction?.getAttribute('aria-label')).toBe('Owner · ranking.profile_action');
expect(profileAction?.querySelector('[data-ranking-profile-name]')?.textContent).toBe('Owner');
});

it('keeps a successful leaderboard visible when metadata loading fails', async () => {
const onRetryMetadata = vi.fn(async () => null);
await renderPage({
Expand Down