diff --git a/packages/app/src/components/GlobalFilterContext.tsx b/packages/app/src/components/GlobalFilterContext.tsx index 4894cfd..a7c2c7a 100644 --- a/packages/app/src/components/GlobalFilterContext.tsx +++ b/packages/app/src/components/GlobalFilterContext.tsx @@ -108,7 +108,7 @@ function buildRunInfo(data: WorkflowInfoResponse): Record { } export function GlobalFilterProvider({ children }: { children: ReactNode }) { - const { hasUrlParam, getUrlParam, setUrlParams } = useUrlState(); + const { hasUrlParam, getUrlParam, setUrlParams, latestParams } = useUrlState(); // ── Core filter state ───────────────────────────────────────────────────── const [selectedModel, setSelectedModel] = useState(() => { @@ -145,6 +145,38 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) { const [selectedRunId, setSelectedRunId] = useState(() => 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(); @@ -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); diff --git a/packages/app/src/components/announcement-banner.tsx b/packages/app/src/components/announcement-banner.tsx new file mode 100644 index 0000000..d695834 --- /dev/null +++ b/packages/app/src/components/announcement-banner.tsx @@ -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(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 = ( + <> +
+ + {banner.message} + {banner.date && ( + + {banner.date} + + )} +
+
+ {banner.linkHref && ( + <> + View + + + )} + +
+ + ); + + if (banner.linkHref) { + return ( + { + dismiss(banner.id); + track('banner_clicked', { banner_id: banner.id, link_href: banner.linkHref }); + setBanner(null); + }} + className={className} + > + {content} + + ); + } + + return
{content}
; +} diff --git a/packages/app/src/components/dashboard-shell.tsx b/packages/app/src/components/dashboard-shell.tsx index 84bd217..c36e77d 100644 --- a/packages/app/src/components/dashboard-shell.tsx +++ b/packages/app/src/components/dashboard-shell.tsx @@ -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'; @@ -16,6 +17,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 f20fdd1..7f0d9f2 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -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); @@ -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(() => { diff --git a/packages/app/src/components/tab-nav.tsx b/packages/app/src/components/tab-nav.tsx index 2fc9774..3bfeb11 100644 --- a/packages/app/src/components/tab-nav.tsx +++ b/packages/app/src/components/tab-nav.tsx @@ -102,8 +102,7 @@ export function TabNav() { return ( <> {/* Mobile: Dropdown */} -
-
+
@@ -128,7 +127,7 @@ export function TabNav() {
{/* Desktop: Nav links */} -
+