From 43787dab14c3896acd976e6b3ecec88b10f06045 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:58:36 -0500 Subject: [PATCH] feat(desktop/settings): DOM-shaped skeleton loaders on every settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model settings was the only page that kept its shape while loading; the rest flashed a centered spinner (LoadingState) or empty placeholders. Standardize on skeletons that mirror the settings rhythm. - Add shared SectionHeadingSkeleton / ListRowSkeleton / SettingsSkeleton to settings/primitives.tsx (mirror SectionHeading + ListRow). - Convert keys, providers, sessions, gateway, custom-endpoints, and config (non-model) from LoadingState to SettingsSkeleton; remove now-dead LoadingState. - billing: BillingSkeleton (summary cards + sections) on first load instead of "—" placeholder cards. - pet: skeleton grid on first load instead of a premature "unreachable" message. --- .../src/app/settings/billing/index.tsx | 50 +++++++++++++++- .../src/app/settings/config-settings.tsx | 8 +-- .../settings/custom-endpoints-settings.tsx | 4 +- .../src/app/settings/gateway-settings.tsx | 4 +- .../src/app/settings/keys-settings.tsx | 4 +- .../desktop/src/app/settings/pet-settings.tsx | 17 +++++- apps/desktop/src/app/settings/primitives.tsx | 59 +++++++++++++++---- .../src/app/settings/providers-settings.tsx | 4 +- .../src/app/settings/sessions-settings.tsx | 4 +- 9 files changed, 128 insertions(+), 26 deletions(-) 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 ( +
+
+
+ + +
+ {!wide && } +
+
+ ) +} + +// 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 (