feat(desktop/settings): DOM-shaped skeleton loaders on every settings page

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 18:58:36 -05:00
parent e4b2b77852
commit 43787dab14
9 changed files with 128 additions and 26 deletions

View file

@ -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 (
<>
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{[0, 1, 2].map(i => (
<div className="min-w-0 space-y-2" key={i}>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-20" />
</div>
))}
</div>
</div>
{[0, 1, 2].map(section => (
<section className="mb-6" key={section}>
<SectionHeadingSkeleton />
<div className="grid gap-1">
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</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 (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingSkeleton />
</SettingsContent>
)
}
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)

View file

@ -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 (
<SettingsContent>
@ -276,7 +276,7 @@ export function ConfigSettings({
)
}
return <LoadingState label={c.loading} />
return <SettingsSkeleton sections={[{ rows: 6 }]} />
}
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields

View file

@ -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 <LoadingState label="Loading custom endpoints..." />
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
}
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))

View file

@ -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 <LoadingState label={g.loading} />
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }, { heading: true, rows: 3 }]} />
}
if (!window.hermesDesktop?.getConnectionConfig) {

View file

@ -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 <LoadingState label={t.settings.keys.loading} />
return <SettingsSkeleton sections={[{ rows: 5 }]} />
}
const visible = groups.filter(g => g.category === view)

View file

@ -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. */}
<div className="mt-3 h-72 overflow-y-auto pr-1">
{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.
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
<Skeleton className="size-10 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-24 max-w-full" />
<Skeleton className="h-3 w-16 max-w-full" />
</div>
</div>
))}
</div>
) : pets.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.unreachable}
</p>

View file

@ -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 <PageLoader> 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 (
<PageLoader
className="-mt-[calc(var(--titlebar-height)+1rem)] h-[calc(100%+var(--titlebar-height)+1rem)]"
label={label}
/>
<div className="mb-2.5 flex items-center gap-2 pt-2">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-36 max-w-full" />
</div>
)
}
export function ListRowSkeleton({ wide = false }: { wide?: boolean }) {
return (
<div className="@container">
<div className={cn('grid gap-3 py-3', !wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center')}>
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-3.5 w-40 max-w-full" />
<Skeleton className="h-3 w-64 max-w-full" />
</div>
{!wide && <Skeleton className="h-8 w-full @2xl:w-72 @2xl:justify-self-end" />}
</div>
</div>
)
}
// 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.
// `<SettingsSkeleton search sections={[{ heading, rows }]} />`.
export function SettingsSkeleton({
search = false,
sections = [{ rows: 4 }]
}: {
search?: boolean
sections?: { heading?: boolean; rows: number }[]
}) {
return (
<SettingsContent>
{search && <Skeleton className="mb-3 h-8 w-full" />}
{sections.map((section, i) => (
<section className={cn(i > 0 && 'mt-6')} key={i}>
{section.heading && <SectionHeadingSkeleton />}
<div className="grid gap-1">
{Array.from({ length: section.rows }, (_, r) => (
<ListRowSkeleton key={r} />
))}
</div>
</section>
))}
</SettingsContent>
)
}

View file

@ -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 <LoadingState label={t.settings.providers.loading} />
return <SettingsSkeleton search sections={[{ rows: 6 }]} />
}
const hasOauth = oauthProviders.length > 0

View file

@ -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 <LoadingState label={s.loading} />
return <SettingsSkeleton sections={[{ rows: 1 }, { heading: true, rows: 4 }]} />
}
return (