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 ( +
+ {row.value && ( + + {row.value} + + )} + {row.action && ( + + )} +
+ ) +} + function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { return @@ -143,22 +185,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B - {presets.map(preset => ( - - ))} + setAmount(value)} + options={presets.map(preset => ({ id: preset.amount, label: preset.label }))} + value={amount} + /> -
@@ -283,43 +319,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa value: 0 } - const width = Math.round(resolvedBar.value * 100) - const isEmpty = resolvedBar.value === 0 - const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0 + // Plain shared primitive — no bespoke track chrome. Only the fill tone carries + // billing meaning: destructive when over-limit, green for healthy remaining + // credits, muted otherwise. Color rides the sanctioned `fillClassName` override. + const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup') return ( -
- {showDangerNub &&
} -
0 ? 4 : undefined, - width: `${width}%` - }} - /> -
+ destructive={resolvedBar.state === 'danger'} + fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'} + fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }} + size="lg" + value={resolvedBar.value} + /> ) } @@ -351,53 +364,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) { ) } -function UsageRefreshRow({ - fixtureName, - isFetching, - onRefresh, - updatedAt -}: { - fixtureName?: BillingFixtureSelection - isFetching: boolean - onRefresh: () => void - updatedAt: number -}) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000) - - return () => window.clearInterval(interval) - }, []) - - if (fixtureName && fixtureName !== 'live') { - return ( -
- fixture: {fixtureName} -
- ) - } - - return ( -
- Updated {formatUsageUpdatedAgo(updatedAt, now)} - - - -
- ) -} - +// 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({ -
-
+ {view.notice && } + +
+
{view.summary.map(item => ( ))}
- {view.notice && } - {view.plan && ( -
- + setSubView('plans')} plan={view.plan} /> -
+ )} - {paymentRow && ( -
- - -
- )} - - {topupRow && ( -
- - -
- )} - - {refillRow && ( -
- - -
+ {(paymentRow || accountRows.length > 0) && ( + : undefined} + icon={CreditCard} + title="Payment & credits" + > + {accountRows.map(row => ( + + ))} + )} {view.usageRows.length > 0 && ( - <> - -
+ +
{view.usageRows.map(row => ( ))} -
- +
)} { @@ -586,9 +582,3 @@ export function BillingSettings() { return } - -function oldestUpdatedAt(...timestamps: number[]): number { - const populated = timestamps.filter(timestamp => timestamp > 0) - - return populated.length > 0 ? Math.min(...populated) : Date.now() -} diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx index 440d43b6869e..b0d1fd6b7032 100644 --- a/apps/desktop/src/app/settings/billing/plans-view.tsx +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl return (
void; tiers: ))}
) : ( -
+
No plans are available to change to right now.
)} 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 ( - +
+ + +
+ ) +} + +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 8bf5cf65db09..9fb69784ec44 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 06df90e6bace..9102014b8ed8 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 ( diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 0ae671535b0a..be2d4c9f8237 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog' import { ErrorIcon, ErrorState } from '@/components/ui/error-state' import { Loader } from '@/components/ui/loader' +import { Progress } from '@/components/ui/progress' import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' import { useI18n } from '@/i18n' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' @@ -396,15 +397,7 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend ) : null}
-
-
-
+ {recentLog.length > 1 ? (
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index f15bd79cdc2d..b701fa9ff6a1 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -6,6 +6,7 @@ import { Codicon } from '@/components/ui/codicon' import { ErrorIcon } from '@/components/ui/error-state' import { Loader } from '@/components/ui/loader' import { LogView } from '@/components/ui/log-view' +import { Progress } from '@/components/ui/progress' import type { DesktopBootstrapEvent, DesktopBootstrapStageDescriptor, @@ -435,12 +436,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP {progressPct}%
-
-
-
+
)} diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 208efcb962f9..0acc4c044d8c 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' import { getGlobalModelOptions } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons' @@ -372,15 +373,12 @@ function Preparing({ boot }: { boot: DesktopBootState }) {

{installing ? t.onboarding.preparingInstall : t.onboarding.starting}

-
-
-
+
{boot.message} {progress}% diff --git a/apps/desktop/src/components/pet/pet-egg-hatch.tsx b/apps/desktop/src/components/pet/pet-egg-hatch.tsx index f542a5a04880..4f3806d9d05f 100644 --- a/apps/desktop/src/components/pet/pet-egg-hatch.tsx +++ b/apps/desktop/src/components/pet/pet-egg-hatch.tsx @@ -9,6 +9,7 @@ import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' interface PetEggHatchProps { subtitle?: string @@ -23,22 +24,17 @@ interface PetEggHatchProps { */ export function PetProgress({ done, total }: { done?: number; total?: number }) { const determinate = typeof done === 'number' && typeof total === 'number' && total > 0 - const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0 return ( -
- {determinate ? ( -
- ) : ( -
- )} -
+ ) } diff --git a/apps/desktop/src/components/ui/input.test.tsx b/apps/desktop/src/components/ui/input.test.tsx new file mode 100644 index 000000000000..6a06b7432dd2 --- /dev/null +++ b/apps/desktop/src/components/ui/input.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Input } from './input' + +afterEach(cleanup) + +describe('Input', () => { + it('renders a bare input carrying the control chrome when unadorned', () => { + const { getByRole } = render() + const el = getByRole('textbox') + + expect(el.tagName).toBe('INPUT') + expect(el.className).toContain('desktop-input-chrome') + // No group wrapper — the input is the top-level node. + expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group') + }) + + it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => { + const { getByRole, getByText } = render() + const el = getByRole('textbox') + const group = el.closest('[data-slot="input-group"]') + + expect(group).not.toBeNull() + // Chrome moves to the wrapper; the input itself goes transparent/borderless. + expect(group?.className).toContain('desktop-input-chrome') + expect(el.className).not.toContain('desktop-input-chrome') + expect(el.className).toContain('bg-transparent') + + const prefix = getByText('$') + expect(group?.contains(prefix)).toBe(true) + }) + + it('renders a trailing suffix inside the group', () => { + const { getByText } = render() + const suffix = getByText('%') + + expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull() + }) + + it('forwards value/onChange through the adorned field', () => { + const onChange = vi.fn() + const { getByRole } = render() + + fireEvent.change(getByRole('textbox'), { target: { value: '100' } }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('dims the group and disables the field when disabled', () => { + const { getByRole } = render() + const el = getByRole('textbox') as HTMLInputElement + const group = el.closest('[data-slot="input-group"]') + + expect(el.disabled).toBe(true) + expect(group?.className).toContain('opacity-50') + }) + + it('lets containerClassName override the wrapper width', () => { + const { getByRole } = render() + const group = getByRole('textbox').closest('[data-slot="input-group"]') + + // twMerge resolves the control's default w-full down to the caller's width. + expect(group?.className).toContain('w-20') + expect(group?.className).not.toContain('w-full') + }) +}) diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx index a195e9a9ab84..8279cd1d459f 100644 --- a/apps/desktop/src/components/ui/input.tsx +++ b/apps/desktop/src/components/ui/input.tsx @@ -4,8 +4,22 @@ import { cn } from '@/lib/utils' import { type ControlVariantProps, controlVariants } from './control' -function Input({ className, type, size, ...props }: Omit, 'size'> & ControlVariantProps) { - return ( +// `prefix`/`suffix` are DOM string attributes on the native element; we shadow +// them with ReactNode adornments, so they're omitted from the base props. +type InputProps = Omit, 'size' | 'prefix' | 'suffix'> & + ControlVariantProps & { + /** Leading adornment rendered inside the field (e.g. a `$` for money). */ + prefix?: React.ReactNode + /** Trailing adornment rendered inside the field (e.g. a unit label). */ + suffix?: React.ReactNode + /** Applied to the wrapper when an adornment promotes the field to a group. */ + containerClassName?: string + } + +function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) { + const grouped = prefix != null || suffix != null + + const field = ( ) + + if (!grouped) { + return field + } + + return ( +
+ {prefix != null && {prefix}} + {field} + {suffix != null && {suffix}} +
+ ) } export { Input } diff --git a/apps/desktop/src/components/ui/progress.tsx b/apps/desktop/src/components/ui/progress.tsx new file mode 100644 index 000000000000..dee39646ded1 --- /dev/null +++ b/apps/desktop/src/components/ui/progress.tsx @@ -0,0 +1,82 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const TRACK_HEIGHT = { + sm: 'h-1', + default: 'h-1.5', + lg: 'h-2' +} as const + +export interface ProgressProps extends Omit, 'children'> { + /** Completion as a 0–1 fraction; clamped. Ignored when `indeterminate`. */ + value?: number + /** No known endpoint — animate instead of showing a fixed width. */ + indeterminate?: boolean + /** + * Use the continuous sliding animation for the indeterminate state (the pet + * hatch look) instead of the default pulse. No effect when determinate. + */ + animated?: boolean + /** Swap the fill to the destructive color for error / over-limit states. */ + destructive?: boolean + size?: keyof typeof TRACK_HEIGHT + /** Override the fill color/shape (e.g. billing's tone mapping, pet's accent). */ + fillClassName?: string + fillStyle?: React.CSSProperties + /** Extra content inside the track, behind the fill (e.g. a threshold marker). */ + children?: React.ReactNode +} + +/** + * The app's one progress/meter bar: a rounded track with an animated fill. The + * track owns `role="progressbar"` and its aria values; the fill is width-driven + * (determinate) or animated (indeterminate). Consumers needing a bespoke look — + * billing's hatched empty state, tone-mapped or accent fills — override + * `className` (track) and `fillClassName` while inheriting the structure, + * sizing, and accessibility. + */ +export function Progress({ + value = 0, + indeterminate = false, + animated = false, + destructive = false, + size = 'default', + className, + fillClassName, + fillStyle, + children, + ...props +}: ProgressProps) { + const pct = Math.round(Math.min(1, Math.max(0, value)) * 100) + const fillColor = destructive ? 'bg-destructive' : 'bg-primary' + + return ( +
+ {children} + {indeterminate && animated ? ( + // Sliding block — width/animation come from `.progress-slide` (styles.css), + // which also honors prefers-reduced-motion. +
+ ) : ( +
+ )} +
+ ) +} diff --git a/apps/desktop/src/components/ui/segmented-control.tsx b/apps/desktop/src/components/ui/segmented-control.tsx index 994cc17a99bd..45c854ecf2be 100644 --- a/apps/desktop/src/components/ui/segmented-control.tsx +++ b/apps/desktop/src/components/ui/segmented-control.tsx @@ -12,6 +12,8 @@ interface SegmentedControlProps { value: T onChange: (id: T) => void className?: string + /** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */ + disabled?: boolean } /** @@ -19,11 +21,18 @@ interface SegmentedControlProps { * (color mode, tool-call display, usage period, etc.). Flat by design — * no per-option borders, just a tinted track with a raised active pill. */ -export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) { +export function SegmentedControl({ + className, + disabled = false, + onChange, + options, + value +}: SegmentedControlProps) { return (
@@ -34,9 +43,10 @@ export function SegmentedControl({ options, value, onChange, c