Skip to content

feat(catalog): mobile-optimised model catalog view#2

Merged
aschkanAH merged 4 commits into
feat-mobile-modelsfrom
ai-mobile-catalog-6194
Apr 27, 2026
Merged

feat(catalog): mobile-optimised model catalog view#2
aschkanAH merged 4 commits into
feat-mobile-modelsfrom
ai-mobile-catalog-6194

Conversation

@aschkanAH
Copy link
Copy Markdown
Owner

@aschkanAH aschkanAH commented Apr 27, 2026

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 on components/ui/* (Sheet, Input, Button, Card, Badge, Skeleton, Select).

What changed

catalog/shared.ts (new)

Promotes module-private constants/utilities out of ModelCatalog.tsx so both views share a single source of truth:

  • EVERYONE_GROUP_ID
  • NEW_CUTOFF_MONTHS
  • FILTERABLE_CAPABILITIES
  • getCheapestInputPriceValue (now context-aware: async / batch)
  • getDisplayCapabilities
  • getCatalogTabForModel
  • formatReleaseDate

The desktop ModelCatalog.tsx was 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 by display_name (with a fallback that strips a trailing -FP8/-INT4/-NVFP4/-Q\d+ suffix for variants without an explicit display name).

Each AggregatedFamily carries roll-ups consumed by family-oriented views: intelligenceMax, priceFrom (active pricing context), contextMax, releasedAt, capabilities (union), and isNew (derived from newCutoff). aggregateFamilies requires { newCutoff, context, providerLabelOf, providerIconOf, displayCapabilitiesOf } exactly as called for; providerLabelOf/providerIconOf are passed in so the caller can wire them up via useProviderDisplayConfigs().

Also exports a nullsLast<T, K>(getKey, dir, cmp) comparator with explicit "null/undefined sorts to bottom regardless of direction" semantics, plus computeNewCutoff(now, months) for testability.

catalog/mobileUrlState.ts (new)

Bidirectional URL serialiser for the mobile catalog. State lives entirely in useSearchParams:

  • search (debounced 250ms)
  • category (all is omitted)
  • providers[], capabilities[], groups[] — repeated params, sorted alphabetically; groups omitted when it equals [EVERYONE_GROUP_ID]
  • sort (default intelligence is omitted)
  • dir (emitted only when it diverges from the default for the active sort: intelligence/released_at/context → desc, cost/alias → asc)
  • modelId

Round-trip is idempotent: serialize(deserialize(x)) === x is asserted by tests.

catalog/MobileModelCatalog.tsx (new)

The mobile UI, gated by block md:hidden. Top-level pieces:

  • Header — sidebar trigger (uses useSidebar().setOpenMobile(true)), conditional balance string (gated on config.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 to localStorage["catalog-pricing-context"].
  • List — vertical <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.
  • FilterDrawerSheet side="bottom" at h-[90vh] with sort selector + direction toggle, and provider/capability/group multi-selects. The Groups section is only rendered when the user has manage-groups. All toggles call setSearchParams(next, { replace: true }).
  • DetailDrawerSheet side="bottom" at h-[90vh]. Looks up the family containing the active modelId and offers a variant <Select>; switching variants uses replace: true. Includes a 2x2 metric grid (intelligence / context / released / cost via getCheapestInputPriceValue(active.tariffs, pricingContext)), full getDisplayCapabilities(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 whenever modelId leaves the URL via any path other than closeModel (e.g. external navigation), so a stale flag can't navigate too far back. Variant switches inside the drawer use replace, keeping the drawer on the same history entry.

Sibling-query gating (no duplicate fetches)

ModelCatalog mounts both MobileModelCatalog and DesktopModelCatalog for CSS-only layout switching, but each child calls useModels with non-overlapping params (limit: 500 vs limit: 100, plus different sort/sort_direction), which produces distinct React-Query cache keys. The wrapper resolves useIsMobile() once and threads it as a prop into both children; each useModels call is gated with { enabled: ... } so only the active viewport fetches. useModels was extended to accept the standard enabled flag 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 nullsLast comparator, so models lacking the sort key sink to the bottom regardless of direction.

Loading / error / empty states

  • 4 <Skeleton className="h-32 w-full rounded-xl" /> cards while useModels is fetching. Toggling pricing context does not trigger skeletons (aggregation is synchronous).
  • Error state shows a <Button> that calls refetch().
  • Empty state offers "Clear all filters" which resets URL params and clears the search input while preserving catalog-pricing-context in 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 by display_name, fallback canonicalisation, primary-variant ordering, computeNewCutoff, and nullsLast (asc, desc, nulls always last).
✓ src/components/features/models/catalog/mobileUrlState.test.ts (8 tests)
✓ src/components/features/models/catalog/modelFamily.test.ts    (8 tests)
…
Test Files  41 passed (41)
     Tests  514 passed (514)

Lint and tsc -b both clean.

Notes / risks

  • useModels gained an optional enabled flag, matching the convention used by other list hooks (useGroups, useEndpoints, etc.). All existing call sites default to enabled: true and are unaffected.
  • The useModels call requests limit: 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).
  • getCheapestInputPriceValue gained an optional context parameter. The desktop call site does not pass one and behaviour is identical (cheapest visible non-playground tariff).
  • The header balance is gated on config.payment_enabled because there is no use_billing feature flag in this codebase. If a use_billing flag is introduced later, swap the gating expression in MobileModelCatalog.tsx.
  • useSidebar is called from inside AppLayout's SidebarProvider, which already wraps /models.
Open in Web Open in Cursor 

cursoragent and others added 2 commits April 27, 2026 09:41
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>
@aschkanAH aschkanAH marked this pull request as ready for review April 27, 2026 09:46
Comment thread dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx Outdated
Comment thread dashboard/src/components/features/models/catalog/ModelCatalog.tsx Outdated
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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread dashboard/src/components/features/models/catalog/modelFamily.ts
Comment thread dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx Outdated
…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>
@aschkanAH aschkanAH merged commit 0d7a8ea into feat-mobile-models Apr 27, 2026
1 check passed
@aschkanAH aschkanAH deleted the ai-mobile-catalog-6194 branch April 27, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants