+
No plans are available to change to right 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 97ccf0cccc7b..e1af94db1feb 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -534,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',
diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx
index 45d48c73fcff..8654ce784f75 100644
--- a/apps/desktop/src/app/settings/primitives.tsx
+++ b/apps/desktop/src/app/settings/primitives.tsx
@@ -28,32 +28,40 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
return
{children}
}
-export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
+export function SectionHeading({
+ aside,
+ icon: Icon,
+ meta,
+ title
+}: {
+ // Right-aligned trailing content on the heading row (e.g. a compact status +
+ // action), so a single-item section needn't repeat its own label as a row.
+ aside?: ReactNode
+ icon: IconComponent
+ meta?: string
+ title: string
+}) {
return (
-
+
{title}
{meta &&
{meta} }
+ {aside &&
{aside}
}
)
}
-// 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({
+ aside,
children,
icon,
meta,
title
}: {
+ aside?: ReactNode
children: ReactNode
icon: IconComponent
meta?: string
@@ -61,7 +69,7 @@ export function SettingsSection({
}) {
return (
)
diff --git a/apps/desktop/src/components/ui/input.test.tsx b/apps/desktop/src/components/ui/input.test.tsx
new file mode 100644
index 000000000000..6a06b7432dd2
--- /dev/null
+++ b/apps/desktop/src/components/ui/input.test.tsx
@@ -0,0 +1,66 @@
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { Input } from './input'
+
+afterEach(cleanup)
+
+describe('Input', () => {
+ it('renders a bare input carrying the control chrome when unadorned', () => {
+ const { getByRole } = render(
)
+ const el = getByRole('textbox')
+
+ expect(el.tagName).toBe('INPUT')
+ expect(el.className).toContain('desktop-input-chrome')
+ // No group wrapper — the input is the top-level node.
+ expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group')
+ })
+
+ it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => {
+ const { getByRole, getByText } = render(
)
+ const el = getByRole('textbox')
+ const group = el.closest('[data-slot="input-group"]')
+
+ expect(group).not.toBeNull()
+ // Chrome moves to the wrapper; the input itself goes transparent/borderless.
+ expect(group?.className).toContain('desktop-input-chrome')
+ expect(el.className).not.toContain('desktop-input-chrome')
+ expect(el.className).toContain('bg-transparent')
+
+ const prefix = getByText('$')
+ expect(group?.contains(prefix)).toBe(true)
+ })
+
+ it('renders a trailing suffix inside the group', () => {
+ const { getByText } = render(
)
+ const suffix = getByText('%')
+
+ expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull()
+ })
+
+ it('forwards value/onChange through the adorned field', () => {
+ const onChange = vi.fn()
+ const { getByRole } = render(
)
+
+ fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('dims the group and disables the field when disabled', () => {
+ const { getByRole } = render(
)
+ const el = getByRole('textbox') as HTMLInputElement
+ const group = el.closest('[data-slot="input-group"]')
+
+ expect(el.disabled).toBe(true)
+ expect(group?.className).toContain('opacity-50')
+ })
+
+ it('lets containerClassName override the wrapper width', () => {
+ const { getByRole } = render(
)
+ const group = getByRole('textbox').closest('[data-slot="input-group"]')
+
+ // twMerge resolves the control's default w-full down to the caller's width.
+ expect(group?.className).toContain('w-20')
+ expect(group?.className).not.toContain('w-full')
+ })
+})
diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx
index a195e9a9ab84..8279cd1d459f 100644
--- a/apps/desktop/src/components/ui/input.tsx
+++ b/apps/desktop/src/components/ui/input.tsx
@@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
-function Input({ className, type, size, ...props }: Omit
, 'size'> & ControlVariantProps) {
- return (
+// `prefix`/`suffix` are DOM string attributes on the native element; we shadow
+// them with ReactNode adornments, so they're omitted from the base props.
+type InputProps = Omit, 'size' | 'prefix' | 'suffix'> &
+ ControlVariantProps & {
+ /** Leading adornment rendered inside the field (e.g. a `$` for money). */
+ prefix?: React.ReactNode
+ /** Trailing adornment rendered inside the field (e.g. a unit label). */
+ suffix?: React.ReactNode
+ /** Applied to the wrapper when an adornment promotes the field to a group. */
+ containerClassName?: string
+ }
+
+function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) {
+ const grouped = prefix != null || suffix != null
+
+ const field = (
)
+
+ if (!grouped) {
+ return field
+ }
+
+ return (
+
+ {prefix != null && {prefix} }
+ {field}
+ {suffix != null && {suffix} }
+
+ )
}
export { Input }
diff --git a/apps/desktop/src/components/ui/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
onChange(id)}
type="button"
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 86a87dbf0ea2..f0ec6bbf0233 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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;