Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions packages/app/src/components/GlobalFilterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function buildRunInfo(data: WorkflowInfoResponse): Record<string, RunInfo> {
}

export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const { hasUrlParam, getUrlParam, setUrlParams } = useUrlState();
const { hasUrlParam, getUrlParam, setUrlParams, latestParams } = useUrlState();

// ── Core filter state ─────────────────────────────────────────────────────
const [selectedModel, setSelectedModel] = useState<Model>(() => {
Expand Down Expand Up @@ -145,6 +145,38 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {

const [selectedRunId, setSelectedRunId] = useState<string>(() => getUrlParam('g_runid') || '');

// When true, keep the user's date if available; otherwise always use latest
const userPickedDateRef = useRef(Boolean(getUrlParam('g_rundate')));

// ── Apply URL params from client-side navigations (e.g. banner clicks) ───
const appliedParamsRef = useRef(latestParams);
useEffect(() => {
if (latestParams === appliedParamsRef.current) return;
appliedParamsRef.current = latestParams;

const model = latestParams.g_model;
if (model && Object.values(Model).includes(model as Model)) {
setSelectedModel(model as Model);
}
const seq = latestParams.i_seq;
if (seq && Object.values(Sequence).includes(seq as Sequence)) {
setSelectedSequence(seq as Sequence);
}
const prec = latestParams.i_prec;
if (prec) {
const precs = prec.split(',').filter((p) => PRECISION_OPTIONS.includes(p as Precision));
if (precs.length > 0) setSelectedPrecisions(precs);
}
const runDate = latestParams.g_rundate;
if (runDate) {
userPickedDateRef.current = true;
setSelectedRunDateBase(runDate);
setSelectedRunDateRev((v) => v + 1);
}
const runId = latestParams.g_runid;
if (runId) setSelectedRunId(runId);
}, [latestParams, setSelectedPrecisions]);

// ── Availability data ─────────────────────────────────────────────────────
const { data: availabilityRows } = useAvailability();

Expand Down Expand Up @@ -213,9 +245,6 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
return [...new Set(rows.map((r) => r.date))].toSorted();
}, [availabilityRows, modelRows, effectiveSequence, effectivePrecisions]);

// When true, keep the user's date if available; otherwise always use latest
const userPickedDateRef = useRef(Boolean(getUrlParam('g_rundate')));

const setSelectedRunDateManual = useCallback((date: string) => {
userPickedDateRef.current = true;
setSelectedRunDateBase(date);
Expand Down
116 changes: 116 additions & 0 deletions packages/app/src/components/announcement-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use client';

import Link from 'next/link';
import { useEffect, useState } from 'react';
import { ChevronRight, Megaphone, X } from 'lucide-react';

import { track } from '@/lib/analytics';
import { fetchAvailability, fetchWorkflowInfo } from '@/lib/api';
import {
type BannerInfo,
buildBannerFromWorkflowInfo,
dismiss,
getHardcodedBanner,
isDismissed,
} from '@/lib/banner-data';

export function AnnouncementBanner() {
const [banner, setBanner] = useState<BannerInfo | null>(null);

useEffect(() => {
// Check hardcoded announcements first (sync, no fetch)
const hardcoded = getHardcodedBanner();
if (hardcoded) {
setBanner(hardcoded);
track('banner_viewed', { banner_id: hardcoded.id });
return;
}

// Fall back to automated changelog banner
let cancelled = false;
(async () => {
try {
const rows = await fetchAvailability();
if (cancelled || rows.length === 0) return;

const latestDate = rows
.reduce((max, r) => (r.date > max ? r.date : max), rows[0].date)
.slice(0, 10);

const data = await fetchWorkflowInfo(latestDate);
if (cancelled) return;

const info = buildBannerFromWorkflowInfo(latestDate, data);
if (!info || isDismissed(info.id)) return;

setBanner(info);
track('banner_viewed', { banner_id: info.id });
} catch {
// Silently fail — banner is non-critical
}
})();
return () => {
cancelled = true;
};
}, []);

if (!banner) return null;

const handleDismiss = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dismiss(banner.id);
track('banner_dismissed', { banner_id: banner.id });
setBanner(null);
};

const className =
'animate-in fade-in slide-in-from-top-2 duration-300 bg-brand/15 border border-brand/30 rounded-lg px-4 py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-brand/25';

const content = (
<>
<div className="flex items-center gap-3 min-w-0">
<Megaphone className="h-4 w-4 text-brand shrink-0" />
<span className="text-sm font-medium text-foreground truncate">{banner.message}</span>
{banner.date && (
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:inline">
{banner.date}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{banner.linkHref && (
<>
<span className="text-xs text-brand font-medium hidden sm:inline">View</span>
<ChevronRight className="h-3.5 w-3.5 text-brand shrink-0" />
</>
)}
<button
onClick={handleDismiss}
className="p-1 hover:bg-brand/20 rounded transition-colors ml-1"
aria-label="Dismiss announcement"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</>
);

if (banner.linkHref) {
return (
<Link
href={banner.linkHref}
onClick={() => {
dismiss(banner.id);
track('banner_clicked', { banner_id: banner.id, link_href: banner.linkHref });
setBanner(null);
}}
className={className}
>
{content}
</Link>
);
}

return <div className={className}>{content}</div>;
}
2 changes: 2 additions & 0 deletions packages/app/src/components/dashboard-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { AnnouncementBanner } from '@/components/announcement-banner';
import { ExportNudge } from '@/components/export-nudge';
import { GlobalFilterProvider } from '@/components/GlobalFilterContext';
import { GradientLabelNudge } from '@/components/gradient-label-nudge';
Expand All @@ -16,6 +17,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
<UnofficialRunProvider>
<main className="relative">
<div className="container mx-auto px-4 lg:px-8 flex flex-col gap-4">
<AnnouncementBanner />
<TabNav />
<GlobalFilterProvider>{children}</GlobalFilterProvider>
</div>
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/components/inference/InferenceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,9 +465,15 @@ export function InferenceProvider({
// When a preset is still active (presetHwFilterRef), re-apply the filter instead of resetting
// to all GPUs — this handles deferred effectivePrecisions changes from late availability data.
const precisionsKey = effectivePrecisions.join(',');
const needsHwResetRef = useRef(false);
useEffect(() => {
needsHwResetRef.current = true;
}, [selectedModel, effectiveSequence, precisionsKey]);
useEffect(() => {
if (!needsHwResetRef.current) return;
if (pendingHwFilterRef.current) return;
if (hwTypesWithData.size === 0) return;
needsHwResetRef.current = false;
const presetFilter = presetHwFilterRef.current;
if (presetFilter) {
const filterSet = new Set(presetFilter);
Expand All @@ -478,7 +484,12 @@ export function InferenceProvider({
}
}
setActiveHwTypes(hwTypesWithData);
}, [selectedModel, effectiveSequence, precisionsKey]);
}, [selectedModel, effectiveSequence, precisionsKey, hwTypesWithData]);

// Reset selected GPUs when model changes (comparison configs are model-specific)
useEffect(() => {
setSelectedGPUs((prev) => (prev.length > 0 ? [] : prev));
}, [selectedModel]);

// Remove selected GPUs that no longer have data for current filters
useEffect(() => {
Expand Down
5 changes: 2 additions & 3 deletions packages/app/src/components/tab-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ export function TabNav() {
return (
<>
{/* Mobile: Dropdown */}
<div className="lg:hidden mb-4">
<div className="w-full pb-6" />
<div className="lg:hidden">
<Card>
<div className="space-y-2">
<Label htmlFor="chart-select">Select Chart</Label>
Expand All @@ -128,7 +127,7 @@ export function TabNav() {
</div>

{/* Desktop: Nav links */}
<div className="hidden lg:flex flex-col mb-4">
<div className="hidden lg:flex flex-col">
<Card className="overflow-x-auto py-6 md:py-6">
<nav
data-testid="chart-section-tabs"
Expand Down
35 changes: 32 additions & 3 deletions packages/app/src/hooks/useUrlState.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
'use client';

import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

import {
type UrlStateKey,
type UrlStateParams,
readUrlParams,
refreshUrlParams,
writeUrlParams,
} from '@/lib/url-state';

/**
* React hook for URL state synchronization.
* Reads URL params once on mount (cached in ref), and provides
* functions to write params back to the URL.
* Reads URL params on mount and on client-side navigations (pushState / popstate),
* clears them from the URL bar, and exposes fresh values via `latestParams`.
*/
export function useUrlState() {
const initialParams = useRef<UrlStateParams | null>(null);
Expand All @@ -22,6 +23,33 @@ export function useUrlState() {
initialParams.current = readUrlParams();
}

const [latestParams, setLatestParams] = useState<UrlStateParams>(initialParams.current);

useEffect(() => {
// pushState handler: params already read & cleared centrally, just apply the result
const pushHandler = (e: Event) => {
const fresh = (e as CustomEvent<UrlStateParams>).detail;
if (fresh && Object.keys(fresh).length > 0) {
initialParams.current = { ...initialParams.current, ...fresh };
setLatestParams((prev) => ({ ...prev, ...fresh }));
}
};
// popstate handler: browser back/forward — need to read & clear ourselves
const popHandler = () => {
const fresh = refreshUrlParams();
if (Object.keys(fresh).length > 0) {
initialParams.current = { ...initialParams.current, ...fresh };
setLatestParams((prev) => ({ ...prev, ...fresh }));
}
};
window.addEventListener('urlparamschange', pushHandler);
window.addEventListener('popstate', popHandler);
return () => {
window.removeEventListener('urlparamschange', pushHandler);
window.removeEventListener('popstate', popHandler);
};
}, []);

const hasUrlParam = useCallback((key: UrlStateKey): boolean => {
const value = initialParams.current?.[key];
return value !== undefined && value !== '';
Expand All @@ -42,6 +70,7 @@ export function useUrlState() {

return {
initialParams: initialParams.current,
latestParams,
hasUrlParam,
getUrlParam,
setUrlParam,
Expand Down
Loading
Loading