diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx
index 7052e111d251..6ba5f99a4f90 100644
--- a/apps/desktop/src/app/settings/billing/index.test.tsx
+++ b/apps/desktop/src/app/settings/billing/index.test.tsx
@@ -15,7 +15,6 @@ import {
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
-import { formatUsageUpdatedAgo } from './use-billing-state'
import { BillingSettings } from './index'
@@ -121,7 +120,9 @@ describe('BillingSettings', () => {
renderBilling()
- expect(await screen.findByText('No card on file')).toBeTruthy()
+ // No card → the payment row collapses to a single "Add payment method" link.
+ expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
+ expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
@@ -265,7 +266,7 @@ describe('BillingSettings', () => {
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
- expect(await screen.findByText('Payment')).toBeTruthy()
+ expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
@@ -277,7 +278,7 @@ describe('BillingSettings', () => {
renderBilling(['/settings?tab=billing&bview=plans'])
- expect(await screen.findByText('Payment')).toBeTruthy()
+ expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
@@ -314,7 +315,7 @@ describe('BillingSettings', () => {
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
- expect(await screen.findByText('Payment')).toBeTruthy()
+ expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
@@ -617,9 +618,11 @@ describe('BillingSettings', () => {
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
- expect(subscriptionTrack.classList.contains('dither')).toBe(true)
- expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
- expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
+ // Plain shared primitive track (no bespoke dither/tinted chrome); the
+ // over-limit signal rides the destructive fill instead.
+ expect(subscriptionTrack.classList.contains('dither')).toBe(false)
+ expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
+ expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
@@ -644,74 +647,45 @@ describe('BillingSettings', () => {
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
- expect(subscriptionTrack.classList.contains('dither')).toBe(true)
+ // Empty tracks are the plain shared primitive now — no hatched placeholder.
+ expect(subscriptionTrack.classList.contains('dither')).toBe(false)
+ expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
- expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
- expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
+ expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
+ expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
- it('refreshes both billing queries from the usage refresh button', async () => {
+ it('shows a warn notice that names the no-card blocker with a portal link', async () => {
+ const fixture = billingDevFixtures['no-card']
+
+ apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
+ apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
+
+ renderBilling()
+
+ expect(await screen.findByText('No payment method on file')).toBeTruthy()
+ expect(
+ screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.')
+ ).toBeTruthy()
+ expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
+ })
+
+ it('does not show the no-card notice when a card is on file', async () => {
+ renderBilling()
+
+ await screen.findByText('$996.47')
+ expect(screen.queryByText('No payment method on file')).toBeNull()
+ })
+
+ it('polls billing on an interval without a manual refresh control', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
- expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
- expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
-
- fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
-
- await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
- expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
- })
-
- it('disables the usage refresh button while either query is fetching', async () => {
- let settleBilling: (value: unknown) => void = () => {}
-
- let settleSubscription: (value: unknown) => void = () => {}
-
- apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
- new Promise(resolve => {
- settleBilling = resolve
- })
- )
- apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
- new Promise(resolve => {
- settleSubscription = resolve
- })
- )
-
- renderBilling()
-
- const refresh = await screen.findByRole('button', { name: 'Refresh' })
-
- fireEvent.click(refresh)
-
- await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
-
- settleBilling(okBilling(todayBillingState))
- settleSubscription(okSubscription(todaySubscriptionState))
-
- await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
- })
-})
-
-describe('formatUsageUpdatedAgo', () => {
- it('formats sub-second and current timestamps as just now', () => {
- expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
- expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
- })
-
- it('formats seconds below a minute', () => {
- expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
- })
-
- it('rounds elapsed time to whole minutes from 61 seconds', () => {
- expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
- })
-
- it('formats one hour and later as hours', () => {
- expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
+ // The manual refresh affordance is gone — the queries poll on their own.
+ expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
+ expect(screen.queryByText(/Updated/)).toBeNull()
})
})
diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx
index 2d234d74c88d..040d37b3b43e 100644
--- a/apps/desktop/src/app/settings/billing/index.tsx
+++ b/apps/desktop/src/app/settings/billing/index.tsx
@@ -3,13 +3,22 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
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 { Tip } from '@/components/ui/tooltip'
-import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
+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 } from '../primitives'
+import {
+ ListRow,
+ ListRowSkeleton,
+ SectionHeading,
+ SectionHeadingSkeleton,
+ SettingsContent,
+ SettingsSection
+} from '../primitives'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
@@ -27,7 +36,6 @@ import {
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
- formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
} from './use-billing-state'
@@ -64,9 +72,18 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | '
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
+ const warn = notice.tone === 'warn'
+
return (
-
-
{notice.title}
+
+
+ {notice.title}
+
{notice.message}
@@ -86,6 +103,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
)
}
+// The payment method as it rides in the "Payment & credits" heading: the current
+// card (muted) plus a single underline text action (Update / Add payment method).
+function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
+ return (
+
- )
-}
-
+// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
+// billing state can be reviewed without a matching live account. Marked with a
+// wrench + "preview" so it never reads as a shipping control (it's compiled out of
+// production builds entirely).
function BillingFixtureSelect({
onValueChange,
value
@@ -406,23 +376,27 @@ function BillingFixtureSelect({
value: BillingFixtureSelection
}) {
return (
-
+
+
+ preview
+
+
)
}
@@ -446,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
@@ -460,19 +462,30 @@ 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)
const billing = billingResult?.ok ? billingResult.data : undefined
- const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
- const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
-
- const refreshUsage = () => {
- void Promise.all([billingState.refetch(), subscriptionState.refetch()])
- }
const { paymentRow, refillRow, topupRow } = view
+ // The payment method rides in the section header (right-aligned) — the
+ // "Payment & credits" title already names it, so a full labelled row would just
+ // repeat "Payment method". The stacked rows are the remaining money controls.
+ const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
+
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
@@ -491,59 +504,42 @@ function BillingSettingsContent({
-
)}
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
index c36ca4033284..a132e057db96 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
@@ -147,11 +147,14 @@ describe('deriveBillingView', () => {
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
- description:
- '💳 No saved card for terminal charges yet. Set one up on the portal ' +
- "(one-time credit buys don't save a reusable card)."
+ // The no-card blocker is explained once by the page-level notice, not
+ // duplicated (emoji and all) into the row description.
+ description: 'A single charge on your card, added to your balance today.'
})
+ expect(buyCredits?.description).not.toContain('💳')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
+ // The page still leads with the warn banner naming the blocker + fix.
+ expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('derives a calm logged-out card with no account or usage rows', () => {
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts
index da02f441ebe0..7611a055508c 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -11,7 +11,12 @@ export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
+// Billing polls on its own while the page is mounted (react-query only ticks an
+// active observer), so the view stays live without a manual refresh control —
+// matching every other data view in the app. It pauses when the window is
+// backgrounded (refetchIntervalInBackground defaults to false).
const BILLING_QUERY_OPTIONS = {
+ refetchInterval: 30_000,
refetchOnWindowFocus: true,
retry: false,
staleTime: 30_000
@@ -30,6 +35,8 @@ export interface BillingNoticeView {
}
message: string
title: string
+ /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
+ tone?: 'info' | 'warn'
}
export interface BillingRowActionView {
@@ -207,7 +214,7 @@ export function deriveBillingView(
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
return {
- notice: undefined,
+ notice: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
@@ -276,26 +283,6 @@ export function formatBillingDate(value?: null | string): string {
return fmtDate.format(date)
}
-export function formatUsageUpdatedAgo(updatedAt: number, now: number): string {
- const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000))
-
- if (elapsedSeconds < 1) {
- return 'just now'
- }
-
- if (elapsedSeconds < 60) {
- return `${elapsedSeconds}s ago`
- }
-
- const elapsedMinutes = Math.floor(elapsedSeconds / 60)
-
- if (elapsedMinutes < 60) {
- return `${elapsedMinutes}m ago`
- }
-
- return `${Math.floor(elapsedMinutes / 60)}h ago`
-}
-
function emptySummary(): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
@@ -311,7 +298,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
message: resolved.message,
- title: resolved.title
+ title: resolved.title,
+ tone: 'warn'
+ }
+}
+
+// A logged-in account with no card can't buy credits or manage auto-refill, and
+// every one of those controls disables silently — so lead the page with a single
+// warn banner that names the blocker and links straight to the fix.
+function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
+ if (billing.card) {
+ return undefined
+ }
+
+ return {
+ action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
+ message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
+ title: 'No payment method on file',
+ tone: 'warn'
}
}
@@ -530,17 +534,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
const card = billing.card
if (!card) {
+ // No card → a single "Add payment method" link, the way every other app does
+ // it. The reason (buys/auto-refill are blocked) already leads the page as a
+ // notice, so the row stays a bare call-to-action with no redundant status text.
return {
- action: { label: 'Update ↗', url: portalUrl },
- description: 'Add a payment method on the portal before buying top-up credits.',
+ action: { label: 'Add payment method', url: portalUrl },
+ description: '',
id: 'payment_method',
- title: 'Payment method',
- value: 'No card on file'
+ title: 'Payment method'
}
}
return {
- action: { label: 'Update ↗', url: portalUrl },
+ action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
id: 'payment_method',
title: 'Payment method',
@@ -550,14 +556,13 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
+ // The no-card blocker is already spelled out by the page-level warn banner
+ // (noCardNotice); repeating it here — emoji and all — just clutters the row,
+ // so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
- description: resolveRefusal({
- kind: 'no_payment_method',
- message: '',
- portalUrl: billing.portal_url ?? undefined
- }).message,
+ description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 1d8a2de0127f..18fa33d744aa 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 5943b1a79189..bea02e2bce79 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 008bd44d2ead..7f8b45c0e786 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 15f33ca7b603..b180689a841d 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 6c2932cd545f..c02b6619375e 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 4e55a2f9f4b9..11d169f52989 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'
@@ -28,16 +28,53 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
return {children}
}
-export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
+export function SectionHeading({
+ aside,
+ icon: Icon,
+ meta,
+ title
+}: {
+ // Right-aligned trailing content on the heading row (e.g. a compact status +
+ // action), so a single-item section needn't repeat its own label as a row.
+ aside?: ReactNode
+ icon: IconComponent
+ meta?: string
+ title: string
+}) {
return (
-
+ {title}
{meta && {meta}}
+ {aside &&
{aside}
}
)
}
+// A titled section: heading + body with the shared vertical rhythm. Keeps the
+// heading and its content welded together so pages stop hand-rolling
+// `
…
` at every call site.
+export function SettingsSection({
+ aside,
+ children,
+ icon,
+ meta,
+ title
+}: {
+ aside?: ReactNode
+ children: ReactNode
+ icon: IconComponent
+ meta?: string
+ title: string
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
export function NavLink({
icon: Icon,
label,
@@ -143,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 (
-
+
+ )
+}
+
+// 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 && }
+