diff --git a/ui/app/model/loading.tsx b/ui/app/model/loading.tsx
new file mode 100644
index 00000000..390be4d8
--- /dev/null
+++ b/ui/app/model/loading.tsx
@@ -0,0 +1,23 @@
+import { AppPageShell } from '@/components/app-page-shell';
+import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
+
+/**
+ * Route-level fallback so clicking "Models" lands on the page immediately
+ * instead of holding the previous route until this one's chunk is parsed.
+ */
+export default function ModelPageLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/components/model-provider-section.tsx b/ui/components/model-provider-section.tsx
index 9f441678..3acd132e 100644
--- a/ui/components/model-provider-section.tsx
+++ b/ui/components/model-provider-section.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import type { Model } from '@/lib/api/schemas/models';
+import { useProgressiveList } from '@/lib/hooks/use-progressive-list';
import type { AdminModelGroup } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units';
import { ModelItemCard } from '@/components/model-item-card';
@@ -24,6 +25,7 @@ import {
Edit3,
Globe,
Key,
+ Loader2,
MoreVertical,
RefreshCw,
} from 'lucide-react';
@@ -103,10 +105,21 @@ export function ModelProviderSection({
});
}, [provider, providerModels]);
+ const { visibleItems: visibleProviderModels, hiddenCount } =
+ useProgressiveList(keyedProviderModels);
+
+ const pendingRowsNotice =
+ hiddenCount > 0 ? (
+
+
+ Rendering {hiddenCount} more model{hiddenCount === 1 ? '' : 's'}…
+
+ ) : null;
+
if (filterProvider) {
return (
- {keyedProviderModels.map(({ model, renderKey }) => (
+ {visibleProviderModels.map(({ model, renderKey }) => (
onDeleteModel(model.id)}
/>
))}
+ {pendingRowsNotice}
);
}
@@ -217,7 +231,7 @@ export function ModelProviderSection({
- {keyedProviderModels.map(({ model, renderKey }) => (
+ {visibleProviderModels.map(({ model, renderKey }) => (
onDeleteModel(model.id)}
/>
))}
+ {pendingRowsNotice}
diff --git a/ui/components/model-selector.tsx b/ui/components/model-selector.tsx
index 8636fb36..54a345b8 100644
--- a/ui/components/model-selector.tsx
+++ b/ui/components/model-selector.tsx
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import {
AdminService,
@@ -13,6 +13,7 @@ import { AddProviderModelDialog } from '@/components/add-provider-model-dialog';
import { EditGroupForm } from '@/components/edit-group-form';
import { ModelProviderSection } from '@/components/model-provider-section';
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
+import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Skeleton } from '@/components/ui/skeleton';
@@ -131,19 +132,14 @@ export function ModelSelector({
const queryClient = useQueryClient();
- // Fetch models and groups
+ // Shared with the page shell, so mounting this panel costs no extra fetch.
const {
- data: modelsData,
+ models,
+ groups,
isLoading: isLoadingModels,
error: modelsError,
refetch: refetchModels,
- } = useQuery({
- queryKey: ['models-with-providers'],
- queryFn: () => AdminService.getModelsWithProviders(),
- refetchOnWindowFocus: false,
- });
-
- const { models = [], groups = [] } = modelsData || {};
+ } = useModelsWithProviders();
const allOverrideModels = useMemo(
() => models.filter(isOverrideModel),
[models]
diff --git a/ui/components/models-page.tsx b/ui/components/models-page.tsx
index bff3a379..2bfc2149 100644
--- a/ui/components/models-page.tsx
+++ b/ui/components/models-page.tsx
@@ -1,16 +1,14 @@
'use client';
import { useMemo, useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import dynamic from 'next/dynamic';
import { AlertCircle } from 'lucide-react';
import type { Model } from '@/lib/api/schemas/models';
-import { AdminService } from '@/lib/api/services/admin';
+import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { ModelSelector } from '@/components/model-selector';
-import { ModelTester } from '@/components/model-tester';
-import { ApiEndpointTester } from '@/components/api-endpoint-tester';
import { ModelSearchFilter } from '@/components/model-search-filter';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
@@ -23,6 +21,19 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+// The testing tabs are never the landing view, so keeping them out of this
+// route's chunk is what lets the navigation itself resolve quickly.
+const ModelTester = dynamic(
+ () => import('@/components/model-tester').then((m) => m.ModelTester),
+ { loading: () => , ssr: false }
+);
+
+const ApiEndpointTester = dynamic(
+ () =>
+ import('@/components/api-endpoint-tester').then((m) => m.ApiEndpointTester),
+ { loading: () => , ssr: false }
+);
+
export function ModelsPage() {
const [filteredModels, setFilteredModels] = useState(
undefined
@@ -31,16 +42,11 @@ export function ModelsPage() {
useState('all');
const {
- data: modelsData,
+ models,
+ groups,
isLoading: isLoadingModels,
error: modelsError,
- } = useQuery({
- queryKey: ['admin-models-with-providers'],
- queryFn: () => AdminService.getModelsWithProviders(),
- refetchOnWindowFocus: false,
- });
-
- const { models = [], groups = [] } = modelsData || {};
+ } = useModelsWithProviders();
const groupedModels = useMemo(
() => groupAndSortModelsByProvider(models),
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts
index e510a571..15daa38d 100644
--- a/ui/lib/api/services/admin.ts
+++ b/ui/lib/api/services/admin.ts
@@ -446,14 +446,50 @@ export class AdminService {
const allModels: AdminModelAsModel[] = [];
const seenModelIds = new Set();
- for (const provider of providers) {
- try {
- const providerModels = await this.getProviderModels(provider.id);
+ // One provider's catalog never depends on another's, and each miss costs an
+ // upstream round trip, so the whole fan-out happens in a single wave.
+ const providerResults = await Promise.all(
+ providers.map(async (provider) => {
+ try {
+ return {
+ provider,
+ models: await this.getProviderModels(provider.id),
+ };
+ } catch (error) {
+ console.error(
+ `Failed to fetch models for provider ${provider.id}:`,
+ error
+ );
+ return null;
+ }
+ })
+ );
- providerModels.db_models.forEach((dbModel) => {
- seenModelIds.add(dbModel.id);
+ for (const result of providerResults) {
+ if (!result) {
+ continue;
+ }
+ const { provider, models: providerModels } = result;
+ providerModels.db_models.forEach((dbModel) => {
+ seenModelIds.add(dbModel.id);
+ const modelWithProvider = {
+ ...dbModel,
+ upstream_provider_id: provider.id,
+ };
+ allModels.push({
+ ...this.transformAdminModelToModel(
+ modelWithProvider,
+ provider.provider_type
+ ),
+ has_own_api_key: false,
+ api_key_type: 'group',
+ });
+ });
+
+ providerModels.remote_models.forEach((remoteModel) => {
+ if (!seenModelIds.has(remoteModel.id)) {
const modelWithProvider = {
- ...dbModel,
+ ...remoteModel,
upstream_provider_id: provider.id,
};
allModels.push({
@@ -462,33 +498,11 @@ export class AdminService {
provider.provider_type
),
has_own_api_key: false,
- api_key_type: 'group',
+ api_key_type: 'remote',
+ soft_deleted: false,
});
- });
-
- providerModels.remote_models.forEach((remoteModel) => {
- if (!seenModelIds.has(remoteModel.id)) {
- const modelWithProvider = {
- ...remoteModel,
- upstream_provider_id: provider.id,
- };
- allModels.push({
- ...this.transformAdminModelToModel(
- modelWithProvider,
- provider.provider_type
- ),
- has_own_api_key: false,
- api_key_type: 'remote',
- soft_deleted: false,
- });
- }
- });
- } catch (error) {
- console.error(
- `Failed to fetch models for provider ${provider.id}:`,
- error
- );
- }
+ }
+ });
}
return { models: allModels, groups };
diff --git a/ui/lib/hooks/use-models-with-providers.ts b/ui/lib/hooks/use-models-with-providers.ts
new file mode 100644
index 00000000..6aeec5cf
--- /dev/null
+++ b/ui/lib/hooks/use-models-with-providers.ts
@@ -0,0 +1,26 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { AdminService } from '@/lib/api/services/admin';
+
+export const modelsWithProvidersQueryKey = ['models-with-providers'] as const;
+
+/**
+ * Shared catalog read for every models view, so the page shell and the
+ * selector panel share one request instead of each fanning out to providers.
+ */
+export function useModelsWithProviders() {
+ const query = useQuery({
+ queryKey: modelsWithProvidersQueryKey,
+ queryFn: () => AdminService.getModelsWithProviders(),
+ refetchOnWindowFocus: false,
+ });
+
+ return {
+ models: query.data?.models ?? [],
+ groups: query.data?.groups ?? [],
+ isLoading: query.isLoading,
+ error: query.error,
+ refetch: query.refetch,
+ };
+}
diff --git a/ui/lib/hooks/use-progressive-list.ts b/ui/lib/hooks/use-progressive-list.ts
new file mode 100644
index 00000000..8b55ffc4
--- /dev/null
+++ b/ui/lib/hooks/use-progressive-list.ts
@@ -0,0 +1,46 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+/**
+ * Reveal a long list in frame-sized batches.
+ *
+ * A provider catalog can hold thousands of rows, and mounting them in one
+ * commit blocks the main thread long enough that the page looks frozen right
+ * after navigation. Each batch yields back to the browser, so the first rows
+ * paint immediately and the rest fill in without freezing input.
+ */
+export function useProgressiveList(
+ items: T[],
+ initialCount = 40,
+ step = 80
+): { visibleItems: T[]; hiddenCount: number } {
+ const [count, setCount] = useState(initialCount);
+ const [trackedItems, setTrackedItems] = useState(items);
+
+ // Reset during render, not in an effect: an effect would first commit the new
+ // list at the old (possibly full) count, which is the freeze this avoids.
+ if (trackedItems !== items) {
+ setTrackedItems(items);
+ setCount(initialCount);
+ }
+
+ useEffect(() => {
+ if (count >= items.length) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ setCount((current) => Math.min(items.length, current + step));
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [count, items.length, step]);
+
+ const visibleCount = Math.min(count, items.length);
+
+ return {
+ visibleItems: items.slice(0, visibleCount),
+ hiddenCount: items.length - visibleCount,
+ };
+}