From f9a8ecc0ce7e8d957a969c6c5564c47f752cf614 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 16:47:52 -0500 Subject: [PATCH 1/5] polish(desktop/billing): auto-poll, no-card notice, grouped card layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review polish of #68722 + #68761: - Usage: drop the manual refresh + 'Updated Xm ago'; billing queries now refetchInterval-poll while the page is mounted, like every other data view. - No card: lead the page with a warn notice naming the blocker + 'Add card ↗', so the silently-disabled buy/auto-refill controls have an obvious cause. - Layout: unify on a shared SettingsCard/SettingsSection primitive; collapse the three floating one-row sections (Payment / One-time top-up / Automatic refill) into a single divide-y 'Payment & credits' card. - Dev fixture switcher: relabel as a wrench + 'preview' dashed control so it reads as the DEV-only tool it is (compiled out of production). Removed formatUsageUpdatedAgo/oldestUpdatedAt/UsageRefreshRow + their tests; added no-card-notice and no-manual-refresh tests. vitest 107 green, tsc + eslint clean. --- .../src/app/settings/billing/index.test.tsx | 90 +++----- .../src/app/settings/billing/index.tsx | 193 +++++++----------- .../app/settings/billing/use-billing-state.ts | 48 +++-- apps/desktop/src/app/settings/primitives.tsx | 29 +++ apps/desktop/src/lib/icons.ts | 2 + 5 files changed, 158 insertions(+), 204 deletions(-) diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 7052e111d251..65fe9c91cda3 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -15,7 +15,6 @@ import { todayBillingState, todaySubscriptionState } from './fixtures.test-util' -import { formatUsageUpdatedAgo } from './use-billing-state' import { BillingSettings } from './index' @@ -265,7 +264,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 +276,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 +313,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() }) @@ -653,65 +652,34 @@ describe('BillingSettings', () => { expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true) }) - it('refreshes both billing queries from the usage refresh button', async () => { + it('shows a warn notice that names the no-card blocker with a portal link', async () => { + const fixture = billingDevFixtures['no-card'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + expect(await screen.findByText('No payment method on file')).toBeTruthy() + expect( + screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.') + ).toBeTruthy() + expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy() + }) + + it('does not show the no-card notice when a card is on file', async () => { + renderBilling() + + await screen.findByText('$996.47') + expect(screen.queryByText('No payment method on file')).toBeNull() + }) + + it('polls billing on an interval without a manual refresh control', async () => { renderBilling() await screen.findByText('$120 of $220 left') - expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1) - expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1) - - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) - - await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2)) - expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2) - }) - - it('disables the usage refresh button while either query is fetching', async () => { - let settleBilling: (value: unknown) => void = () => {} - - let settleSubscription: (value: unknown) => void = () => {} - - apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce( - new Promise(resolve => { - settleBilling = resolve - }) - ) - apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce( - new Promise(resolve => { - settleSubscription = resolve - }) - ) - - renderBilling() - - const refresh = await screen.findByRole('button', { name: 'Refresh' }) - - fireEvent.click(refresh) - - await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true)) - - settleBilling(okBilling(todayBillingState)) - settleSubscription(okSubscription(todaySubscriptionState)) - - await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false)) - }) -}) - -describe('formatUsageUpdatedAgo', () => { - it('formats sub-second and current timestamps as just now', () => { - expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now') - expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now') - }) - - it('formats seconds below a minute', () => { - expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago') - }) - - it('rounds elapsed time to whole minutes from 61 seconds', () => { - expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago') - }) - - it('formats one hour and later as hours', () => { - expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago') + // The manual refresh affordance is gone — the queries poll on their own. + expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull() + expect(screen.queryByText(/Updated/)).toBeNull() }) }) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 2d234d74c88d..2f783827f281 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -4,12 +4,11 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' 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 { 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, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives' import { RowValue } from './account-row-value' import { BillingApiProvider } from './api' @@ -27,7 +26,6 @@ import { type BillingNoticeView, type BillingUsageRowView, deriveBillingView, - formatUsageUpdatedAgo, useBillingState, useSubscriptionState } from './use-billing-state' @@ -64,9 +62,23 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | ' } function NoticeCard({ notice }: { notice: BillingNoticeView }) { + const warn = notice.tone === 'warn' + return ( -
-
{notice.title}
+
+
+ {notice.title} +
{notice.message}
@@ -351,53 +363,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) { ) } -function UsageRefreshRow({ - fixtureName, - isFetching, - onRefresh, - updatedAt -}: { - fixtureName?: BillingFixtureSelection - isFetching: boolean - onRefresh: () => void - updatedAt: number -}) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000) - - return () => window.clearInterval(interval) - }, []) - - if (fixtureName && fixtureName !== 'live') { - return ( -
- fixture: {fixtureName} -
- ) - } - - return ( -
- Updated {formatUsageUpdatedAgo(updatedAt, now)} - - - -
- ) -} - +// DEV-only preview switcher: swaps the whole page onto a canned fixture so every +// billing state can be reviewed without a matching live account. Marked with a +// wrench + "preview" so it never reads as a shipping control (it's compiled out of +// production builds entirely). function BillingFixtureSelect({ onValueChange, value @@ -406,23 +375,27 @@ function BillingFixtureSelect({ value: BillingFixtureSelection }) { return ( - +
+ + preview + +
) } @@ -464,15 +437,16 @@ function BillingSettingsContent({ 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 & credits card groups every money control (payment method, + // one-time top-up, auto-refill) into a single divide-y list instead of three + // free-floating one-row sections. + const accountRows = [paymentRow, 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 +465,42 @@ function BillingSettingsContent({ -
-
+ {view.notice && } + +
+ {view.summary.map(item => ( ))} -
+
- {view.notice && } - {view.plan && ( -
- - setSubView('plans')} plan={view.plan} /> -
+ + + setSubView('plans')} plan={view.plan} /> + + )} - {paymentRow && ( -
- - -
- )} - - {topupRow && ( -
- - -
- )} - - {refillRow && ( -
- - -
+ {accountRows.length > 0 && ( + + + {accountRows.map(row => ( + + ))} + + )} {view.usageRows.length > 0 && ( - <> - -
+ + {view.usageRows.map(row => ( ))} - -
- + + )} { @@ -586,9 +543,3 @@ export function BillingSettings() { return } - -function oldestUpdatedAt(...timestamps: number[]): number { - const populated = timestamps.filter(timestamp => timestamp > 0) - - return populated.length > 0 ? Math.min(...populated) : Date.now() -} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index da02f441ebe0..97ccf0cccc7b 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -11,7 +11,12 @@ export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' +// Billing polls on its own while the page is mounted (react-query only ticks an +// active observer), so the view stays live without a manual refresh control — +// matching every other data view in the app. It pauses when the window is +// backgrounded (refetchIntervalInBackground defaults to false). const BILLING_QUERY_OPTIONS = { + refetchInterval: 30_000, refetchOnWindowFocus: true, retry: false, staleTime: 30_000 @@ -30,6 +35,8 @@ export interface BillingNoticeView { } message: string title: string + /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ + tone?: 'info' | 'warn' } export interface BillingRowActionView { @@ -207,7 +214,7 @@ export function deriveBillingView( const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending) return { - notice: undefined, + notice: noCardNotice(billing), paymentRow: paymentMethodRow(billing), plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending), refillRow: autoReloadRow(billing), @@ -276,26 +283,6 @@ export function formatBillingDate(value?: null | string): string { return fmtDate.format(date) } -export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { - const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) - - if (elapsedSeconds < 1) { - return 'just now' - } - - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s ago` - } - - const elapsedMinutes = Math.floor(elapsedSeconds / 60) - - if (elapsedMinutes < 60) { - return `${elapsedMinutes}m ago` - } - - return `${Math.floor(elapsedMinutes / 60)}h ago` -} - function emptySummary(): BillingSummaryItemView[] { return [ { label: 'Balance', value: EMPTY_BILLING_VALUE }, @@ -311,7 +298,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView { return { action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined, message: resolved.message, - title: resolved.title + title: resolved.title, + tone: 'warn' + } +} + +// A logged-in account with no card can't buy credits or manage auto-refill, and +// every one of those controls disables silently — so lead the page with a single +// warn banner that names the blocker and links straight to the fix. +function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined { + if (billing.card) { + return undefined + } + + return { + action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL }, + message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.', + title: 'No payment method on file', + tone: 'warn' } } diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 4e55a2f9f4b9..45d48c73fcff 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -38,6 +38,35 @@ export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponen ) } +// The canonical settings surface: a soft-bordered muted well. Callers own the +// inner padding (a `divide-y` list wants none; a single block wants `p-4`) so the +// one container styling stays consistent across every settings page. +export function SettingsCard({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +// A titled section: heading + body with the shared vertical rhythm. Keeps the +// heading and its content welded together so pages stop hand-rolling +// `
` at every call site. +export function SettingsSection({ + children, + icon, + meta, + title +}: { + children: ReactNode + icon: IconComponent + meta?: string + title: string +}) { + return ( +
+ + {children} +
+ ) +} + export function NavLink({ icon: Icon, label, diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e66a433b30ec..1fda821b796e 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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, From c4acc4d2c5eb04492d051657ee6b32d9f407fb0e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:17:33 -0500 Subject: [PATCH 2/5] feat(desktop): add prefix/suffix adornments to Input primitive; use $ prefix in billing top-up --- .../src/app/settings/billing/index.test.tsx | 4 +- .../src/app/settings/billing/index.tsx | 123 +++++++++++------- .../src/app/settings/billing/plans-view.tsx | 8 +- .../app/settings/billing/use-billing-state.ts | 12 +- apps/desktop/src/app/settings/primitives.tsx | 28 ++-- apps/desktop/src/components/ui/input.test.tsx | 66 ++++++++++ apps/desktop/src/components/ui/input.tsx | 47 ++++++- .../src/components/ui/segmented-control.tsx | 14 +- apps/desktop/src/styles.css | 1 + 9 files changed, 230 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/components/ui/input.test.tsx diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 65fe9c91cda3..b111d9f1d8e5 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -120,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) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 2f783827f281..2c2ba0b1ab3a 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -3,12 +3,13 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +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' import { cn } from '@/lib/utils' import { useRouteEnumParam } from '../../hooks/use-route-enum-param' -import { ListRow, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives' +import { ListRow, SectionHeading, SettingsContent, SettingsSection } from '../primitives' import { RowValue } from './account-row-value' import { BillingApiProvider } from './api' @@ -45,6 +46,16 @@ 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 (
@@ -65,16 +76,11 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { const warn = notice.tone === 'warn' return ( -
+
{notice.title} @@ -98,6 +104,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { ) } +// The payment method as it rides in the "Payment & credits" heading: the current +// card (muted) plus a single underline text action (Update / Add payment method). +function PaymentMethodAside({ row }: { row: BillingAccountRowView }) { + return ( +
+ {row.value && ( + + {row.value} + + )} + {row.action && ( + + )} +
+ ) +} + function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { return @@ -155,22 +186,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B - {presets.map(preset => ( - - ))} + setAmount(value)} + options={presets.map(preset => ({ id: preset.amount, label: preset.label }))} + value={amount} + /> -
@@ -311,8 +336,9 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa 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)]' + ? // 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" > @@ -381,7 +407,7 @@ function BillingFixtureSelect({ ) + const el = getByRole('textbox') + + expect(el.tagName).toBe('INPUT') + expect(el.className).toContain('desktop-input-chrome') + // No group wrapper — the input is the top-level node. + expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group') + }) + + it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => { + const { getByRole, getByText } = render() + const el = getByRole('textbox') + const group = el.closest('[data-slot="input-group"]') + + expect(group).not.toBeNull() + // Chrome moves to the wrapper; the input itself goes transparent/borderless. + expect(group?.className).toContain('desktop-input-chrome') + expect(el.className).not.toContain('desktop-input-chrome') + expect(el.className).toContain('bg-transparent') + + const prefix = getByText('$') + expect(group?.contains(prefix)).toBe(true) + }) + + it('renders a trailing suffix inside the group', () => { + const { getByText } = render() + const suffix = getByText('%') + + expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull() + }) + + it('forwards value/onChange through the adorned field', () => { + const onChange = vi.fn() + const { getByRole } = render() + + fireEvent.change(getByRole('textbox'), { target: { value: '100' } }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('dims the group and disables the field when disabled', () => { + const { getByRole } = render() + const el = getByRole('textbox') as HTMLInputElement + const group = el.closest('[data-slot="input-group"]') + + expect(el.disabled).toBe(true) + expect(group?.className).toContain('opacity-50') + }) + + it('lets containerClassName override the wrapper width', () => { + const { getByRole } = render() + const group = getByRole('textbox').closest('[data-slot="input-group"]') + + // twMerge resolves the control's default w-full down to the caller's width. + expect(group?.className).toContain('w-20') + expect(group?.className).not.toContain('w-full') + }) +}) diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx index a195e9a9ab84..8279cd1d459f 100644 --- a/apps/desktop/src/components/ui/input.tsx +++ b/apps/desktop/src/components/ui/input.tsx @@ -4,8 +4,22 @@ import { cn } from '@/lib/utils' import { type ControlVariantProps, controlVariants } from './control' -function Input({ className, type, size, ...props }: Omit, 'size'> & ControlVariantProps) { - return ( +// `prefix`/`suffix` are DOM string attributes on the native element; we shadow +// them with ReactNode adornments, so they're omitted from the base props. +type InputProps = Omit, 'size' | 'prefix' | 'suffix'> & + ControlVariantProps & { + /** Leading adornment rendered inside the field (e.g. a `$` for money). */ + prefix?: React.ReactNode + /** Trailing adornment rendered inside the field (e.g. a unit label). */ + suffix?: React.ReactNode + /** Applied to the wrapper when an adornment promotes the field to a group. */ + containerClassName?: string + } + +function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) { + const grouped = prefix != null || suffix != null + + const field = ( ) + + if (!grouped) { + return field + } + + return ( +
+ {prefix != null && {prefix}} + {field} + {suffix != null && {suffix}} +
+ ) } export { Input } diff --git a/apps/desktop/src/components/ui/segmented-control.tsx b/apps/desktop/src/components/ui/segmented-control.tsx index 994cc17a99bd..45c854ecf2be 100644 --- a/apps/desktop/src/components/ui/segmented-control.tsx +++ b/apps/desktop/src/components/ui/segmented-control.tsx @@ -12,6 +12,8 @@ interface SegmentedControlProps { value: T onChange: (id: T) => void className?: string + /** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */ + disabled?: boolean } /** @@ -19,11 +21,18 @@ interface SegmentedControlProps { * (color mode, tool-call display, usage period, etc.). Flat by design — * no per-option borders, just a tinted track with a raised active pill. */ -export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) { +export function SegmentedControl({ + className, + disabled = false, + onChange, + options, + value +}: SegmentedControlProps) { return (
@@ -34,9 +43,10 @@ export function SegmentedControl({ options, value, onChange, c