Expand AITURK Turkish settings and desktop surfaces for beta 5

This commit is contained in:
2026-09-06 05:03:32 +03:00
parent 237d1e2060
commit 5e76f58637
108 changed files with 6675 additions and 1034 deletions
@@ -228,7 +228,7 @@ function MarketplaceThemeResults({
const header = (
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
From the VS Code Marketplace
{t.settings.runtime.marketplaceThemes}
</p>
)
@@ -519,7 +519,7 @@ export function AppearanceSettings() {
<input
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
onChange={event => setQuery(event.target.value)}
placeholder="Search your themes or the VS Code Marketplace…"
placeholder={t.settings.runtime.themeSearch}
spellCheck={false}
value={query}
/>
@@ -531,7 +531,7 @@ export function AppearanceSettings() {
{filteredThemes.length === 0 ? (
needle ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No installed themes match "{query.trim()}".
{t.settings.runtime.noMatchingThemes(query.trim())}
</p>
) : null
) : (
@@ -11,6 +11,7 @@ import { RowValue } from './account-row-value'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
import { useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import type { BillingAutoReload, BillingStateResponse } from './types'
import type { BillingAccountRowView } from './use-billing-state'
@@ -24,6 +25,8 @@ export function AutoReloadRow({
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const copy = useBillingCopy()
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
@@ -45,7 +48,7 @@ export function AutoReloadRow({
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds, copy)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
@@ -95,7 +98,7 @@ export function AutoReloadRow({
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setMessage({ kind: 'success', text: copy.refillUpdated })
setEditing(false)
}
@@ -125,7 +128,7 @@ export function AutoReloadRow({
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setMessage({ kind: 'success', text: copy.refillOff })
setEditing(false)
}
@@ -178,9 +181,9 @@ export function AutoReloadRow({
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
{copy.threshold}
<Input
aria-label="Auto-refill threshold"
aria-label={copy.refillThreshold}
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
@@ -195,9 +198,9 @@ export function AutoReloadRow({
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
{copy.reloadTo}
<Input
aria-label="Auto-refill reload-to amount"
aria-label={copy.refillAmount}
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
@@ -218,9 +221,9 @@ export function AutoReloadRow({
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<span>{copy.turnOffQuestion}</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
{copy.turnOff}
</Button>
<Button
disabled={busy}
@@ -229,7 +232,7 @@ export function AutoReloadRow({
type="button"
variant="ghost"
>
Cancel
{copy.cancel}
</Button>
</div>
) : (
@@ -241,7 +244,7 @@ export function AutoReloadRow({
type="button"
variant="outline"
>
Disable
{copy.disable}
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
@@ -261,15 +264,15 @@ export function AutoReloadRow({
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
{busy ? copy.saving : copy.save}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
{copy.cancel}
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
{copy.manage}
</Button>
)}
</div>
@@ -1,3 +1,4 @@
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
import type { BillingStateResponse } from './types'
import { EMPTY_BILLING_VALUE } from './use-billing-state'
@@ -49,22 +50,23 @@ export function initialAutoReloadAmount(...candidates: Array<null | string | und
export function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
const threshold = validateBillingAmount(copy.threshold, thresholdRaw, bounds, copy)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
const reloadTo = validateBillingAmount(copy.reloadLabel, reloadToRaw, bounds, copy)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
return { error: copy.reloadGreater }
}
return {
@@ -78,30 +80,31 @@ export function validateAutoReloadInputs(
export function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
return { error: copy.invalidAmount(label) }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
return { error: copy.positiveAmount(label) }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
return { error: copy.minAmount(label, formatMoney(min)) }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
return { error: copy.maxAmount(label, formatMoney(max)) }
}
return { amount }
@@ -0,0 +1,12 @@
import { useI18n } from '@/i18n'
import { EN_BILLING } from '@/i18n/en-billing'
import { TR_BILLING } from '@/i18n/tr-billing'
export type BillingCopy = typeof EN_BILLING
export const DEFAULT_BILLING_COPY: BillingCopy = EN_BILLING
export function useBillingCopy(): BillingCopy {
const { locale } = useI18n()
return locale === 'tr' ? TR_BILLING : EN_BILLING
}
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import { openExternal } from './open-external'
import { TierArt } from './tier-art'
@@ -8,6 +9,8 @@ import type { BillingPlanCardView } from './use-billing-state'
import { useResumeFlow } from './use-subscription-change'
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
const copy = useBillingCopy()
const resumeFlow = useResumeFlow()
return (
@@ -22,7 +25,8 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
{plan.price}
{copy.monthSuffix}
</span>
)}
</div>
@@ -40,7 +44,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
{plan.pending && (
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
{resumeFlow.busy ? copy.undoing : copy.undo}
</Button>
)}
{plan.link && (
+46 -53
View File
@@ -1,4 +1,5 @@
import type { BillingRefusal } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
export interface BillingRefusalPresentation {
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
@@ -8,51 +9,51 @@ export interface BillingRefusalPresentation {
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
const retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
const retryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
return copy.rateLimited(mins)
}
const stripeRetryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
const stripeRetryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
return `Stripe is having trouble — try again shortly${mins}`
return copy.stripeRetry(mins)
}
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
export const resolveRefusal = (
refusal: BillingRefusal,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingRefusalPresentation => {
switch (refusal.kind) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
message: copy.confirmCardMessage,
title: copy.confirmCardTitle
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
message: copy.spendingApprovalMessage,
title: copy.spendingApprovalTitle
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
const who = refusal.actor === 'admin' ? copy.adminRevoked : copy.userRevoked
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Remote spending was stopped'
message: copy.reauthorize(who),
title: copy.spendingRevoked
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
message: copy.sessionRevokedMessage,
title: copy.sessionRevokedTitle
}
case 'cli_billing_disabled':
@@ -60,50 +61,44 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
message: copy.spendingDisabledMessage,
title: copy.spendingDisabledTitle
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
message: copy.adminRequiredMessage,
title: copy.adminRequiredTitle
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
message: copy.conflictingChargeMessage,
title: copy.conflictingChargeTitle
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
message: copy.noSavedCardMessage,
title: copy.noSavedCard
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
message: copy.orgDeniedMessage,
title: copy.orgDeniedTitle
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
message: remaining != null ? copy.monthlyRemaining(String(remaining)) : copy.monthlyReached,
title: copy.monthlyReachedTitle
}
}
@@ -112,52 +107,50 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
message: retryMessage(refusal, copy),
title: copy.rateLimitedTitle
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
message: stripeRetryMessage(refusal, copy),
title: copy.stripeTitle
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
message: copy.dailyLimitMessage,
title: copy.dailyLimitTitle
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
message: refusal.message || copy.endpointMessage,
title: copy.endpointTitle
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
message: refusal.message || copy.requestTimeoutMessage,
title: copy.requestTimeoutTitle
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
message: refusal.message || copy.connectionFailedMessage,
title: copy.connectionFailedTitle
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
message: refusal.message || copy.requestFailedMessage,
title: copy.requestFailedTitle
}
}
}
@@ -1,9 +1,13 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render as renderUi, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider, useI18n } from '@/i18n'
import { renderWithEnglish as render } from '../test-locale'
import { formatMoney } from './billing-amounts'
import {
billingDevFixtures,
@@ -692,3 +696,38 @@ describe('BillingSettings', () => {
expect(screen.queryByText(/Updated/)).toBeNull()
})
})
function BillingLocaleSwitch() {
const { setLocale } = useI18n()
return <button onClick={() => void setLocale('tr')}>Türkçe</button>
}
it('switches loaded billing content to Turkish and preserves auto-refill request values', async () => {
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
renderUi(
<I18nProvider configClient={null} initialLocale="en">
<BillingLocaleSwitch />
<MemoryRouter initialEntries={['/settings?tab=billing']}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
</I18nProvider>
)
await screen.findByText('Buy credits now')
fireEvent.click(screen.getByRole('button', { name: 'Türkçe' }))
expect(await screen.findByText('Şimdi kredi satın al')).toBeTruthy()
expect(screen.queryByText('Buy credits now')).toBeNull()
expect(screen.getByText('Bakiye')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Yönet' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme eşiği' }), { target: { value: '15' } })
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme tutarı' }), { target: { value: '20' } })
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: true, threshold_usd: '15', reload_to_usd: '20' })
)
expect(await screen.findByText('Otomatik yükleme güncellendi.')).toBeTruthy()
expect(apiMocks.charge).not.toHaveBeenCalled()
})
+25 -14
View File
@@ -24,6 +24,7 @@ import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
import { AutoReloadRow } from './auto-reload-row'
import { clampAmount, formatMoney } from './billing-amounts'
import { useBillingCopy } from './copy'
import { CurrentPlanCard } from './current-plan-card'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { StepUpInlineAction } from './inline-feedback'
@@ -155,6 +156,8 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const copy = useBillingCopy()
const presets = useMemo(
() =>
billing.charge_presets.map((amount, index) => ({
@@ -192,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
value={amount}
/>
<Input
aria-label="Custom credit amount"
aria-label={copy.customCredit}
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
@@ -211,7 +214,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
{copy.buy}
</Button>
</div>
}
@@ -250,12 +253,14 @@ function BuyCreditsOutcome({
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const copy = useBillingCopy()
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing checking settlement
{copy.processing}
</div>
)
}
@@ -267,7 +272,7 @@ function BuyCreditsOutcome({
if (outcome.kind === 'success') {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
{copy.balanceAdded(formatMoney(outcome.amountUsd ?? amount))}
</div>
)
}
@@ -280,7 +285,7 @@ function BuyCreditsOutcome({
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</Button>
)}
@@ -297,13 +302,13 @@ function BuyCreditsOutcome({
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
{copy.retry}
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</Button>
)}
@@ -312,8 +317,10 @@ function BuyCreditsOutcome({
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const copy = useBillingCopy()
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
label: copy.usageLabel(fallbackLabel),
state: 'neutral',
tone: 'topup',
value: 0
@@ -407,11 +414,13 @@ function BillingHeader({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const copy = useBillingCopy()
return (
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
<span>{copy.billing}</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
@@ -455,6 +464,8 @@ function BillingSettingsContent({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const copy = useBillingCopy()
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
// Fixture mode flows through the SAME query path — the simulated api (supplied by
@@ -476,7 +487,7 @@ function BillingSettingsContent({
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const view = deriveBillingView(billingResult, subscriptionResult, copy)
const billing = billingResult?.ok ? billingResult.data : undefined
const { paymentRow, refillRow, topupRow } = view
@@ -515,7 +526,7 @@ function BillingSettingsContent({
</div>
{view.plan && (
<SettingsSection icon={Package} title="Plan">
<SettingsSection icon={Package} title={copy.plan}>
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsSection>
)}
@@ -524,7 +535,7 @@ function BillingSettingsContent({
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
title={copy.paymentCredits}
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
@@ -533,7 +544,7 @@ function BillingSettingsContent({
)}
{view.usageRows.length > 0 && (
<SettingsSection icon={BarChart3} title="Usage">
<SettingsSection icon={BarChart3} title={copy.usage}>
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
@@ -544,7 +555,7 @@ function BillingSettingsContent({
{
// no endpoint yet — NAS capability-board gap
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title={copy.invoices} /> : null
}
</SettingsContent>
)
@@ -3,16 +3,19 @@ import { openExternalLink } from '@/lib/external-link'
import { ExternalLink } from '@/lib/icons'
import type { BillingRefusal } from './api'
import { useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
import { useStepUpFlow } from './use-step-up'
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
const copy = useBillingCopy()
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
{copy.verificationPage}
<ExternalLink className="size-3.5" />
</Button>
</span>
@@ -26,31 +29,33 @@ export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUp
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
{copy.dismiss}
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
return <span>{copy.waitingVerification}</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
{copy.verifyContinue}
</Button>
)
}
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const copy = useBillingCopy()
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const resolved = resolveRefusal(refusal, copy)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
@@ -61,7 +66,7 @@ export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | nu
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
Open portal
{copy.portal}
<ExternalLink className="size-3.5" />
</Button>
)}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { TR_BILLING } from '@/i18n/tr-billing'
import { validateAutoReloadInputs } from './billing-amounts'
import { resolveRefusal } from './errors'
import { okBilling, okSubscription, postTrainBillingState, postTrainSubscriptionState } from './fixtures.test-util'
import { deriveBillingView } from './use-billing-state'
describe('Turkish billing contracts', () => {
it('keeps routes, amount bounds and action policy unchanged across languages', () => {
for (const kind of [
'consent_required',
'insufficient_scope',
'remote_spending_revoked',
'session_revoked',
'cli_billing_disabled',
'remote_spending_disabled',
'role_required',
'idempotency_conflict',
'no_payment_method',
'org_access_denied',
'monthly_cap_exceeded',
'rate_limited',
'temporarily_unavailable',
'stripe_unavailable',
'upgrade_cap_exceeded',
'endpoint_unavailable',
'timeout',
'transport',
'unknown'
]) {
const refusal = { kind, message: '', portalUrl: 'https://portal.nousresearch.com/billing', retryAfter: 120 }
const english = resolveRefusal(refusal)
const turkish = resolveRefusal(refusal, TR_BILLING)
expect(turkish.action).toEqual(english.action)
expect(turkish.title).not.toBe(english.title)
expect(turkish.message).not.toBe(english.message)
}
const bounds = { min_usd: '10', max_usd: '100' }
expect(validateAutoReloadInputs('15', '20', bounds, TR_BILLING)).toEqual(
validateAutoReloadInputs('15', '20', bounds)
)
expect(validateAutoReloadInputs('20', '15', bounds, TR_BILLING).error).toBe(TR_BILLING.reloadGreater)
expect(resolveRefusal({ kind: 'transport', message: 'upstream diagnostic' }, TR_BILLING).message).toBe(
'upstream diagnostic'
)
})
it('translates the derived overview without altering prices or plan identities', () => {
const args = [okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)] as const
const english = deriveBillingView(...args)
const turkish = deriveBillingView(...args, TR_BILLING)
expect(turkish.summary[0].label).toBe('Bakiye')
expect(turkish.summary[0].value).toBe(english.summary[0].value)
expect(turkish.plan?.tierName).toBe(english.plan?.tierName)
expect(turkish.plan?.price).toBe(english.plan?.price)
expect(turkish.paymentRow?.action?.url).toBe(english.paymentRow?.action?.url)
expect(turkish.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])).toEqual(
english.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])
)
})
})
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
@@ -16,9 +17,13 @@ type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
// The human sentence for the panel body, derived purely from the phase. `null` while
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
function previewMessage(
phase: DowngradePhase,
fallbackTierName: string,
copy: BillingCopy = DEFAULT_BILLING_COPY
): null | string {
if (phase.kind === 'previewing') {
return 'Checking this change…'
return copy.checkingChange
}
if (phase.kind === 'previewFailed') {
@@ -27,28 +32,27 @@ function previewMessage(phase: DowngradePhase, fallbackTierName: string): null |
const { preview } = phase
const targetName = preview.target_tier_name ?? fallbackTierName
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta, copy)
switch (preview.effect) {
case 'blocked':
return preview.reason ?? 'That change cannot be made here.'
return preview.reason ?? copy.cannotChange
case 'no_op':
return `You are already on ${targetName} — nothing to change.`
return copy.alreadyPlan(targetName)
case 'scheduled':
return (
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
)
return copy.scheduledChange(targetName, formatBillingDate(preview.effective_at, copy), creditsDelta)
default:
return 'This change cannot be scheduled here.'
return copy.cannotSchedule
}
}
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
const open = active?.target.tierId === tier.tierId
@@ -69,7 +73,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name)
const message = previewMessage(phase, tier.name, copy)
const canConfirm =
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
@@ -91,19 +95,19 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
<div className="flex min-w-0 flex-wrap items-center gap-2">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
Try again
{copy.tryAgain}
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling'
? 'Scheduling…'
? copy.scheduling
: phase.kind === 'scheduleFailed'
? 'Try again'
: 'Confirm downgrade'}
? copy.tryAgain
: copy.confirmDowngrade}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
Cancel
{copy.cancel}
</Button>
</div>
</div>
@@ -111,6 +115,8 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
@@ -143,7 +149,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
{tier.priceDisplay}
{copy.monthSuffix}
</div>
</div>
</div>
@@ -155,9 +162,9 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
)}
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">Current plan</Pill>}
{isCurrent && <Pill tone="primary">{copy.currentPlan}</Pill>}
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
{tier.state === 'scheduled' && <Pill>{copy.scheduled}</Pill>}
{tier.state === 'upgrade' && (
<Button
@@ -183,7 +190,7 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
type="button"
variant="outline"
>
Downgrade
{copy.downgrade}
</Button>
))}
</div>
@@ -192,6 +199,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
const copy = useBillingCopy()
// A scheduled downgrade lands the user back on the overview, where the plan card
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
@@ -200,7 +209,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
aria-label={copy.backBilling}
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
@@ -210,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
<span>{copy.plans}</span>
</div>
{tiers.length > 0 ? (
@@ -221,7 +230,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
</div>
) : (
<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.
{copy.noPlans}
</div>
)}
</div>
@@ -1,9 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { fmtDate } from '@/lib/time'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
import { resolveRefusal } from './errors'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
@@ -30,7 +29,7 @@ const BILLING_QUERY_OPTIONS = {
} as const
export interface BillingSummaryItemView {
label: 'Auto-refill' | 'Balance' | 'Plan'
label: string
tone?: 'muted' | 'primary'
value: string
}
@@ -172,12 +171,13 @@ export function useSubscriptionState(enabled = true) {
export function deriveBillingView(
stateResult?: BillingResult<BillingStateResponse>,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
subscriptionResult?: BillingResult<SubscriptionStateResponse>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingView {
if (!stateResult) {
return {
status: 'loading',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -185,9 +185,9 @@ export function deriveBillingView(
if (!stateResult.ok) {
return {
notice: refusalNotice(stateResult.refusal),
notice: refusalNotice(stateResult.refusal, copy),
status: 'refusal',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -199,12 +199,12 @@ export function deriveBillingView(
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
title: 'Connect your Nous account'
action: { label: copy.portalArrow, url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: copy.connectMessage,
title: copy.connectTitle
},
status: 'logged_out',
summary: emptySummary(),
summary: emptySummary(copy),
tiers: [],
usageRows: []
}
@@ -216,27 +216,27 @@ export function deriveBillingView(
const capable = plansCapable(subscription, subscriptionResult)
// Computed once and threaded to both the card (caption + undo) and the grid
// (Scheduled marker), so the two never disagree about what's pending.
const pending = pendingTransition(subscription?.current)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
const pending = pendingTransition(subscription?.current, copy)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending, copy)
return {
notice: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
notice: noCardNotice(billing, copy),
paymentRow: paymentMethodRow(billing, copy),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending, copy),
refillRow: autoReloadRow(billing, copy),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
{ label: copy.balance, value: displayBalance(billing) },
{ label: copy.plan, value: displayPlan(subscription, billing.usage, copy) },
{
label: 'Auto-refill',
label: copy.autoRefill,
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
value: billing.auto_reload ? (billing.auto_reload.enabled ? copy.enabled : copy.off) : EMPTY_BILLING_VALUE
}
],
tiers,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
topupRow: buyCreditsRow(billing, copy),
usageRows: deriveUsageRows(billing, subscription, copy)
}
}
@@ -275,7 +275,7 @@ export function buildManageSubscriptionUrl(
return FALLBACK_PORTAL_BILLING_URL
}
export function formatBillingDate(value?: null | string): string {
export function formatBillingDate(value?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
if (!value) {
return EMPTY_BILLING_VALUE
}
@@ -286,23 +286,23 @@ export function formatBillingDate(value?: null | string): string {
return EMPTY_BILLING_VALUE
}
return fmtDate.format(date)
return copy.formatDate(date)
}
function emptySummary(): BillingSummaryItemView[] {
function emptySummary(copy: BillingCopy = DEFAULT_BILLING_COPY): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
{ label: copy.balance, value: EMPTY_BILLING_VALUE },
{ label: copy.plan, value: EMPTY_BILLING_VALUE },
{ label: copy.autoRefill, value: EMPTY_BILLING_VALUE }
]
}
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const resolved = resolveRefusal(refusal)
function refusalNotice(refusal: BillingRefusal, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingNoticeView {
const resolved = resolveRefusal(refusal, copy)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
action: portalUrl ? { label: copy.portalArrow, url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title,
tone: 'warn'
@@ -312,15 +312,18 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
// 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 {
function noCardNotice(
billing: BillingStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): 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',
action: { label: copy.addCard, url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: copy.noCardMessage,
title: copy.noCardTitle,
tone: 'warn'
}
}
@@ -348,10 +351,13 @@ function plansCapable(
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
function creditsPerMonthDisplay(
monthlyCredits: null | string,
copy: BillingCopy = DEFAULT_BILLING_COPY
): string | undefined {
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
return Number.isFinite(credits) && credits > 0 ? copy.creditsMonthly(credits.toLocaleString('en-US')) : undefined
}
/**
@@ -360,14 +366,17 @@ function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefin
* ("$88/mo"), never the raw number. Zero / absent → null so the caller hides
* the line entirely.
*/
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
export function formatMonthlyCreditsDelta(
delta?: null | string,
copy: BillingCopy = DEFAULT_BILLING_COPY
): null | string {
const amount = parseAmount(delta)
if (amount == null || amount === 0) {
return null
}
return `${amount < 0 ? '' : '+'}${formatMoney(Math.abs(amount))}/mo`
return `${amount < 0 ? '' : '+'}${formatMoney(Math.abs(amount))}${copy.monthSuffix}`
}
/**
@@ -384,25 +393,26 @@ function derivePlanCard(
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean,
pending: PendingPlanTransition | undefined
pending: PendingPlanTransition | undefined,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? copy.free
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
// still shows its price.
const price = findCurrentTier(subscription)?.dollars_per_month_display
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at, copy)
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
? copy.subscriptionUnavailable
: pending
? pending.kind === 'downgrade'
? `Changes to ${pending.tierName} on ${pending.when}.`
: `Cancels on ${pending.when}.`
? copy.changesPlan(pending.tierName, pending.when)
: copy.cancelsOn(pending.when)
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
? copy.renewsOn(renewal)
: copy.noSubscription
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
@@ -410,14 +420,14 @@ function derivePlanCard(
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
if (capable && hasActionableTier) {
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
return { action: { label: current ? copy.changePlan : copy.viewPlans }, caption, pending, price, tierName }
}
return {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan ↗',
label: copy.adjustPlan,
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
@@ -432,20 +442,21 @@ function derivePlanCard(
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
// tier, the stronger, more specific signal, and is what the grid marks.
function pendingTransition(
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>,
copy: BillingCopy = DEFAULT_BILLING_COPY
): PendingPlanTransition | undefined {
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
return {
kind: 'downgrade',
tierName: current.pending_downgrade_tier_name,
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at, copy)
}
}
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
return {
kind: 'cancellation',
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at, copy)
}
}
@@ -471,7 +482,8 @@ function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean,
pending: PendingPlanTransition | undefined
pending: PendingPlanTransition | undefined,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
@@ -503,7 +515,7 @@ function derivePlanTiers(
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits, copy),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
@@ -529,13 +541,16 @@ function derivePlanTiers(
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
action: { label: copy.choose, url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
}
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
function paymentMethodRow(
billing: BillingStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingAccountRowView {
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
const card = billing.card
@@ -544,81 +559,80 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
// 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: 'Add payment method', url: portalUrl },
action: { label: copy.addPayment, url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method'
title: copy.paymentMethod
}
}
return {
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
action: { label: copy.update, url: portalUrl },
description: copy.manageCard,
id: 'payment_method',
title: 'Payment method',
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
title: copy.paymentMethod,
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via, copy)}`
}
}
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
function buyCreditsRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): 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' },
action: { disabled: true, label: copy.buy },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
description: copy.buyDescription,
id: 'buy_credits',
title: 'Buy credits now'
title: copy.buyCredits
}
}
const disabledReason = buyCreditsDisabledReason(billing)
const disabledReason = buyCreditsDisabledReason(billing, copy)
if (disabledReason) {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits now'
title: copy.buyCredits
}
}
return {
action: { disabled: true, label: 'Buy' },
action: { disabled: true, label: copy.buy },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
description: copy.buyDescription,
id: 'buy_credits',
title: 'Buy credits now'
title: copy.buyCredits
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
function autoReloadRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingAccountRowView {
const autoReload = billing.auto_reload
if (!autoReload) {
return {
action: { disabled: true, label: 'Manage' },
caption: 'Manage auto-refill from the portal.',
description: AUTO_REFILL_GENERIC,
action: { disabled: true, label: copy.manage },
caption: copy.manageRefill,
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Refill when low'
title: copy.refillWhenLow
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: AUTO_REFILL_GENERIC,
caption: copy.turnOnRefill,
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Refill when low'
pill: { label: copy.off, tone: 'muted' },
title: copy.refillWhenLow
}
}
@@ -626,16 +640,16 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
const { brand, last4 } = autoReload.card
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : copy.anotherCard
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
return {
action: { label: 'Reconcile ↗', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: AUTO_REFILL_GENERIC,
action: { label: copy.reconcile, url: portalUrl },
caption: copy.reconcileCard(cardLabel),
description: copy.refillDescription,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
pill: { label: copy.enabled, tone: 'primary' },
title: copy.refillWhenLow
}
}
@@ -643,22 +657,23 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
action: { label: copy.manage },
// Numbers live in the first sentence (spec §8); the swap region below carries
// the editable fields, so no redundant caption here.
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
description: copy.chargesBelow(reloadTo, threshold),
id: 'auto_reload',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
pill: { label: copy.enabled, tone: 'primary' },
title: copy.refillWhenLow
}
}
function deriveUsageRows(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse
subscription: null | SubscriptionStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): BillingUsageRowView[] {
const rows: BillingUsageRowView[] = []
const current = subscription?.current
@@ -671,8 +686,8 @@ function deriveUsageRows(
const subscriptionValue =
remaining != null && monthly != null
? remaining < 0
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
? copy.remaining(formatMoney(0), formatMoney(monthly), formatMoney(Math.abs(remaining)))
: copy.remaining(formatMoney(remaining), formatMoney(monthly))
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
@@ -681,16 +696,16 @@ function deriveUsageRows(
bar:
remainingFraction != null
? {
label: 'Subscription credits remaining',
label: copy.subscriptionRemaining,
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
tone: 'subscription',
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
value: clamp01(remainingFraction)
}
: undefined,
caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`,
caption: copy.resetsOn(formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at, copy)),
id: 'subscription_credits',
title: 'Subscription credits',
title: copy.subscriptionCredits,
value: subscriptionValue
})
@@ -699,9 +714,9 @@ function deriveUsageRows(
// No bar: top-ups have no denominator (the wire carries only the current
// balance, and the pool is open-ended), so a fill fraction would be fiction.
rows.push({
caption: 'Does not expire',
caption: copy.noExpiry,
id: 'topup_credits',
title: 'Top-up credits',
title: copy.topupCredits,
value: topupValue
})
@@ -711,22 +726,22 @@ function deriveUsageRows(
const limit = parseAmount(cap.limit_usd)
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
const usedFraction = limit != null && limit > 0 ? spent / limit : null
const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used`
const value = copy.used(cap.spent_display || formatMoney(spent), cap.limit_display || formatMoney(limit))
rows.push({
bar:
usedFraction != null
? {
label: 'Monthly spend cap used',
label: copy.capUsed,
state: usedFraction >= 0.9 ? 'danger' : 'ok',
tone: 'cap',
track: usedFraction >= 1 ? 'danger' : undefined,
value: clamp01(usedFraction)
}
: undefined,
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
caption: cap.is_default_ceiling ? copy.defaultCeiling : copy.monthlyRemoteSpending,
id: 'monthly_cap',
title: 'Monthly spend cap',
title: copy.monthlyCap,
value
})
}
@@ -738,7 +753,11 @@ function displayBalance(billing: BillingStateResponse): string {
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
}
function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string {
function displayPlan(
subscription: null | SubscriptionStateResponse,
usage?: UsageModelData,
copy: BillingCopy = DEFAULT_BILLING_COPY
): string {
const current = subscription?.current
const tier = current?.tier_name ?? usage?.plan_name
@@ -748,7 +767,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa
const price = findCurrentTier(subscription)?.dollars_per_month_display
return price ? `${tier} · ${price}/mo` : tier
return price ? `${tier} · ${price}${copy.monthSuffix}` : tier
}
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
@@ -760,33 +779,40 @@ function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData
)
}
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
function buyCreditsDisabledReason(
billing: BillingStateResponse,
copy: BillingCopy = DEFAULT_BILLING_COPY
): null | string {
if (!billing.is_admin) {
return resolveRefusal({ kind: 'role_required', message: '' }).message
return resolveRefusal({ kind: 'role_required', message: '' }, copy).message
}
if (!billing.cli_billing_enabled) {
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
return resolveRefusal(
{ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
copy
).message
}
if (!billing.can_charge) {
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
return resolveRefusal(
{ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
copy
).message
}
return null
}
function provenanceSuffix(resolvedVia?: null | string): string {
function provenanceSuffix(resolvedVia?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
if (!resolvedVia) {
return ''
}
const labels: Record<string, string> = {
autoRefill: 'auto-refill card',
customerDefault: 'customer default',
subPin: 'subscription card'
autoRefill: copy.refillCard,
customerDefault: copy.customerDefault,
subPin: copy.subscriptionCard
}
return ` - ${labels[resolvedVia] ?? resolvedVia}`
@@ -3,6 +3,8 @@ import { act, renderHook } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import type { BillingResult } from './api'
import type { BillingChargeStatusResponse } from './types'
@@ -58,7 +60,11 @@ function controlledClock() {
function wrapper({ children }: PropsWithChildren) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return createElement(QueryClientProvider, { client }, children)
return createElement(I18nProvider, {
configClient: null,
initialLocale: 'en',
children: createElement(QueryClientProvider, { client }, children)
})
}
beforeEach(() => {
@@ -9,6 +9,7 @@ import { useCallback, useRef, useState } from 'react'
import type { BillingApi, BillingRefusal } from './api'
import { useBillingApi } from './api'
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
import type { BillingChargeStatusResponse } from './types'
@@ -43,6 +44,7 @@ export interface ChargePollClock {
}
export interface ChargePollOptions extends ChargePollClock {
copy?: BillingCopy
portalUrl?: null | string
}
@@ -66,6 +68,7 @@ export async function pollChargeSettlement(
chargeId: string,
opts: ChargePollOptions = {}
): Promise<ChargeFlowOutcome> {
const copy = opts.copy ?? DEFAULT_BILLING_COPY
const sleep = opts.sleep ?? defaultSleep
const now = opts.now ?? Date.now
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
@@ -96,51 +99,51 @@ export async function pollChargeSettlement(
return {
amountUsd: settlement.status.amount_usd,
kind: 'success',
message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.'
message: settlement.status.amount_usd ? copy.chargeAdded(settlement.status.amount_usd) : copy.creditsAdded
}
case 'failed':
return {
action: { type: 'retry' },
kind: 'failure',
message: renderChargeFailed(settlement.status.reason),
message: renderChargeFailed(settlement.status.reason, copy),
retryFreshKey: true,
title: 'Charge failed'
title: copy.chargeFailedTitle
}
case 'ambiguous': {
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
const resolved = resolveRefusal(refusal)
const resolved = resolveRefusal(refusal, copy)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
return {
kind: 'ambiguous',
message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
message: copy.unconfirmed(resolved.message),
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
title: 'Charge outcome unconfirmed'
title: copy.unconfirmedTitle
}
}
return {
kind: 'failure',
message: observed.refusal?.message || 'Could not check the charge.',
message: observed.refusal?.message || copy.checkFailedMessage,
retryFreshKey: true,
title: 'Could not check charge'
title: copy.checkFailedTitle
}
}
case 'refused':
return {
kind: 'failure',
message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.',
message: observed.refusal?.message || settlement.status.message || copy.checkFailedMessage,
retryFreshKey: true,
title: 'Could not check charge'
title: copy.checkFailedTitle
}
case 'cancelled':
case 'timed_out':
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl)
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl, copy)
}
}
@@ -173,6 +176,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
export function useChargeFlow() {
const copy = useBillingCopy()
const api = useBillingApi()
const queryClient = useQueryClient()
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
@@ -206,7 +210,7 @@ export function useChargeFlow() {
const chargeResult = await api.charge(amountUsd, idempotencyKey)
if (!chargeResult.ok) {
const resolved = resolveRefusal(chargeResult.refusal)
const resolved = resolveRefusal(chargeResult.refusal, copy)
const action =
resolved.action.type === 'portal'
@@ -239,9 +243,9 @@ export function useChargeFlow() {
if (!chargeId) {
setOutcome({
kind: 'failure',
message: 'The billing service accepted the request but did not return a charge id.',
message: copy.missingChargeId,
retryFreshKey: true,
title: 'Charge could not be tracked'
title: copy.untrackedTitle
})
setPhaseState('done')
@@ -251,6 +255,7 @@ export function useChargeFlow() {
setPhaseState('polling')
const pollOutcome = await pollChargeSettlement(api, chargeId, {
copy,
portalUrl: chargeResult.data.portal_url
})
@@ -261,7 +266,7 @@ export function useChargeFlow() {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
},
[api, queryClient, setPhaseState]
[api, queryClient, setPhaseState, copy]
)
return { outcome, phase, reset, start }
@@ -271,27 +276,27 @@ function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
return retryableSendKinds.has(refusal.kind)
}
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
function timeoutOutcome(portalUrl?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): ChargeFlowOutcome {
return {
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
message: copy.stillProcessingMessage,
portalUrl: portalUrl ?? undefined,
title: 'Still processing after 5 minutes'
title: copy.stillProcessingTitle
}
}
function renderChargeFailed(reason?: null | string): string {
function renderChargeFailed(reason?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
switch ((reason || '').trim()) {
case 'authentication_required':
return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.'
return copy.bankVerification
case 'payment_method_expired':
return 'Your card has expired. Update it on the portal.'
return copy.cardExpired
case 'card_declined':
return 'Your card was declined. Try another card on the portal.'
return copy.cardDeclined
default:
return `The charge didn't go through (${reason || 'processing_error'}).`
return copy.chargeFailed(reason || 'processing_error')
}
}
@@ -3,6 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
const apiMocks = vi.hoisted(() => ({
stepUp: vi.fn()
}))
@@ -56,7 +58,11 @@ import { useStepUpFlow } from './use-step-up'
function createWrapper(client: QueryClient) {
return function wrapper({ children }: PropsWithChildren) {
return createElement(QueryClientProvider, { client }, children)
return createElement(I18nProvider, {
configClient: null,
initialLocale: 'en',
children: createElement(QueryClientProvider, { client }, children)
})
}
}
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { $gateway } from '@/store/gateway'
import { useBillingApi } from './api'
import { useBillingCopy } from './copy'
import { resolveRefusal } from './errors'
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
@@ -26,6 +27,7 @@ interface StepUpVerificationPayload {
}
export function useStepUpFlow() {
const copy = useBillingCopy()
const api = useBillingApi()
const gateway = useStore($gateway)
const queryClient = useQueryClient()
@@ -107,7 +109,7 @@ export function useStepUpFlow() {
unsubscribe()
if (!result.ok) {
const resolved = resolveRefusal(result.refusal)
const resolved = resolveRefusal(result.refusal, copy)
setMessage({
kind: 'error',
@@ -121,8 +123,8 @@ export function useStepUpFlow() {
if (!result.data.granted) {
setMessage({
kind: 'error',
text: 'Verification finished without allowing Remote Spending for this terminal.',
title: 'Verification was not approved'
text: copy.verificationDeniedMessage,
title: copy.verificationDeniedTitle
})
return
@@ -134,10 +136,10 @@ export function useStepUpFlow() {
])
setMessage({
kind: 'success',
text: 'Remote Spending is allowed for this terminal.',
title: 'Verification complete'
text: copy.verificationDoneMessage,
title: copy.verificationDoneTitle
})
}, [api, gateway, queryClient, unsubscribe])
}, [api, gateway, queryClient, unsubscribe, copy])
return { dismiss, message, openVerification, phase, start, verification }
}
@@ -4,6 +4,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
@@ -34,6 +35,7 @@ export function ComboboxInput({
placeholder?: string
className?: string
}) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -66,7 +68,7 @@ export function ComboboxInput({
value={value}
/>
<button
aria-label="Show options"
aria-label={t.settings.runtime.showOptions}
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
onClick={() => {
setOpen(current => !current)
@@ -0,0 +1,63 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import { ComputerUsePanel } from './computer-use-panel'
const getStatus = vi.hoisted(() => vi.fn())
vi.mock('@/hermes', () => ({
getComputerUseStatus: getStatus,
getActionStatus: vi.fn(),
grantComputerUsePermissions: vi.fn()
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('Computer Use localized permissions', () => {
it('renders macOS permission labels, explanations, and states in Turkish', async () => {
getStatus.mockResolvedValue({
platform: 'darwin',
platform_supported: true,
installed: true,
can_grant: true,
accessibility: true,
screen_recording: false,
ready: false,
checks: []
})
render(
<I18nProvider configClient={null} initialLocale="tr">
<ComputerUsePanel />
</I18nProvider>
)
expect(await screen.findByText('Erişilebilirlik')).toBeTruthy()
expect(screen.getByText('Ekran Kaydı')).toBeTruthy()
expect(screen.getByText('İzin verildi')).toBeTruthy()
expect(screen.getByText('İzin verilmedi')).toBeTruthy()
expect(screen.getByText(/uygulama pencerelerinin ekran görüntüsünü/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'İzinleri ver' })).toBeTruthy()
})
it('retains English and represents a ready Windows driver without macOS permission controls', async () => {
getStatus.mockResolvedValue({
platform: 'win32',
platform_supported: true,
installed: true,
can_grant: false,
ready: true,
checks: []
})
render(
<I18nProvider configClient={null} initialLocale="en">
<ComputerUsePanel />
</I18nProvider>
)
expect(await screen.findByText('Driver health')).toBeTruthy()
expect(screen.getByText('Ready')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Grant permissions' })).toBeNull()
})
})
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
@@ -17,10 +18,6 @@ interface ComputerUsePanelProps {
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
// drives the permission rows instead, so it has no entry here.
const PLATFORM_NOTE: Record<string, string> = {
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
}
function tone(granted: boolean | null) {
return granted === true ? 'primary' : 'muted'
@@ -33,6 +30,9 @@ function GrantIcon({ granted }: { granted: boolean | null }) {
}
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
const { t } = useI18n()
const copy = t.settings.runtime.computerUse
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<div className="min-w-0">
@@ -41,7 +41,7 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
</div>
<Pill tone={tone(granted)}>
<GrantIcon granted={granted} />
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
{granted === true ? copy.granted : granted === false ? copy.notGranted : copy.unknown}
</Pill>
</div>
)
@@ -61,6 +61,8 @@ function PermissionRow({ granted, label, hint }: { granted: boolean | null; labe
* below this card (the generic ToolsetConfigPanel).
*/
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
const { t } = useI18n()
const copy = t.settings.runtime.computerUse
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
const [loading, setLoading] = useState(true)
const [granting, setGranting] = useState(false)
@@ -70,11 +72,11 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
try {
setStatus(await getComputerUseStatus())
} catch (err) {
notifyError(err, 'Could not read Computer Use status')
notifyError(err, copy.statusFailed)
} finally {
setLoading(false)
}
}, [])
}, [copy.statusFailed])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@@ -91,15 +93,15 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
const started = await grantComputerUsePermissions()
if (!started.ok) {
notifyError(new Error('spawn failed'), 'Could not request permissions')
notifyError(new Error('spawn failed'), copy.permissionsFailed)
return
}
notify({
kind: 'info',
title: 'Approve in System Settings',
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
title: copy.approveTitle,
message: copy.approveBody
})
// The driver waits for the user to flip the switch — poll until it exits.
@@ -124,20 +126,20 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
}
} catch (err) {
if (activeRef.current) {
notifyError(err, 'Could not request permissions')
notifyError(err, copy.permissionsFailed)
}
} finally {
if (activeRef.current) {
setGranting(false)
}
}
}, [onConfiguredChange, refresh])
}, [onConfiguredChange, refresh, copy])
if (loading) {
return (
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking Computer Use status
{copy.checking}
</div>
)
}
@@ -147,18 +149,14 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
}
if (!status.platform_supported) {
return (
<p className="px-1 text-xs text-muted-foreground">
Computer Use isn&apos;t supported on this platform ({status.platform}).
</p>
)
return <p className="px-1 text-xs text-muted-foreground">{copy.unsupported(status.platform)}</p>
}
if (!status.installed) {
return (
<p className="px-1 text-xs text-muted-foreground">
Install the cua-driver backend below to drive this machine.
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
{copy.install}
{status.can_grant && copy.installGrant}
</p>
)
}
@@ -170,40 +168,35 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
<div className="min-w-0">
{status.can_grant ? (
<p className="text-[0.72rem] text-muted-foreground">
Grants attach to CuaDriver&apos;s own identity (com.trycua.driver), not Hermes so the dialog is
attributed to the process that drives your Mac.
</p>
<p className="text-[0.72rem] text-muted-foreground">{copy.identity}</p>
) : (
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
<p className="text-[0.72rem] text-muted-foreground">
{status.platform === 'linux' ? copy.linux : status.platform === 'win32' ? copy.win32 : ''}
</p>
)}
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
</div>
<Button onClick={() => void refresh()} size="sm" variant="text">
<RefreshCw className="size-3.5" />
Recheck
{copy.recheck}
</Button>
</div>
{status.can_grant ? (
<>
<PermissionRow
granted={status.accessibility}
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
label="Accessibility"
/>
<PermissionRow granted={status.accessibility} hint={copy.accessibilityHint} label={copy.accessibility} />
<PermissionRow
granted={status.screen_recording}
hint="Lets cua-driver capture screenshots of app windows."
label="Screen Recording"
hint={copy.screenRecordingHint}
label={copy.screenRecording}
/>
</>
) : (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
<span className="text-sm font-medium">Driver health</span>
<span className="text-sm font-medium">{copy.driverHealth}</span>
<Pill tone={tone(status.ready)}>
<GrantIcon granted={status.ready} />
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
{status.ready === true ? copy.ready : status.ready === false ? copy.notReady : copy.unknown}
</Pill>
</div>
)}
@@ -225,13 +218,13 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
{status.ready ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Check className="size-3.5" />
Computer Use is ready. Ask the agent to capture an app and click around.
{copy.readyHint}
</div>
) : (
status.can_grant && (
<Button disabled={granting} onClick={() => void grant()} size="sm">
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
{granting ? 'Waiting for approval…' : 'Grant permissions'}
{granting ? copy.waiting : copy.grant}
</Button>
)
)}
@@ -146,7 +146,7 @@ export function ConfigField({
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option
? (optionLabels?.[option] ?? prettyName(option))
? (optionLabels?.[option] ?? t.settings.fieldOptions?.[schemaKey]?.[option] ?? prettyName(option))
: schemaKey === 'display.personality'
? c.none
: schemaKey === 'memory.provider'
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
@@ -11,6 +11,7 @@ import {
sameBackendPeerLabel,
sshCompositeKey
} from './connections-registry'
import { renderWithEnglish as render } from './test-locale'
const list = vi.fn()
const save = vi.fn()
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { prettyName, providerDescription, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
@@ -254,9 +254,9 @@ export function CredentialKeyCard({
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const { t } = useI18n()
const { t, locale } = useI18n()
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const description = providerDescription(group.name, group.description, locale)
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
@@ -1,7 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithEnglish as render } from './test-locale'
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@@ -1,6 +1,8 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { cleanup, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithEnglish as render } from './test-locale'
const getConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
+9
View File
@@ -1,3 +1,4 @@
import { TR_PROVIDER_DESCRIPTIONS } from '@/i18n/tr-provider-descriptions'
import { asText, normalize } from '@/lib/text'
import type { ConfigFieldSchema, HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
@@ -50,6 +51,14 @@ export const providerMeta = (name: string) =>
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
export function providerDescription(name: string, description: string | undefined, locale: string): string | undefined {
const original = description?.trim()
return locale === 'tr' && original && original === providerMeta(name)?.description
? (TR_PROVIDER_DESCRIPTIONS[name] ?? original)
: original
}
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
function isSafePart(part: string): boolean {
@@ -104,7 +104,7 @@ const REFUSED_MODEL: LocalCatalogModel = {
function renderPane() {
return render(
<MemoryRouter>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
@@ -429,7 +429,7 @@ describe('BrowseSection', () => {
render(
<MemoryRouter>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
</MemoryRouter>
@@ -534,7 +534,7 @@ describe('quickstart completion navigation', () => {
render(
<MemoryRouter initialEntries={['/settings']}>
<I18nProvider>
<I18nProvider configClient={null} initialLocale="en">
<LocalModelsSettings />
</I18nProvider>
<Probe />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
@@ -13,6 +14,8 @@ const POLL_TIMEOUT_MS = 120_000
// backend-driven: the status route 404s for providers without an oauth_flow
// module, so non-OAuth providers render nothing.
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
const { t } = useI18n()
const copy = t.settings.runtime.memory
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
const [connected, setConnected] = useState(false)
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
@@ -75,8 +78,8 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
await startMemoryProviderOAuth(provider, profile)
} catch (err) {
setPhase('error')
setDetail('Could not start the connection.')
notifyError(err, 'Failed to start connection')
setDetail(copy.startFailed)
notifyError(err, copy.startFailed)
return
}
@@ -92,7 +95,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
if (Date.now() > deadline.current) {
stop()
setPhase('error')
setDetail('Timed out — try again.')
setDetail(copy.timeout)
}
return
@@ -104,7 +107,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
if (next.state === 'error') {
setPhase('error')
setDetail(next.detail || 'Connection failed.')
setDetail(next.detail || copy.connectionFailed)
} else {
setPhase('idle')
}
@@ -113,7 +116,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
}
})()
}, POLL_MS)
}, [profile, provider, stop])
}, [profile, provider, stop, copy])
const cancel = useCallback(() => {
stop()
@@ -124,24 +127,24 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
return null
}
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
const connectLabel = connected ? (auth === 'apikey' ? copy.connectOauth : copy.reconnect) : copy.connect
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
{auth === 'apikey' ? copy.apiKeySet : copy.oauthSet}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent
{copy.waiting}
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
{t.common.cancel}
</Button>
</>
) : (
@@ -3,6 +3,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Check, Info } from '@/lib/icons'
import type { MemoryProviderField } from '@/types/hermes'
@@ -13,6 +14,8 @@ const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground
// Field label with an optional info tooltip, shared by the panel and modal rows.
export function FieldTitle({ field }: { field: MemoryProviderField }) {
const { t } = useI18n()
if (!field.info) {
return <>{field.label}</>
}
@@ -21,7 +24,7 @@ export function FieldTitle({ field }: { field: MemoryProviderField }) {
<span className="inline-flex items-center gap-1.5">
{field.label}
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
<Info aria-label={t.settings.runtime.memory.about(field.label)} className="size-3.5 text-muted-foreground/70" />
</Tip>
</span>
)
@@ -41,6 +44,8 @@ export function FieldControl({
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
onCommit?: (value: string) => void
}) {
const { t } = useI18n()
const set = (next: string) => {
onChange(next)
onCommit?.(next)
@@ -103,14 +108,14 @@ export function FieldControl({
className={`w-full ${FIELD_INPUT}`}
onBlur={commitDraft}
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
placeholder={field.is_set ? t.settings.runtime.memory.keepSecret : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
<Check className="size-3 text-(--ui-accent-secondary)" />
set
{t.settings.runtime.memory.set}
</span>
)}
</div>
@@ -1,8 +1,10 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { renderWithEnglish as render } from '../test-locale'
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
@@ -12,6 +12,8 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { saveMemoryProviderConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
@@ -59,6 +61,8 @@ export function ProviderConfigModal({
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void> | void
}) {
const { t, locale } = useI18n()
const copy = t.settings.runtime.memory
const activeProfile = useStore($activeGatewayProfile)
const [values, setValues] = useState<Record<string, string>>({})
const [seeded, setSeeded] = useState<Record<string, string>>({})
@@ -81,11 +85,11 @@ export function ProviderConfigModal({
try {
await saveMemoryProviderConfig(provider, edited, profile)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
notify({ kind: 'success', title: copy.saved(config.label), message: copy.updated })
await onSaved()
onOpenChange(false)
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
notifyError(err, copy.saveFailed(config.label))
} finally {
setSaving(false)
}
@@ -95,11 +99,8 @@ export function ProviderConfigModal({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{config.label} full configuration</DialogTitle>
<DialogDescription>
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> profile.
Blank fields fall back to the resolved host or built-in default.
</DialogDescription>
<DialogTitle icon={SlidersHorizontal}>{copy.fullTitle(config.label)}</DialogTitle>
<DialogDescription>{copy.fullDescription(config.label, profile ?? activeProfile)}</DialogDescription>
{config.docs_url && (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
@@ -111,48 +112,50 @@ export function ProviderConfigModal({
rel="noreferrer"
target="_blank"
>
{config.label} configuration reference
{copy.reference(config.label)}
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<div className="min-w-0">
{groupFields(config.fields).map(([group, fields]) => (
<section className="mt-6 first:mt-2" key={group}>
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
{group}
</h3>
<div className="pl-1">
{fields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
</section>
))}
{groupFields(config.fields.map(field => localizeMemoryField(config.name, field, locale))).map(
([group, fields]) => (
<section className="mt-6 first:mt-2" key={group}>
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
{group === 'Other' ? copy.other : group}
</h3>
<div className="pl-1">
{fields.map(field => (
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
<ListRow
action={
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
}
description={field.description}
title={<FieldTitle field={field} />}
/>
</div>
))}
</div>
</section>
)
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save changes
{copy.saveChanges}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,8 +1,11 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render as renderLocale, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import type { MemoryProviderConfig } from '@/types/hermes'
import { renderWithEnglish as render } from '../test-locale'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
@@ -198,6 +201,24 @@ describe('ProviderConfigPanel', () => {
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
})
it('renders Turkish field copy and saves the original backend key and value', async () => {
const { ProviderConfigPanel } = await import('./provider-config-panel')
renderLocale(
<I18nProvider configClient={null} initialLocale="tr">
<ProviderConfigPanel provider="honcho" />
</I18nProvider>
)
expect(await screen.findByText('Çalışma alanı')).toBeTruthy()
expect(
screen.getByText('Honcho çalışma alanı kimliği. Varsayılan olarak profilin ana bilgisayarını kullanır.')
).toBeTruthy()
expect(screen.getByRole('combobox').textContent).toContain('Bulut')
const workspace = screen.getByDisplayValue('myws')
fireEvent.change(workspace, { target: { value: 'ekibim' } })
fireEvent.blur(workspace)
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { workspace: 'ekibim' }))
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
@@ -4,6 +4,8 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
import { SlidersHorizontal } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
@@ -21,6 +23,8 @@ function seedValues(config: MemoryProviderConfig): Record<string, string> {
}
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
const { t, locale } = useI18n()
const copy = t.settings.runtime.memory
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const [values, setValues] = useState<Record<string, string>>({})
@@ -38,9 +42,9 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
setLoadError(null)
} catch (err) {
setConfig(null)
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
setLoadError(err instanceof Error ? err.message : copy.loadFailed)
}
}, [profile, provider])
}, [profile, provider, copy.loadFailed])
useEffect(() => {
setConfig(null)
@@ -71,10 +75,10 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
setSaved(current => ({ ...current, [field.key]: value }))
}
} catch (err) {
notifyError(err, `Failed to save ${field.label}`)
notifyError(err, copy.saveFailed(field.label))
}
},
[profile, provider, saved]
[profile, provider, saved, copy]
)
// Providers without a declared config surface (e.g. builtin) render nothing.
@@ -87,20 +91,21 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Memory provider settings failed to load: {loadError}
{copy.loadFailed}: {loadError}
</span>
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
Retry
{t.common.retry}
</Button>
</div>
)
}
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
return <PageLoader className="min-h-24" label={copy.loading} />
}
const inlineFields = config.fields.filter(field => field.inline)
const secretFields = config.fields.filter(field => field.kind === 'secret')
const fields = config.fields.map(field => localizeMemoryField(config.name, field, locale))
const inlineFields = fields.filter(field => field.inline)
const secretFields = fields.filter(field => field.kind === 'secret')
const hasFullConfig = config.fields.some(field => !field.inline)
return (
@@ -114,16 +119,16 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
>
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
{copy.settings(config.label)}
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
<Pill key={field.key}>{copy.credentialStatus(field.label, field.is_set)}</Pill>
))}
</button>
{hasFullConfig && (
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
<SlidersHorizontal className="size-3.5" />
Full config
{copy.fullConfig}
</Button>
)}
</div>
@@ -1,8 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithEnglish as render } from './test-locale'
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
// them to let the dropdown open in tests.
@@ -394,8 +396,8 @@ describe('ModelSettings', () => {
fireEvent.click(applyButton)
// The switch-time notice names the pinned provider and offers a reset.
expect(await screen.findByText(/still run on/)).toBeTruthy()
expect(screen.getByText('nous')).toBeTruthy()
expect(await screen.findByText(/still run on nous/)).toBeTruthy()
expect(screen.getAllByRole('button', { name: 'Reset all to main' }).length).toBeGreaterThan(0)
})
it('shows a persistent banner when a loaded aux slot mismatches the main provider', async () => {
@@ -157,6 +157,8 @@ interface StaleAuxWarningProps {
// $0-balance provider after switching main away from it) and offers the
// existing one-click reset rather than auto-clearing legitimate pins.
function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
const { t } = useI18n()
if (!slots.length) {
return null
}
@@ -169,11 +171,14 @@ function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarnin
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="grow">
{slots.length} auxiliary task{slots.length === 1 ? '' : 's'} ({names}) still run on{' '}
<span className="font-mono">{allSameProvider ? provider : 'other providers'}</span>, not your main model.
{t.settings.runtime.auxiliaryWarning(
slots.length,
names,
allSameProvider ? provider : t.settings.runtime.otherProviders
)}
</span>
<Button disabled={applying} onClick={onReset} size="sm" variant="textStrong">
Reset all to main
{t.settings.model.resetAllToMain}
</Button>
</div>
)
@@ -852,7 +857,9 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
void activateApiKeyProvider()
}
}}
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
placeholder={t.settings.runtime.pasteProviderKey(
selectedProviderRow?.key_env ?? t.settings.runtime.apiKey
)}
type="password"
value={apiKeyDraft}
/>
@@ -862,12 +869,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
>
{activating && <Loader2 className="size-3.5 animate-spin" />}
{activating ? 'Activating...' : 'Activate'}
{activating ? t.settings.runtime.activatingProvider : t.settings.runtime.activateProvider}
</Button>
</>
) : (
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
Set up {selectedProviderRow?.name ?? 'provider'}
{t.settings.runtime.setupProvider(selectedProviderRow?.name ?? t.settings.runtime.provider)}
</Button>
)
) : (
@@ -1085,15 +1092,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</section>
{moa && currentMoaPreset && (
<section>
<SectionHeading icon={Cpu} title="Mixture of Agents" />
<p className="mb-2 text-xs text-muted-foreground">
Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the
acting model.
</p>
<SectionHeading icon={Cpu} title={t.settings.runtime.moa.title} />
<p className="mb-2 text-xs text-muted-foreground">{t.settings.runtime.moa.description}</p>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedMoaPreset} value={selectedMoaPreset || moa.default_preset}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder="Preset" />
<SelectValue placeholder={t.settings.runtime.moa.preset} />
</SelectTrigger>
<SelectContent>
{Object.keys(moa.presets).map(name => (
@@ -1104,7 +1108,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</SelectContent>
</Select>
<label className="flex items-center gap-2 rounded-sm border border-border px-2 py-1 text-xs">
Enabled
{t.settings.runtime.moa.enabled}
<Switch
checked={currentMoaPreset.enabled !== false}
disabled={applying}
@@ -1125,7 +1129,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="text"
>
Set default
{t.settings.runtime.moa.setDefault}
</Button>
<Button
disabled={Object.keys(moa.presets).length <= 1 || applying}
@@ -1151,12 +1155,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="ghost"
>
Delete
{t.common.delete}
</Button>
<Input
className={cn('w-40', CONTROL_TEXT)}
onChange={event => setNewMoaPresetName(event.target.value)}
placeholder="new preset"
placeholder={t.settings.runtime.moa.newPreset}
value={newMoaPresetName}
/>
<Button
@@ -1179,18 +1183,18 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="textStrong"
>
Add preset
{t.settings.runtime.moa.addPreset}
</Button>
</div>
<div className="mb-2 text-xs text-muted-foreground">
Default: <span className="font-mono">{moa.default_preset}</span>
{t.settings.runtime.moa.default} <span className="font-mono">{moa.default_preset}</span>
</div>
<div className="grid gap-1">
{currentMoaPreset.reference_models.map((slot, index) => (
<ListRow
action={
<Switch
aria-label={`${slot.enabled !== false ? 'Disable' : 'Enable'} reference ${index + 1}`}
aria-label={t.settings.runtime.moa.toggleReference(index + 1, slot.enabled === false)}
checked={slot.enabled !== false}
disabled={applying}
onCheckedChange={checked =>
@@ -1267,7 +1271,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="ghost"
>
Remove
{t.common.remove}
</Button>
</div>
}
@@ -1278,7 +1282,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
</span>
}
key={`${selectedMoaPreset}-${index}`}
title={`Reference ${index + 1}`}
title={t.settings.runtime.moa.reference(index + 1)}
/>
))}
<Button
@@ -1292,7 +1296,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
size="sm"
variant="textStrong"
>
Add reference model
{t.settings.runtime.moa.addReference}
</Button>
<ListRow
below={
@@ -1354,7 +1358,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
{currentMoaPreset.aggregator.provider} · {currentMoaPreset.aggregator.model}
</span>
}
title="Aggregator"
title={t.settings.runtime.moa.aggregator}
/>
</div>
</section>
@@ -1,7 +1,9 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithEnglish as render } from './test-locale'
const { requestGateway, getProfiles } = vi.hoisted(() => ({
requestGateway: vi.fn(),
getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
@@ -11,7 +11,7 @@ import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { getProfiles } from '@/hermes'
import { useI18n } from '@/i18n'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
import { normalize } from '@/lib/text'
@@ -48,6 +48,9 @@ function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
const pluginText = (key: keyof Translations['settings']['runtime']['plugins']) =>
translateNow(`settings.runtime.plugins.${key}`)
async function revealPluginsDir() {
try {
// Electron owns the local plugin root — deriving it from the backend's
@@ -55,7 +58,7 @@ async function revealPluginsDir() {
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
notifyError(pluginText('unavailable'), pluginText('resolveFailed'))
return
}
@@ -65,10 +68,10 @@ async function revealPluginsDir() {
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
notifyError(result.error ?? pluginText('unknownError'), pluginText('openFailed'))
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
notifyError(err, pluginText('resolveFailed'))
}
}
@@ -82,7 +85,7 @@ async function revealAgentPluginsDir(request: GatewayRequest) {
const home = (result?.home ?? '').trim()
if (!home) {
notifyError('The backend did not report its home directory', 'Could not open the plugins folder')
notifyError(pluginText('noHome'), pluginText('openFailed'))
return
}
@@ -90,10 +93,10 @@ async function revealAgentPluginsDir(request: GatewayRequest) {
const opened = await window.hermesDesktop?.openDir?.(`${home}/plugins`)
if (opened && !opened.ok) {
notifyError(opened.error ?? 'unknown error', 'Could not open the plugins folder')
notifyError(opened.error ?? pluginText('unknownError'), pluginText('openFailed'))
}
} catch (err) {
notifyError(err, 'Could not open the plugins folder')
notifyError(err, pluginText('openFailed'))
}
}
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { ListRow } from '@/app/settings/primitives'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { $poolLimits, loadPoolLimits, savePoolLimits } from '@/store/pool-limits'
// Bounds imported from main's clamp module so the advertised input ranges
@@ -16,6 +17,8 @@ const IDLE_MS_MAX = POOL_LIMITS_BOUNDS.idleMsMax
* Device-local (not profile-scoped): the pool is sized once per machine and
* changes apply live — main evicts/reaps to converge without a restart. */
export function PoolLimitsSetting() {
const { t } = useI18n()
const copy = t.settings.runtime.pool
const limits = useStore($poolLimits)
const [maxDraft, setMaxDraft] = useState(String(limits.maxBackends))
const [idleDraft, setIdleDraft] = useState(String(limits.idleMs))
@@ -63,7 +66,7 @@ export function PoolLimitsSetting() {
action={
<div className="flex items-center gap-2">
<Input
aria-label="Warm bot backends"
aria-label={copy.warm}
className="w-20"
inputMode="numeric"
max={MAX_BACKENDS_MAX}
@@ -80,14 +83,14 @@ export function PoolLimitsSetting() {
/>
</div>
}
description="How many bot backends stay running for instant switching. Higher = faster switches, more memory (~60MB per backend). Applies immediately."
title="Warm Bot Backends"
description={copy.warmHint}
title={copy.warm}
/>
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label="Backend idle timeout in milliseconds"
aria-label={copy.idleAria}
className="w-28"
inputMode="numeric"
max={IDLE_MS_MAX}
@@ -105,8 +108,8 @@ export function PoolLimitsSetting() {
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">ms</span>
</div>
}
description="How long an unused bot backend stays warm before it is shut down. Raise this so bots you revisit every few minutes never pay a cold start."
title="Backend Idle Timeout"
description={copy.idleHint}
title={copy.idle}
/>
</>
)
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,6 +6,8 @@ import { ConfirmHost } from '@/components/confirm-host'
import { $confirmRequest } from '@/store/confirm'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { renderWithEnglish as render } from './test-locale'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
const getEnvVars = vi.fn()
@@ -30,7 +30,7 @@ import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
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 { providerDescription, providerGroup, providerMeta, providerPriority } from './helpers'
import { LocalModelsSettings } from './local-models-settings'
import { SettingsContent, SettingsSkeleton } from './primitives'
@@ -346,7 +346,7 @@ export function ProvidersSettings({
onViewChange,
view
}: ProvidersSettingsProps) {
const { t } = useI18n()
const { t, locale } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
@@ -466,9 +466,15 @@ export function ProvidersSettings({
const visibleGroups = q
? keyGroups.filter(group => {
const haystack = [group.name, group.description ?? '', group.primary[0], ...group.advanced.map(([k]) => k)]
const haystack = [
group.name,
providerDescription(group.name, group.description, locale) ?? '',
group.description ?? '',
group.primary[0],
...group.advanced.map(([k]) => k)
]
return haystack.some(s => s.toLowerCase().includes(q))
return haystack.some(s => normalize(s).includes(q))
})
: keyGroups
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { stubResizeObserver } from '@/test/jsdom'
@@ -6,6 +6,7 @@ import type { ConfigFieldSchema } from '@/types/hermes'
import { ConfigField } from './config-field'
import { rankSearchOption, SearchableSelect } from './searchable-select'
import { renderWithEnglish as render } from './test-locale'
beforeAll(() => {
stubResizeObserver()
@@ -4,6 +4,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
@@ -42,8 +43,8 @@ export function SearchableSelect({
value,
onChange,
options,
placeholder = 'Search…',
emptyMessage = 'No results found.',
placeholder,
emptyMessage,
clearLabel
}: {
value: string
@@ -55,6 +56,8 @@ export function SearchableSelect({
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
clearLabel?: string
}) {
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t.settings.runtime.search
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -66,7 +69,7 @@ export function SearchableSelect({
[onChange]
)
const displayValue = value !== '' && value !== undefined ? value : placeholder
const displayValue = value !== '' && value !== undefined ? value : searchPlaceholder
return (
<Popover onOpenChange={setOpen} open={open}>
@@ -90,9 +93,9 @@ export function SearchableSelect({
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={placeholder} />
<CommandInput autoFocus placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandEmpty>{emptyMessage ?? t.settings.runtime.noResults}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
@@ -1,8 +1,10 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalBackendsResponse } from '@/types/hermes'
import { renderWithEnglish as render } from './test-locale'
const getTerminalBackends = vi.fn()
const selectTerminalBackend = vi.fn()
@@ -0,0 +1,17 @@
import { type RenderOptions, render as renderUi } from '@testing-library/react'
import type { ReactNode } from 'react'
import { I18nProvider } from '@/i18n/context'
/** Component behavior fixtures use English explicitly; application-default
* and Turkish rendering are verified by the locale integration tests. */
export function renderWithEnglish(ui: ReactNode, options?: RenderOptions) {
return renderUi(ui, {
wrapper: ({ children }) => (
<I18nProvider configClient={null} initialLocale="en">
{children}
</I18nProvider>
),
...options
})
}
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router'
import type * as ReactRouterDom from 'react-router'
@@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ToolsetConfig } from '@/types/hermes'
import { renderWithEnglish as rtlRender } from './test-locale'
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
// needs a router context. The navigate spy asserts the deep-link target.
const navigateSpy = vi.fn()