Merge pull request #69691 from NousResearch/bb/desktop-billing-polish

Desktop billing polish + shared Progress primitive + settings skeletons
This commit is contained in:
brooklyn! 2026-07-22 21:03:34 -05:00 committed by GitHub
commit da3c506db5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 605 additions and 373 deletions

View file

@ -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()
})
})

View file

@ -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 (
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-(--ui-yellow)' : 'text-foreground'
)}
>
{notice.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
@ -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 (
<div className="flex min-w-0 items-center gap-2.5">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
{row.value}
</span>
)}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
size="sm"
type="button"
variant="textStrong"
>
{row.action.label}
</Button>
)}
</div>
)
}
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 <BuyCreditsRow billing={billing} row={row} />
@ -143,22 +185,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{presets.map(preset => (
<Button
aria-pressed={amount === preset.amount}
disabled={controlsDisabled}
key={preset.amount}
onClick={() => setAmount(preset.amount)}
size="sm"
type="button"
variant={amount === preset.amount ? 'default' : 'outline'}
>
{preset.label}
</Button>
))}
<SegmentedControl
disabled={controlsDisabled}
onChange={value => setAmount(value)}
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
value={amount}
/>
<Input
aria-label="Custom credit amount"
className="w-24 py-[3px]"
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
@ -168,13 +203,14 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
size="sm"
placeholder={billing.min_usd ?? ''}
prefix="$"
size="xs"
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
</Button>
</div>
@ -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 (
<div
<Progress
aria-label={resolvedBar.label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={width}
className={cn(
// Radius follows the app-wide rounded-full progress-bar idiom.
'relative h-2 w-full overflow-hidden rounded-full',
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? 'dither bg-(--ui-bg-elevated)'
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
resolvedBar.state === 'danger'
? 'bg-destructive'
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
? 'bg-(--ui-green)'
: 'bg-muted-foreground/45'
)}
style={{
minWidth: resolvedBar.value > 0 ? 4 : undefined,
width: `${width}%`
}}
/>
</div>
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 (
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
fixture: {fixtureName}
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
<Tip label="Refresh">
<Button
aria-label="Refresh"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={isFetching}
onClick={onRefresh}
size="sm"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
</Button>
</Tip>
</div>
)
}
// 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 (
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing fixture"
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
<Wrench className="size-3.5 shrink-0" />
<span className="text-xs font-normal">preview</span>
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing preview fixture (dev only)"
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@ -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 (
<>
<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
@ -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 (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingSkeleton />
</SettingsContent>
)
}
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({
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<div className="@container mb-5">
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{view.notice && <NoticeCard notice={view.notice} />}
{view.plan && (
<div className="mb-5">
<SectionHeading icon={Package} title="Plan" />
<SettingsSection icon={Package} title="Plan">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</div>
</SettingsSection>
)}
{paymentRow && (
<div className="mb-5">
<SectionHeading icon={Lock} title="Payment" />
<AccountRow billing={billing} row={paymentRow} />
</div>
)}
{topupRow && (
<div className="mb-5">
<SectionHeading icon={Plus} title="One-time top-up" />
<AccountRow billing={billing} row={topupRow} />
</div>
)}
{refillRow && (
<div className="mb-5">
<SectionHeading icon={RefreshCw} title="Automatic refill" />
<AccountRow billing={billing} row={refillRow} />
</div>
{(paymentRow || accountRows.length > 0) && (
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Usage" />
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
<SettingsSection icon={BarChart3} title="Usage">
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
<UsageRefreshRow
fixtureName={fixtureName}
isFetching={usageIsFetching}
onRefresh={refreshUsage}
updatedAt={usageUpdatedAt}
/>
</div>
</>
</SettingsSection>
)}
{
@ -586,9 +582,3 @@ export function BillingSettings() {
return <BillingSettingsContent />
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}

View file

@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
))}
</div>
) : (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}

View file

@ -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', () => {

View file

@ -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'
}

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'
@ -28,16 +28,53 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
return <Badge variant={PILL_VARIANT[tone]}>{children}</Badge>
}
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 (
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
</div>
)
}
// A titled section: heading + body with the shared vertical rhythm. Keeps the
// heading and its content welded together so pages stop hand-rolling
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
export function SettingsSection({
aside,
children,
icon,
meta,
title
}: {
aside?: ReactNode
children: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<section className="mb-6">
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
{children}
</section>
)
}
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 <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 (

View file

@ -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}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
percent === null && 'w-1/3 animate-pulse'
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>
</div>
<Progress aria-label={label} indeterminate={percent === null} size="lg" value={percent === null ? 0 : percent / 100} />
{recentLog.length > 1 ? (
<div className="max-h-24 overflow-hidden rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-left font-mono text-[11px] leading-4 text-muted-foreground">

View file

@ -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
</span>
<span className="tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
<div
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${progressPct}%` }}
/>
</div>
<Progress
aria-label={copy.progress(completedCount, totalCount)}
className="bg-(--ui-bg-tertiary)"
destructive={failed}
value={progressPct / 100}
/>
</div>
)}

View file

@ -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 }) {
<p className="text-sm text-muted-foreground">
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full bg-primary transition-[width] duration-300 ease-out',
hasError && 'bg-destructive'
)}
style={{ width: `${progress}%` }}
/>
</div>
<Progress
aria-label={installing ? t.onboarding.preparingInstall : t.onboarding.starting}
destructive={hasError}
size="lg"
value={progress / 100}
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{boot.message}</span>
<span>{progress}%</span>

View file

@ -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 (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={determinate ? pct : undefined}
className="pet-progress"
role="progressbar"
>
{determinate ? (
<div className="pet-progress__fill" style={{ width: `${pct}%` }} />
) : (
<div className="pet-progress__indeterminate" />
)}
</div>
<Progress
animated
aria-label="Hatching progress"
className="bg-[color-mix(in_srgb,var(--ui-accent)_15%,transparent)]"
fillClassName="bg-(--ui-accent)"
indeterminate={!determinate}
size="sm"
value={determinate ? done / total : 0}
/>
)
}

View file

@ -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(<Input aria-label="plain" />)
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(<Input aria-label="amount" prefix="$" />)
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(<Input aria-label="rate" suffix="%" />)
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(<Input aria-label="amount" onChange={onChange} prefix="$" value="" />)
fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('dims the group and disables the field when disabled', () => {
const { getByRole } = render(<Input aria-label="amount" disabled prefix="$" />)
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(<Input aria-label="amount" containerClassName="w-20" prefix="$" />)
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')
})
})

View file

@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, '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<React.ComponentProps<'input'>, '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 = (
<input
// Off by default for every consumer — these are code/config/search fields,
// not prose. Callers can re-enable per-instance by passing the prop.
@ -13,7 +27,12 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
autoComplete="off"
autoCorrect="off"
className={cn(
controlVariants({ size }),
// When adorned, the wrapper owns the chrome (border/background/focus
// glow) and the input goes transparent so the whole thing reads as one
// box; otherwise the input carries the chrome itself.
grouped
? 'min-w-0 flex-1 border-0 bg-transparent p-0 text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground'
: controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
@ -23,6 +42,28 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
{...props}
/>
)
if (!grouped) {
return field
}
return (
<div
className={cn(
// Same control chrome/sizing as a bare input; `.desktop-input-chrome`
// lights on `:focus-within` (styles.css) since the div never focuses.
controlVariants({ size }),
'inline-flex items-center gap-1',
props.disabled && 'cursor-not-allowed opacity-50',
containerClassName
)}
data-slot="input-group"
>
{prefix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{prefix}</span>}
{field}
{suffix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{suffix}</span>}
</div>
)
}
export { Input }

View file

@ -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<React.ComponentProps<'div'>, 'children'> {
/** Completion as a 01 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 (
<div
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={indeterminate ? undefined : pct}
className={cn('relative w-full overflow-hidden rounded-full bg-muted', TRACK_HEIGHT[size], className)}
data-slot="progress"
role="progressbar"
{...props}
>
{children}
{indeterminate && animated ? (
// Sliding block — width/animation come from `.progress-slide` (styles.css),
// which also honors prefers-reduced-motion.
<div className={cn('progress-slide absolute inset-y-0 rounded-full', fillColor, fillClassName)} />
) : (
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
fillColor,
indeterminate && 'w-1/3 animate-pulse',
fillClassName
)}
style={indeterminate ? fillStyle : { width: `${pct}%`, ...fillStyle }}
/>
)}
</div>
)
}

View file

@ -12,6 +12,8 @@ interface SegmentedControlProps<T extends string> {
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<T extends string> {
* (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<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
export function SegmentedControl<T extends string>({
className,
disabled = false,
onChange,
options,
value
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
disabled && 'opacity-50',
className
)}
>
@ -34,9 +43,10 @@ export function SegmentedControl<T extends string>({ options, value, onChange, c
<button
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors disabled:cursor-default',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
disabled={disabled}
key={id}
onClick={() => onChange(id)}
type="button"

View file

@ -34,6 +34,7 @@ import {
IconCopy as Copy,
IconCopy as CopyIcon,
IconCpu as Cpu,
IconCreditCard as CreditCard,
IconDownload as Download,
IconEgg as Egg,
IconExternalLink as ExternalLink,
@ -155,6 +156,7 @@ export {
Copy,
CopyIcon,
Cpu,
CreditCard,
Download,
Egg,
ExternalLink,

View file

@ -840,6 +840,7 @@ text-* variant utilities. */ .btn-arc {
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
open Radix moves focus into the list, so the trigger itself loses :focus. */
.desktop-input-chrome:focus,
.desktop-input-chrome:focus-within,
.desktop-input-chrome[data-state='open'] {
border-color: var(--dt-composer-ring);
box-shadow: none;
@ -1805,35 +1806,14 @@ text-* variant utilities. */ .btn-arc {
/* Pet generation progress bar — determinate (hatch rows: done/total) or */
/* indeterminate (drafts, which return together so a % would just snap). */
.pet-progress {
position: relative;
height: 0.25rem;
width: 100%;
overflow: hidden;
border-radius: 9999px;
background: color-mix(in srgb, var(--ui-accent) 15%, transparent);
}
.pet-progress__fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: 9999px;
background: var(--ui-accent);
transition: width 320ms ease;
}
.pet-progress__indeterminate {
position: absolute;
top: 0;
bottom: 0;
/* Sliding indeterminate block for the `accent` Progress variant (pet hatch
flow). Color/positioning come from the primitive; this owns the animation. */
.progress-slide {
width: 40%;
border-radius: 9999px;
background: var(--ui-accent);
animation: pet-progress-slide 1.15s ease-in-out infinite;
animation: progress-slide 1.15s ease-in-out infinite;
}
@keyframes pet-progress-slide {
@keyframes progress-slide {
0% {
left: -42%;
}
@ -1843,7 +1823,7 @@ text-* variant utilities. */ .btn-arc {
}
@media (prefers-reduced-motion: reduce) {
.pet-progress__indeterminate {
.progress-slide {
animation: none;
left: 0;
width: 100%;