diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx
index 4bd8e70a968..040d37b3b43 100644
--- a/apps/desktop/src/app/settings/billing/index.tsx
+++ b/apps/desktop/src/app/settings/billing/index.tsx
@@ -6,11 +6,19 @@ import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Skeleton } from '@/components/ui/skeleton'
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
-import { ListRow, SectionHeading, SettingsContent, SettingsSection } from '../primitives'
+import {
+ ListRow,
+ ListRowSkeleton,
+ SectionHeading,
+ SectionHeadingSkeleton,
+ SettingsContent,
+ SettingsSection
+} from '../primitives'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
@@ -412,6 +420,34 @@ function BillingHeader({
)
}
+// Loading shape for the billing overview: three summary cards over the Plan /
+// Payment & credits / Usage sections. Rendered under the real header.
+function BillingSkeleton() {
+ return (
+ <>
+
+
+ {[0, 1, 2].map(i => (
+
+
+
+
+ ))}
+
+
+ {[0, 1, 2].map(section => (
+
+ ))}
+ >
+ )
+}
+
function BillingSettingsContent({
fixtureName,
onFixtureChange
@@ -426,6 +462,18 @@ function BillingSettingsContent({
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
+
+ // First load keeps the page's shape via a skeleton instead of flashing "—"
+ // summary cards (background refetches leave `isPending` false, so no flicker).
+ if (billingState.isPending) {
+ return (
+
+
+
+
+ )
+ }
+
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 1d8a2de0127..18fa33d744a 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -21,7 +21,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
-import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
+import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
// provider — otherwise every provider's options render at once (the "totally
@@ -264,8 +264,8 @@ export function ConfigSettings({
)
}
- // Model keeps its shape via a skeleton (its catalog fetch is the slow part);
- // other sections are quick config/schema reads, so a light loader is fine.
+ // Every section keeps its shape via a skeleton; model gets its bespoke one
+ // (its catalog fetch is the slow part), the rest the shared field rhythm.
if (activeSectionId === 'model') {
return (
@@ -276,7 +276,7 @@ export function ConfigSettings({
)
}
- return
+ return
}
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx
index 5943b1a7918..bea02e2bce7 100644
--- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx
+++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
-import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
+import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
interface CustomEndpointsSettingsProps {
onConfigSaved?: () => void
@@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
}
if (loading) {
- return
+ return
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx
index 008bd44d2ea..7f8b45c0e78 100644
--- a/apps/desktop/src/app/settings/gateway-settings.tsx
+++ b/apps/desktop/src/app/settings/gateway-settings.tsx
@@ -27,7 +27,7 @@ import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
-import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
+import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives'
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
@@ -985,7 +985,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
if (loading) {
- return
+ return
}
if (!window.hermesDesktop?.getConnectionConfig) {
diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx
index 15f33ca7b60..b180689a841 100644
--- a/apps/desktop/src/app/settings/keys-settings.tsx
+++ b/apps/desktop/src/app/settings/keys-settings.tsx
@@ -6,7 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
-import { LoadingState, SettingsContent } from './primitives'
+import { SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
@@ -64,7 +64,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
}, [vars])
if (!vars) {
- return
+ return
}
const visible = groups.filter(g => g.category === view)
diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx
index 6c2932cd545..c02b6619375 100644
--- a/apps/desktop/src/app/settings/pet-settings.tsx
+++ b/apps/desktop/src/app/settings/pet-settings.tsx
@@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
+import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -139,7 +140,21 @@ export function PetSettings() {
{/* Fixed-height scroll area so filtering never grows/shrinks the
page (no layout thrash); the grid scrolls inside it. */}
- {pets.length === 0 ? (
+ {status === 'loading' && pets.length === 0 ? (
+ // First load keeps the grid's shape rather than flashing the
+ // "unreachable" copy before the gallery has even arrived.
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ ))}
+
+ ) : pets.length === 0 ? (
{copy.unreachable}
diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx
index 8654ce784f7..11d169f5298 100644
--- a/apps/desktop/src/app/settings/primitives.tsx
+++ b/apps/desktop/src/app/settings/primitives.tsx
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
-import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { triggerHaptic } from '@/lib/haptics'
import type { IconComponent } from '@/lib/icons'
@@ -180,16 +180,55 @@ export function ToggleRow({
)
}
-// The settings panels render this as the sole child of the top-padded
-// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx).
-// Cancel that top pad so the loader centers in the whole card, not just the
-// band beneath it. Inline loaders (mid-panel) should use
directly.
-export function LoadingState({ label }: { label: string }) {
+// Skeleton primitives mirroring the settings layout rhythm — a loading page keeps
+// its shape (like ModelSettings) instead of collapsing to a centered spinner.
+export function SectionHeadingSkeleton() {
return (
-
+
+
+
+
+ )
+}
+
+export function ListRowSkeleton({ wide = false }: { wide?: boolean }) {
+ return (
+
+ )
+}
+
+// A full settings page in its loading shape: an optional leading search field
+// over one or more sections, each an optional heading above a run of rows.
+// ``.
+export function SettingsSkeleton({
+ search = false,
+ sections = [{ rows: 4 }]
+}: {
+ search?: boolean
+ sections?: { heading?: boolean; rows: number }[]
+}) {
+ return (
+
+ {search && }
+ {sections.map((section, i) => (
+ 0 && 'mt-6')} key={i}>
+ {section.heading && }
+
+ {Array.from({ length: section.rows }, (_, r) => (
+
+ ))}
+
+
+ ))}
+
)
}
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
index 8bf5cf65db0..9fb69784ec4 100644
--- a/apps/desktop/src/app/settings/providers-settings.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -28,7 +28,7 @@ import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { CustomEndpointsSettings } from './custom-endpoints-settings'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
-import { LoadingState, SettingsContent } from './primitives'
+import { SettingsContent, SettingsSkeleton } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
@@ -431,7 +431,7 @@ export function ProvidersSettings({
}
if (!vars) {
- return
+ return
}
const hasOauth = oauthProviders.length > 0
diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx
index 06df90e6bac..9102014b8ed 100644
--- a/apps/desktop/src/app/settings/sessions-settings.tsx
+++ b/apps/desktop/src/app/settings/sessions-settings.tsx
@@ -12,7 +12,7 @@ import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
-import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
+import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const ARCHIVED_FETCH_LIMIT = 200
@@ -107,7 +107,7 @@ export function SessionsSettings() {
})
if (loading) {
- return
+ return
}
return (