mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #69691 from NousResearch/bb/desktop-billing-polish
Desktop billing polish + shared Progress primitive + settings skeletons
This commit is contained in:
commit
da3c506db5
23 changed files with 605 additions and 373 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
|||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
|
||||
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
|
|
@ -264,8 +264,8 @@ export function ConfigSettings({
|
|||
)
|
||||
}
|
||||
|
||||
// Model keeps its shape via a skeleton (its catalog fetch is the slow part);
|
||||
// other sections are quick config/schema reads, so a light loader is fine.
|
||||
// Every section keeps its shape via a skeleton; model gets its bespoke one
|
||||
// (its catalog fetch is the slow part), the rest the shared field rhythm.
|
||||
if (activeSectionId === 'model') {
|
||||
return (
|
||||
<SettingsContent>
|
||||
|
|
@ -276,7 +276,7 @@ export function ConfigSettings({
|
|||
)
|
||||
}
|
||||
|
||||
return <LoadingState label={c.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 6 }]} />
|
||||
}
|
||||
|
||||
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
|||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
|
||||
interface CustomEndpointsSettingsProps {
|
||||
onConfigSaved?: () => void
|
||||
|
|
@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
|||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading custom endpoints..." />
|
||||
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
|
||||
}
|
||||
|
||||
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { notify, notifyError } from '@/store/notifications'
|
|||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
|
||||
|
||||
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
|
||||
|
|
@ -985,7 +985,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
|||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={g.loading} />
|
||||
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }, { heading: true, rows: 3 }]} />
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes'
|
|||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||
import { useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
|
||||
|
|
@ -64,7 +64,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
|||
}, [vars])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.keys.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 5 }]} />
|
||||
}
|
||||
|
||||
const visible = groups.filter(g => g.category === view)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
|
@ -139,7 +140,21 @@ export function PetSettings() {
|
|||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{pets.length === 0 ? (
|
||||
{status === 'loading' && pets.length === 0 ? (
|
||||
// First load keeps the grid's shape rather than flashing the
|
||||
// "unreachable" copy before the gallery has even arrived.
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
|
||||
<Skeleton className="size-10 shrink-0 rounded-md" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24 max-w-full" />
|
||||
<Skeleton className="h-3 w-16 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
|||
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||
|
||||
// The embedded terminal (and thus the "run disconnect command" path) only
|
||||
// exists in the Electron desktop shell, not the web dashboard.
|
||||
|
|
@ -431,7 +431,7 @@ export function ProvidersSettings({
|
|||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
return <SettingsSkeleton search sections={[{ rows: 6 }]} />
|
||||
}
|
||||
|
||||
const hasOauth = oauthProviders.length > 0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { untombstoneSessions } from '@/store/projects'
|
|||
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
|
@ -107,7 +107,7 @@ export function SessionsSettings() {
|
|||
})
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={s.loading} />
|
||||
return <SettingsSkeleton sections={[{ rows: 1 }, { heading: true, rows: 4 }]} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
66
apps/desktop/src/components/ui/input.test.tsx
Normal file
66
apps/desktop/src/components/ui/input.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
82
apps/desktop/src/components/ui/progress.tsx
Normal file
82
apps/desktop/src/components/ui/progress.tsx
Normal 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 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue