From 9603c478c15b61e850b548a04c81b0a223d77382 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 09:41:01 +0000 Subject: [PATCH 1/4] feat(catalog): extract shared catalog utils + family aggregation 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 --- .../models/catalog/modelFamily.test.ts | 163 +++++++++++++++ .../features/models/catalog/modelFamily.ts | 192 ++++++++++++++++++ .../features/models/catalog/shared.ts | 101 +++++++++ 3 files changed, 456 insertions(+) create mode 100644 dashboard/src/components/features/models/catalog/modelFamily.test.ts create mode 100644 dashboard/src/components/features/models/catalog/modelFamily.ts create mode 100644 dashboard/src/components/features/models/catalog/shared.ts diff --git a/dashboard/src/components/features/models/catalog/modelFamily.test.ts b/dashboard/src/components/features/models/catalog/modelFamily.test.ts new file mode 100644 index 000000000..f50c5da44 --- /dev/null +++ b/dashboard/src/components/features/models/catalog/modelFamily.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import type { Model } from "../../../../api/control-layer/types"; +import { + aggregateFamilies, + computeNewCutoff, + nullsLast, + type AggregatedFamily, +} from "./modelFamily"; +import { getDisplayCapabilities } from "./shared"; + +function build(model: Partial & { id: string }): Model { + return { + alias: model.alias ?? model.id, + model_name: model.model_name ?? model.alias ?? model.id, + model_type: "CHAT", + is_composite: true, + metadata: null, + ...model, + } as Model; +} + +const baseOpts = { + newCutoff: "2025-12-31", + context: "async" as const, + providerLabelOf: (m: Model) => m.metadata?.provider ?? "Other", + providerIconOf: (m: Model) => m.metadata?.provider?.toLowerCase() ?? null, + displayCapabilitiesOf: getDisplayCapabilities, +}; + +describe("aggregateFamilies", () => { + it("groups variants sharing display_name into a single family", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + display_name: "Qwen 3.5 397B", + metadata: { + intelligence_index: 45, + context_window: 262144, + released_at: "2026-02-16", + provider: "Alibaba", + }, + }), + build({ + id: "b", + display_name: "Qwen 3.5 397B", + metadata: { + intelligence_index: 47, + context_window: 131072, + released_at: "2026-02-20", + provider: "Alibaba", + }, + }), + ], + baseOpts, + ); + expect(families).toHaveLength(1); + const fam = families[0]; + expect(fam.label).toBe("Qwen 3.5 397B"); + expect(fam.variants.map((v) => v.id)).toEqual(["a", "b"]); + expect(fam.intelligenceMax).toBe(47); + expect(fam.contextMax).toBe(262144); + expect(fam.releasedAt).toBe("2026-02-20"); + expect(fam.isNew).toBe(true); + }); + + it("falls back to canonicalised model_name when display_name is missing", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + model_name: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8", + alias: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP8", + }), + build({ + id: "b", + model_name: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP16", + alias: "Qwen/Qwen3-VL-30B-A3B-Instruct-FP16", + }), + ], + baseOpts, + ); + expect(families).toHaveLength(1); + expect(families[0].variants).toHaveLength(2); + }); + + it("preserves input order and selects the first variant as primary", () => { + const families = aggregateFamilies( + [ + build({ id: "first", display_name: "GLM 5.1" }), + build({ id: "other", display_name: "Kimi K2.6" }), + build({ id: "second", display_name: "GLM 5.1" }), + ], + baseOpts, + ); + expect(families.map((f) => f.id)).toEqual(["first", "other"]); + expect(families[0].variants.map((v) => v.id)).toEqual(["first", "second"]); + }); +}); + +describe("computeNewCutoff", () => { + it("subtracts the requested number of months", () => { + expect(computeNewCutoff(new Date("2026-04-15T00:00:00Z"), 3)).toBe( + "2026-01-15", + ); + }); +}); + +describe("nullsLast", () => { + type Item = { v: number | null }; + const cmp = (a: number, b: number) => a - b; + + it("sorts non-null values ascending when dir=1", () => { + const items: Item[] = [{ v: 3 }, { v: 1 }, { v: 2 }]; + items.sort(nullsLast((i) => i.v, 1, cmp)); + expect(items.map((i) => i.v)).toEqual([1, 2, 3]); + }); + + it("sorts non-null values descending when dir=-1", () => { + const items: Item[] = [{ v: 1 }, { v: 3 }, { v: 2 }]; + items.sort(nullsLast((i) => i.v, -1, cmp)); + expect(items.map((i) => i.v)).toEqual([3, 2, 1]); + }); + + it("keeps null/undefined values at the end regardless of direction", () => { + const ascending: Item[] = [ + { v: 2 }, + { v: null }, + { v: 1 }, + { v: null }, + ]; + ascending.sort(nullsLast((i) => i.v, 1, cmp)); + expect(ascending.map((i) => i.v)).toEqual([1, 2, null, null]); + + const descending: Item[] = [ + { v: 2 }, + { v: null }, + { v: 1 }, + { v: null }, + ]; + descending.sort(nullsLast((i) => i.v, -1, cmp)); + expect(descending.map((i) => i.v)).toEqual([2, 1, null, null]); + }); +}); + +describe("AggregatedFamily shape", () => { + it("retains all expected fields", () => { + const families = aggregateFamilies( + [ + build({ + id: "a", + display_name: "Test", + capabilities: ["vision"], + metadata: { provider: "Acme" }, + }), + ], + baseOpts, + ); + const fam: AggregatedFamily = families[0]; + expect(fam.providerLabel).toBe("Acme"); + expect(fam.capabilities).toContain("vision"); + }); +}); diff --git a/dashboard/src/components/features/models/catalog/modelFamily.ts b/dashboard/src/components/features/models/catalog/modelFamily.ts new file mode 100644 index 000000000..7d20ca83c --- /dev/null +++ b/dashboard/src/components/features/models/catalog/modelFamily.ts @@ -0,0 +1,192 @@ +import type { + Model, + ModelDisplayCategory, +} from "../../../../api/control-layer/types"; +import { + getCatalogTabForModel, + getCheapestInputPriceValue, + type PricingContext, +} from "./shared"; + +export interface AggregatedFamily { + /** Stable id derived from the primary variant. */ + id: string; + /** User-facing label, typically display_name of the primary variant. */ + label: string; + /** Lowercase provider key for icon lookup. */ + providerKey: string; + /** Human-readable provider name. */ + providerLabel: string; + /** Optional provider icon string (URL or initials). */ + providerIcon?: string | null; + /** Catalog category for the family. */ + category: ModelDisplayCategory | null; + /** Variants within the family, ordered with the primary variant first. */ + variants: Model[]; + /** Highest intelligence_index across variants (null if none have one). */ + intelligenceMax: number | null; + /** Cheapest visible input price (per token) across variants for the active pricing context. */ + priceFrom: number | null; + /** Largest context window across variants. */ + contextMax: number | null; + /** Most recent released_at across variants (ISO YYYY-MM-DD). */ + releasedAt: string | null; + /** Union of display capabilities across variants. */ + capabilities: string[]; + /** Whether the family was released after the new-cutoff date. */ + isNew: boolean; +} + +export interface AggregateFamiliesOptions { + /** ISO date (YYYY-MM-DD) below which a release is no longer considered "new". */ + newCutoff: string; + /** Pricing context to use when computing priceFrom across variants. */ + context: PricingContext; + /** Resolves a model to a human-readable provider label. */ + providerLabelOf: (model: Model) => string; + /** Resolves a model to an optional provider icon. */ + providerIconOf: (model: Model) => string | null | undefined; + /** Computes a list of display capabilities for a given variant. */ + displayCapabilitiesOf: (model: Model) => string[]; +} + +/** + * Build a stable family key for a model. We prefer `display_name` (which is + * curated to be the family-level label, with the variant suffix dropped). When + * `display_name` is absent we fall back to a heuristic on the `model_name` + * (strip the leading provider prefix and any trailing FP8/INT4/etc suffix). + */ +function familyKeyOf(model: Model): string { + const display = (model.display_name ?? "").trim(); + if (display) return display.toLowerCase(); + + const raw = model.model_name || model.alias; + const slashIdx = raw.indexOf("/"); + const tail = slashIdx >= 0 ? raw.slice(slashIdx + 1) : raw; + const withoutQuant = tail.replace(/-(?:fp8|fp16|int4|int8|nvfp4|q\d+)$/i, ""); + return withoutQuant.toLowerCase(); +} + +function maxNumber(a: number | null, b: number | null): number | null { + if (a == null) return b; + if (b == null) return a; + return Math.max(a, b); +} + +function minNumber(a: number | null, b: number | null): number | null { + if (a == null) return b; + if (b == null) return a; + return Math.min(a, b); +} + +function maxIso(a: string | null, b: string | null): string | null { + if (!a) return b; + if (!b) return a; + return a > b ? a : b; +} + +/** + * Aggregate a flat list of (composite) models into families. Each family + * groups all variants that share a `display_name` (or, lacking one, a + * canonicalised model_name), preserving the order in which the variants + * arrived from the API. The primary variant is the first one in the input. + */ +export function aggregateFamilies( + models: Model[], + options: AggregateFamiliesOptions, +): AggregatedFamily[] { + const { + newCutoff, + context, + providerLabelOf, + providerIconOf, + displayCapabilitiesOf, + } = options; + + const order: string[] = []; + const byKey = new Map(); + + for (const model of models) { + const key = familyKeyOf(model); + let family = byKey.get(key); + + const variantPrice = getCheapestInputPriceValue(model.tariffs, context); + const intelligence = model.metadata?.intelligence_index ?? null; + const ctx = model.metadata?.context_window ?? null; + const releasedAt = model.metadata?.released_at ?? null; + const variantCaps = displayCapabilitiesOf(model); + + if (!family) { + const label = + (model.display_name && model.display_name.trim()) || + model.alias || + model.model_name; + const providerLabel = providerLabelOf(model); + const providerIcon = providerIconOf(model) ?? null; + const providerKey = (model.metadata?.provider?.trim() || "Other") + .toLowerCase(); + + family = { + id: model.id, + label, + providerKey, + providerLabel, + providerIcon, + category: getCatalogTabForModel(model), + variants: [model], + intelligenceMax: intelligence, + priceFrom: variantPrice, + contextMax: ctx, + releasedAt, + capabilities: [...variantCaps], + isNew: !!(releasedAt && releasedAt >= newCutoff), + }; + byKey.set(key, family); + order.push(key); + continue; + } + + family.variants.push(model); + family.intelligenceMax = maxNumber(family.intelligenceMax, intelligence); + family.priceFrom = minNumber(family.priceFrom, variantPrice); + family.contextMax = maxNumber(family.contextMax, ctx); + family.releasedAt = maxIso(family.releasedAt, releasedAt); + for (const cap of variantCaps) { + if (!family.capabilities.includes(cap)) family.capabilities.push(cap); + } + if (releasedAt && releasedAt >= newCutoff) family.isNew = true; + } + + return order.map((k) => byKey.get(k) as AggregatedFamily); +} + +/** + * Compute the ISO date (YYYY-MM-DD) used as the "new" cutoff, given a number + * of months back from `now`. Pure function for testability. + */ +export function computeNewCutoff(now: Date, months: number): string { + const cutoff = new Date(now); + cutoff.setMonth(cutoff.getMonth() - months); + return cutoff.toISOString().slice(0, 10); +} + +/** + * Build a comparator that sorts items by `getKey`, applying `dir` (1=asc, + * -1=desc), with null/undefined keys ALWAYS sorted to the end regardless of + * direction. Falling back to a final tiebreaker is the caller's + * responsibility. + */ +export function nullsLast( + getKey: (item: T) => K | null | undefined, + dir: 1 | -1, + cmp: (a: K, b: K) => number, +): (a: T, b: T) => number { + return (a, b) => { + const av = getKey(a); + const bv = getKey(b); + if (av == null && bv == null) return 0; + if (av == null) return 1; + if (bv == null) return -1; + return cmp(av, bv) * dir; + }; +} diff --git a/dashboard/src/components/features/models/catalog/shared.ts b/dashboard/src/components/features/models/catalog/shared.ts new file mode 100644 index 000000000..13a63fccb --- /dev/null +++ b/dashboard/src/components/features/models/catalog/shared.ts @@ -0,0 +1,101 @@ +import type { + Model, + ModelDisplayCategory, +} from "../../../../api/control-layer/types"; +import { getUserFacingTariffs } from "../../../../utils/formatters"; + +/** UUID for the implicit "Everyone" group. */ +export const EVERYONE_GROUP_ID = "00000000-0000-0000-0000-000000000000"; + +/** + * Number of months a model is considered "new" since release. Used to derive + * the cutoff date for badge / new-section calculations. + */ +export const NEW_CUTOFF_MONTHS = 3; + +/** + * Capabilities that the catalog allows users to filter by. Order is meaningful + * for filter UIs. + */ +export const FILTERABLE_CAPABILITIES: { key: string; label: string }[] = [ + { key: "vision", label: "Vision" }, + { key: "reasoning", label: "Reasoning" }, + { key: "enhanced_structured_generation", label: "Structured" }, +]; + +/** Pricing context that controls which tariff window powers price aggregation. */ +export type PricingContext = "async" | "batch"; + +/** + * Compute the cheapest visible input price (per token) from a tariffs array. + * When `context` is provided, restricts to tariffs matching the context: + * - "async" → realtime tariffs (with completion_window omitted) + * - "batch" → batch tariffs (any window) + * Returns null when no matching tariff exists. + */ +export function getCheapestInputPriceValue( + tariffs: Model["tariffs"], + context?: PricingContext, +): number | null { + if (!tariffs) return null; + let visible = getUserFacingTariffs(tariffs); + if (context === "async") { + visible = visible.filter((t) => t.api_key_purpose !== "batch"); + } else if (context === "batch") { + visible = visible.filter((t) => t.api_key_purpose === "batch"); + } + if (visible.length === 0) return null; + let cheapest = Infinity; + for (const t of visible) { + const price = parseFloat(t.input_price_per_token); + if (price < cheapest) cheapest = price; + } + return Number.isFinite(cheapest) ? cheapest : null; +} + +/** + * Derive a stable, ordered list of display capabilities for a model. The + * primary capability is implied by `model_type` (text / embeddings); explicit + * capabilities (vision, reasoning, etc.) are appended. + */ +export function getDisplayCapabilities(model: Model): string[] { + const caps: string[] = []; + if (model.model_type === "CHAT") caps.push("text"); + else if (model.model_type === "EMBEDDINGS") caps.push("embeddings"); + if (model.capabilities) { + for (const c of model.capabilities) { + if (c !== "text" && c !== "embeddings" && !caps.includes(c)) { + caps.push(c); + } + } + } + return caps; +} + +/** + * Map a model into one of the catalog's top-level tabs. Returns null when the + * model has no recognised display category (it should be hidden in that case). + */ +export function getCatalogTabForModel( + model: Model, +): ModelDisplayCategory | null { + if (model.metadata?.display_category) { + return model.metadata.display_category; + } + if (model.model_type === "EMBEDDINGS") return "embedding"; + if (model.model_type === "CHAT" || model.model_type === "RERANKER") { + return "generation"; + } + return null; +} + +/** + * Format an ISO release date as a "Mon YYYY" string. The input must be a + * YYYY-MM-DD string (the metadata.released_at format). Invalid input is + * returned as-is to surface the data issue to the user. + */ +export function formatReleaseDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + if (Number.isNaN(date.getTime())) return dateStr; + return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} From 445e3ff607b3d4ac8c673493eb7a2013d1216a58 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 09:41:20 +0000 Subject: [PATCH 2/4] feat(catalog): mobile-optimised catalog view at --- .../models/catalog/MobileModelCatalog.tsx | 1190 +++++++++++++++++ .../features/models/catalog/ModelCatalog.tsx | 94 +- .../models/catalog/mobileUrlState.test.ts | 116 ++ .../features/models/catalog/mobileUrlState.ts | 160 +++ .../models/manage/MobileModelsView.tsx | 200 --- 5 files changed, 1488 insertions(+), 272 deletions(-) create mode 100644 dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx create mode 100644 dashboard/src/components/features/models/catalog/mobileUrlState.test.ts create mode 100644 dashboard/src/components/features/models/catalog/mobileUrlState.ts delete mode 100644 dashboard/src/components/features/models/manage/MobileModelsView.tsx diff --git a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx new file mode 100644 index 000000000..a3c7486fa --- /dev/null +++ b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx @@ -0,0 +1,1190 @@ +import { useEffect, useMemo, useState, useCallback } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + Menu, + Search, + SlidersHorizontal, + ExternalLink, + Code, + MessageSquare, + Eye, + Brain, + Layers, + Braces, + Copy, + Check, + ArrowDown, + ArrowUp, +} from "lucide-react"; +import { useModels, useGroups, useProviderDisplayConfigs } from "@/api/control-layer"; +import type { Model } from "@/api/control-layer/types"; +import { + useConfig, + useUser, + useUserBalance, +} from "@/api/control-layer/hooks"; +import { useSidebar } from "@/components/ui/sidebar"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Markdown } from "@/components/ui/markdown"; +import { ApiExamples } from "@/components/modals"; +import { + formatContextLength, + formatTariffPrice, +} from "@/utils/formatters"; +import { formatDollars } from "@/utils/money"; +import { copyToClipboard } from "@/utils/clipboard"; +import { useAuthorization } from "@/utils"; +import { isPlaygroundDenied } from "@/utils/modelAccess"; +import { + EVERYONE_GROUP_ID, + FILTERABLE_CAPABILITIES, + NEW_CUTOFF_MONTHS, + formatReleaseDate, + getCheapestInputPriceValue, + getDisplayCapabilities, +} from "./shared"; +import { + aggregateFamilies, + computeNewCutoff, + nullsLast, + type AggregatedFamily, +} from "./modelFamily"; +import { + DEFAULT_SORT_DIRECTIONS, + activeFilterCount, + defaultUrlState, + deserializeUrlState, + serializeUrlState, + type CatalogCategory, + type MobileCatalogUrlState, + type MobileSortField, + type SortDirection, +} from "./mobileUrlState"; + +const PRICING_CONTEXT_KEY = "catalog-pricing-context"; +type PricingContext = "async" | "batch"; + +const CATEGORY_TABS: { value: CatalogCategory; label: string }[] = [ + { value: "all", label: "All" }, + { value: "generation", label: "Generative" }, + { value: "embedding", label: "Embedding" }, + { value: "ocr", label: "OCR" }, +]; + +const CAPABILITY_ICON: Record> = { + text: MessageSquare, + vision: Eye, + reasoning: Brain, + embeddings: Layers, + enhanced_structured_generation: Braces, + code: Code, +}; + +const SORT_OPTIONS: { value: MobileSortField; label: string }[] = [ + { value: "intelligence", label: "Intelligence" }, + { value: "cost", label: "Cost" }, + { value: "context", label: "Context window" }, + { value: "released_at", label: "Release date" }, + { value: "alias", label: "Name" }, +]; + +function loadPricingContext(): PricingContext { + try { + const stored = localStorage.getItem(PRICING_CONTEXT_KEY); + if (stored === "batch" || stored === "async") return stored; + } catch { + // ignore (e.g. SSR or disabled storage) + } + return "async"; +} + +function CapabilityRow({ caps }: { caps: string[] }) { + return ( +
+ {caps.map((cap) => { + const Icon = CAPABILITY_ICON[cap]; + if (!Icon) return null; + return ( + + ); + })} +
+ ); +} + +interface FamilyCardProps { + family: AggregatedFamily; + onOpen: (modelId: string) => void; + pricingContext: PricingContext; +} + +function FamilyCard({ family, onOpen, pricingContext }: FamilyCardProps) { + const navigate = useNavigate(); + const primary = family.variants[0]; + const playgroundAvailable = !isPlaygroundDenied(primary); + const priceFrom = + family.priceFrom ?? + getCheapestInputPriceValue(primary.tariffs, pricingContext); + + return ( + onOpen(primary.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(primary.id); + } + }} + className="cursor-pointer p-4 gap-3 transition-colors hover:bg-doubleword-neutral-50 active:scale-[0.99]" + > +
+
+
+

+ {family.label} +

+ {family.isNew && ( + + New + + )} + {primary.metadata?.quantization && ( + + {primary.metadata.quantization} + + )} +
+
+ {family.providerLabel} +
+
+ +
+ +
+
+
+ Intel +
+
+ {family.intelligenceMax ?? "—"} +
+
+
+
+ Cost +
+
+ {priceFrom != null ? ( + <> + {formatTariffPrice(priceFrom)} + + {" "}/M + + + ) : ( + "——" + )} +
+
+
+
+ Context +
+
+ {family.contextMax != null + ? formatContextLength(family.contextMax) + : "—"} +
+
+
+ +
+ + +
+
+ ); +} + +interface FilterDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + state: MobileCatalogUrlState; + setState: ( + update: (prev: MobileCatalogUrlState) => MobileCatalogUrlState, + ) => void; + providerOptions: { key: string; label: string }[]; + groupOptions: { id: string; name: string }[]; + canManageGroups: boolean; + visibleCount: number; +} + +function FilterDrawer({ + open, + onOpenChange, + state, + setState, + providerOptions, + groupOptions, + canManageGroups, + visibleCount, +}: FilterDrawerProps) { + const toggleProvider = (key: string) => + setState((prev) => ({ + ...prev, + providers: prev.providers.includes(key) + ? prev.providers.filter((p) => p !== key) + : [...prev.providers, key].sort(), + })); + + const toggleCapability = (key: string) => + setState((prev) => ({ + ...prev, + capabilities: prev.capabilities.includes(key) + ? prev.capabilities.filter((c) => c !== key) + : [...prev.capabilities, key].sort(), + })); + + const toggleGroup = (id: string) => + setState((prev) => { + const isSelected = prev.groups.includes(id); + const next = isSelected + ? prev.groups.filter((g) => g !== id) + : [...prev.groups.filter((g) => g !== EVERYONE_GROUP_ID), id].sort(); + return { + ...prev, + groups: next.length === 0 ? [EVERYONE_GROUP_ID] : next, + }; + }); + + const setSort = (sort: MobileSortField) => + setState((prev) => ({ + ...prev, + sort, + dir: + prev.sort === sort ? prev.dir : DEFAULT_SORT_DIRECTIONS[sort], + })); + + const setDir = (dir: SortDirection) => + setState((prev) => ({ ...prev, dir })); + + const reset = () => + setState((prev) => ({ + ...defaultUrlState(), + // Keep search and modelId in URL across reset to be predictable + search: prev.search, + modelId: prev.modelId, + })); + + return ( + + + + Sort & Filter + + + +
+
+

+ Sort by +

+ +
+ + +
+
+ +
+ +
+

+ Providers +

+
+ {providerOptions.length === 0 ? ( +

+ No providers available +

+ ) : ( + providerOptions.map((p) => { + const active = state.providers.includes(p.key); + return ( + + ); + }) + )} +
+
+ +
+ +
+

+ Capabilities (has all) +

+
+ {FILTERABLE_CAPABILITIES.map((c) => { + const active = state.capabilities.includes(c.key); + return ( + + ); + })} +
+
+ + {canManageGroups && groupOptions.length > 0 && ( + <> +
+
+

+ Groups +

+
+ {groupOptions.map((g) => { + const active = state.groups.includes(g.id); + return ( + + ); + })} +
+
+ + )} +
+ +
+ +
+
+
+ ); +} + +interface DetailDrawerProps { + family: AggregatedFamily | null; + activeVariantId: string | null; + onVariantChange: (id: string) => void; + onClose: () => void; + pricingContext: PricingContext; +} + +const CAPABILITY_LABEL: Record = { + text: "Chat", + vision: "Vision", + reasoning: "Reasoning", + embeddings: "Embeddings", + enhanced_structured_generation: "Structured", + code: "Code", +}; + +function DetailDrawer({ + family, + activeVariantId, + onVariantChange, + onClose, + pricingContext, +}: DetailDrawerProps) { + const navigate = useNavigate(); + const [aliasCopied, setAliasCopied] = useState(false); + const [showApiExamples, setShowApiExamples] = useState(false); + + const activeVariant = useMemo(() => { + if (!family) return null; + return ( + family.variants.find((v) => v.id === activeVariantId) ?? + family.variants[0] + ); + }, [family, activeVariantId]); + + if (!family || !activeVariant) { + return ( + + + + ); + } + + const playgroundAvailable = !isPlaygroundDenied(activeVariant); + const cheapest = getCheapestInputPriceValue( + activeVariant.tariffs, + pricingContext, + ); + + return ( + <> + !o && onClose()}> + + + {family.label} +

+ {family.providerLabel} +

+
+ +
+ {family.variants.length > 1 && ( + + )} + +
+ + {activeVariant.alias} + + +
+ +
+
+
+ Intelligence +
+
+ {activeVariant.metadata?.intelligence_index ?? "—"} +
+
+
+
+ Context window +
+
+ {activeVariant.metadata?.context_window + ? formatContextLength(activeVariant.metadata.context_window) + : "—"} +
+
+
+
+ Released +
+
+ {activeVariant.metadata?.released_at + ? formatReleaseDate(activeVariant.metadata.released_at) + : "—"} +
+
+
+
+ Cost ({pricingContext}) +
+
+ {cheapest != null ? `${formatTariffPrice(cheapest)}/M` : "—"} +
+
+
+ +
+
+ Capabilities +
+
+ {getDisplayCapabilities(activeVariant).map((cap) => { + const Icon = CAPABILITY_ICON[cap]; + return ( + + {Icon && } + {CAPABILITY_LABEL[cap] ?? cap} + + ); + })} +
+
+ + {activeVariant.description && ( +
+
+ Description +
+ + {activeVariant.description} + +
+ )} +
+ +
+ + +
+
+
+ setShowApiExamples(false)} + model={activeVariant} + /> + + ); +} + +function MobileLoadingSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +export const MobileModelCatalog: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const urlState = useMemo( + () => deserializeUrlState(searchParams), + [searchParams], + ); + + const { hasPermission } = useAuthorization(); + const canManageGroups = hasPermission("manage-groups"); + const sidebar = useSidebar(); + const { data: currentUser } = useUser("current"); + const { data: balance, isLoading: balanceLoading } = useUserBalance( + currentUser?.id ?? "", + ); + const { data: config } = useConfig(); + const billingEnabled = !!config?.payment_enabled; + + // Local state for the search box (debounced into URL) + const [searchInput, setSearchInput] = useState(urlState.search); + useEffect(() => { + // Sync local input when the URL was changed externally (e.g. back button). + setSearchInput(urlState.search); + }, [urlState.search]); + + const [debouncedSearch, setDebouncedSearch] = useState(urlState.search); + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchInput), 250); + return () => clearTimeout(timer); + }, [searchInput]); + + // Persist the debounced search into the URL with replace semantics. + const updateUrl = useCallback( + ( + updater: (prev: MobileCatalogUrlState) => MobileCatalogUrlState, + options: { mode: "replace" | "push" } = { mode: "replace" }, + ) => { + // Read latest from current params instead of `urlState` to avoid stale + // state when batching updates. + const current = deserializeUrlState( + new URLSearchParams(window.location.search), + ); + const next = updater(current); + const params = serializeUrlState(next); + setSearchParams(params, { replace: options.mode === "replace" }); + }, + [setSearchParams], + ); + + useEffect(() => { + if (debouncedSearch === urlState.search) return; + updateUrl((prev) => ({ ...prev, search: debouncedSearch })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + const [pricingContext, setPricingContextState] = useState( + () => loadPricingContext(), + ); + const setPricingContext = (next: PricingContext) => { + setPricingContextState(next); + try { + localStorage.setItem(PRICING_CONTEXT_KEY, next); + } catch { + // ignore + } + }; + + const [filterOpen, setFilterOpen] = useState(false); + + // ----- API queries ----- + const groupFilter = + canManageGroups && + !(urlState.groups.length === 1 && urlState.groups[0] === EVERYONE_GROUP_ID) + ? urlState.groups.join(",") + : undefined; + + const { data, isLoading, isFetching, error, refetch } = useModels({ + search: debouncedSearch || undefined, + is_composite: true, + include: "pricing", + limit: 500, + group: groupFilter, + }); + + const { data: providerDisplayConfigs = [] } = useProviderDisplayConfigs(); + const { data: groupsData } = useGroups({ + limit: 100, + enabled: canManageGroups, + }); + + const providerConfigMap = useMemo( + () => + new Map( + providerDisplayConfigs.map((c) => [c.provider_key.toLowerCase(), c]), + ), + [providerDisplayConfigs], + ); + + const providerLabelOf = useCallback( + (m: Model) => { + const key = (m.metadata?.provider?.trim() || "Other").toLowerCase(); + const cfg = providerConfigMap.get(key); + return cfg?.display_name || m.metadata?.provider?.trim() || "Other"; + }, + [providerConfigMap], + ); + const providerIconOf = useCallback( + (m: Model) => { + const key = (m.metadata?.provider?.trim() || "Other").toLowerCase(); + return providerConfigMap.get(key)?.icon ?? null; + }, + [providerConfigMap], + ); + + // ----- Aggregate families. Re-runs when pricingContext changes. ----- + const newCutoff = useMemo( + () => computeNewCutoff(new Date(), NEW_CUTOFF_MONTHS), + [], + ); + const allFamilies = useMemo( + () => + aggregateFamilies(data?.data ?? [], { + newCutoff, + context: pricingContext, + providerLabelOf, + providerIconOf, + displayCapabilitiesOf: getDisplayCapabilities, + }), + [ + data?.data, + newCutoff, + pricingContext, + providerLabelOf, + providerIconOf, + ], + ); + + // Provider options derived from current data; key = lowercase provider. + const providerOptions = useMemo(() => { + const seen = new Map(); + for (const fam of allFamilies) { + if (!seen.has(fam.providerKey)) { + seen.set(fam.providerKey, fam.providerLabel); + } + } + return [...seen.entries()] + .map(([key, label]) => ({ key, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [allFamilies]); + + const groupOptions = useMemo( + () => + (groupsData?.data ?? []).map((g) => ({ id: g.id, name: g.name })), + [groupsData?.data], + ); + + // ----- Client-side filter pipeline ----- + const filteredFamilies = useMemo(() => { + let result = allFamilies; + + if (urlState.category !== "all") { + result = result.filter((f) => f.category === urlState.category); + } + + if (urlState.providers.length > 0) { + const providerSet = new Set(urlState.providers); + result = result.filter((f) => providerSet.has(f.providerKey)); + } + + if (urlState.capabilities.length > 0) { + result = result.filter((f) => + urlState.capabilities.every((cap) => f.capabilities.includes(cap)), + ); + } + + return result; + }, [ + allFamilies, + urlState.category, + urlState.providers, + urlState.capabilities, + ]); + + // ----- Sorting (always nullsLast) ----- + const sortedFamilies = useMemo(() => { + const dir: 1 | -1 = urlState.dir === "asc" ? 1 : -1; + const list = [...filteredFamilies]; + const numericCmp = (a: number, b: number) => a - b; + const stringCmp = (a: string, b: string) => a.localeCompare(b); + + switch (urlState.sort) { + case "intelligence": + list.sort( + nullsLast( + (f) => f.intelligenceMax, + dir, + numericCmp, + ), + ); + break; + case "cost": + list.sort( + nullsLast( + (f) => f.priceFrom, + dir, + numericCmp, + ), + ); + break; + case "context": + list.sort( + nullsLast( + (f) => f.contextMax, + dir, + numericCmp, + ), + ); + break; + case "released_at": + list.sort( + nullsLast( + (f) => f.releasedAt, + dir, + stringCmp, + ), + ); + break; + case "alias": + list.sort( + nullsLast( + (f) => f.label, + dir, + stringCmp, + ), + ); + break; + } + + return list; + }, [filteredFamilies, urlState.sort, urlState.dir]); + + // ----- Drawer handling ----- + const openModel = (id: string) => + updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "push" }); + const closeModel = () => + updateUrl((prev) => ({ ...prev, modelId: null }), { mode: "push" }); + const setActiveVariant = (id: string) => + updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "replace" }); + + const drawerFamily = useMemo(() => { + if (!urlState.modelId) return null; + return ( + allFamilies.find((f) => + f.variants.some((v) => v.id === urlState.modelId), + ) ?? null + ); + }, [allFamilies, urlState.modelId]); + + const filterCount = activeFilterCount(urlState); + const showSkeleton = isLoading && !data; + + const clearAll = () => { + setSearchInput(""); + setDebouncedSearch(""); + updateUrl(() => ({ ...defaultUrlState() })); + }; + + return ( +
+
+
+ + {billingEnabled && currentUser ? ( +
+ Balance: + {!balanceLoading && ( + + {formatDollars(balance ?? 0)} + + )} +
+ ) : ( +
+ )} + + Docs + + +
+ +

+ Models +

+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search models..." + aria-label="Search models" + className="pl-9" + /> +
+ +
+ +
+ {CATEGORY_TABS.map((tab) => { + const active = urlState.category === tab.value; + return ( + + ); + })} +
+ +
+ + Pricing + +
+ {(["async", "batch"] as const).map((opt) => ( + + ))} +
+
+
+ +
+ {error ? ( +
+

+ Failed to load models. +

+ +
+ ) : showSkeleton ? ( + + ) : sortedFamilies.length === 0 ? ( +
+

+ No models found +

+

+ Try adjusting your filters or search. +

+ +
+ ) : ( + <> + {isFetching && !showSkeleton && ( +
+ Updating… +
+ )} + {sortedFamilies.map((family) => ( + + ))} + + )} +
+ + updateUrl(updater)} + providerOptions={providerOptions} + groupOptions={groupOptions} + canManageGroups={canManageGroups} + visibleCount={sortedFamilies.length} + /> + + {drawerFamily && ( + + )} +
+ ); +}; + +export default MobileModelCatalog; diff --git a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx index d7b5b1164..6f57d6f33 100644 --- a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx +++ b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx @@ -57,11 +57,14 @@ import { import { Skeleton } from "../../../ui/skeleton"; import { ApiExamples } from "../../../modals"; import { CatalogIcon } from "./CatalogIcon"; -import { useIsMobile } from "../../../../hooks/use-mobile"; -import { MobileModelsView } from "../manage/MobileModelsView"; - - -const EVERYONE_GROUP_ID = "00000000-0000-0000-0000-000000000000"; +import { MobileModelCatalog } from "./MobileModelCatalog"; +import { + EVERYONE_GROUP_ID, + formatReleaseDate, + getCatalogTabForModel, + getCheapestInputPriceValue, + getDisplayCapabilities, +} from "./shared"; const MODEL_PURPOSE_SECTIONS: { type: ModelDisplayCategory; label: string }[] = [ { type: "generation", label: "Generation" }, @@ -88,62 +91,9 @@ const DEFAULT_SORT_DIRECTIONS: Partial> = price_from: "asc", }; -function formatReleaseDate(dateStr: string): string { - const date = new Date(dateStr + "T00:00:00"); - return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); -} - function getCheapestInputPrice(tariffs: Model["tariffs"]): string | null { - if (!tariffs) return null; - const visible = getUserFacingTariffs(tariffs); - if (visible.length === 0) return null; - let cheapest = Infinity; - for (const t of visible) { - const price = parseFloat(t.input_price_per_token); - if (price < cheapest) cheapest = price; - } - if (!isFinite(cheapest)) return null; - return formatTariffPrice(String(cheapest)); -} - -function getCheapestInputPriceValue(tariffs: Model["tariffs"]): number | null { - if (!tariffs) return null; - const visible = getUserFacingTariffs(tariffs); - if (visible.length === 0) return null; - let cheapest = Infinity; - for (const t of visible) { - const price = parseFloat(t.input_price_per_token); - if (price < cheapest) cheapest = price; - } - return Number.isFinite(cheapest) ? cheapest : null; -} - -/** Derive display capabilities from model type + backend capabilities. */ -function getDisplayCapabilities(model: Model): string[] { - const caps: string[] = []; - // Implicit capability from model type - if (model.model_type === "CHAT") caps.push("text"); - else if (model.model_type === "EMBEDDINGS") caps.push("embeddings"); - // Explicit capabilities from backend (vision, reasoning, etc.) - if (model.capabilities) { - for (const c of model.capabilities) { - if (c !== "text" && c !== "embeddings" && !caps.includes(c)) { - caps.push(c); - } - } - } - return caps; -} - -function getCatalogTabForModel(model: Model): ModelDisplayCategory | null { - if (model.metadata?.display_category) { - return model.metadata.display_category; - } - if (model.model_type === "EMBEDDINGS") return "embedding"; - if (model.model_type === "CHAT" || model.model_type === "RERANKER") { - return "generation"; - } - return null; + const value = getCheapestInputPriceValue(tariffs); + return value == null ? null : formatTariffPrice(String(value)); } function CapabilityIcons({ capabilities }: { capabilities: string[] }) { @@ -780,9 +730,8 @@ function LoadingSkeleton() { ); } -export const ModelCatalog: React.FC = () => { +const DesktopModelCatalog: React.FC = () => { const navigate = useNavigate(); - const isMobile = useIsMobile(); const { hasPermission } = useAuthorization(); const canManageGroups = hasPermission("manage-groups"); @@ -883,15 +832,15 @@ export const ModelCatalog: React.FC = () => { const hasAnyFilters = !!debouncedSearch; return ( -
+
{/* Header */}

Models

-
- {canManageGroups && !isMobile && ( +
+ {canManageGroups && (
Group: @@ -985,7 +934,7 @@ export const ModelCatalog: React.FC = () => {
)} -
+
{ ? "No models matching your filters" : "No models available"}
- ) : isMobile ? ( - navigate(`/models/${id}`)} - /> ) : (
{sections.map((section) => ( @@ -1047,4 +990,11 @@ export const ModelCatalog: React.FC = () => { ); }; +export const ModelCatalog: React.FC = () => ( + <> + + + +); + export default ModelCatalog; diff --git a/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts b/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts new file mode 100644 index 000000000..8a55a1398 --- /dev/null +++ b/dashboard/src/components/features/models/catalog/mobileUrlState.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + defaultUrlState, + deserializeUrlState, + serializeUrlState, + activeFilterCount, + type MobileCatalogUrlState, +} from "./mobileUrlState"; +import { EVERYONE_GROUP_ID } from "./shared"; + +function roundtrip(state: MobileCatalogUrlState): MobileCatalogUrlState { + return deserializeUrlState(serializeUrlState(state)); +} + +describe("mobileUrlState", () => { + it("round-trips the default state to an empty URL", () => { + const state = defaultUrlState(); + const params = serializeUrlState(state); + expect(params.toString()).toBe(""); + expect(roundtrip(state)).toEqual(state); + }); + + it("omits dir when it matches the default for the active sort", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + sort: "cost", + dir: "asc", + }; + const params = serializeUrlState(state); + expect(params.get("sort")).toBe("cost"); + expect(params.get("dir")).toBeNull(); + expect(roundtrip(state)).toEqual(state); + }); + + it("emits dir when it diverges from the default", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + sort: "cost", + dir: "desc", + }; + const params = serializeUrlState(state); + expect(params.get("dir")).toBe("desc"); + expect(roundtrip(state)).toEqual(state); + }); + + it("encodes repeated providers/capabilities alphabetically", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + providers: ["openai", "alibaba", "moonshot"], + capabilities: ["vision", "reasoning"], + }; + const params = serializeUrlState(state); + expect(params.getAll("providers")).toEqual([ + "alibaba", + "moonshot", + "openai", + ]); + expect(params.getAll("capabilities")).toEqual(["reasoning", "vision"]); + expect(roundtrip(state)).toEqual({ + ...state, + providers: ["alibaba", "moonshot", "openai"], + capabilities: ["reasoning", "vision"], + }); + }); + + it("treats the default Everyone group as omitted", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + groups: [EVERYONE_GROUP_ID], + }; + expect(serializeUrlState(state).getAll("groups")).toEqual([]); + + const customGroups: MobileCatalogUrlState = { + ...defaultUrlState(), + groups: ["bb-group", "aa-group"], + }; + const params = serializeUrlState(customGroups); + expect(params.getAll("groups")).toEqual(["aa-group", "bb-group"]); + expect(roundtrip(customGroups)).toEqual({ + ...customGroups, + groups: ["aa-group", "bb-group"], + }); + }); + + it("preserves modelId when set", () => { + const state: MobileCatalogUrlState = { + ...defaultUrlState(), + modelId: "abc-123", + }; + expect(serializeUrlState(state).get("modelId")).toBe("abc-123"); + expect(roundtrip(state)).toEqual(state); + }); + + it("falls back to defaults for invalid params", () => { + const params = new URLSearchParams("category=bogus&sort=garbage&dir=sideways"); + const state = deserializeUrlState(params); + expect(state.category).toBe("all"); + expect(state.sort).toBe("intelligence"); + expect(state.dir).toBe("desc"); + }); + + it("counts active filters", () => { + expect(activeFilterCount(defaultUrlState())).toBe(0); + expect( + activeFilterCount({ ...defaultUrlState(), providers: ["x"] }), + ).toBe(1); + expect( + activeFilterCount({ + ...defaultUrlState(), + providers: ["x"], + capabilities: ["vision"], + groups: ["custom"], + }), + ).toBe(3); + }); +}); diff --git a/dashboard/src/components/features/models/catalog/mobileUrlState.ts b/dashboard/src/components/features/models/catalog/mobileUrlState.ts new file mode 100644 index 000000000..dcbd47e53 --- /dev/null +++ b/dashboard/src/components/features/models/catalog/mobileUrlState.ts @@ -0,0 +1,160 @@ +import { EVERYONE_GROUP_ID } from "./shared"; + +/** Top-level catalog category. `all` is encoded by omitting the param. */ +export type CatalogCategory = "all" | "generation" | "embedding" | "ocr"; + +/** Sort field exposed via the mobile UI. Composite-aware. */ +export type MobileSortField = + | "intelligence" + | "cost" + | "context" + | "released_at" + | "alias"; + +export type SortDirection = "asc" | "desc"; + +/** + * Default sort direction per field. The URL serialiser emits `dir` only when + * it differs from the default, so the absence of `dir` implies the default. + */ +export const DEFAULT_SORT_DIRECTIONS: Record = { + intelligence: "desc", + released_at: "desc", + context: "desc", + cost: "asc", + alias: "asc", +}; + +export interface MobileCatalogUrlState { + search: string; + category: CatalogCategory; + providers: string[]; + capabilities: string[]; + groups: string[]; + sort: MobileSortField; + dir: SortDirection; + modelId: string | null; +} + +/** + * Empty / default URL state. Used by the empty-state "Clear all filters" + * action to produce a canonical reset URL (preserves nothing). + */ +export function defaultUrlState(): MobileCatalogUrlState { + return { + search: "", + category: "all", + providers: [], + capabilities: [], + groups: [EVERYONE_GROUP_ID], + sort: "intelligence", + dir: DEFAULT_SORT_DIRECTIONS.intelligence, + modelId: null, + }; +} + +const VALID_CATEGORIES: ReadonlySet = new Set([ + "all", + "generation", + "embedding", + "ocr", +]); + +const VALID_SORTS: ReadonlySet = new Set([ + "intelligence", + "cost", + "context", + "released_at", + "alias", +]); + +/** + * Decode URLSearchParams into a typed mobile catalog state. Unknown / invalid + * values fall back to defaults so a hand-crafted URL never crashes the UI. + */ +export function deserializeUrlState( + params: URLSearchParams, +): MobileCatalogUrlState { + const rawCategory = params.get("category"); + const category: CatalogCategory = + rawCategory && VALID_CATEGORIES.has(rawCategory as CatalogCategory) + ? (rawCategory as CatalogCategory) + : "all"; + + const rawSort = params.get("sort"); + const sort: MobileSortField = + rawSort && VALID_SORTS.has(rawSort as MobileSortField) + ? (rawSort as MobileSortField) + : "intelligence"; + + const rawDir = params.get("dir"); + const dir: SortDirection = + rawDir === "asc" || rawDir === "desc" + ? (rawDir as SortDirection) + : DEFAULT_SORT_DIRECTIONS[sort]; + + const providers = [...params.getAll("providers")].sort(); + const capabilities = [...params.getAll("capabilities")].sort(); + const groupsRaw = params.getAll("groups"); + const groups = + groupsRaw.length === 0 ? [EVERYONE_GROUP_ID] : [...groupsRaw].sort(); + + return { + search: params.get("search") ?? "", + category, + providers, + capabilities, + groups, + sort, + dir, + modelId: params.get("modelId"), + }; +} + +/** + * Encode a mobile catalog state into URLSearchParams using stable key order + * and alphabetical repeated-param order. Defaults are omitted to keep URLs + * short and to make `serialize(deserialize(x))` idempotent. + */ +export function serializeUrlState( + state: MobileCatalogUrlState, +): URLSearchParams { + const params = new URLSearchParams(); + + if (state.search) params.set("search", state.search); + if (state.category !== "all") params.set("category", state.category); + + for (const p of [...state.providers].sort()) params.append("providers", p); + for (const c of [...state.capabilities].sort()) { + params.append("capabilities", c); + } + + const groupsAreDefault = + state.groups.length === 1 && state.groups[0] === EVERYONE_GROUP_ID; + if (!groupsAreDefault) { + for (const g of [...state.groups].sort()) params.append("groups", g); + } + + if (state.sort !== "intelligence") params.set("sort", state.sort); + if (state.dir !== DEFAULT_SORT_DIRECTIONS[state.sort]) { + params.set("dir", state.dir); + } + + if (state.modelId) params.set("modelId", state.modelId); + + return params; +} + +/** + * Compose the count of "real" filters (providers/capabilities/groups) that + * differ from the default. Used to badge the filter button. + */ +export function activeFilterCount(state: MobileCatalogUrlState): number { + let count = 0; + if (state.providers.length > 0) count += 1; + if (state.capabilities.length > 0) count += 1; + const groupsAreDefault = + state.groups.length === 1 && state.groups[0] === EVERYONE_GROUP_ID; + if (!groupsAreDefault) count += 1; + return count; +} diff --git a/dashboard/src/components/features/models/manage/MobileModelsView.tsx b/dashboard/src/components/features/models/manage/MobileModelsView.tsx deleted file mode 100644 index 7a5be8e54..000000000 --- a/dashboard/src/components/features/models/manage/MobileModelsView.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useState, useMemo } from "react"; -import type { Model, ProviderDisplayConfig } from "../../../../api/control-layer/types"; -import { CatalogIcon } from "../catalog/CatalogIcon"; - -type FilterType = "category" | "capability"; - -const FILTERS: { key: string; label: string; type: FilterType }[] = [ - { key: "generation", label: "Generative", type: "category" }, - { key: "embedding", label: "Embedding", type: "category" }, - { key: "ocr", label: "OCR", type: "category" }, - { key: "vision", label: "Vision", type: "capability" }, - { key: "reasoning", label: "Reasoning", type: "capability" }, - { key: "enhanced_structured_generation", label: "Enhanced Structured Generation", type: "capability" }, -]; - -function getModelCategory(model: Model): string { - if (model.metadata?.display_category) return model.metadata.display_category; - if (model.model_type === "EMBEDDINGS") return "embedding"; - return "generation"; -} - -function matchesFilter(model: Model, key: string, type: FilterType): boolean { - if (type === "category") return getModelCategory(model) === key; - return model.capabilities?.includes(key) ?? false; -} - -function sortByNewest(models: Model[]): Model[] { - return [...models].sort((a, b) => - (b.metadata?.released_at || "").localeCompare(a.metadata?.released_at || ""), - ); -} - -interface SwimlaneCardProps { - model: Model; - onTap: () => void; -} - -const SwimlaneCard: React.FC = ({ - model, - onTap, -}) => ( - -); - -interface SwimlaneProps { - title: string; - titleIcon?: string; - models: Model[]; - onNavigate: (modelId: string) => void; -} - -const Swimlane: React.FC = ({ - title, - titleIcon, - models, - onNavigate, -}) => ( -
-
- {titleIcon && ( - - )} -

- {title} -

-
-
    - {models.map((model) => ( -
  • - onNavigate(model.id)} - /> -
  • - ))} -
-
-); - -export interface MobileModelsViewProps { - models: Model[]; - providerConfigMap: Map; - onNavigate: (modelId: string) => void; -} - -export const MobileModelsView: React.FC = ({ - models, - providerConfigMap, - onNavigate, -}) => { - const [activeFilter, setActiveFilter] = useState("all"); - - const filtered = useMemo(() => { - if (activeFilter === "all") return models; - const filter = FILTERS.find((f) => f.key === activeFilter); - if (!filter) return models; - return models.filter((m) => matchesFilter(m, filter.key, filter.type)); - }, [models, activeFilter]); - - const newModels = useMemo(() => { - const withDate = filtered.filter((m) => m.metadata?.released_at); - if (withDate.length === 0) return []; - return sortByNewest(withDate).slice(0, 3); - }, [filtered]); - - const providerGroups = useMemo(() => { - const groups: Record = {}; - filtered.forEach((m) => { - const rawProvider = m.metadata?.provider || "Other"; - const key = rawProvider.toLowerCase().trim(); - if (!groups[key]) { - const config = providerConfigMap.get(key); - groups[key] = { - displayName: config?.display_name || rawProvider, - models: [], - }; - } - groups[key].models.push(m); - }); - return Object.entries(groups) - .sort((a, b) => b[1].models.length - a[1].models.length) - .map(([key, { displayName, models: laneModels }]) => ({ - key, - displayName, - models: sortByNewest(laneModels), - })); - }, [filtered, providerConfigMap]); - - return ( -
- {/* Filter chips */} -
- - {FILTERS.map(({ key, label }) => ( - - ))} -
- - {filtered.length === 0 ? ( -
- No models match this filter -
- ) : ( - <> - {newModels.length > 0 && ( - - )} - - {providerGroups.map(({ key, displayName, models: laneModels }) => { - const config = providerConfigMap.get(key); - return ( - - ); - })} - - )} -
- ); -}; From 391f0d317b8e914d0855fe645f79a8605acb3fd1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 10:07:42 +0000 Subject: [PATCH 3/4] fix(catalog): gate sibling list query + pop history on drawer close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dashboard/src/api/control-layer/hooks.ts | 8 +-- .../models/catalog/MobileModelCatalog.tsx | 52 +++++++++++++++++-- .../features/models/catalog/ModelCatalog.tsx | 26 +++++++--- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/dashboard/src/api/control-layer/hooks.ts b/dashboard/src/api/control-layer/hooks.ts index 34dc9c005..7ab28aa1f 100644 --- a/dashboard/src/api/control-layer/hooks.ts +++ b/dashboard/src/api/control-layer/hooks.ts @@ -148,13 +148,15 @@ export function useDeleteUser() { } // Models hooks -export function useModels(options?: ModelsQuery) { +export function useModels(options?: ModelsQuery & { enabled?: boolean }) { const queryClient = useQueryClient(); + const { enabled = true, ...queryOptions } = options || {}; return useQuery({ - queryKey: queryKeys.models.query(options), - queryFn: () => dwctlApi.models.list(options), + queryKey: queryKeys.models.query(queryOptions), + queryFn: () => dwctlApi.models.list(queryOptions), placeholderData: keepPreviousData, + enabled, // Populate individual model caches when list is fetched select: (data) => { // Seed the cache with individual models diff --git a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx index a3c7486fa..5d63aaa3b 100644 --- a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx +++ b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useCallback } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Menu, @@ -726,7 +726,16 @@ function MobileLoadingSkeleton() { ); } -export const MobileModelCatalog: React.FC = () => { +interface MobileModelCatalogProps { + /** True when the viewport is below the md breakpoint. Drives query gating + * so we don't fire a duplicate list request while the desktop view owns + * the fetch (or vice versa). */ + isMobile: boolean; +} + +export const MobileModelCatalog: React.FC = ({ + isMobile, +}) => { const [searchParams, setSearchParams] = useSearchParams(); const urlState = useMemo( () => deserializeUrlState(searchParams), @@ -807,6 +816,11 @@ export const MobileModelCatalog: React.FC = () => { include: "pricing", limit: 500, group: groupFilter, + // Avoid issuing the mobile query (and a duplicate desktop query) when + // the desktop view is the one actually rendered. The viewport-only + // wrappers (`hidden md:block` / `block md:hidden`) keep both views + // mounted, but only the active one should hit the network. + enabled: isMobile, }); const { data: providerDisplayConfigs = [] } = useProviderDisplayConfigs(); @@ -967,10 +981,38 @@ export const MobileModelCatalog: React.FC = () => { }, [filteredFamilies, urlState.sort, urlState.dir]); // ----- Drawer handling ----- - const openModel = (id: string) => + // Tracks whether the current topmost history entry is one we pushed to + // open the drawer. When that is the case, closing the drawer should pop + // history so the user doesn't have to press Back twice (once to no-op + // through the close-replace, then once to actually leave the page). + const ownsHistoryEntry = useRef(false); + const navigate = useNavigate(); + + // If the URL drops modelId via any path other than `closeModel` (popstate, + // navigation away, etc.) we no longer own a history entry to pop. Keep the + // ref in sync with the URL so a stale `true` can't make a future Close + // navigate too far back. + useEffect(() => { + if (!urlState.modelId) ownsHistoryEntry.current = false; + }, [urlState.modelId]); + + const openModel = (id: string) => { + ownsHistoryEntry.current = true; updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "push" }); - const closeModel = () => - updateUrl((prev) => ({ ...prev, modelId: null }), { mode: "push" }); + }; + const closeModel = () => { + if (ownsHistoryEntry.current) { + // Reverse the push: this both removes modelId AND removes the + // intermediate history entry, so a subsequent Back leaves the page + // instead of reopening the drawer. + ownsHistoryEntry.current = false; + navigate(-1); + return; + } + // Drawer was opened by a deep link / external navigation; we don't own + // the topmost entry, so just strip modelId in place. + updateUrl((prev) => ({ ...prev, modelId: null }), { mode: "replace" }); + }; const setActiveVariant = (id: string) => updateUrl((prev) => ({ ...prev, modelId: id }), { mode: "replace" }); diff --git a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx index 6f57d6f33..73bbe6a75 100644 --- a/dashboard/src/components/features/models/catalog/ModelCatalog.tsx +++ b/dashboard/src/components/features/models/catalog/ModelCatalog.tsx @@ -57,6 +57,7 @@ import { import { Skeleton } from "../../../ui/skeleton"; import { ApiExamples } from "../../../modals"; import { CatalogIcon } from "./CatalogIcon"; +import { useIsMobile } from "../../../../hooks/use-mobile"; import { MobileModelCatalog } from "./MobileModelCatalog"; import { EVERYONE_GROUP_ID, @@ -730,7 +731,7 @@ function LoadingSkeleton() { ); } -const DesktopModelCatalog: React.FC = () => { +const DesktopModelCatalog: React.FC<{ isMobile: boolean }> = ({ isMobile }) => { const navigate = useNavigate(); const { hasPermission } = useAuthorization(); const canManageGroups = hasPermission("manage-groups"); @@ -778,6 +779,10 @@ const DesktopModelCatalog: React.FC = () => { search: debouncedSearch || undefined, sort: "released_at", sort_direction: "desc", + // The mobile sibling fetches its own list with different params; gate on + // viewport so only the active view triggers a network request rather + // than burning two on every page load. + enabled: !isMobile, }); const { data: providerDisplayConfigs = [] } = useProviderDisplayConfigs(); @@ -990,11 +995,18 @@ const DesktopModelCatalog: React.FC = () => { ); }; -export const ModelCatalog: React.FC = () => ( - <> - - - -); +export const ModelCatalog: React.FC = () => { + // Both children mount unconditionally so the layout switch is CSS-only + // (preserves SSR/initial-paint, no flicker). The viewport flag is plumbed + // through so each child can gate its own data fetches and avoid issuing + // two distinct list queries with non-overlapping cache keys. + const isMobile = useIsMobile(); + return ( + <> + + + + ); +}; export default ModelCatalog; From fed29aa15d8a50cc1dc0ed9cb1dc0637cc0672d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 10:21:53 +0000 Subject: [PATCH 4/4] fix(catalog): dedupe PricingContext + clamp computeNewCutoff month overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../models/catalog/MobileModelCatalog.tsx | 2 +- .../models/catalog/modelFamily.test.ts | 19 ++++++++++++++++--- .../features/models/catalog/modelFamily.ts | 19 ++++++++++++++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx index 5d63aaa3b..ca6790dfa 100644 --- a/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx +++ b/dashboard/src/components/features/models/catalog/MobileModelCatalog.tsx @@ -59,6 +59,7 @@ import { formatReleaseDate, getCheapestInputPriceValue, getDisplayCapabilities, + type PricingContext, } from "./shared"; import { aggregateFamilies, @@ -79,7 +80,6 @@ import { } from "./mobileUrlState"; const PRICING_CONTEXT_KEY = "catalog-pricing-context"; -type PricingContext = "async" | "batch"; const CATEGORY_TABS: { value: CatalogCategory; label: string }[] = [ { value: "all", label: "All" }, diff --git a/dashboard/src/components/features/models/catalog/modelFamily.test.ts b/dashboard/src/components/features/models/catalog/modelFamily.test.ts index f50c5da44..3a6d7c4ae 100644 --- a/dashboard/src/components/features/models/catalog/modelFamily.test.ts +++ b/dashboard/src/components/features/models/catalog/modelFamily.test.ts @@ -100,9 +100,22 @@ describe("aggregateFamilies", () => { describe("computeNewCutoff", () => { it("subtracts the requested number of months", () => { - expect(computeNewCutoff(new Date("2026-04-15T00:00:00Z"), 3)).toBe( - "2026-01-15", - ); + expect(computeNewCutoff(new Date(2026, 3, 15), 3)).toBe("2026-01-15"); + }); + + it("clamps to the last day of the target month when the source day overflows", () => { + // May has 31 days, February doesn't. Naive setMonth(getMonth() - 3) on + // May 31 produces "Feb 31" → JS normalises to early March, pushing the + // cutoff days forward. The clamped impl must stay in February. + expect(computeNewCutoff(new Date(2026, 4, 31), 3)).toBe("2026-02-28"); + // Leap year: target Feb has 29 days. + expect(computeNewCutoff(new Date(2024, 4, 31), 3)).toBe("2024-02-29"); + // March 31 → Nov 30 of previous year (Nov has 30 days). + expect(computeNewCutoff(new Date(2026, 2, 31), 4)).toBe("2025-11-30"); + }); + + it("crosses year boundaries correctly", () => { + expect(computeNewCutoff(new Date(2026, 0, 15), 3)).toBe("2025-10-15"); }); }); diff --git a/dashboard/src/components/features/models/catalog/modelFamily.ts b/dashboard/src/components/features/models/catalog/modelFamily.ts index 7d20ca83c..c96df25cc 100644 --- a/dashboard/src/components/features/models/catalog/modelFamily.ts +++ b/dashboard/src/components/features/models/catalog/modelFamily.ts @@ -163,11 +163,24 @@ export function aggregateFamilies( /** * Compute the ISO date (YYYY-MM-DD) used as the "new" cutoff, given a number * of months back from `now`. Pure function for testability. + * + * Implementation note: a naive `setMonth(getMonth() - months)` overflows + * silently when the source day exceeds the target month's length (e.g. + * May 31 → "Feb 31" → Mar 3, three days later than intended), pushing the + * cutoff forward and incorrectly demoting borderline-new models. We clamp + * the day to the last day of the target month to avoid that wrap-around. */ export function computeNewCutoff(now: Date, months: number): string { - const cutoff = new Date(now); - cutoff.setMonth(cutoff.getMonth() - months); - return cutoff.toISOString().slice(0, 10); + const year = now.getFullYear(); + const monthIdx = now.getMonth() - months; + // Day 0 of (target+1) is the last day of the target month — used to clamp. + const lastDayOfTargetMonth = new Date(year, monthIdx + 1, 0).getDate(); + const day = Math.min(now.getDate(), lastDayOfTargetMonth); + const cutoff = new Date(year, monthIdx, day); + const isoYear = String(cutoff.getFullYear()).padStart(4, "0"); + const isoMonth = String(cutoff.getMonth() + 1).padStart(2, "0"); + const isoDay = String(cutoff.getDate()).padStart(2, "0"); + return `${isoYear}-${isoMonth}-${isoDay}`; } /**