From 916a2958d0eb41a0034a5b0b8670785bc52b92f4 Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sat, 9 May 2026 13:23:19 -0400 Subject: [PATCH 1/6] feat(inference): separate gpu comparison menu items from model and chart menu items --- .../component/inference-chart-controls.cy.tsx | 49 +-- .../app/cypress/e2e/historical-trends.cy.ts | 4 +- .../components/inference/InferenceContext.tsx | 6 +- .../components/inference/ui/ChartControls.tsx | 76 +---- .../components/inference/ui/ChartDisplay.tsx | 58 +--- .../inference/ui/GpuComparisonCard.tsx | 254 +++++++++++++++ .../inference/ui/WorkflowInfoDisplay.tsx | 295 ++++++++++-------- .../utils/normalize-comparison-gpus.test.ts | 21 ++ .../utils/normalize-comparison-gpus.ts | 12 + .../trends/HistoricalTrendsDisplay.tsx | 4 +- .../hooks/api/use-comparison-changelogs.ts | 2 +- 11 files changed, 494 insertions(+), 287 deletions(-) create mode 100644 packages/app/src/components/inference/ui/GpuComparisonCard.tsx create mode 100644 packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts create mode 100644 packages/app/src/components/inference/utils/normalize-comparison-gpus.ts diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index 03e6a50c..56c0b598 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -1,4 +1,5 @@ import InferenceChartControls from '@/components/inference/ui/ChartControls'; +import GpuComparisonCard from '@/components/inference/ui/GpuComparisonCard'; import { mountWithProviders } from '../support/test-utils'; describe('Inference ChartControls', () => { @@ -41,39 +42,43 @@ describe('Inference ChartControls', () => { cy.get('@setSelectedYAxisMetric').should('have.been.calledOnce'); }); - it('hides the GPU comparison section when no GPUs are selected', () => { - // Default mock: selectedGPUs = [] — GPU date range pickers should not render + it('does not render GPU comparison date controls (moved to GpuComparisonCard)', () => { cy.contains('Comparison Date Range').should('not.exist'); - cy.contains('Intermediary Dates').should('not.exist'); - }); - - it('renders the GPU config multi-select', () => { - // The GPU Config label should be present (hideGpuComparison defaults to false) - cy.contains('GPU Config').should('be.visible'); - cy.get('[data-testid="gpu-multiselect"]').should('be.visible'); + cy.contains('GPU Comparison').should('not.exist'); }); }); -describe('Inference ChartControls with GPUs selected', () => { - it('shows the date range picker when GPUs are selected', () => { - mountWithProviders(, { +describe('GpuComparisonCard', () => { + it('renders two GPU slot selectors and no date range until two GPUs are selected', () => { + mountWithProviders(, { inference: { - selectedGPUs: ['h100'], - selectedDateRange: { startDate: '', endDate: '' }, + selectedGPUs: [], + availableGPUs: [ + { value: 'h100_sglang', label: 'H100 SGLang' }, + { value: 'b200_sglang', label: 'B200 SGLang' }, + ], }, }); - cy.contains('Comparison Date Range').should('be.visible'); + cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); + cy.contains('GPU Comparison').should('be.visible'); + cy.get('[data-testid="gpu-comparison-select-1"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-select-2"]').should('be.visible'); + cy.contains('Comparison Date Range').should('not.exist'); }); -}); -describe('Inference ChartControls with hideGpuComparison', () => { - it('hides GPU config selector when hideGpuComparison is true', () => { - mountWithProviders(, { - inference: {}, + it('shows Comparison Date Range when two GPUs are selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: [ + { value: 'h100_sglang', label: 'H100 SGLang' }, + { value: 'b200_sglang', label: 'B200 SGLang' }, + ], + }, }); - cy.contains('GPU Config').should('not.exist'); - cy.get('[data-testid="gpu-multiselect"]').should('not.exist'); + cy.contains('Comparison Date Range').should('be.visible'); }); }); diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index f0a70a56..10975000 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -123,8 +123,8 @@ describe('Historical Trends — Content & Interactions', () => { cy.get('#historical-log-scale').should('have.attr', 'data-state', 'unchecked'); }); - it('GPU Config multi-select is hidden (Historical Trends uses hideGpuComparison)', () => { - cy.get('[data-testid="gpu-multiselect"]').should('not.exist'); + it('GPU Comparison card is not shown on Historical Trends (inference-only)', () => { + cy.get('[data-testid="gpu-comparison-card"]').should('not.exist'); }); it('Y-axis metric selector is present and can be changed', () => { diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index a2b8752b..84669640 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -52,6 +52,7 @@ import { clearAllMtpFamilies, resolveMtpToggle } from '@/lib/mtp-exclusion'; import { filterRunsByModel, getDisplayLabel } from '@/lib/utils'; import { useChartData } from './hooks/useChartData'; +import { normalizeComparisonGpuList } from './utils/normalize-comparison-gpus'; /** @internal Exported for test provider wrapping only. */ export const InferenceContext = createContext(undefined); @@ -108,7 +109,8 @@ export function InferenceProvider({ // ── Inference-specific filter state ───────────────────────────────────────── const [selectedGPUs, setSelectedGPUs] = useState(() => { const urlGpus = getUrlParam('i_gpus'); - return urlGpus ? urlGpus.split(',').filter(Boolean) : []; + const parts = urlGpus ? urlGpus.split(',').filter(Boolean) : []; + return normalizeComparisonGpuList(parts); }); const [selectedYAxisMetric, setSelectedYAxisMetric] = useState( () => getUrlParam('i_metric') || 'y_tpPerGpu', @@ -814,7 +816,7 @@ export function InferenceProvider({ setActivePresetId(preset.id); setHighContrast(true); if (config.gpus && config.gpus.length > 0) { - setSelectedGPUs(config.gpus); + setSelectedGPUs(normalizeComparisonGpuList(config.gpus)); if (config.useDateRange) { setSelectedDateRange({ startDate: '', endDate: '' }); setSelectedDates([]); diff --git a/packages/app/src/components/inference/ui/ChartControls.tsx b/packages/app/src/components/inference/ui/ChartControls.tsx index 0b1705b0..ec6db4a0 100644 --- a/packages/app/src/components/inference/ui/ChartControls.tsx +++ b/packages/app/src/components/inference/ui/ChartControls.tsx @@ -10,9 +10,7 @@ import { SequenceSelector, PrecisionSelector, } from '@/components/ui/chart-selectors'; -import { DateRangePicker } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; -import { MultiSelect } from '@/components/ui/multi-select'; import { Select, SelectContent, @@ -73,12 +71,7 @@ const GROUPED_Y_AXIS_OPTIONS = METRIC_GROUPS.map((group) => ({ .map((m) => ({ value: m, label: METRIC_TITLE_MAP.get(m)! })), })).filter((g) => g.options.length > 0); -interface ChartControlsProps { - /** Hide GPU Config selector and related date pickers (used by Historical Trends tab) */ - hideGpuComparison?: boolean; -} - -export default function ChartControls({ hideGpuComparison = false }: ChartControlsProps) { +export default function ChartControls() { const [openDropdown, setOpenDropdown] = useState(null); const handleDropdownOpenChange = (dropdownKey: string) => (open: boolean) => { if (open) { @@ -97,13 +90,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro selectedYAxisMetric, setSelectedYAxisMetric, graphs, - selectedGPUs, - setSelectedGPUs, - availableGPUs, - selectedDateRange, - setSelectedDateRange, - dateRangeAvailableDates, - isCheckingAvailableDates, availablePrecisions, availableSequences, availableModels, @@ -164,14 +150,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro setTimeout(trackCombinedFilters, 0); }; - const handleGPUChange = (value: string[]) => { - setSelectedGPUs(value); - track('inference_gpu_selected', { - gpus: value.join(','), - }); - setTimeout(trackCombinedFilters, 0); - }; - const handleXAxisMetricChange = (value: string) => { setSelectedXAxisMetric(value); track('inference_x_axis_metric_selected', { @@ -194,14 +172,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro return title.toLowerCase().includes('input'); })(); - const handleDateRangeChange = (range: { startDate: string; endDate: string }) => { - setSelectedDateRange(range); - track('inference_date_range_changed', { - startDate: range.startDate, - endDate: range.endDate, - }); - }; - return ( @@ -301,50 +271,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro )} - - {!hideGpuComparison && ( - - - - - - - )} - - {!hideGpuComparison && selectedGPUs.length > 0 && ( - - - 0 && - (!selectedDateRange.startDate || !selectedDateRange.endDate) - ? 'border-red-500 ring-4 ring-red-500/40 animate-pulse' - : '' - } - /> - - )} diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 71323b99..4a67e598 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -40,13 +40,12 @@ import { getPrecisionLabel, getSequenceLabel, } from '@/lib/data-mappings'; -import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; import { useTrendData } from '@/components/inference/hooks/useTrendData'; import ChartControls from './ChartControls'; -import ComparisonChangelog from './ComparisonChangelog'; import CustomCosts from './CustomCosts'; import CustomPowers from './CustomPowers'; +import GpuComparisonCard from './GpuComparisonCard'; import GPUGraph from './GPUGraph'; import TrendChart from './TrendChart'; @@ -134,10 +133,7 @@ export default function ChartDisplay() { selectedE2eXAxisMetric, selectedGPUs, selectedPrecisions, - selectedDates, - setSelectedDates, selectedDateRange, - dateRangeAvailableDates, selectedModel, selectedSequence, selectedRunDate, @@ -151,11 +147,7 @@ export default function ChartDisplay() { setSelectedE2eXAxisMetric, } = useInference(); - const { - changelogs, - loading: changelogsLoading, - totalDatesQueried, - } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); + const comparisonReady = useMemo(() => selectedGPUs.length === 2, [selectedGPUs]); const [viewModes, setViewModes] = useState>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; @@ -325,9 +317,7 @@ export default function ChartDisplay() { 0 + selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady ? 'gpu_timeseries' : graph.chartDefinition.chartType === 'e2e' ? 'latency' @@ -347,9 +337,7 @@ export default function ChartDisplay() { exportFileName={`InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`} onExportCsv={() => { const isTimeline = - selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0; + selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady; const visibleData = graph.data.filter((d) => isTimeline ? activeDates.has(`${d.date}_${d.hwKey}`) @@ -413,7 +401,7 @@ export default function ChartDisplay() { const zoomPrefix = selectedDateRange.startDate && selectedDateRange.endDate && - selectedGPUs.length > 0 + comparisonReady ? 'gpu_timeseries' : 'latency'; return ( @@ -493,7 +481,7 @@ export default function ChartDisplay() { return selectedDateRange.startDate && selectedDateRange.endDate && - selectedGPUs.length > 0 ? ( + comparisonReady ? ( - {selectedGPUs.length > 0 && + {comparisonReady && (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( @@ -560,35 +548,15 @@ export default function ChartDisplay() { - {selectedGPUs.length === 0 && } - {selectedGPUs.length > 0 && ( - { - if (!selectedDates.includes(date)) { - setSelectedDates([...selectedDates, date]); - } - }} - onRemoveDate={(date) => { - setSelectedDates(selectedDates.filter((d) => d !== date)); - }} - onAddAllDates={(dates) => { - const merged = [...new Set([...selectedDates, ...dates])]; - setSelectedDates(merged); - }} - firstAvailableDate={dateRangeAvailableDates[0]} - /> - )} + + + + + {selectedYAxisMetric === 'y_costUser' && ( @@ -605,7 +573,7 @@ export default function ChartDisplay() { 0 && - !(selectedDateRange.startDate && selectedDateRange.endDate && selectedGPUs.length > 0) + !(selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady) } onOpenChange={(open) => { if (!open) { diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx new file mode 100644 index 00000000..384170bb --- /dev/null +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useMemo } from 'react'; + +import { track } from '@/lib/analytics'; + +import { useInference } from '@/components/inference/InferenceContext'; +import ComparisonChangelog from './ComparisonChangelog'; +import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { X } from 'lucide-react'; + +const GPU_OPTIONS_GROUP = 'GPUs'; + +function buildNextGpuSelection( + selectedGPUs: string[], + slotIndex: 0 | 1, + rawValue: string, +): string[] { + const value = rawValue.trim(); + const prev0 = selectedGPUs[0] ?? ''; + const prev1 = selectedGPUs[1] ?? ''; + let next0 = slotIndex === 0 ? value : prev0; + let next1 = slotIndex === 1 ? value : prev1; + if (next0 && next1 && next0 === next1) { + if (slotIndex === 0) next1 = ''; + else next0 = ''; + } + // Keep slot order: never leave GPU 1 empty while GPU 2 is set (compact upward). + if (!next0 && next1) { + next0 = next1; + next1 = ''; + } + const out: string[] = []; + if (next0) out.push(next0); + if (next1) out.push(next1); + return out; +} + +export default function GpuComparisonCard() { + const { + selectedGPUs, + setSelectedGPUs, + availableGPUs, + selectedDateRange, + setSelectedDateRange, + dateRangeAvailableDates, + isCheckingAvailableDates, + selectedModel, + selectedSequence, + selectedPrecisions, + selectedYAxisMetric, + selectedDates, + setSelectedDates, + } = useInference(); + + const { + changelogs, + loading: changelogsLoading, + totalDatesQueried, + } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); + + const gpu0 = selectedGPUs[0] ?? ''; + const gpu1 = selectedGPUs[1] ?? ''; + const comparisonReady = selectedGPUs.length === 2; + + const options0 = useMemo(() => { + const filtered = gpu1 ? availableGPUs.filter((o) => o.value !== gpu1) : availableGPUs; + return [{ label: GPU_OPTIONS_GROUP, options: filtered }]; + }, [availableGPUs, gpu1]); + + const options1 = useMemo(() => { + const filtered = gpu0 ? availableGPUs.filter((o) => o.value !== gpu0) : availableGPUs; + return [{ label: GPU_OPTIONS_GROUP, options: filtered }]; + }, [availableGPUs, gpu0]); + + const trackCombinedFilters = () => { + if (selectedModel && selectedSequence && selectedPrecisions.length > 0 && selectedYAxisMetric) { + track('inference_filters_changed', { + model: selectedModel, + sequence: selectedSequence, + precision: selectedPrecisions.join(','), + yAxisMetric: selectedYAxisMetric, + }); + } + }; + + const handleSlotChange = (slot: 0 | 1, value: string) => { + const next = buildNextGpuSelection(selectedGPUs, slot, value); + setSelectedGPUs(next); + track('inference_gpu_comparison_slot_selected', { + slot: slot + 1, + value: value || '', + gpus: next.join(','), + }); + setTimeout(trackCombinedFilters, 0); + }; + + const handleDateRangeChange = (range: { startDate: string; endDate: string }) => { + setSelectedDateRange(range); + track('inference_date_range_changed', { + startDate: range.startDate, + endDate: range.endDate, + }); + }; + + const clearSlot = (slot: 0 | 1) => { + handleSlotChange(slot, ''); + }; + + return ( + + + + + GPU Comparison + + Compare historical performance for two GPU configurations over a date range. Select + one configuration in each dropdown — both are required before choosing dates or + viewing the comparison chart. + + {!comparisonReady && ( + + Select two different GPU configurations to enable comparison. + + )} + + + + + + + + handleSlotChange(0, v)} + placeholder="Select GPU configuration" + trackPrefix="inference_gpu_comparison_1" + groups={options0} + /> + + {gpu0 ? ( + clearSlot(0)} + > + + + ) : null} + + + + + + + + handleSlotChange(1, v)} + placeholder="Select GPU configuration" + trackPrefix="inference_gpu_comparison_2" + groups={options1} + /> + + {gpu1 ? ( + clearSlot(1)} + > + + + ) : null} + + + + + {comparisonReady && ( + + + + + )} + + {comparisonReady && ( + + { + if (!selectedDates.includes(date)) { + setSelectedDates([...selectedDates, date]); + } + }} + onRemoveDate={(date) => { + setSelectedDates(selectedDates.filter((d) => d !== date)); + }} + onAddAllDates={(dates) => { + const merged = [...new Set([...selectedDates, ...dates])]; + setSelectedDates(merged); + }} + firstAvailableDate={dateRangeAvailableDates[0]} + /> + + )} + + + + ); +} diff --git a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx index 8458d597..a8f998cc 100644 --- a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx +++ b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx @@ -12,7 +12,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { updateRepoUrl } from '@/lib/utils'; +import { cn, updateRepoUrl } from '@/lib/utils'; import { useGlobalFilters } from '@/components/GlobalFilterContext'; import { useInference } from '@/components/inference/InferenceContext'; @@ -50,8 +50,11 @@ function RunConclusionDot({ conclusion }: { conclusion: string | null }) { export default function WorkflowInfoDisplay({ workflowInfo, + controlsDisabled = false, }: { workflowInfo: WorkflowInfo[] | null; + /** When true, run date / run / changelog stay visible but are non-interactive (GPU comparison). */ + controlsDisabled?: boolean; }) { const { selectedRunDate, @@ -90,15 +93,29 @@ export default function WorkflowInfoDisplay({ } }; + const shellClass = cn( + controlsDisabled && 'pointer-events-none cursor-not-allowed select-none opacity-50 saturate-50', + ); + const shellA11y = controlsDisabled + ? { + role: 'group' as const, + 'aria-disabled': true as const, + title: + 'Official run date and run list are fixed while GPU comparison is active. Clear a GPU slot in GPU Comparison to change them.', + } + : {}; + if (!workflowInfo || workflowInfo.length === 0 || !workflowInfo[0]) { return ( - - setSelectedRunDate(date)} - availableDates={availableDates} - isCheckingAvailableDates={isCheckingAvailableDates} - /> + + + setSelectedRunDate(date)} + availableDates={availableDates} + isCheckingAvailableDates={isCheckingAvailableDates} + /> + ); } @@ -120,144 +137,146 @@ export default function WorkflowInfoDisplay({ })(); return ( - - {/* + + + {/* Run Date: {workflowInfo[0].run_date} UTC */} - setSelectedRunDate(date)} - availableDates={availableDates} - isCheckingAvailableDates={isCheckingAvailableDates} - /> - {availableRuns ? ( - - - - - { - track('inference_run_selected', { run: value }); - setSelectedRunId(value); - }} - > - { - const target = e.target as HTMLElement; - if (target.closest('[data-external-link]')) { - e.preventDefault(); - e.stopPropagation(); - const runUrl = availableRuns[selectedRunId]?.runUrl; - if (runUrl) { - window.open(updateRepoUrl(runUrl), '_blank', 'noopener,noreferrer'); - } - } + setSelectedRunDate(date)} + availableDates={availableDates} + isCheckingAvailableDates={isCheckingAvailableDates} + /> + {availableRuns ? ( + + + + + { + track('inference_run_selected', { run: value }); + setSelectedRunId(value); }} > - - - - {Object.keys(availableRuns).map((run, index) => { - const runUrl = updateRepoUrl(availableRuns[run].runUrl); - return ( - { - const target = e.target as HTMLElement; - if (target.closest('[data-external-link]')) { - e.preventDefault(); - e.stopPropagation(); - window.open(runUrl, '_blank', 'noopener,noreferrer'); - } - }} - > - - - Run {index + 1}/{runIds.length} - - + { + const target = e.target as HTMLElement; + if (target.closest('[data-external-link]')) { + e.preventDefault(); + e.stopPropagation(); + const runUrl = availableRuns[selectedRunId]?.runUrl; + if (runUrl) { + window.open(updateRepoUrl(runUrl), '_blank', 'noopener,noreferrer'); + } + } + }} + > + + + + {Object.keys(availableRuns).map((run, index) => { + const runUrl = updateRepoUrl(availableRuns[run].runUrl); + return ( + { + const target = e.target as HTMLElement; + if (target.closest('[data-external-link]')) { + e.preventDefault(); + e.stopPropagation(); + window.open(runUrl, '_blank', 'noopener,noreferrer'); + } + }} + > + + + Run {index + 1}/{runIds.length} + + + - - - ); - })} - - - - - - - ) : null} - - - - - Changelog - + + ); + })} + + + + - - - - {changelog && changelog.entries.length > 0 ? ( - <> - {changelog.entries.map((entry, index) => ( - - {index > 0 && } - - Description - {formatChangelogDescription(entry.description)} - Updated Configs - - {entry.config_keys.map((key: string) => ( - {formatConfigKeys(key)} - ))} - + + ) : null} + + + + + Changelog + + + + + + {changelog && changelog.entries.length > 0 ? ( + <> + {changelog.entries.map((entry, index) => ( + + {index > 0 && } + + Description + {formatChangelogDescription(entry.description)} + Updated Configs + + {entry.config_keys.map((key: string) => ( + {formatConfigKeys(key)} + ))} + + - - ))} - {changelog.entries[0]?.head_ref && ( - - Git Commit - - )} - > - ) : ( - - Description - No changelog data available. - - This date predates changelog tracking. - - - )} - - - + ))} + {changelog.entries[0]?.head_ref && ( + + Git Commit + + )} + > + ) : ( + + Description + No changelog data available. + + This date predates changelog tracking. + + + )} + + + + ); diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts new file mode 100644 index 00000000..688fcf5e --- /dev/null +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeComparisonGpuList } from './normalize-comparison-gpus'; + +describe('normalizeComparisonGpuList', () => { + it('returns empty for empty input', () => { + expect(normalizeComparisonGpuList([])).toEqual([]); + }); + + it('dedupes and preserves order', () => { + expect(normalizeComparisonGpuList(['a', 'a', 'b'])).toEqual(['a', 'b']); + }); + + it('caps at two distinct keys', () => { + expect(normalizeComparisonGpuList(['x', 'y', 'z'])).toEqual(['x', 'y']); + }); + + it('skips empty strings', () => { + expect(normalizeComparisonGpuList(['', 'h100', ''])).toEqual(['h100']); + }); +}); diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts new file mode 100644 index 00000000..018d175d --- /dev/null +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts @@ -0,0 +1,12 @@ +/** At most two distinct hwKeys for GPU comparison (URL, presets, ordered picks). */ +export function normalizeComparisonGpuList(gpus: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const g of gpus) { + if (!g || seen.has(g)) continue; + seen.add(g); + out.push(g); + if (out.length >= 2) break; + } + return out; +} diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index 813a0883..ad2bb6e1 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -167,7 +167,7 @@ export default function HistoricalTrendsDisplay() { Interpolated performance metrics over time at a fixed interactivity operating point. - + @@ -197,7 +197,7 @@ export default function HistoricalTrendsDisplay() { - + {/* Target interactivity slider */} {!loading && hasInteractivityChart && ( diff --git a/packages/app/src/hooks/api/use-comparison-changelogs.ts b/packages/app/src/hooks/api/use-comparison-changelogs.ts index 7d3e2556..3424c3aa 100644 --- a/packages/app/src/hooks/api/use-comparison-changelogs.ts +++ b/packages/app/src/hooks/api/use-comparison-changelogs.ts @@ -19,7 +19,7 @@ export function useComparisonChangelogs( selectedDateRange: { startDate: string; endDate: string }, availableDates: string[], ) { - const hasGPUs = selectedGPUs.length > 0; + const hasGPUs = selectedGPUs.length === 2; const hasDateRange = Boolean(selectedDateRange.startDate) && Boolean(selectedDateRange.endDate); // When GPUs selected: fetch all available dates. When date range also set: limit to range. From 19d61c419addde5906ae73bfb78b0f6e3cec00f4 Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sat, 9 May 2026 14:31:32 -0400 Subject: [PATCH 2/6] feat(inference): add gpu comparison sensible defaults --- .../component/date-range-picker.cy.tsx | 12 +- .../component/inference-chart-controls.cy.tsx | 86 ++++- .../components/inference/InferenceContext.tsx | 15 + .../components/inference/ui/ChartDisplay.tsx | 48 ++- .../inference/ui/ComparisonChangelog.test.ts | 4 +- .../inference/ui/ComparisonChangelog.tsx | 21 +- .../inference/ui/GpuComparisonCard.tsx | 293 ++++++++++-------- .../utils/normalize-comparison-gpus.test.ts | 6 +- .../utils/normalize-comparison-gpus.ts | 7 +- .../ui/date-range-picker.quick-ranges.test.ts | 43 +++ .../src/components/ui/date-range-picker.tsx | 106 ++++--- .../hooks/api/use-comparison-changelogs.ts | 2 +- 12 files changed, 414 insertions(+), 229 deletions(-) create mode 100644 packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts diff --git a/packages/app/cypress/component/date-range-picker.cy.tsx b/packages/app/cypress/component/date-range-picker.cy.tsx index 9fd7c4ed..516fdd62 100644 --- a/packages/app/cypress/component/date-range-picker.cy.tsx +++ b/packages/app/cypress/component/date-range-picker.cy.tsx @@ -5,9 +5,11 @@ import { type DateRange, DateRangePicker } from '@/components/ui/date-range-pick function DateRangePickerHarness({ initialRange = { startDate: '', endDate: '' }, availableDates, + disabled, }: { initialRange?: DateRange; availableDates?: string[]; + disabled?: boolean; }) { const [range, setRange] = useState(initialRange); return ( @@ -17,6 +19,7 @@ function DateRangePickerHarness({ onChange={setRange} availableDates={availableDates} placeholder="Select date range" + disabled={disabled} /> {range.startDate && range.endDate ? `${range.startDate} to ${range.endDate}` : 'no range'} @@ -31,6 +34,13 @@ describe('DateRangePicker', () => { cy.get('[data-testid="date-range-wrapper"]').should('contain', 'Select date range'); }); + it('does not open dialog when disabled', () => { + cy.mount(); + cy.contains('Select date range').should('be.disabled'); + cy.contains('Select date range').click({ force: true }); + cy.get('[role="dialog"]').should('not.exist'); + }); + it('opens dialog when clicked', () => { cy.mount(); cy.contains('Select date range').click(); @@ -64,7 +74,7 @@ describe('DateRangePicker', () => { const dates = ['2025-12-01', '2025-12-15', '2026-01-01', '2026-02-01', '2026-03-01']; cy.mount(); cy.contains('Select date range').click(); - cy.contains('button', 'Max Range').should('be.visible'); + cy.contains('button', 'All Time').should('be.visible'); }); it('shows overlay when no available dates', () => { diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index 56c0b598..2130ce16 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -49,14 +49,18 @@ describe('Inference ChartControls', () => { }); describe('GpuComparisonCard', () => { - it('renders two GPU slot selectors and no date range until two GPUs are selected', () => { + const gpuOptions = [ + { value: 'h100_sglang', label: 'H100 SGLang' }, + { value: 'b200_sglang', label: 'B200 SGLang' }, + { value: 'mi300x_sglang', label: 'MI300X SGLang' }, + { value: 'h200_sglang', label: 'H200 SGLang' }, + ]; + + it('renders two required GPU slots and an Add GPU button; date range disabled', () => { mountWithProviders(, { inference: { selectedGPUs: [], - availableGPUs: [ - { value: 'h100_sglang', label: 'H100 SGLang' }, - { value: 'b200_sglang', label: 'B200 SGLang' }, - ], + availableGPUs: gpuOptions, }, }); @@ -64,21 +68,81 @@ describe('GpuComparisonCard', () => { cy.contains('GPU Comparison').should('be.visible'); cy.get('[data-testid="gpu-comparison-select-1"]').should('be.visible'); cy.get('[data-testid="gpu-comparison-select-2"]').should('be.visible'); - cy.contains('Comparison Date Range').should('not.exist'); + cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); + cy.get('[data-testid="gpu-comparison-select-4"]').should('not.exist'); + cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible'); + cy.contains('Comparison Date Range').should('be.visible'); + cy.get('#gpu-comparison-date-picker').should('be.disabled'); + cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); }); - it('shows Comparison Date Range when two GPUs are selected', () => { + it('shows enabled shortcut buttons that call setSelectedDateRange when two GPUs are selected', () => { mountWithProviders(, { inference: { selectedGPUs: ['h100_sglang', 'b200_sglang'], selectedDateRange: { startDate: '', endDate: '' }, - availableGPUs: [ - { value: 'h100_sglang', label: 'H100 SGLang' }, - { value: 'b200_sglang', label: 'B200 SGLang' }, + availableGPUs: gpuOptions, + dateRangeAvailableDates: [ + '2025-10-05', + '2025-11-01', + '2025-12-01', + '2026-01-01', + '2026-02-01', + '2026-03-01', ], }, }); - cy.contains('Comparison Date Range').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + cy.get('[data-testid="date-shortcut-all-time"]').click(); + cy.get('@setSelectedDateRange').should('have.been.calledOnce'); + }); + + it('shows slot 3 after clicking Add GPU when two GPUs are selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, + }); + + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); + cy.get('[data-testid="gpu-comparison-add-slot"]').click(); + cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-select-4"]').should('not.exist'); + cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible'); + }); + + it('shows all four slots when mounted with three GPUs and Add GPU clicked', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, + }); + + cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-add-slot"]').click(); + cy.get('[data-testid="gpu-comparison-select-4"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-add-slot"]').should('not.exist'); + }); + + it('removes an optional slot when its X button is clicked', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, + }); + + cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-clear-3"]').click(); + cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); + cy.get('@setSelectedGPUs').should('have.been.called'); }); }); diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 84669640..febad53b 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -638,6 +638,21 @@ export function InferenceProvider({ } }, [dateRangeAvailableDates]); + // Auto-default to max date range when GPU comparison becomes ready + useEffect(() => { + if (selectedGPUs.length < 2) return; + if (selectedDateRange.startDate && selectedDateRange.endDate) return; + if (dateRangeAvailableDates.length < 2) return; + const startDate = dateRangeAvailableDates[0]; + const endDate = dateRangeAvailableDates.at(-1)!; + setSelectedDateRange({ startDate, endDate }); + track('inference_date_range_auto_defaulted', { + startDate, + endDate, + gpuCount: selectedGPUs.length, + }); + }, [selectedGPUs, selectedDateRange, dateRangeAvailableDates]); + useEffect(() => { setActiveDates(allDateIds); }, [allDateIds, setActiveDates]); diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 4a67e598..71c2ca1f 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -147,7 +147,7 @@ export default function ChartDisplay() { setSelectedE2eXAxisMetric, } = useInference(); - const comparisonReady = useMemo(() => selectedGPUs.length === 2, [selectedGPUs]); + const comparisonReady = useMemo(() => selectedGPUs.length >= 2, [selectedGPUs]); const [viewModes, setViewModes] = useState>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; @@ -496,34 +496,24 @@ export default function ChartDisplay() { caption={chartCaption} /> ) : ( - - - {comparisonReady && - (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( - - - Select a date range to view GPU comparison - - - )} - + ); })()} diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts b/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts index 2f3c519d..6d43c9b2 100644 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts +++ b/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; /** - * Tests for the "add to chart" logic used in ComparisonChangelog. + * Tests for pin / date-on-chart logic used in ComparisonChangelog. * Verifies date filtering: which dates are on chart, which are addable. */ @@ -42,7 +42,7 @@ const changelogs: MockChangelog[] = [ }, ]; -describe('ComparisonChangelog add-to-chart logic', () => { +describe('ComparisonChangelog pin logic', () => { it('all dates are addable when none are selected', () => { const onChart = computeDatesOnChart([], { startDate: '', endDate: '' }); const addable = computeAddableDates(changelogs, onChart); diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx index 8106fe90..0a09819a 100644 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx +++ b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx @@ -1,7 +1,7 @@ 'use client'; import { ChevronDown, ChevronUp, FileText, Lock, Minus, Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { track } from '@/lib/analytics'; import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; @@ -27,6 +27,8 @@ interface ComparisonChangelogProps { onAddAllDates: (dates: string[]) => void; /** Earliest date the selected GPU config has benchmark data */ firstAvailableDate?: string; + /** When this flips from false to true, expand the panel (e.g. comparison + range just became ready). */ + expandWhenActive?: boolean; } export default function ComparisonChangelog({ @@ -41,8 +43,17 @@ export default function ComparisonChangelog({ onRemoveDate, onAddAllDates, firstAvailableDate, + expandWhenActive = false, }: ComparisonChangelogProps) { const [isExpanded, setIsExpanded] = useState(true); + const wasExpandActiveRef = useRef(false); + + useEffect(() => { + if (expandWhenActive && !wasExpandActiveRef.current) { + setIsExpanded(true); + } + wasExpandActiveRef.current = expandWhenActive; + }, [expandWhenActive]); // Filter changelog entries to only show those matching selected GPUs and precisions. // Always keep range endpoints and first appearance date visible. @@ -135,7 +146,7 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-brand hover:text-brand/80 transition-colors flex items-center gap-1" > - Add all to chart + Pin all dates )} @@ -194,12 +205,12 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-muted-foreground hover:text-destructive transition-colors flex items-center gap-0.5" > - Remove from chart + Unpin ) : ( - On chart + Pinned ) ) : ( @@ -212,7 +223,7 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-brand hover:text-brand/80 transition-colors flex items-center gap-0.5" > - Add to chart + Pin )} diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx index 384170bb..8a7899a7 100644 --- a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -1,44 +1,47 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; import { useInference } from '@/components/inference/InferenceContext'; -import ComparisonChangelog from './ComparisonChangelog'; +import { MAX_COMPARISON_GPUS } from '@/components/inference/utils/normalize-comparison-gpus'; import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; -import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { DateRangePicker, getQuickDateRanges } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { X } from 'lucide-react'; +import { Plus, X } from 'lucide-react'; + +import ComparisonChangelog from './ComparisonChangelog'; const GPU_OPTIONS_GROUP = 'GPUs'; -function buildNextGpuSelection( +const MIN_SLOTS = 2; + +const SLOT_INDICES = Array.from({ length: MAX_COMPARISON_GPUS }, (_, i) => i); + +function buildSelectionAfterSlotChange( selectedGPUs: string[], - slotIndex: 0 | 1, + slotIndex: number, rawValue: string, ): string[] { - const value = rawValue.trim(); - const prev0 = selectedGPUs[0] ?? ''; - const prev1 = selectedGPUs[1] ?? ''; - let next0 = slotIndex === 0 ? value : prev0; - let next1 = slotIndex === 1 ? value : prev1; - if (next0 && next1 && next0 === next1) { - if (slotIndex === 0) next1 = ''; - else next0 = ''; - } - // Keep slot order: never leave GPU 1 empty while GPU 2 is set (compact upward). - if (!next0 && next1) { - next0 = next1; - next1 = ''; + const v = rawValue.trim(); + const slots: string[] = []; + for (let j = 0; j < MAX_COMPARISON_GPUS; j++) { + slots.push(j === slotIndex ? v : (selectedGPUs[j] ?? '')); } const out: string[] = []; - if (next0) out.push(next0); - if (next1) out.push(next1); + const seen = new Set(); + for (const x of slots) { + if (!x) continue; + if (seen.has(x)) continue; + seen.add(x); + out.push(x); + } return out; } @@ -65,19 +68,20 @@ export default function GpuComparisonCard() { totalDatesQueried, } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); - const gpu0 = selectedGPUs[0] ?? ''; - const gpu1 = selectedGPUs[1] ?? ''; - const comparisonReady = selectedGPUs.length === 2; + const comparisonReady = selectedGPUs.length >= 2; - const options0 = useMemo(() => { - const filtered = gpu1 ? availableGPUs.filter((o) => o.value !== gpu1) : availableGPUs; - return [{ label: GPU_OPTIONS_GROUP, options: filtered }]; - }, [availableGPUs, gpu1]); + const [slotCount, setSlotCount] = useState(() => Math.max(MIN_SLOTS, selectedGPUs.length)); - const options1 = useMemo(() => { - const filtered = gpu0 ? availableGPUs.filter((o) => o.value !== gpu0) : availableGPUs; - return [{ label: GPU_OPTIONS_GROUP, options: filtered }]; - }, [availableGPUs, gpu0]); + const optionsBySlot = useMemo( + () => + SLOT_INDICES.map((i) => ({ + label: GPU_OPTIONS_GROUP, + options: availableGPUs.filter( + (o) => !selectedGPUs.some((g, j) => j !== i && g === o.value), + ), + })), + [availableGPUs, selectedGPUs], + ); const trackCombinedFilters = () => { if (selectedModel && selectedSequence && selectedPrecisions.length > 0 && selectedYAxisMetric) { @@ -90,11 +94,11 @@ export default function GpuComparisonCard() { } }; - const handleSlotChange = (slot: 0 | 1, value: string) => { - const next = buildNextGpuSelection(selectedGPUs, slot, value); + const handleSlotChange = (slotIndex: number, value: string) => { + const next = buildSelectionAfterSlotChange(selectedGPUs, slotIndex, value); setSelectedGPUs(next); track('inference_gpu_comparison_slot_selected', { - slot: slot + 1, + slot: slotIndex + 1, value: value || '', gpus: next.join(','), }); @@ -109,118 +113,145 @@ export default function GpuComparisonCard() { }); }; - const clearSlot = (slot: 0 | 1) => { - handleSlotChange(slot, ''); + const clearSlot = (slotIndex: number) => { + if (slotIndex < MIN_SLOTS) { + handleSlotChange(slotIndex, ''); + return; + } + const next = selectedGPUs.filter((_, j) => j !== slotIndex); + setSelectedGPUs(next); + setSlotCount((c) => Math.max(MIN_SLOTS, c - 1)); + track('inference_gpu_comparison_slot_removed', { + slot: slotIndex + 1, + gpus: next.join(','), + }); + }; + + const addSlot = () => { + setSlotCount((c) => Math.min(MAX_COMPARISON_GPUS, c + 1)); + track('inference_gpu_comparison_slot_added', { newSlotCount: slotCount + 1 }); }; + const canAddSlot = slotCount < MAX_COMPARISON_GPUS && slotCount < availableGPUs.length; + + const slotDisabled = (i: number) => i > 0 && selectedGPUs.length < i; + return ( - - GPU Comparison - - Compare historical performance for two GPU configurations over a date range. Select - one configuration in each dropdown — both are required before choosing dates or - viewing the comparison chart. - - {!comparisonReady && ( - - Select two different GPU configurations to enable comparison. - - )} - + GPU Comparison - - - - - handleSlotChange(0, v)} - placeholder="Select GPU configuration" - trackPrefix="inference_gpu_comparison_1" - groups={options0} + {SLOT_INDICES.slice(0, slotCount).map((i) => { + const value = selectedGPUs[i] ?? ''; + const slotGroups = [optionsBySlot[i]!]; + const isOptional = i >= MIN_SLOTS; + return ( + + + + + handleSlotChange(i, v)} + placeholder="Select GPU configuration" + trackPrefix={`inference_gpu_comparison_${i + 1}`} + groups={slotGroups} + disabled={slotDisabled(i)} + /> + + {(value || isOptional) && ( + clearSlot(i)} + > + + + )} + - {gpu0 ? ( - clearSlot(0)} - > - - - ) : null} - - + ); + })} + - - - - - handleSlotChange(1, v)} - placeholder="Select GPU configuration" - trackPrefix="inference_gpu_comparison_2" - groups={options1} - /> - - {gpu1 ? ( + {canAddSlot && ( + + + Add GPU + + )} + + + + + + {getQuickDateRanges(dateRangeAvailableDates).map(({ label, range }) => { + const isActive = + selectedDateRange.startDate === range.startDate && + selectedDateRange.endDate === range.endDate; + return ( clearSlot(1)} + variant={isActive ? 'secondary' : 'outline'} + size="sm" + disabled={!comparisonReady} + data-testid={`date-shortcut-${label.toLowerCase().replaceAll(/\s+/gu, '-')}`} + onClick={() => { + handleDateRangeChange(range); + track('inference_date_range_quick_select', { + label, + startDate: range.startDate, + endDate: range.endDate, + }); + }} > - + {label} - ) : null} - + ); + })} - {comparisonReady && ( - - - - - )} - {comparisonReady && ( { if (!selectedDates.includes(date)) { setSelectedDates([...selectedDates, date]); diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts index 688fcf5e..3b07de02 100644 --- a/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { normalizeComparisonGpuList } from './normalize-comparison-gpus'; +import { MAX_COMPARISON_GPUS, normalizeComparisonGpuList } from './normalize-comparison-gpus'; describe('normalizeComparisonGpuList', () => { it('returns empty for empty input', () => { @@ -11,8 +11,8 @@ describe('normalizeComparisonGpuList', () => { expect(normalizeComparisonGpuList(['a', 'a', 'b'])).toEqual(['a', 'b']); }); - it('caps at two distinct keys', () => { - expect(normalizeComparisonGpuList(['x', 'y', 'z'])).toEqual(['x', 'y']); + it(`caps at ${MAX_COMPARISON_GPUS} distinct keys`, () => { + expect(normalizeComparisonGpuList(['w', 'x', 'y', 'z', 'extra'])).toEqual(['w', 'x', 'y', 'z']); }); it('skips empty strings', () => { diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts index 018d175d..dde270f1 100644 --- a/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts @@ -1,4 +1,7 @@ -/** At most two distinct hwKeys for GPU comparison (URL, presets, ordered picks). */ +/** Maximum distinct hwKeys for GPU comparison (URL, presets, UI slots). */ +export const MAX_COMPARISON_GPUS = 4; + +/** Up to {@link MAX_COMPARISON_GPUS} distinct hwKeys, preserving order. */ export function normalizeComparisonGpuList(gpus: string[]): string[] { const seen = new Set(); const out: string[] = []; @@ -6,7 +9,7 @@ export function normalizeComparisonGpuList(gpus: string[]): string[] { if (!g || seen.has(g)) continue; seen.add(g); out.push(g); - if (out.length >= 2) break; + if (out.length >= MAX_COMPARISON_GPUS) break; } return out; } diff --git a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts new file mode 100644 index 00000000..2d3e0f87 --- /dev/null +++ b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getQuickDateRanges } from '@/components/ui/date-range-picker'; + +describe('getQuickDateRanges', () => { + it('returns empty when fewer than 2 dates', () => { + expect(getQuickDateRanges([])).toEqual([]); + expect(getQuickDateRanges(['2026-01-01'])).toEqual([]); + }); + + it('returns All Time spanning first and last date', () => { + const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; + const ranges = getQuickDateRanges(dates); + expect(ranges[0]).toEqual({ + label: 'All Time', + range: { startDate: '2025-10-01', endDate: '2026-01-15' }, + }); + }); + + it('includes Last 90 Days and Last 30 Days when enough data in window', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-09T12:00:00Z')); + const dates: string[] = []; + for ( + const d = new Date('2026-01-01'); + d <= new Date('2026-05-09'); + d.setDate(d.getDate() + 7) + ) { + dates.push(d.toISOString().slice(0, 10)); + } + const ranges = getQuickDateRanges(dates); + const labels = ranges.map((r) => r.label); + expect(labels).toContain('All Time'); + expect(labels).toContain('Last 90 Days'); + expect(labels).toContain('Last 30 Days'); + for (const { range } of ranges) { + expect(range.startDate <= range.endDate).toBe(true); + expect(dates.includes(range.startDate)).toBe(true); + expect(dates.includes(range.endDate)).toBe(true); + } + vi.useRealTimers(); + }); +}); diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index 6e643bc8..185f0bfa 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -32,6 +32,39 @@ export interface DateRange { endDate: string; } +export interface QuickDateRange { + label: string; + range: DateRange; +} + +/** Compute the standard quick-select date ranges from a sorted list of available dates. */ +export function getQuickDateRanges(availableDates: string[]): QuickDateRange[] { + if (availableDates.length < 2) return []; + const allTime: QuickDateRange = { + label: 'All Time', + range: { startDate: availableDates[0], endDate: availableDates.at(-1)! }, + }; + const rolling = ( + [ + { label: 'Last 90 Days', days: 90 }, + { label: 'Last 30 Days', days: 30 }, + ] as const + ) + .map(({ label, days }): QuickDateRange | null => { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const filtered = availableDates.filter((d) => d >= cutoffStr); + if (filtered.length < 2) return null; + return { + label, + range: { startDate: filtered[0], endDate: filtered.at(-1)! }, + }; + }) + .filter((x): x is QuickDateRange => x !== null); + return [allTime, ...rolling]; +} + export interface DateRangePickerProps { dateRange: DateRange; onChange: (dateRange: DateRange) => void; @@ -41,6 +74,10 @@ export interface DateRangePickerProps { maxDate?: string; availableDates?: string[]; isCheckingAvailableDates?: boolean; + /** When true, the trigger is non-interactive and the dialog cannot open. */ + disabled?: boolean; + /** Sets `id` on the trigger button (e.g. for `Label htmlFor`). */ + triggerId?: string; } /** @@ -56,6 +93,8 @@ export function DateRangePicker({ maxDate, availableDates, isCheckingAvailableDates, + disabled = false, + triggerId, }: DateRangePickerProps) { const [open, setOpen] = useState(false); const [tempRange, setTempRange] = useState(dateRange); @@ -127,6 +166,7 @@ export function DateRangePicker({ // Reset when opening const handleOpenChange = (isOpen: boolean) => { + if (disabled && isOpen) return; track(isOpen ? 'date_range_picker_opened' : 'date_range_picker_closed'); if (isOpen) { setTempRange(dateRange); @@ -135,6 +175,10 @@ export function DateRangePicker({ setOpen(isOpen); }; + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + useEffect(() => { setError(''); }, [open]); @@ -144,10 +188,14 @@ export function DateRangePicker({ @@ -243,52 +291,18 @@ export function DateRangePicker({ {availableDates && availableDates.length >= 2 ? ( - {[ - { - label: 'Max Range', - getRange: () => ({ - startDate: availableDates[0], - endDate: availableDates.at(-1)!, - }), - }, - { - label: 'Last 90 Days', - getRange: () => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().slice(0, 10); - const filtered = availableDates.filter((d) => d >= cutoffStr); - if (filtered.length < 2) return null; - return { startDate: filtered[0], endDate: filtered.at(-1)! }; - }, - }, - { - label: 'Last 30 Days', - getRange: () => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 30); - const cutoffStr = cutoff.toISOString().slice(0, 10); - const filtered = availableDates.filter((d) => d >= cutoffStr); - if (filtered.length < 2) return null; - return { startDate: filtered[0], endDate: filtered.at(-1)! }; - }, - }, - ].map(({ label, getRange }) => { - const range = getRange(); - if (!range) return null; - return ( - { - setTempRange(range); - track('date_range_picker_quick_select', { label }); - }} - > - {label} - - ); - })} + {getQuickDateRanges(availableDates).map(({ label, range }) => ( + { + setTempRange(range); + track('date_range_picker_quick_select', { label }); + }} + > + {label} + + ))} ) : ( diff --git a/packages/app/src/hooks/api/use-comparison-changelogs.ts b/packages/app/src/hooks/api/use-comparison-changelogs.ts index 3424c3aa..f601c7e1 100644 --- a/packages/app/src/hooks/api/use-comparison-changelogs.ts +++ b/packages/app/src/hooks/api/use-comparison-changelogs.ts @@ -19,7 +19,7 @@ export function useComparisonChangelogs( selectedDateRange: { startDate: string; endDate: string }, availableDates: string[], ) { - const hasGPUs = selectedGPUs.length === 2; + const hasGPUs = selectedGPUs.length >= 2; const hasDateRange = Boolean(selectedDateRange.startDate) && Boolean(selectedDateRange.endDate); // When GPUs selected: fetch all available dates. When date range also set: limit to range. From c0e6b47fef076904c67ba5acefe66c1ce3bd5f8a Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sat, 9 May 2026 15:01:18 -0400 Subject: [PATCH 3/6] feat(inference): update comparison card to show date range option buttons --- .../component/inference-chart-controls.cy.tsx | 2 + .../inference/ui/GpuComparisonCard.tsx | 99 ++++++++++--------- .../ui/date-range-picker.quick-ranges.test.ts | 25 ++++- .../src/components/ui/date-range-picker.tsx | 73 +++++++++----- 4 files changed, 129 insertions(+), 70 deletions(-) diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index 2130ce16..d97e1802 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -75,6 +75,8 @@ describe('GpuComparisonCard', () => { cy.get('#gpu-comparison-date-picker').should('be.disabled'); cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); + cy.get('[data-testid="date-shortcut-last-90-days"]').should('be.disabled'); + cy.get('[data-testid="date-shortcut-last-30-days"]').should('be.disabled'); }); it('shows enabled shortcut buttons that call setSelectedDateRange when two GPUs are selected', () => { diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx index 8a7899a7..0db9829b 100644 --- a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'; import { useInference } from '@/components/inference/InferenceContext'; import { MAX_COMPARISON_GPUS } from '@/components/inference/utils/normalize-comparison-gpus'; import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; -import { DateRangePicker, getQuickDateRanges } from '@/components/ui/date-range-picker'; +import { DateRangePicker, getQuickDateRangeShortcuts } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -141,6 +141,11 @@ export default function GpuComparisonCard() { GPU Comparison + {!comparisonReady && ( + + Select at least two for date range comparison. + + )} {SLOT_INDICES.slice(0, slotCount).map((i) => { @@ -203,52 +208,54 @@ export default function GpuComparisonCard() { )} - - - + + + + + - {getQuickDateRanges(dateRangeAvailableDates).map(({ label, range }) => { - const isActive = - selectedDateRange.startDate === range.startDate && - selectedDateRange.endDate === range.endDate; - return ( - { - handleDateRangeChange(range); - track('inference_date_range_quick_select', { - label, - startDate: range.startDate, - endDate: range.endDate, - }); - }} - > - {label} - - ); - })} + {getQuickDateRangeShortcuts(dateRangeAvailableDates).map( + ({ id, label, range, isAvailable }) => { + const canUse = comparisonReady && isAvailable && Boolean(range); + const isActive = + range !== null && + selectedDateRange.startDate === range.startDate && + selectedDateRange.endDate === range.endDate; + return ( + { + if (!range) return; + handleDateRangeChange(range); + track('inference_date_range_quick_select', { + label, + startDate: range.startDate, + endDate: range.endDate, + }); + }} + > + {label} + + ); + }, + )} diff --git a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts index 2d3e0f87..9f3881fe 100644 --- a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts +++ b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts @@ -1,6 +1,29 @@ import { describe, expect, it, vi } from 'vitest'; -import { getQuickDateRanges } from '@/components/ui/date-range-picker'; +import { getQuickDateRangeShortcuts, getQuickDateRanges } from '@/components/ui/date-range-picker'; + +describe('getQuickDateRangeShortcuts', () => { + it('always returns three entries', () => { + expect(getQuickDateRangeShortcuts([])).toHaveLength(3); + expect(getQuickDateRangeShortcuts(['2026-01-01'])).toHaveLength(3); + }); + + it('marks none available when fewer than two dates', () => { + const s = getQuickDateRangeShortcuts(['2026-01-01']); + expect(s.every((x) => !x.isAvailable)).toBe(true); + expect(s.every((x) => x.range === null)).toBe(true); + }); + + it('matches getQuickDateRanges for the available subset', () => { + const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; + const shortcuts = getQuickDateRangeShortcuts(dates); + const fromHelper = getQuickDateRanges(dates); + const fromShortcuts = shortcuts + .filter((x) => x.isAvailable && x.range) + .map((x) => ({ label: x.label, range: x.range! })); + expect(fromShortcuts).toEqual(fromHelper); + }); +}); describe('getQuickDateRanges', () => { it('returns empty when fewer than 2 dates', () => { diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index 185f0bfa..ae437011 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -37,32 +37,59 @@ export interface QuickDateRange { range: DateRange; } -/** Compute the standard quick-select date ranges from a sorted list of available dates. */ -export function getQuickDateRanges(availableDates: string[]): QuickDateRange[] { - if (availableDates.length < 2) return []; - const allTime: QuickDateRange = { - label: 'All Time', - range: { startDate: availableDates[0], endDate: availableDates.at(-1)! }, - }; +export interface QuickDateRangeShortcut { + id: 'all-time' | 'last-90-days' | 'last-30-days'; + label: string; + range: DateRange | null; + /** True when this shortcut has a valid range (at least two dates in scope). */ + isAvailable: boolean; +} + +/** All three comparison shortcuts, with availability driven by `availableDates` (e.g. intersection for selected GPUs). */ +export function getQuickDateRangeShortcuts(availableDates: string[]): QuickDateRangeShortcut[] { + const allTimeAvailable = availableDates.length >= 2; + const allTimeRange: DateRange | null = allTimeAvailable + ? { startDate: availableDates[0], endDate: availableDates.at(-1)! } + : null; + const rolling = ( [ - { label: 'Last 90 Days', days: 90 }, - { label: 'Last 30 Days', days: 30 }, + { id: 'last-90-days' as const, label: 'Last 90 Days', days: 90 }, + { id: 'last-30-days' as const, label: 'Last 30 Days', days: 30 }, ] as const - ) - .map(({ label, days }): QuickDateRange | null => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - days); - const cutoffStr = cutoff.toISOString().slice(0, 10); - const filtered = availableDates.filter((d) => d >= cutoffStr); - if (filtered.length < 2) return null; - return { - label, - range: { startDate: filtered[0], endDate: filtered.at(-1)! }, - }; - }) - .filter((x): x is QuickDateRange => x !== null); - return [allTime, ...rolling]; + ).map(({ id, label, days }) => { + if (availableDates.length < 2) { + return { id, label, range: null, isAvailable: false }; + } + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const filtered = availableDates.filter((d) => d >= cutoffStr); + const ok = filtered.length >= 2; + return { + id, + label, + range: ok ? { startDate: filtered[0], endDate: filtered.at(-1)! } : null, + isAvailable: ok, + }; + }); + + return [ + { + id: 'all-time', + label: 'All Time', + range: allTimeRange, + isAvailable: allTimeAvailable, + }, + ...rolling, + ]; +} + +/** Quick-select entries that are usable in the date picker dialog (omit unavailable). */ +export function getQuickDateRanges(availableDates: string[]): QuickDateRange[] { + return getQuickDateRangeShortcuts(availableDates) + .filter((s) => s.isAvailable && s.range) + .map((s) => ({ label: s.label, range: s.range! })); } export interface DateRangePickerProps { From b1bf907bb99987bca7958d6da72ac4cf0ea098ed Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sat, 9 May 2026 17:12:39 -0400 Subject: [PATCH 4/6] test(inference): add gpu comparison tests --- .../component/inference-chart-controls.cy.tsx | 8 +- .../app/cypress/e2e/inference-chart.cy.ts | 38 ++++++++ .../components/inference/InferenceContext.tsx | 8 +- .../inference/ui/ComparisonChangelog.test.ts | 92 ------------------- .../inference/ui/ComparisonChangelog.tsx | 24 ++--- .../inference/ui/GpuComparisonCard.tsx | 65 ++++++++----- .../inference/ui/WorkflowInfoDisplay.tsx | 4 +- .../utils/comparison-changelog-dates.test.ts | 81 ++++++++++++++++ .../utils/comparison-changelog-dates.ts | 28 ++++++ .../ui/date-range-picker.quick-ranges.test.ts | 15 ++- .../src/components/ui/date-range-picker.tsx | 11 ++- 11 files changed, 228 insertions(+), 146 deletions(-) delete mode 100644 packages/app/src/components/inference/ui/ComparisonChangelog.test.ts create mode 100644 packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts create mode 100644 packages/app/src/components/inference/utils/comparison-changelog-dates.ts diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index d97e1802..54cdd2b9 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -133,7 +133,7 @@ describe('GpuComparisonCard', () => { cy.get('[data-testid="gpu-comparison-add-slot"]').should('not.exist'); }); - it('removes an optional slot when its X button is clicked', () => { + it('calls setSelectedGPUs without the removed GPU when its X button is clicked', () => { mountWithProviders(, { inference: { selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], @@ -144,7 +144,9 @@ describe('GpuComparisonCard', () => { cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); cy.get('[data-testid="gpu-comparison-clear-3"]').click(); - cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); - cy.get('@setSelectedGPUs').should('have.been.called'); + cy.get('@setSelectedGPUs').should( + 'have.been.calledWith', + Cypress.sinon.match((v: string[]) => v.length === 2 && !v.includes('mi300x_sglang')), + ); }); }); diff --git a/packages/app/cypress/e2e/inference-chart.cy.ts b/packages/app/cypress/e2e/inference-chart.cy.ts index 93350d69..0a119b7b 100644 --- a/packages/app/cypress/e2e/inference-chart.cy.ts +++ b/packages/app/cypress/e2e/inference-chart.cy.ts @@ -50,3 +50,41 @@ describe('Inference Chart', () => { cy.get('.sidebar-legend').should('be.visible'); }); }); + +describe('GPU Comparison Card', () => { + beforeEach(() => { + cy.visit('/inference', { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }, + }); + cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); + }); + + it('renders the GPU comparison card with two slot selectors', () => { + cy.get('[data-testid="gpu-comparison-select-1"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-select-2"]').should('be.visible'); + cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); + }); + + it('shows date range shortcuts that are disabled until two GPUs are selected', () => { + cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); + }); + + it('selects two GPUs and verifies date range auto-defaults', () => { + cy.get('[data-testid="gpu-comparison-select-1"]').click(); + cy.get('[role="option"]').first().click(); + + cy.get('[data-testid="gpu-comparison-select-2"]').click(); + cy.get('[role="option"]').first().click(); + + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + }); + + it('Add GPU button reveals a third slot', () => { + cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible').click(); + cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); + }); +}); diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index febad53b..191bd8ef 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -638,8 +638,14 @@ export function InferenceProvider({ } }, [dateRangeAvailableDates]); - // Auto-default to max date range when GPU comparison becomes ready + // Auto-default to max date range once when GPU comparison first becomes ready. + // Uses a ref to fire only on the transition from <2 GPUs to >=2, avoiding a loop + // when the user intentionally clears the date range. + const prevGpuCountRef = useRef(selectedGPUs.length); useEffect(() => { + const wasBelow = prevGpuCountRef.current < 2; + prevGpuCountRef.current = selectedGPUs.length; + if (!wasBelow) return; if (selectedGPUs.length < 2) return; if (selectedDateRange.startDate && selectedDateRange.endDate) return; if (dateRangeAvailableDates.length < 2) return; diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts b/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts deleted file mode 100644 index 6d43c9b2..00000000 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -/** - * Tests for pin / date-on-chart logic used in ComparisonChangelog. - * Verifies date filtering: which dates are on chart, which are addable. - */ - -interface MockChangelog { - date: string; - entries: { config_keys: string[]; description: string; pr_link: string | null }[]; -} - -function computeDatesOnChart( - selectedDates: string[], - selectedDateRange: { startDate: string; endDate: string }, -): Set { - const set = new Set(selectedDates); - if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); - if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); - return set; -} - -function computeAddableDates( - filteredChangelogs: MockChangelog[], - datesOnChart: Set, -): string[] { - return filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)); -} - -const changelogs: MockChangelog[] = [ - { - date: '2026-01-15', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Update', pr_link: null }], - }, - { - date: '2026-01-20', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Bump', pr_link: null }], - }, - { - date: '2026-01-25', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Tweak', pr_link: null }], - }, -]; - -describe('ComparisonChangelog pin logic', () => { - it('all dates are addable when none are selected', () => { - const onChart = computeDatesOnChart([], { startDate: '', endDate: '' }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-15', '2026-01-20', '2026-01-25']); - }); - - it('dates in selectedDates are marked as on chart', () => { - const onChart = computeDatesOnChart(['2026-01-15', '2026-01-20'], { - startDate: '', - endDate: '', - }); - expect(onChart.has('2026-01-15')).toBe(true); - expect(onChart.has('2026-01-20')).toBe(true); - expect(onChart.has('2026-01-25')).toBe(false); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-25']); - }); - - it('range endpoints are marked as on chart', () => { - const onChart = computeDatesOnChart([], { - startDate: '2026-01-15', - endDate: '2026-01-25', - }); - expect(onChart.has('2026-01-15')).toBe(true); - expect(onChart.has('2026-01-25')).toBe(true); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-20']); - }); - - it('addable excludes both selectedDates and range endpoints', () => { - const onChart = computeDatesOnChart(['2026-01-20'], { - startDate: '2026-01-15', - endDate: '2026-01-25', - }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual([]); - }); - - it('returns empty addable when all dates are already on chart', () => { - const onChart = computeDatesOnChart(['2026-01-15', '2026-01-20', '2026-01-25'], { - startDate: '', - endDate: '', - }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual([]); - }); -}); diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx index 0a09819a..01db1af0 100644 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx +++ b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx @@ -7,6 +7,10 @@ import { track } from '@/lib/analytics'; import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; import type { ComparisonChangelog as ComparisonChangelogType } from '@/hooks/api/use-comparison-changelogs'; +import { + buildDatesOnComparisonChart, + getAddableChangelogDates, +} from '@/components/inference/utils/comparison-changelog-dates'; import { configKeyMatchesHwKey, formatChangelogDescription, @@ -45,14 +49,14 @@ export default function ComparisonChangelog({ firstAvailableDate, expandWhenActive = false, }: ComparisonChangelogProps) { - const [isExpanded, setIsExpanded] = useState(true); - const wasExpandActiveRef = useRef(false); + const [isExpanded, setIsExpanded] = useState(expandWhenActive); + const prevExpandActiveRef = useRef(expandWhenActive); useEffect(() => { - if (expandWhenActive && !wasExpandActiveRef.current) { + if (expandWhenActive && !prevExpandActiveRef.current) { setIsExpanded(true); } - wasExpandActiveRef.current = expandWhenActive; + prevExpandActiveRef.current = expandWhenActive; }, [expandWhenActive]); // Filter changelog entries to only show those matching selected GPUs and precisions. @@ -92,15 +96,13 @@ export default function ComparisonChangelog({ .toSorted((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); }, [changelogs, selectedGPUs, selectedPrecisions, pinnedDates]); - const datesOnChart = useMemo(() => { - const set = new Set(selectedDates); - if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); - if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); - return set; - }, [selectedDates, selectedDateRange]); + const datesOnChart = useMemo( + () => buildDatesOnComparisonChart(selectedDates, selectedDateRange), + [selectedDates, selectedDateRange], + ); const addableDates = useMemo( - () => filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)), + () => getAddableChangelogDates(filteredChangelogs, datesOnChart), [filteredChangelogs, datesOnChart], ); diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx index 0db9829b..af5ffefe 100644 --- a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { track } from '@/lib/analytics'; import { cn } from '@/lib/utils'; @@ -24,14 +24,19 @@ const MIN_SLOTS = 2; const SLOT_INDICES = Array.from({ length: MAX_COMPARISON_GPUS }, (_, i) => i); -function buildSelectionAfterSlotChange( +/** + * Build the new dense GPU list after a slot value changes. + * Keeps selections in visual slot order, dedupes, and strips empties. + */ +export function buildSelectionAfterSlotChange( selectedGPUs: string[], + slotCount: number, slotIndex: number, rawValue: string, ): string[] { const v = rawValue.trim(); const slots: string[] = []; - for (let j = 0; j < MAX_COMPARISON_GPUS; j++) { + for (let j = 0; j < slotCount; j++) { slots.push(j === slotIndex ? v : (selectedGPUs[j] ?? '')); } const out: string[] = []; @@ -54,10 +59,7 @@ export default function GpuComparisonCard() { setSelectedDateRange, dateRangeAvailableDates, isCheckingAvailableDates, - selectedModel, - selectedSequence, selectedPrecisions, - selectedYAxisMetric, selectedDates, setSelectedDates, } = useInference(); @@ -72,6 +74,15 @@ export default function GpuComparisonCard() { const [slotCount, setSlotCount] = useState(() => Math.max(MIN_SLOTS, selectedGPUs.length)); + // Keep slotCount in sync when GPUs change externally (presets, URL restore) + useEffect(() => { + if (selectedGPUs.length > slotCount) { + setSlotCount(selectedGPUs.length); + } + }, [selectedGPUs.length, slotCount]); + + const newSlotRef = useRef(null); + const optionsBySlot = useMemo( () => SLOT_INDICES.map((i) => ({ @@ -83,26 +94,24 @@ export default function GpuComparisonCard() { [availableGPUs, selectedGPUs], ); - const trackCombinedFilters = () => { - if (selectedModel && selectedSequence && selectedPrecisions.length > 0 && selectedYAxisMetric) { - track('inference_filters_changed', { - model: selectedModel, - sequence: selectedSequence, - precision: selectedPrecisions.join(','), - yAxisMetric: selectedYAxisMetric, - }); + // Auto-focus newly added slot + useEffect(() => { + if (newSlotRef.current !== null) { + const id = `gpu-comparison-slot-${newSlotRef.current + 1}`; + const el = document.querySelector(`#${id}`); + if (el) el.focus(); + newSlotRef.current = null; } - }; + }); const handleSlotChange = (slotIndex: number, value: string) => { - const next = buildSelectionAfterSlotChange(selectedGPUs, slotIndex, value); + const next = buildSelectionAfterSlotChange(selectedGPUs, slotCount, slotIndex, value); setSelectedGPUs(next); track('inference_gpu_comparison_slot_selected', { slot: slotIndex + 1, value: value || '', gpus: next.join(','), }); - setTimeout(trackCombinedFilters, 0); }; const handleDateRangeChange = (range: { startDate: string; endDate: string }) => { @@ -128,24 +137,30 @@ export default function GpuComparisonCard() { }; const addSlot = () => { - setSlotCount((c) => Math.min(MAX_COMPARISON_GPUS, c + 1)); - track('inference_gpu_comparison_slot_added', { newSlotCount: slotCount + 1 }); + const nextCount = Math.min(MAX_COMPARISON_GPUS, slotCount + 1); + setSlotCount(nextCount); + newSlotRef.current = nextCount - 1; + track('inference_gpu_comparison_slot_added', { newSlotCount: nextCount }); }; const canAddSlot = slotCount < MAX_COMPARISON_GPUS && slotCount < availableGPUs.length; - const slotDisabled = (i: number) => i > 0 && selectedGPUs.length < i; + // Slot 0 is always enabled. Later slots are disabled only if they're empty + // and there's still a gap in an earlier slot (forces top-down filling). + const slotDisabled = (i: number): boolean => { + if (i === 0) return false; + if (selectedGPUs[i]) return false; + return !selectedGPUs[i - 1]; + }; return ( GPU Comparison - {!comparisonReady && ( - - Select at least two for date range comparison. - - )} + + Select at least two for date range comparison. + {SLOT_INDICES.slice(0, slotCount).map((i) => { diff --git a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx index a8f998cc..2c6c297e 100644 --- a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx +++ b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx @@ -94,11 +94,11 @@ export default function WorkflowInfoDisplay({ }; const shellClass = cn( - controlsDisabled && 'pointer-events-none cursor-not-allowed select-none opacity-50 saturate-50', + controlsDisabled && 'cursor-not-allowed select-none opacity-50 saturate-50', ); const shellA11y = controlsDisabled ? { - role: 'group' as const, + inert: true as unknown as boolean, 'aria-disabled': true as const, title: 'Official run date and run list are fixed while GPU comparison is active. Clear a GPU slot in GPU Comparison to change them.', diff --git a/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts b/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts new file mode 100644 index 00000000..28e7fc02 --- /dev/null +++ b/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDatesOnComparisonChart, + getAddableChangelogDates, +} from '@/components/inference/utils/comparison-changelog-dates'; + +const changelogDates = [{ date: '2026-01-15' }, { date: '2026-01-20' }, { date: '2026-01-25' }]; + +describe('buildDatesOnComparisonChart', () => { + it('includes only selected dates when range is empty', () => { + const set = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20'], { + startDate: '', + endDate: '', + }); + expect(set.has('2026-01-15')).toBe(true); + expect(set.has('2026-01-20')).toBe(true); + expect(set.has('2026-01-25')).toBe(false); + }); + + it('adds non-empty range endpoints', () => { + const set = buildDatesOnComparisonChart([], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(set.has('2026-01-15')).toBe(true); + expect(set.has('2026-01-25')).toBe(true); + expect(set.has('2026-01-20')).toBe(false); + }); + + it('merges pins and range endpoints', () => { + const set = buildDatesOnComparisonChart(['2026-01-20'], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect([...set].toSorted()).toEqual(['2026-01-15', '2026-01-20', '2026-01-25']); + }); +}); + +describe('getAddableChangelogDates', () => { + it('returns all changelog dates when chart has none', () => { + const onChart = buildDatesOnComparisonChart([], { startDate: '', endDate: '' }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([ + '2026-01-15', + '2026-01-20', + '2026-01-25', + ]); + }); + + it('excludes dates already on chart from pins', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20'], { + startDate: '', + endDate: '', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual(['2026-01-25']); + }); + + it('excludes range endpoints from addable', () => { + const onChart = buildDatesOnComparisonChart([], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual(['2026-01-20']); + }); + + it('returns empty when every changelog date is on chart', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-20'], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([]); + }); + + it('returns empty when all dates are pinned', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20', '2026-01-25'], { + startDate: '', + endDate: '', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([]); + }); +}); diff --git a/packages/app/src/components/inference/utils/comparison-changelog-dates.ts b/packages/app/src/components/inference/utils/comparison-changelog-dates.ts new file mode 100644 index 00000000..1a8f3fbd --- /dev/null +++ b/packages/app/src/components/inference/utils/comparison-changelog-dates.ts @@ -0,0 +1,28 @@ +export interface ComparisonDateRange { + startDate: string; + endDate: string; +} + +/** + * Dates currently represented on the comparison chart: explicitly pinned dates + * plus comparison range endpoints (when set). + */ +export function buildDatesOnComparisonChart( + selectedDates: string[], + selectedDateRange: ComparisonDateRange, +): Set { + const set = new Set(selectedDates); + if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); + if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); + return set; +} + +/** + * Changelog dates that are not already shown on the chart (candidates for "Pin" / "Pin all"). + */ +export function getAddableChangelogDates( + filteredChangelogs: T[], + datesOnChart: Set, +): string[] { + return filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)); +} diff --git a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts index 9f3881fe..42df8202 100644 --- a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts +++ b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getQuickDateRangeShortcuts, getQuickDateRanges } from '@/components/ui/date-range-picker'; +const FIXED_NOW = new Date('2026-05-09T12:00:00Z'); + describe('getQuickDateRangeShortcuts', () => { it('always returns three entries', () => { expect(getQuickDateRangeShortcuts([])).toHaveLength(3); @@ -16,8 +18,8 @@ describe('getQuickDateRangeShortcuts', () => { it('matches getQuickDateRanges for the available subset', () => { const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; - const shortcuts = getQuickDateRangeShortcuts(dates); - const fromHelper = getQuickDateRanges(dates); + const shortcuts = getQuickDateRangeShortcuts(dates, FIXED_NOW); + const fromHelper = getQuickDateRanges(dates, FIXED_NOW); const fromShortcuts = shortcuts .filter((x) => x.isAvailable && x.range) .map((x) => ({ label: x.label, range: x.range! })); @@ -33,7 +35,7 @@ describe('getQuickDateRanges', () => { it('returns All Time spanning first and last date', () => { const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; - const ranges = getQuickDateRanges(dates); + const ranges = getQuickDateRanges(dates, FIXED_NOW); expect(ranges[0]).toEqual({ label: 'All Time', range: { startDate: '2025-10-01', endDate: '2026-01-15' }, @@ -41,8 +43,6 @@ describe('getQuickDateRanges', () => { }); it('includes Last 90 Days and Last 30 Days when enough data in window', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-05-09T12:00:00Z')); const dates: string[] = []; for ( const d = new Date('2026-01-01'); @@ -51,7 +51,7 @@ describe('getQuickDateRanges', () => { ) { dates.push(d.toISOString().slice(0, 10)); } - const ranges = getQuickDateRanges(dates); + const ranges = getQuickDateRanges(dates, FIXED_NOW); const labels = ranges.map((r) => r.label); expect(labels).toContain('All Time'); expect(labels).toContain('Last 90 Days'); @@ -61,6 +61,5 @@ describe('getQuickDateRanges', () => { expect(dates.includes(range.startDate)).toBe(true); expect(dates.includes(range.endDate)).toBe(true); } - vi.useRealTimers(); }); }); diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index ae437011..b1d8d1af 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -46,7 +46,10 @@ export interface QuickDateRangeShortcut { } /** All three comparison shortcuts, with availability driven by `availableDates` (e.g. intersection for selected GPUs). */ -export function getQuickDateRangeShortcuts(availableDates: string[]): QuickDateRangeShortcut[] { +export function getQuickDateRangeShortcuts( + availableDates: string[], + now?: Date, +): QuickDateRangeShortcut[] { const allTimeAvailable = availableDates.length >= 2; const allTimeRange: DateRange | null = allTimeAvailable ? { startDate: availableDates[0], endDate: availableDates.at(-1)! } @@ -61,7 +64,7 @@ export function getQuickDateRangeShortcuts(availableDates: string[]): QuickDateR if (availableDates.length < 2) { return { id, label, range: null, isAvailable: false }; } - const cutoff = new Date(); + const cutoff = new Date(now ?? Date.now()); cutoff.setDate(cutoff.getDate() - days); const cutoffStr = cutoff.toISOString().slice(0, 10); const filtered = availableDates.filter((d) => d >= cutoffStr); @@ -86,8 +89,8 @@ export function getQuickDateRangeShortcuts(availableDates: string[]): QuickDateR } /** Quick-select entries that are usable in the date picker dialog (omit unavailable). */ -export function getQuickDateRanges(availableDates: string[]): QuickDateRange[] { - return getQuickDateRangeShortcuts(availableDates) +export function getQuickDateRanges(availableDates: string[], now?: Date): QuickDateRange[] { + return getQuickDateRangeShortcuts(availableDates, now) .filter((s) => s.isAvailable && s.range) .map((s) => ({ label: s.label, range: s.range! })); } From 00d29d6c5ed07379da2ea059f714681b67b1948e Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sat, 9 May 2026 17:49:44 -0400 Subject: [PATCH 5/6] test(gpu-comparison): update to compare different configs --- packages/app/cypress/e2e/inference-chart.cy.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/cypress/e2e/inference-chart.cy.ts b/packages/app/cypress/e2e/inference-chart.cy.ts index 0a119b7b..eaab5b97 100644 --- a/packages/app/cypress/e2e/inference-chart.cy.ts +++ b/packages/app/cypress/e2e/inference-chart.cy.ts @@ -76,8 +76,12 @@ describe('GPU Comparison Card', () => { cy.get('[data-testid="gpu-comparison-select-1"]').click(); cy.get('[role="option"]').first().click(); + // Pick the second available option (eq(1)) so the two GPUs come from + // different hardware families with distinct dates — picking .first() + // can land on the same-base MTP variant that shares a single date, + // leaving dateRangeAvailableDates < 2 and "All Time" still disabled. cy.get('[data-testid="gpu-comparison-select-2"]').click(); - cy.get('[role="option"]').first().click(); + cy.get('[role="option"]').eq(1).click(); cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); From 5681d5877cda65e078c2b2ddd2c4399fd749dd40 Mon Sep 17 00:00:00 2001 From: Rafay K <82721722+rafaykhan-source@users.noreply.github.com> Date: Sun, 10 May 2026 15:02:09 -0400 Subject: [PATCH 6/6] feat(inference): bring back multiselect combo box, fix spacing --- .../component/inference-chart-controls.cy.tsx | 63 ++- .../app/cypress/e2e/inference-chart.cy.ts | 90 ++-- .../app/src/components/dashboard-shell.tsx | 2 +- .../components/inference/InferenceContext.tsx | 6 +- .../components/inference/ui/ChartDisplay.tsx | 12 +- .../inference/ui/GpuComparisonCard.tsx | 393 +++++++----------- packages/app/src/components/tab-nav.tsx | 4 +- .../app/src/components/ui/multi-select.tsx | 7 +- .../hooks/api/use-comparison-changelogs.ts | 2 +- 9 files changed, 275 insertions(+), 304 deletions(-) diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index 54cdd2b9..197ee814 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -56,7 +56,7 @@ describe('GpuComparisonCard', () => { { value: 'h200_sglang', label: 'H200 SGLang' }, ]; - it('renders two required GPU slots and an Add GPU button; date range disabled', () => { + it('renders GPU multiselect; date range disabled until a GPU is selected', () => { mountWithProviders(, { inference: { selectedGPUs: [], @@ -66,11 +66,16 @@ describe('GpuComparisonCard', () => { cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); cy.contains('GPU Comparison').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-1"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-2"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); - cy.get('[data-testid="gpu-comparison-select-4"]').should('not.exist'); - cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible'); + cy.contains('Select one or more GPUs for date range comparison.').should('not.exist'); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'false', + ); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + cy.contains('Select one or more GPUs for date range comparison.').should('be.visible'); + cy.get('[data-testid="gpu-multiselect"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); cy.contains('Comparison Date Range').should('be.visible'); cy.get('#gpu-comparison-date-picker').should('be.disabled'); cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); @@ -79,6 +84,20 @@ describe('GpuComparisonCard', () => { cy.get('[data-testid="date-shortcut-last-30-days"]').should('be.disabled'); }); + it('enables date range picker and shortcuts when one GPU is selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + dateRangeAvailableDates: ['2025-10-05', '2025-11-01', '2025-12-01'], + }, + }); + + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + }); + it('shows enabled shortcut buttons that call setSelectedDateRange when two GPUs are selected', () => { mountWithProviders(, { inference: { @@ -101,7 +120,7 @@ describe('GpuComparisonCard', () => { cy.get('@setSelectedDateRange').should('have.been.calledOnce'); }); - it('shows slot 3 after clicking Add GPU when two GPUs are selected', () => { + it('selecting a third GPU from the multiselect calls setSelectedGPUs with three GPUs', () => { mountWithProviders(, { inference: { selectedGPUs: ['h100_sglang', 'b200_sglang'], @@ -111,29 +130,32 @@ describe('GpuComparisonCard', () => { }); cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); - cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); - cy.get('[data-testid="gpu-comparison-add-slot"]').click(); - cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-4"]').should('not.exist'); - cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').click(); + cy.contains('[role="option"]', 'MI300X SGLang').click(); + cy.get('@setSelectedGPUs').should( + 'have.been.calledWith', + Cypress.sinon.match((v: string[]) => v.length === 3 && v.includes('mi300x_sglang')), + ); }); - it('shows all four slots when mounted with three GPUs and Add GPU clicked', () => { + it('shows max-selection summary when four GPUs are selected', () => { mountWithProviders(, { inference: { - selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], + selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang', 'h200_sglang'], selectedDateRange: { startDate: '', endDate: '' }, availableGPUs: gpuOptions, }, }); - cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-add-slot"]').click(); - cy.get('[data-testid="gpu-comparison-select-4"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-add-slot"]').should('not.exist'); + // Wait for card expansion, then open the dropdown. + // With 4 chips the center of the trigger may land on a chip's remove + // button (which stopPropagates), so target the chevron icon instead. + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"] svg').last().click(); + cy.contains('4 / 4 selected').should('be.visible'); }); - it('calls setSelectedGPUs without the removed GPU when its X button is clicked', () => { + it('calls setSelectedGPUs without the removed GPU when a chip remove control is clicked', () => { mountWithProviders(, { inference: { selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], @@ -142,8 +164,7 @@ describe('GpuComparisonCard', () => { }, }); - cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-clear-3"]').click(); + cy.get('[aria-label="Remove MI300X SGLang"]').click(); cy.get('@setSelectedGPUs').should( 'have.been.calledWith', Cypress.sinon.match((v: string[]) => v.length === 2 && !v.includes('mi300x_sglang')), diff --git a/packages/app/cypress/e2e/inference-chart.cy.ts b/packages/app/cypress/e2e/inference-chart.cy.ts index eaab5b97..eeb4362b 100644 --- a/packages/app/cypress/e2e/inference-chart.cy.ts +++ b/packages/app/cypress/e2e/inference-chart.cy.ts @@ -1,3 +1,8 @@ +/** Opens dropdown without hitting chip remove controls (they stopPropagation). */ +function openGpuMultiselect() { + cy.get('[data-testid="gpu-multiselect-trigger"]').find('svg').last().click(); +} + describe('Inference Chart', () => { before(() => { cy.window().then((win) => { @@ -47,7 +52,9 @@ describe('Inference Chart', () => { }); it('shows the sidebar legend for GPU types', () => { - cy.get('.sidebar-legend').should('be.visible'); + // GpuComparisonCard sits above charts; first chart legend may be below the fold. + cy.get('.sidebar-legend').first().scrollIntoView(); + cy.get('.sidebar-legend').first().should('be.visible'); }); }); @@ -61,34 +68,69 @@ describe('GPU Comparison Card', () => { cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); }); - it('renders the GPU comparison card with two slot selectors', () => { - cy.get('[data-testid="gpu-comparison-select-1"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-2"]').should('be.visible'); - cy.get('[data-testid="gpu-comparison-select-3"]').should('not.exist'); + it('starts collapsed; expanding shows GPU comparison controls', () => { + cy.contains('Select one or more GPUs for date range comparison.').should('not.exist'); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'false', + ); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'true', + ); + cy.contains('Select one or more GPUs for date range comparison.').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); }); - it('shows date range shortcuts that are disabled until two GPUs are selected', () => { - cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); - cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); - }); + describe('when expanded', () => { + beforeEach(() => { + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + }); - it('selects two GPUs and verifies date range auto-defaults', () => { - cy.get('[data-testid="gpu-comparison-select-1"]').click(); - cy.get('[role="option"]').first().click(); + it('renders the GPU comparison card with a single GPU multiselect', () => { + cy.get('[data-testid="gpu-multiselect"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + }); - // Pick the second available option (eq(1)) so the two GPUs come from - // different hardware families with distinct dates — picking .first() - // can land on the same-base MTP variant that shares a single date, - // leaving dateRangeAvailableDates < 2 and "All Time" still disabled. - cy.get('[data-testid="gpu-comparison-select-2"]').click(); - cy.get('[role="option"]').eq(1).click(); + it('shows date range shortcuts that are disabled until a GPU is selected', () => { + cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); + }); - cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); - cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); - }); + it('selects one GPU and verifies date range controls unlock', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); - it('Add GPU button reveals a third slot', () => { - cy.get('[data-testid="gpu-comparison-add-slot"]').should('be.visible').click(); - cy.get('[data-testid="gpu-comparison-select-3"]').should('be.visible'); + // "All Time" stays disabled when the selected GPU has only one date in fixtures. + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + }); + + it('selects two GPUs and verifies date range auto-defaults', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); + + // Pick eq(2) so the second GPU is a different family (e.g. b300) with a + // distinct date — eq(1) can be the MTP pair of eq(0) sharing one date. + openGpuMultiselect(); + cy.get('[role="option"]').eq(2).click(); + + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + }); + + it('selecting a third GPU adds a third removable chip to the multiselect', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); + openGpuMultiselect(); + cy.get('[role="option"]').eq(1).click(); + openGpuMultiselect(); + cy.get('[role="option"]').eq(2).click(); + cy.get('[data-testid="gpu-multiselect"]') + .find('[aria-label^="Remove "]') + .should('have.length', 3); + }); }); }); diff --git a/packages/app/src/components/dashboard-shell.tsx b/packages/app/src/components/dashboard-shell.tsx index 17eb1386..c7bd2065 100644 --- a/packages/app/src/components/dashboard-shell.tsx +++ b/packages/app/src/components/dashboard-shell.tsx @@ -11,7 +11,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) { - + {children} diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index 191bd8ef..2eaa852b 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -639,14 +639,14 @@ export function InferenceProvider({ }, [dateRangeAvailableDates]); // Auto-default to max date range once when GPU comparison first becomes ready. - // Uses a ref to fire only on the transition from <2 GPUs to >=2, avoiding a loop + // Uses a ref to fire only on the transition from 0 GPUs to >=1, avoiding a loop // when the user intentionally clears the date range. const prevGpuCountRef = useRef(selectedGPUs.length); useEffect(() => { - const wasBelow = prevGpuCountRef.current < 2; + const wasBelow = prevGpuCountRef.current < 1; prevGpuCountRef.current = selectedGPUs.length; if (!wasBelow) return; - if (selectedGPUs.length < 2) return; + if (selectedGPUs.length === 0) return; if (selectedDateRange.startDate && selectedDateRange.endDate) return; if (dateRangeAvailableDates.length < 2) return; const startDate = dateRangeAvailableDates[0]; diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 71c2ca1f..456777bb 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -147,7 +147,7 @@ export default function ChartDisplay() { setSelectedE2eXAxisMetric, } = useInference(); - const comparisonReady = useMemo(() => selectedGPUs.length >= 2, [selectedGPUs]); + const comparisonReady = useMemo(() => selectedGPUs.length > 0, [selectedGPUs]); const [viewModes, setViewModes] = useState>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; @@ -522,14 +522,14 @@ export default function ChartDisplay() { )); return ( - + - - + + - Inference Performance - + Inference Performance + Inference performance metrics across different models, hardware configurations, and serving parameters. diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx index af5ffefe..0f6289f0 100644 --- a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { track } from '@/lib/analytics'; import { cn } from '@/lib/utils'; @@ -10,46 +11,13 @@ import { MAX_COMPARISON_GPUS } from '@/components/inference/utils/normalize-comp import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; import { DateRangePicker, getQuickDateRangeShortcuts } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; -import { SearchableSelect } from '@/components/ui/searchable-select'; +import { MultiSelect } from '@/components/ui/multi-select'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Plus, X } from 'lucide-react'; import ComparisonChangelog from './ComparisonChangelog'; -const GPU_OPTIONS_GROUP = 'GPUs'; - -const MIN_SLOTS = 2; - -const SLOT_INDICES = Array.from({ length: MAX_COMPARISON_GPUS }, (_, i) => i); - -/** - * Build the new dense GPU list after a slot value changes. - * Keeps selections in visual slot order, dedupes, and strips empties. - */ -export function buildSelectionAfterSlotChange( - selectedGPUs: string[], - slotCount: number, - slotIndex: number, - rawValue: string, -): string[] { - const v = rawValue.trim(); - const slots: string[] = []; - for (let j = 0; j < slotCount; j++) { - slots.push(j === slotIndex ? v : (selectedGPUs[j] ?? '')); - } - const out: string[] = []; - const seen = new Set(); - for (const x of slots) { - if (!x) continue; - if (seen.has(x)) continue; - seen.add(x); - out.push(x); - } - return out; -} - export default function GpuComparisonCard() { const { selectedGPUs, @@ -70,47 +38,20 @@ export default function GpuComparisonCard() { totalDatesQueried, } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); - const comparisonReady = selectedGPUs.length >= 2; + const comparisonReady = selectedGPUs.length > 0; - const [slotCount, setSlotCount] = useState(() => Math.max(MIN_SLOTS, selectedGPUs.length)); + const [isExpanded, setIsExpanded] = useState(false); - // Keep slotCount in sync when GPUs change externally (presets, URL restore) useEffect(() => { - if (selectedGPUs.length > slotCount) { - setSlotCount(selectedGPUs.length); + if (comparisonReady) { + setIsExpanded(true); } - }, [selectedGPUs.length, slotCount]); + }, [comparisonReady]); - const newSlotRef = useRef(null); - - const optionsBySlot = useMemo( - () => - SLOT_INDICES.map((i) => ({ - label: GPU_OPTIONS_GROUP, - options: availableGPUs.filter( - (o) => !selectedGPUs.some((g, j) => j !== i && g === o.value), - ), - })), - [availableGPUs, selectedGPUs], - ); - - // Auto-focus newly added slot - useEffect(() => { - if (newSlotRef.current !== null) { - const id = `gpu-comparison-slot-${newSlotRef.current + 1}`; - const el = document.querySelector(`#${id}`); - if (el) el.focus(); - newSlotRef.current = null; - } - }); - - const handleSlotChange = (slotIndex: number, value: string) => { - const next = buildSelectionAfterSlotChange(selectedGPUs, slotCount, slotIndex, value); - setSelectedGPUs(next); - track('inference_gpu_comparison_slot_selected', { - slot: slotIndex + 1, - value: value || '', - gpus: next.join(','), + const handleGPUChange = (value: string[]) => { + setSelectedGPUs(value); + track('inference_gpu_selected', { + gpus: value.join(','), }); }; @@ -122,188 +63,156 @@ export default function GpuComparisonCard() { }); }; - const clearSlot = (slotIndex: number) => { - if (slotIndex < MIN_SLOTS) { - handleSlotChange(slotIndex, ''); - return; - } - const next = selectedGPUs.filter((_, j) => j !== slotIndex); - setSelectedGPUs(next); - setSlotCount((c) => Math.max(MIN_SLOTS, c - 1)); - track('inference_gpu_comparison_slot_removed', { - slot: slotIndex + 1, - gpus: next.join(','), - }); - }; - - const addSlot = () => { - const nextCount = Math.min(MAX_COMPARISON_GPUS, slotCount + 1); - setSlotCount(nextCount); - newSlotRef.current = nextCount - 1; - track('inference_gpu_comparison_slot_added', { newSlotCount: nextCount }); - }; - - const canAddSlot = slotCount < MAX_COMPARISON_GPUS && slotCount < availableGPUs.length; - - // Slot 0 is always enabled. Later slots are disabled only if they're empty - // and there's still a gap in an earlier slot (forces top-down filling). - const slotDisabled = (i: number): boolean => { - if (i === 0) return false; - if (selectedGPUs[i]) return false; - return !selectedGPUs[i - 1]; - }; - return ( - + - - GPU Comparison - - Select at least two for date range comparison. - - - - {SLOT_INDICES.slice(0, slotCount).map((i) => { - const value = selectedGPUs[i] ?? ''; - const slotGroups = [optionsBySlot[i]!]; - const isOptional = i >= MIN_SLOTS; - return ( - + + { + setIsExpanded((prev) => { + const next = !prev; + track('inference_gpu_comparison_toggled', { expanded: next }); + return next; + }); + }} + > + + GPU Comparison + + + + + + + + {isExpanded && ( + + Select one or more GPUs for date range comparison. + + )} + - - - handleSlotChange(i, v)} - placeholder="Select GPU configuration" - trackPrefix={`inference_gpu_comparison_${i + 1}`} - groups={slotGroups} - disabled={slotDisabled(i)} - /> - - {(value || isOptional) && ( - clearSlot(i)} - > - - - )} + + - ); - })} - - {canAddSlot && ( - - - Add GPU - - )} + + + + + + + {getQuickDateRangeShortcuts(dateRangeAvailableDates).map( + ({ id, label, range, isAvailable }) => { + const canUse = comparisonReady && isAvailable && Boolean(range); + const isActive = + range !== null && + selectedDateRange.startDate === range.startDate && + selectedDateRange.endDate === range.endDate; + return ( + { + if (!range) return; + handleDateRangeChange(range); + track('inference_date_range_quick_select', { + label, + startDate: range.startDate, + endDate: range.endDate, + }); + }} + > + {label} + + ); + }, + )} + + - - - - - - - {getQuickDateRangeShortcuts(dateRangeAvailableDates).map( - ({ id, label, range, isAvailable }) => { - const canUse = comparisonReady && isAvailable && Boolean(range); - const isActive = - range !== null && - selectedDateRange.startDate === range.startDate && - selectedDateRange.endDate === range.endDate; - return ( - { - if (!range) return; - handleDateRangeChange(range); - track('inference_date_range_quick_select', { - label, - startDate: range.startDate, - endDate: range.endDate, - }); + {comparisonReady && ( + + { + if (!selectedDates.includes(date)) { + setSelectedDates([...selectedDates, date]); + } }} - > - {label} - - ); - }, - )} + onRemoveDate={(date) => { + setSelectedDates(selectedDates.filter((d) => d !== date)); + }} + onAddAllDates={(dates) => { + const merged = [...new Set([...selectedDates, ...dates])]; + setSelectedDates(merged); + }} + firstAvailableDate={dateRangeAvailableDates[0]} + /> + + )} + - - {comparisonReady && ( - - { - if (!selectedDates.includes(date)) { - setSelectedDates([...selectedDates, date]); - } - }} - onRemoveDate={(date) => { - setSelectedDates(selectedDates.filter((d) => d !== date)); - }} - onAddAllDates={(dates) => { - const merged = [...new Set([...selectedDates, ...dates])]; - setSelectedDates(merged); - }} - firstAvailableDate={dateRangeAvailableDates[0]} - /> - - )} diff --git a/packages/app/src/components/tab-nav.tsx b/packages/app/src/components/tab-nav.tsx index ee2bfb2a..4d424949 100644 --- a/packages/app/src/components/tab-nav.tsx +++ b/packages/app/src/components/tab-nav.tsx @@ -132,7 +132,7 @@ export function TabNav() { return ( <> {/* Mobile: Dropdown */} - + @@ -158,7 +158,7 @@ export function TabNav() { {/* Desktop: Nav links */} - + )} {showSelectionSummary && - (maxSelections !== undefined || minSelections !== undefined) && ( + (typeof maxSelections === 'number' || typeof minSelections === 'number') && ( - {value.length} - {maxSelections !== undefined && ` / ${maxSelections}`} selected - {minSelections !== undefined && minSelections > 0 && ( + {`${value.length}${typeof maxSelections === 'number' ? ` / ${maxSelections}` : ''} selected`} + {typeof minSelections === 'number' && minSelections > 0 && ( Minimum: {minSelections} )} diff --git a/packages/app/src/hooks/api/use-comparison-changelogs.ts b/packages/app/src/hooks/api/use-comparison-changelogs.ts index f601c7e1..7d3e2556 100644 --- a/packages/app/src/hooks/api/use-comparison-changelogs.ts +++ b/packages/app/src/hooks/api/use-comparison-changelogs.ts @@ -19,7 +19,7 @@ export function useComparisonChangelogs( selectedDateRange: { startDate: string; endDate: string }, availableDates: string[], ) { - const hasGPUs = selectedGPUs.length >= 2; + const hasGPUs = selectedGPUs.length > 0; const hasDateRange = Boolean(selectedDateRange.startDate) && Boolean(selectedDateRange.endDate); // When GPUs selected: fetch all available dates. When date range also set: limit to range.
@@ -560,35 +548,15 @@ export default function ChartDisplay() {
+ Compare historical performance for two GPU configurations over a date range. Select + one configuration in each dropdown — both are required before choosing dates or + viewing the comparison chart. +
+ Select two different GPU configurations to enable comparison. +
- Select a date range to view GPU comparison -
- Compare historical performance for two GPU configurations over a date range. Select - one configuration in each dropdown — both are required before choosing dates or - viewing the comparison chart. -
- Select two different GPU configurations to enable comparison. -
+ Select at least two for date range comparison. +
- Select at least two for date range comparison. -
+
Inference performance metrics across different models, hardware configurations, and serving parameters.
+ Select one or more GPUs for date range comparison. +