mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
e4b2b77852
commit
43787dab14
9 changed files with 128 additions and 26 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue