mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
refactor(desktop): extract shared Progress primitive; billing uses it
Add components/ui/progress.tsx — one rounded track + animated fill that owns role="progressbar" + aria. Migrate the hand-rolled bars (updates overlay, onboarding, install overlay, billing usage, pet hatch) onto it. - Pet's bar was never a color variant (--primary and --ui-accent are the same brand color); its only real difference is the sliding indeterminate, now an `animated` prop. Color stays a normal `fillClassName` override. - Billing usage now uses the plain primitive — dropped the bespoke dither track, inset shadow, and danger nub; tone rides `destructive`/`fillClassName`. - Drop the redundant emoji "no saved card" row description; the page-level warn banner is the single explainer. - Rename pet CSS .pet-progress* -> generic .progress-slide.
This commit is contained in:
parent
066b53b282
commit
e4b2b77852
10 changed files with 146 additions and 127 deletions
|
|
@ -618,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 () => {
|
||||
|
|
@ -645,13 +647,15 @@ 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('shows a warn notice that names the no-card blocker with a portal link', async () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
|
||||
|
|
@ -46,16 +47,6 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
|
|||
|
||||
type BillingFixtureSelection = 'live' | BillingDevFixtureName
|
||||
|
||||
// DEV-only: a canned ~60%-full "ok" bar so the active progress-bar treatment can be
|
||||
// eyeballed on any account (a live/empty account only ever shows the hatch track).
|
||||
const DEV_PREVIEW_USAGE_ROW: BillingUsageRowView = {
|
||||
bar: { label: 'Example usage (dev preview)', state: 'ok', tone: 'subscription', value: 0.62 },
|
||||
caption: 'Preview only — dev build',
|
||||
id: 'subscription_credits',
|
||||
title: 'Example bar (dev preview)',
|
||||
value: '$62 of $100 left'
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
|
|
@ -320,44 +311,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
|
||||
? // Muted currentColor so the hatch reads as a faint placeholder, not solid ink.
|
||||
'dither text-(--ui-text-quaternary) bg-(--ui-bg-elevated)'
|
||||
: 'bg-(--ui-bg-tertiary) 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -523,9 +490,6 @@ function BillingSettingsContent({
|
|||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
))}
|
||||
{/* DEV-only: a fully-filled example so the active (non-disabled) bar can be
|
||||
reviewed on any account. Compiled out of production. */}
|
||||
{import.meta.env.DEV && <UsageRow row={DEV_PREVIEW_USAGE_ROW} />}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -556,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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1806,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%;
|
||||
}
|
||||
|
|
@ -1844,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