feat(catalog): mobile-optimised model catalog view#2
Merged
Conversation
Promote a small surface of constants and helpers out of ModelCatalog.tsx so that both the desktop table and an upcoming mobile catalog can share the same provenance for category/capability/cutoff/pricing logic. The promoted module also introduces aggregateFamilies, which collapses a flat list of composite models into family rows keyed by display_name (falling back to a canonicalised model_name for variants without a display name). Each family carries roll-ups (intelligenceMax, priceFrom, contextMax, releasedAt, capabilities) used by family-oriented views. Includes a nullsLast comparator used by sort pipelines, and a deterministic computeNewCutoff helper for testability. Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
Adds MobileModelCatalog, a vertical card list that consumes the same
useModels({ is_composite: true, include: 'pricing' }) endpoint as the
desktop table and runs aggregateFamilies + nullsLast sorting entirely
client-side against the resulting families.
State lives in the URL via useSearchParams: search (debounced 250ms),
category, providers/capabilities/groups (repeated params, alphabetised),
sort + dir (dir omitted when it matches the field's default), and
modelId for the detail drawer. Filter toggles use replace semantics so
they don't pollute history; opening the detail drawer pushes a new
entry, and tapping back closes it. Variant switching inside the drawer
is replace-only so the back button always closes rather than cycling
through variants.
The mobile pricing context (async vs. batch) is persisted in
localStorage under catalog-pricing-context and feeds back into the
family aggregation so price roll-ups reflect the active context without
triggering loading skeletons.
Desktop and mobile both render under /models with CSS-only switching
(hidden md:block and block md:hidden) to avoid SSR/initial-paint
flicker. The previous experimental MobileModelsView swimlane is removed
since its surface is fully subsumed by the new card view.
Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
Two bugs in the mobile/desktop dual-mount setup:
1. Both views call useModels with non-overlapping params (limit:500 vs
limit:100, plus different sort), which produces distinct React Query
cache keys. Mounting both views unconditionally meant every page load
issued two list requests regardless of viewport — one of them always
wasted. Pass an isMobile flag from the wrapper into both children and
gate each useModels call with { enabled: ... } so only the active
view fetches. Layout switching remains CSS-only, so SSR/initial paint
is preserved.
2. closeModel pushed a fresh history entry on top of the openModel push.
Result: after open+close the back button rolled into the still-in-
history modelId entry and reopened the drawer. Track whether we own
the topmost entry via a ref; pop history (navigate(-1)) on close when
we do, otherwise fall back to replace. The ref is reset whenever
modelId leaves the URL by any path other than closeModel (e.g. nav
away then back) so a stale 'we own it' flag can't navigate too far.
Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 391f0d3. Configure here.
…erflow Two issues raised in review: 1. PricingContext was redeclared in MobileModelCatalog.tsx as '"async" | "batch"' even though shared.ts already exports the same type (and modelFamily.ts already imports it from there). Drop the local copy and import the shared type so the two definitions can't drift. 2. computeNewCutoff used 'cutoff.setMonth(getMonth() - months)' which silently overflows when the source day exceeds the target month's length. May 31 minus 3 months sets month=February but day-31 wraps forward to March 3, pushing the cutoff later than intended and incorrectly demoting borderline-new models. Clamp the source day to the last valid day of the target month before constructing the date, so May 31 → Feb 28 (or Feb 29 in a leap year), Mar 31 minus 4 months → Nov 30 of the previous year, etc. Also stop building the ISO string via toISOString() (which is UTC and disagreed with the local- time getMonth/getDate elsewhere in the function); format it directly from the local-time getters. Adds tests for: standard month subtraction, day clamping (incl. leap year), year boundary crossing. Co-authored-by: aschkanAH <aschkanAH@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds a mobile-first React view for the Models catalog that renders alongside the existing desktop table via CSS-only breakpoint switching (
block md:hidden/hidden md:block). No new runtime dependencies; everything is built oncomponents/ui/*(Sheet, Input, Button, Card, Badge, Skeleton, Select).What changed
catalog/shared.ts(new)Promotes module-private constants/utilities out of
ModelCatalog.tsxso both views share a single source of truth:EVERYONE_GROUP_IDNEW_CUTOFF_MONTHSFILTERABLE_CAPABILITIESgetCheapestInputPriceValue(now context-aware:async/batch)getDisplayCapabilitiesgetCatalogTabForModelformatReleaseDateThe desktop
ModelCatalog.tsxwas updated to import these helpers instead of redeclaring them.catalog/modelFamily.ts(new)Implements
aggregateFamilies(models, opts)which collapses the flat composite-model list into family rows keyed bydisplay_name(with a fallback that strips a trailing-FP8/-INT4/-NVFP4/-Q\d+suffix for variants without an explicit display name).Each
AggregatedFamilycarries roll-ups consumed by family-oriented views:intelligenceMax,priceFrom(active pricing context),contextMax,releasedAt,capabilities(union), andisNew(derived fromnewCutoff).aggregateFamiliesrequires{ newCutoff, context, providerLabelOf, providerIconOf, displayCapabilitiesOf }exactly as called for;providerLabelOf/providerIconOfare passed in so the caller can wire them up viauseProviderDisplayConfigs().Also exports a
nullsLast<T, K>(getKey, dir, cmp)comparator with explicit "null/undefined sorts to bottom regardless of direction" semantics, pluscomputeNewCutoff(now, months)for testability.catalog/mobileUrlState.ts(new)Bidirectional URL serialiser for the mobile catalog. State lives entirely in
useSearchParams:search(debounced 250ms)category(allis omitted)providers[],capabilities[],groups[]— repeated params, sorted alphabetically;groupsomitted when it equals[EVERYONE_GROUP_ID]sort(defaultintelligenceis omitted)dir(emitted only when it diverges from the default for the active sort:intelligence/released_at/context→ desc,cost/alias→ asc)modelIdRound-trip is idempotent:
serialize(deserialize(x)) === xis asserted by tests.catalog/MobileModelCatalog.tsx(new)The mobile UI, gated by
block md:hidden. Top-level pieces:useSidebar().setOpenMobile(true)), conditional balance string (gated onconfig.payment_enabled), Docs link, search input (debounced into URL), filter button with active-count badge, scrollable category tabs, and an inline async/batch pricing toggle persisted tolocalStorage["catalog-pricing-context"].<Card>per family with a 3-column hero metrics grid (Intel / Cost / Context) showing the family-level roll-ups. Tapping the card pushes?modelId=<primaryVariant.id>(so back closes the drawer). "Try it" stops propagation and routes to/playground?model=<id>when not denied.Sheet side="bottom"ath-[90vh]with sort selector + direction toggle, and provider/capability/group multi-selects. The Groups section is only rendered when the user hasmanage-groups. All toggles callsetSearchParams(next, { replace: true }).Sheet side="bottom"ath-[90vh]. Looks up the family containing the activemodelIdand offers a variant<Select>; switching variants usesreplace: true. Includes a 2x2 metric grid (intelligence / context / released / cost viagetCheapestInputPriceValue(active.tariffs, pricingContext)), fullgetDisplayCapabilities(active)badges, alias copy button, Markdown description, and footer buttons for "API Examples" (renders the existing<ApiExamples>modal as a nested portal) and "Try it".History semantics for the detail drawer
Opening the drawer pushes
?modelId=…. Closing pops that entry (navigate(-1)) when we own it, so the back button leaves the page rather than re-entering the just-closed drawer. A ref tracks ownership and is reset whenevermodelIdleaves the URL via any path other thancloseModel(e.g. external navigation), so a stale flag can't navigate too far back. Variant switches inside the drawer usereplace, keeping the drawer on the same history entry.Sibling-query gating (no duplicate fetches)
ModelCatalogmounts bothMobileModelCatalogandDesktopModelCatalogfor CSS-only layout switching, but each child callsuseModelswith non-overlapping params (limit: 500vslimit: 100, plus differentsort/sort_direction), which produces distinct React-Query cache keys. The wrapper resolvesuseIsMobile()once and threads it as a prop into both children; eachuseModelscall is gated with{ enabled: ... }so only the active viewport fetches.useModelswas extended to accept the standardenabledflag in line with the other list hooks.Filter & sort pipeline
Search is the sole server-side filter (it runs across composites, so variant-only terms like "fp8" may miss — by design). Provider, capability, group, category and sort all run client-side against the aggregated families. Sorts always use the
nullsLastcomparator, so models lacking the sort key sink to the bottom regardless of direction.Loading / error / empty states
<Skeleton className="h-32 w-full rounded-xl" />cards whileuseModelsis fetching. Toggling pricing context does not trigger skeletons (aggregation is synchronous).<Button>that callsrefetch().catalog-pricing-contextin localStorage.Removal:
MobileModelsView(swimlane)The previously-merged experimental swimlane (
features/models/manage/MobileModelsView.tsx) is deleted. Its surface is fully subsumed by the new card view, and keeping both would force the user-visible mobile experience through two different code paths.Tests
mobileUrlState.test.ts— round-trip, dir omission/emission, alphabetical encoding of repeated params, default-group omission, modelId preservation, invalid-param fallbacks, active-filter counter.modelFamily.test.ts— family grouping bydisplay_name, fallback canonicalisation, primary-variant ordering,computeNewCutoff, andnullsLast(asc, desc, nulls always last).Lint and
tsc -bboth clean.Notes / risks
useModelsgained an optionalenabledflag, matching the convention used by other list hooks (useGroups,useEndpoints, etc.). All existing call sites default toenabled: trueand are unaffected.useModelscall requestslimit: 500. The current MSW mock honours this directly, but production may cap to 100; aggregating fewer-than-expected families is graceful (just shows fewer cards).getCheapestInputPriceValuegained an optionalcontextparameter. The desktop call site does not pass one and behaviour is identical (cheapest visible non-playground tariff).config.payment_enabledbecause there is nouse_billingfeature flag in this codebase. If ause_billingflag is introduced later, swap the gating expression inMobileModelCatalog.tsx.useSidebaris called from insideAppLayout'sSidebarProvider, which already wraps/models.