From 5e76f586377efd33477e3afbf86c064bcae9d105 Mon Sep 17 00:00:00 2001 From: yilsem Date: Sun, 6 Sep 2026 05:03:32 +0300 Subject: [PATCH] Expand AITURK Turkish settings and desktop surfaces for beta 5 --- .../assets/mac/tr.lproj/InfoPlist.strings | 12 + apps/desktop/docs/aiturk-turkish-beta5.md | 44 + apps/desktop/e2e/fixtures.ts | 10 +- apps/desktop/e2e/turkish-settings.spec.ts | 77 + apps/desktop/package.json | 10 +- .../src/app/chat/composer/voice-activity.tsx | 2 +- apps/desktop/src/app/chat/route-tile.tsx | 22 +- apps/desktop/src/app/chat/session-tile.tsx | 5 +- .../src/app/chat/sidebar/filter-menu.tsx | 125 +- .../src/app/contrib/hooks/use-pet-bridge.ts | 14 +- .../contrib/hooks/use-quick-entry-bridge.ts | 6 +- apps/desktop/src/app/contrib/panes.tsx | 5 +- .../components/generate-unavailable.tsx | 13 +- .../components/reference-chip.tsx | 2 +- .../app/pet-generate/pet-generate-content.tsx | 2 +- .../src/app/pet-overlay/overlay-root.tsx | 7 +- .../src/app/pet-overlay/pet-overlay-app.tsx | 11 +- .../app/quick-entry/quick-entry-app.test.tsx | 60 + .../src/app/quick-entry/quick-entry-app.tsx | 18 +- .../src/app/quick-entry/quick-entry-root.tsx | 5 +- .../src/app/settings/appearance-settings.tsx | 6 +- .../app/settings/billing/auto-reload-row.tsx | 31 +- .../app/settings/billing/billing-amounts.ts | 21 +- apps/desktop/src/app/settings/billing/copy.ts | 12 + .../settings/billing/current-plan-card.tsx | 8 +- .../src/app/settings/billing/errors.ts | 99 +- .../src/app/settings/billing/index.test.tsx | 41 +- .../src/app/settings/billing/index.tsx | 39 +- .../app/settings/billing/inline-feedback.tsx | 17 +- .../app/settings/billing/localization.test.ts | 64 + .../src/app/settings/billing/plans-view.tsx | 55 +- .../app/settings/billing/use-billing-state.ts | 258 ++- .../billing/use-charge-poller.test.ts | 8 +- .../app/settings/billing/use-charge-poller.ts | 51 +- .../app/settings/billing/use-step-up.test.tsx | 8 +- .../src/app/settings/billing/use-step-up.ts | 14 +- .../src/app/settings/combobox-input.tsx | 4 +- .../app/settings/computer-use-panel.test.tsx | 63 + .../src/app/settings/computer-use-panel.tsx | 67 +- .../desktop/src/app/settings/config-field.tsx | 2 +- .../settings/connections-registry.test.tsx | 3 +- .../src/app/settings/credential-key-ui.tsx | 6 +- .../settings/fallback-models-field.test.tsx | 4 +- .../app/settings/gateway-settings.test.tsx | 4 +- apps/desktop/src/app/settings/helpers.ts | 9 + .../settings/local-models-settings.test.tsx | 6 +- .../src/app/settings/memory/connect.tsx | 21 +- .../src/app/settings/memory/field-control.tsx | 11 +- .../memory/provider-config-modal.test.tsx | 4 +- .../settings/memory/provider-config-modal.tsx | 71 +- .../memory/provider-config-panel.test.tsx | 23 +- .../settings/memory/provider-config-panel.tsx | 29 +- .../src/app/settings/model-settings.test.tsx | 8 +- .../src/app/settings/model-settings.tsx | 50 +- .../app/settings/plugins-settings.test.tsx | 4 +- .../src/app/settings/plugins-settings.tsx | 17 +- .../src/app/settings/pool-limits-setting.tsx | 15 +- .../app/settings/providers-settings.test.tsx | 4 +- .../src/app/settings/providers-settings.tsx | 14 +- .../app/settings/searchable-select.test.tsx | 3 +- .../src/app/settings/searchable-select.tsx | 13 +- .../settings/terminal-backend-panel.test.tsx | 4 +- apps/desktop/src/app/settings/test-locale.tsx | 17 + .../settings/toolset-config-panel.test.tsx | 4 +- .../src/app/shell/model-catalog-menu.tsx | 2 +- apps/desktop/src/app/skills/index.tsx | 10 +- apps/desktop/src/app/skills/mcp-tab.tsx | 2 +- .../src/app/starmap/node-context-menu.tsx | 18 +- apps/desktop/src/app/starmap/star-map.tsx | 9 +- apps/desktop/src/app/starmap/timeline.tsx | 6 +- .../src/components/pet/pet-bubble.test.tsx | 42 + .../desktop/src/components/pet/pet-bubble.tsx | 63 +- apps/desktop/src/i18n/context.test.tsx | 24 +- apps/desktop/src/i18n/en-billing.ts | 168 ++ apps/desktop/src/i18n/en-runtime-settings.ts | 105 + apps/desktop/src/i18n/en-surfaces.ts | 101 + apps/desktop/src/i18n/en.ts | 5 + apps/desktop/src/i18n/tr-billing.ts | 174 ++ apps/desktop/src/i18n/tr-capabilities.ts | 166 ++ apps/desktop/src/i18n/tr-command-center.ts | 246 ++ apps/desktop/src/i18n/tr-connections.ts | 228 ++ apps/desktop/src/i18n/tr-desktop.ts | 210 ++ apps/desktop/src/i18n/tr-fields.ts | 260 +++ apps/desktop/src/i18n/tr-keybinds.ts | 90 + apps/desktop/src/i18n/tr-local-models.ts | 118 + .../desktop/src/i18n/tr-memory-fields.test.ts | 44 + apps/desktop/src/i18n/tr-memory-fields.ts | 196 ++ apps/desktop/src/i18n/tr-messaging.ts | 233 ++ apps/desktop/src/i18n/tr-plugins.test.tsx | 59 + apps/desktop/src/i18n/tr-profiles.ts | 269 +++ .../src/i18n/tr-provider-descriptions.test.ts | 20 + .../src/i18n/tr-provider-descriptions.ts | 28 + apps/desktop/src/i18n/tr-runtime-settings.ts | 107 + apps/desktop/src/i18n/tr-settings.test.tsx | 91 + apps/desktop/src/i18n/tr-settings.ts | 215 ++ apps/desktop/src/i18n/tr-surfaces.ts | 80 + apps/desktop/src/i18n/tr-toolsets.ts | 140 ++ apps/desktop/src/i18n/tr.ts | 2026 +++++++++++++---- apps/desktop/src/i18n/types.ts | 6 + apps/desktop/src/i18n/zh.ts | 4 + .../src/plugins/hermes-bots/i18n.test.ts | 11 +- apps/desktop/src/plugins/hermes-bots/i18n.ts | 5 +- apps/desktop/src/plugins/hermes-bots/tr.ts | 218 ++ apps/desktop/src/plugins/kanban/i18n.ts | 6 +- apps/desktop/src/plugins/kanban/tr.ts | 219 ++ apps/desktop/src/store/pet-overlay.ts | 18 + apps/desktop/src/store/quick-entry.ts | 3 + package-lock.json | 2 +- 108 files changed, 6675 insertions(+), 1034 deletions(-) create mode 100644 apps/desktop/assets/mac/tr.lproj/InfoPlist.strings create mode 100644 apps/desktop/docs/aiturk-turkish-beta5.md create mode 100644 apps/desktop/e2e/turkish-settings.spec.ts create mode 100644 apps/desktop/src/app/quick-entry/quick-entry-app.test.tsx create mode 100644 apps/desktop/src/app/settings/billing/copy.ts create mode 100644 apps/desktop/src/app/settings/billing/localization.test.ts create mode 100644 apps/desktop/src/app/settings/computer-use-panel.test.tsx create mode 100644 apps/desktop/src/app/settings/test-locale.tsx create mode 100644 apps/desktop/src/components/pet/pet-bubble.test.tsx create mode 100644 apps/desktop/src/i18n/en-billing.ts create mode 100644 apps/desktop/src/i18n/en-runtime-settings.ts create mode 100644 apps/desktop/src/i18n/en-surfaces.ts create mode 100644 apps/desktop/src/i18n/tr-billing.ts create mode 100644 apps/desktop/src/i18n/tr-capabilities.ts create mode 100644 apps/desktop/src/i18n/tr-command-center.ts create mode 100644 apps/desktop/src/i18n/tr-connections.ts create mode 100644 apps/desktop/src/i18n/tr-desktop.ts create mode 100644 apps/desktop/src/i18n/tr-fields.ts create mode 100644 apps/desktop/src/i18n/tr-keybinds.ts create mode 100644 apps/desktop/src/i18n/tr-local-models.ts create mode 100644 apps/desktop/src/i18n/tr-memory-fields.test.ts create mode 100644 apps/desktop/src/i18n/tr-memory-fields.ts create mode 100644 apps/desktop/src/i18n/tr-messaging.ts create mode 100644 apps/desktop/src/i18n/tr-plugins.test.tsx create mode 100644 apps/desktop/src/i18n/tr-profiles.ts create mode 100644 apps/desktop/src/i18n/tr-provider-descriptions.test.ts create mode 100644 apps/desktop/src/i18n/tr-provider-descriptions.ts create mode 100644 apps/desktop/src/i18n/tr-runtime-settings.ts create mode 100644 apps/desktop/src/i18n/tr-settings.test.tsx create mode 100644 apps/desktop/src/i18n/tr-settings.ts create mode 100644 apps/desktop/src/i18n/tr-surfaces.ts create mode 100644 apps/desktop/src/i18n/tr-toolsets.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/tr.ts create mode 100644 apps/desktop/src/plugins/kanban/tr.ts diff --git a/apps/desktop/assets/mac/tr.lproj/InfoPlist.strings b/apps/desktop/assets/mac/tr.lproj/InfoPlist.strings new file mode 100644 index 0000000..21122bb --- /dev/null +++ b/apps/desktop/assets/mac/tr.lproj/InfoPlist.strings @@ -0,0 +1,12 @@ +/* macOS displays these explanations when the user grants an OS permission. */ +"CFBundleDisplayName" = "AITURK IDE"; +"NSAudioCaptureUsageDescription" = "AITURK IDE, sesli konuşmalar için ses yakalamayı kullanır."; +"NSCameraUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde kamerayı kullanır."; +"NSMicrophoneUsageDescription" = "AITURK IDE, sesli giriş ve sesli konuşmalar için mikrofonu kullanır."; +"NSCalendarsUsageDescription" = "AITURK IDE, istediğiniz toplantı ve zamanlama işlemlerini yapmak için Takvim’e erişir."; +"NSCalendarsFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde etkinlikleri okumak ve yönetmek için Takvim’e tam erişim ister."; +"NSRemindersUsageDescription" = "AITURK IDE, istediğiniz kişisel asistan ve zamanlama işlemlerini yapmak için Anımsatıcılar’a erişir."; +"NSRemindersFullAccessUsageDescription" = "AITURK IDE, açıkça istediğinizde anımsatıcıları okumak ve yönetmek için Anımsatıcılar’a tam erişim ister."; +"NSScreenCaptureUsageDescription" = "AITURK IDE, ajandan ekran görüntüsü veya ekran kaydı istediğinizde ekranı yakalar."; +"NSLocalNetworkUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde yerel ağınızdaki cihazlara bağlanır."; +"NSAppleMusicUsageDescription" = "AITURK IDE, etkinleştirdiğiniz bir eklenti veya özellik istediğinde müzik arşivinize erişir."; diff --git a/apps/desktop/docs/aiturk-turkish-beta5.md b/apps/desktop/docs/aiturk-turkish-beta5.md new file mode 100644 index 0000000..e04f972 --- /dev/null +++ b/apps/desktop/docs/aiturk-turkish-beta5.md @@ -0,0 +1,44 @@ +# AITURK IDE 1.0.0-beta.5 — Türkçe dil kapsamı + +Türkçe dil desteği, menü başlıklarından ayar alanlarının açıklamalarına, +seçeneklerine, doğrulama ve işlem sonuçlarına genişletildi. Gelişmiş bağlantı, +yerel model, bilgisayar kullanımı, araç, bellek, profil, mesajlaşma ve görev +ayarları Türkçe metinler sunar. Faturalandırma ekranındaki planlar, tutar +doğrulamaları, otomatik yükleme ve ödeme hataları da dil seçimine uyar. + +Sohbet filtreleri, zaman çizelgesi, bellek/beceri işlemleri, görsel üretim +kurulumu, Bots ve Kanban eklentileri Türkçe kapsamına alındı. Hızlı giriş ve +masaüstü maskotu ana pencerenin dil seçimini mevcut IPC durum aktarımıyla +izler; ayrı bir sunucu bağlantısı veya ayar yazıcısı oluşturmaz. + +Çeviri yalnızca sunum metinlerini değiştirir. Model ve sağlayıcı kimlikleri, +özel profil/proje adları, API alanları, ayar enum değerleri, oturum hedefleri, +ödeme tutarları ve idempotency davranışı korunur. Harici sağlayıcının kendi +hata mesajları ve kullanıcı tarafından eklenmiş açıklamalar değiştirilmez. + +## Doğrulama + +- Türkçe sözlük, İngilizceye geri düşmeden tüm kayıtlı çekirdek mesaj + anahtarlarını ve ayar alanlarının etiket/açıklamalarını kapsar. +- Arayüz testleri Türkçe seçeneklerin özgün API değerlerini kaydettiğini, + ödeme tutarlarının değişmediğini ve dil geçişinde hızlı giriş taslağı ile + hedef oturum kimliğinin korunduğunu kontrol eder. +- Gerçek Electron uygulamasında güvenlik ayarındaki “Elle onayla” seçeneği + masaüstü köprüsünden kaydedilir; API cevabı ve izole kullanıcı dizinindeki + YAML dosyasında `approvals.mode: manual` doğrulanır. +- Paket doğrulaması, temiz kaynak commit'ini içeren `install-stamp.json`, + Windows kurulum dosyasının SHA-256 değeri ve indirilen dosyanın eşleşmesiyle + tamamlanmalıdır. Bir derlemenin varlığı, yayımlandığı anlamına gelmez. + +## Platform durumu + +Windows paketi önceki beta sürümünden ayrı, değiştirilmeyen bir yayın varlığı +olarak hazırlanır. Windows Authenticode imzası yoksa yayın notunda açıkça +belirtilir; dosya özeti yayıncı imzası olarak sunulmaz. + +macOS için Apple Silicon ve Intel derleme komutları ile Türkçe sistem izin +metinleri eklendi. Gerçek macOS derleme/çalıştırma ve varsa Apple imzalama/ +notarizasyon doğrulaması tamamlanmadan Mac paketi hazır olarak duyurulmaz. + +CLI 1.10.24 ayrı bir üründür ve bu IDE sürümünün yayınlanması CLI'nin imzalı +yayın kontrolünü karşılamaz. diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 70beb36..030dbe9 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -27,7 +27,7 @@ import * as path from 'node:path' import { _electron, type ElectronApplication, type Page } from '@playwright/test' import { resolveElectronBinary } from './electron-binary' -import { startMockServer, type MockServerOptions } from './mock-server' +import { type MockServerOptions, startMockServer } from './mock-server' import { installErrorBannerGuard } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') @@ -259,6 +259,8 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record = {} return { ...clean, + // AITURK ignores inherited Hermes state; use the same explicit sandbox. + AITURK_IDE_HOME: sandbox.hermesHome, HERMES_HOME: sandbox.hermesHome, HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, HERMES_DESKTOP_IGNORE_EXISTING: '1', @@ -366,6 +368,7 @@ export interface MockBackendFixture { } export interface MockBackendOptions { + mockServer?: MockServerOptions /** * Optional YAML lines to inject under the `display:` section of the * generated config.yaml. Used by the interim-message e2e test to toggle @@ -385,10 +388,6 @@ export interface MockBackendOptions { * 3. Launch the desktop app * 4. Return handles for test interaction */ -export interface MockBackendOptions { - mockServer?: MockServerOptions -} - export async function setupMockBackend(options: MockBackendOptions = {}): Promise { // 1. Start mock server const mock = await startMockServer(options.mockServer) @@ -643,6 +642,7 @@ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFi // `position: fixed; inset: 0`. If the hit element or an ancestor // is a full-viewport fixed overlay, we're still covered. let node: Element | null = el + while (node) { const cs = window.getComputedStyle(node) diff --git a/apps/desktop/e2e/turkish-settings.spec.ts b/apps/desktop/e2e/turkish-settings.spec.ts new file mode 100644 index 0000000..aa4a665 --- /dev/null +++ b/apps/desktop/e2e/turkish-settings.spec.ts @@ -0,0 +1,77 @@ +import { execFileSync } from 'node:child_process' +import * as path from 'node:path' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +let fixture: MockBackendFixture | null = null +type DesktopTestWindow = Window & { + hermesDesktop: { api(request: { path: string }): Promise } +} + +test.beforeAll(async () => { + test.setTimeout(150_000) + fixture = await setupMockBackend({ extraDisplayConfig: ' language: tr' }) + await waitForAppReady(fixture, 120_000) + await expect + .poll( + async () => { + const status = await fixture!.page.evaluate(() => + (window as unknown as DesktopTestWindow).hermesDesktop.api({ path: '/api/status' }) + ) + + return Boolean(status) + }, + { timeout: 30_000 } + ) + .toBe(true) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('Turkish safety settings persist the backend enum through the real desktop bridge', async () => { + const { page, sandbox } = fixture! + await page.evaluate(() => { + window.location.hash = '/settings?tab=config%3Asafety' + }) + await expect(page.getByText('Gizli bilgileri maskele', { exact: true })).toBeVisible() + await expect( + page.getByText('Algılanan gizli bilgileri mümkün olduğunda modelin görebildiği içerikten gizler.', { exact: true }) + ).toBeVisible() + const approvals = page.locator('[data-tour="field-approvals.mode"]') + await approvals.getByRole('combobox').click() + await page.getByRole('option', { name: 'Elle onayla', exact: true }).click() + await expect + .poll( + async () => { + const config = await page.evaluate(() => + (window as unknown as DesktopTestWindow).hermesDesktop.api>({ path: '/api/config' }) + ) + + return (config.approvals as { mode?: string } | undefined)?.mode + }, + { timeout: 30_000 } + ) + .toBe('manual') + await expect + .poll( + () => { + return execFileSync( + process.env.HERMES_DESKTOP_PYTHON || 'python', + [ + '-c', + 'import pathlib, sys, yaml; print(yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")).get("approvals", {}).get("mode", ""))', + path.join(sandbox.hermesHome, 'config.yaml') + ], + { encoding: 'utf8' } + ).trim() + }, + { timeout: 15_000 } + ) + .toBe('manual') + await expect(approvals.getByRole('combobox')).toContainText('Elle onayla') + await page.screenshot({ path: test.info().outputPath('turkish-safety-settings.png'), fullPage: true }) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 34a0302..4ef9c5e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { @@ -35,6 +35,8 @@ "pack": "npm run build && npm run builder -- --dir --publish never", "dist": "npm run build && npm run builder", "dist:mac": "npm run build && npm run builder -- --mac", + "dist:mac:arm64": "npm run build && npm run builder -- --mac --arm64", + "dist:mac:x64": "npm run build && npm run builder -- --mac --x64", "dist:mac:dmg": "npm run build && npm run builder -- --mac dmg", "dist:mac:zip": "npm run build && npm run builder -- --mac zip", "dist:win": "npm run build && npm run builder -- --win", @@ -229,6 +231,12 @@ "dist/**" ], "mac": { + "extraResources": [ + { + "from": "assets/mac/tr.lproj/InfoPlist.strings", + "to": "tr.lproj/InfoPlist.strings" + } + ], "category": "public.app-category.developer-tools", "entitlements": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.inherit.plist", diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index bd1f330..5212b09 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -245,7 +245,7 @@ export function VoicePlaybackActivity() { variant="ghost" > - Stop + {t.surfaces.stop} ) diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx index 1388f48..8947121 100644 --- a/apps/desktop/src/app/chat/route-tile.tsx +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -11,6 +11,7 @@ import { lazy, type ReactNode, Suspense } from 'react' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' +import { translateNow, type Translations, useI18n } from '@/i18n' import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles' import { ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes' @@ -22,10 +23,10 @@ const MessagingView = lazy(async () => ({ default: (await import('../messaging') const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView })) // Built-in page views + their pane titles, keyed by route. -const BUILTIN_PAGES: Record ReactNode; title: string }> = { - [ARTIFACTS_ROUTE]: { render: () => , title: 'Artifacts' }, - [MESSAGING_ROUTE]: { render: () => , title: 'Messaging' }, - [SKILLS_ROUTE]: { render: () => , title: 'Capabilities' } +const BUILTIN_PAGES: Record ReactNode; titleKey: keyof Translations['sidebar']['nav'] }> = { + [ARTIFACTS_ROUTE]: { render: () => , titleKey: 'artifacts' }, + [MESSAGING_ROUTE]: { render: () => , titleKey: 'messaging' }, + [SKILLS_ROUTE]: { render: () => , titleKey: 'skills' } } /** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */ @@ -41,13 +42,21 @@ const humanizePath = (path: string): string => * else a humanized path — never the internal `${source}:${id}` key. */ function routeTitle(path: string): string { if (BUILTIN_PAGES[path]) { - return BUILTIN_PAGES[path].title + return translateNow(`sidebar.nav.${BUILTIN_PAGES[path].titleKey}`) } return contributedRoutes().find(r => r.path === path)?.title ?? humanizePath(path) } +function RouteTileTitle({ path }: { path: string }) { + const { t } = useI18n() + const builtin = BUILTIN_PAGES[path] + + return <>{builtin ? t.sidebar.nav[builtin.titleKey] : routeTitle(path)} +} + function RouteTilePane({ path }: { path: string }) { + const { t } = useI18n() const builtin = BUILTIN_PAGES[path] // Subscribe so a plugin page tile appears the moment its route registers. @@ -74,7 +83,7 @@ function RouteTilePane({ path }: { path: string }) { return (
- no page at {path} + {t.surfaces.noPage(path)}
) } @@ -91,6 +100,7 @@ export const watchRouteTiles = paneMirror({ dir: t => t.dir, minWidth: '22rem', title: routeTitle, + tabTitle: path => , render: path => , close: closeRouteTile }) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 899c026..6f0d02d 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -333,6 +333,7 @@ function TileChat({ } export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) { + const { t } = useI18n() const tiles = useStore($sessionTiles) const tile = tiles.find(t => t.storedSessionId === storedSessionId) const ownerRoute = tile?.ownerRoute @@ -462,10 +463,10 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } return (
-
Couldn't open this session
+
{t.surfaces.sessionUnavailable}
{tile.error}
diff --git a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx index 9aa683d..7349bcb 100644 --- a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx @@ -72,47 +72,6 @@ interface Option { label: string } -const GROUPINGS: Option[] = [ - { icon: 'clock', id: 'date', label: 'Updated' }, - { icon: 'root-folder', id: 'project', label: 'Project' }, - { icon: 'pulse', id: 'status', label: 'Status' }, - { icon: 'account', id: 'profile', label: 'Profile' } -] - -const ORDERINGS: Option[] = [ - { icon: 'clock', id: 'updated', label: 'Updated' }, - { icon: 'add', id: 'created', label: 'Created' }, - { icon: 'pulse', id: 'status', label: 'Status' }, - { icon: 'symbol-numeric', id: 'tokens', label: 'Tokens' }, - { icon: 'credit-card', id: 'cost', label: 'Cost' }, - { icon: 'list-ordered', id: 'manual', label: 'Manual' } -] - -const ROW_META: Option[] = [ - { icon: 'clock', id: 'updated', label: 'Updated' }, - { icon: 'comment', id: 'preview', label: 'Preview' }, - { icon: 'symbol-numeric', id: 'tokens', label: 'Tokens' }, - { icon: 'credit-card', id: 'cost', label: 'Cost' }, - { icon: 'git-pull-request', id: 'pr', label: 'PR' }, - { icon: 'account', id: 'profile', label: 'Profile' } -] - -const PR_FILTERS: Option[] = [ - { icon: 'git-pull-request', id: 'open', label: 'Open' }, - { icon: 'git-pull-request-draft', id: 'draft', label: 'Draft' }, - { icon: 'git-merge', id: 'merged', label: 'Merged' }, - { icon: 'git-pull-request-closed', id: 'closed', label: 'Closed' }, - { icon: 'circle-slash', id: 'none', label: 'No PR' } -] - -const STATUS_FILTERS: Option[] = [ - { dot: sessionDotClassName('needs-input'), id: 'needs-input', label: 'Needs input' }, - { dot: sessionDotClassName('working'), id: 'working', label: 'Working' }, - { dot: sessionDotClassName('unread'), id: 'unread', label: 'Unread' }, - { dot: sessionDotClassName('draft'), id: 'draft', label: 'Draft' }, - { dot: cn(sessionDotClassName('idle'), 'bg-(--ui-text-quaternary)'), id: 'idle', label: 'Idle' } -] - function OptionGlyph({ option }: { option: Option }) { if (option.dot) { return {outcome.portalUrl && ( )} @@ -297,13 +302,13 @@ function BuyCreditsOutcome({ {outcome.action?.type === 'retry' && ( )} {outcome.action?.type === 'step_up' && } {portalUrl && ( )} @@ -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 (
- Billing + {copy.billing}
{import.meta.env.DEV && fixtureName && onFixtureChange ? ( @@ -455,6 +464,8 @@ function BillingSettingsContent({ fixtureName?: BillingFixtureSelection onFixtureChange?: (value: BillingFixtureSelection) => void }) { + const copy = useBillingCopy() + const [subView, setSubView] = useRouteEnumParam('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({
{view.plan && ( - + setSubView('plans')} plan={view.plan} /> )} @@ -524,7 +535,7 @@ function BillingSettingsContent({ : undefined} icon={CreditCard} - title="Payment & credits" + title={copy.paymentCredits} > {accountRows.map(row => ( @@ -533,7 +544,7 @@ function BillingSettingsContent({ )} {view.usageRows.length > 0 && ( - +
{view.usageRows.map(row => ( @@ -544,7 +555,7 @@ function BillingSettingsContent({ { // no endpoint yet — NAS capability-board gap - FEATURE_BILLING_INVOICES ? : null + FEATURE_BILLING_INVOICES ? : null } ) diff --git a/apps/desktop/src/app/settings/billing/inline-feedback.tsx b/apps/desktop/src/app/settings/billing/inline-feedback.tsx index dcc3e27..97f050b 100644 --- a/apps/desktop/src/app/settings/billing/inline-feedback.tsx +++ b/apps/desktop/src/app/settings/billing/inline-feedback.tsx @@ -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 }) { + const copy = useBillingCopy() + if (flow.verification) { return ( {flow.verification.code} @@ -26,31 +29,33 @@ export function StepUpInlineAction({ flow }: { flow: ReturnType ) } if (flow.phase === 'waiting') { - return Waiting for verification link… + return {copy.waitingVerification} } return ( ) } 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' && } {portalUrl && ( )} diff --git a/apps/desktop/src/app/settings/billing/localization.test.ts b/apps/desktop/src/app/settings/billing/localization.test.ts new file mode 100644 index 0000000..280923d --- /dev/null +++ b/apps/desktop/src/app/settings/billing/localization.test.ts @@ -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]) + ) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx index 728a814..5871236 100644 --- a/apps/desktop/src/app/settings/billing/plans-view.tsx +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -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 // 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(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
{phase.kind === 'previewFailed' ? ( ) : canConfirm ? ( ) : null}
@@ -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(null) @@ -143,7 +149,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi {tier.name}
- {tier.priceDisplay}/mo + {tier.priceDisplay} + {copy.monthSuffix}
@@ -155,9 +162,9 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi )}
- {isCurrent && Current plan} + {isCurrent && {copy.currentPlan}} - {tier.state === 'scheduled' && Scheduled} + {tier.state === 'scheduled' && {copy.scheduled}} {tier.state === 'upgrade' && ( ))}
@@ -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:
- Plans + {copy.plans}
{tiers.length > 0 ? ( @@ -221,7 +230,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
) : (
- No plans are available to change to right now. + {copy.noPlans}
)} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 3ccc869..0172c1f 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -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, - subscriptionResult?: BillingResult + subscriptionResult?: BillingResult, + 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 | 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 + current: null | undefined | NonNullable, + 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 = { - autoRefill: 'auto-refill card', - customerDefault: 'customer default', - subPin: 'subscription card' + autoRefill: copy.refillCard, + customerDefault: copy.customerDefault, + subPin: copy.subscriptionCard } return ` - ${labels[resolvedVia] ?? resolvedVia}` diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts index 82f6df1..8a38236 100644 --- a/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts @@ -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(() => { diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.ts index 02ef7db..f267ceb 100644 --- a/apps/desktop/src/app/settings/billing/use-charge-poller.ts +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.ts @@ -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 { + 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 { } export function useChargeFlow() { + const copy = useBillingCopy() const api = useBillingApi() const queryClient = useQueryClient() const [phase, setPhase] = useState('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') } } diff --git a/apps/desktop/src/app/settings/billing/use-step-up.test.tsx b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx index ee90129..778b939 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.test.tsx +++ b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx @@ -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) + }) } } diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts index 2966f60..4b3f35e 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.ts +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -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 } } diff --git a/apps/desktop/src/app/settings/combobox-input.tsx b/apps/desktop/src/app/settings/combobox-input.tsx index 63dd426..e25273c 100644 --- a/apps/desktop/src/app/settings/combobox-input.tsx +++ b/apps/desktop/src/app/settings/combobox-input.tsx @@ -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(null) @@ -66,7 +68,7 @@ export function ComboboxInput({ value={value} /> {status.can_grant ? ( <> - + ) : (
- Driver health + {copy.driverHealth} - {status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'} + {status.ready === true ? copy.ready : status.ready === false ? copy.notReady : copy.unknown}
)} @@ -225,13 +218,13 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {status.ready ? (
- Computer Use is ready. Ask the agent to capture an app and click around. + {copy.readyHint}
) : ( status.can_grant && ( ) )} diff --git a/apps/desktop/src/app/settings/config-field.tsx b/apps/desktop/src/app/settings/config-field.tsx index 43d713e..fce0bc0 100644 --- a/apps/desktop/src/app/settings/config-field.tsx +++ b/apps/desktop/src/app/settings/config-field.tsx @@ -146,7 +146,7 @@ export function ConfigField({ {selectOptions.map(option => ( {option - ? (optionLabels?.[option] ?? prettyName(option)) + ? (optionLabels?.[option] ?? t.settings.fieldOptions?.[schemaKey]?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? c.none : schemaKey === 'memory.provider' diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index e6cf553..77670df 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -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() diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx index e963c3c..0fcc4f2 100644 --- a/apps/desktop/src/app/settings/credential-key-ui.tsx +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -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 ( diff --git a/apps/desktop/src/app/settings/fallback-models-field.test.tsx b/apps/desktop/src/app/settings/fallback-models-field.test.tsx index a514396..7f1d912 100644 --- a/apps/desktop/src/app/settings/fallback-models-field.test.tsx +++ b/apps/desktop/src/app/settings/fallback-models-field.test.tsx @@ -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() diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx index 02d04b7..f27d205 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx @@ -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() diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index b51162f..ab33c82 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -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 { diff --git a/apps/desktop/src/app/settings/local-models-settings.test.tsx b/apps/desktop/src/app/settings/local-models-settings.test.tsx index b779434..d82b057 100644 --- a/apps/desktop/src/app/settings/local-models-settings.test.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.test.tsx @@ -104,7 +104,7 @@ const REFUSED_MODEL: LocalCatalogModel = { function renderPane() { return render( - + @@ -429,7 +429,7 @@ describe('BrowseSection', () => { render( - + @@ -534,7 +534,7 @@ describe('quickstart completion navigation', () => { render( - + diff --git a/apps/desktop/src/app/settings/memory/connect.tsx b/apps/desktop/src/app/settings/memory/connect.tsx index 1c09317..0a97f6c 100644 --- a/apps/desktop/src/app/settings/memory/connect.tsx +++ b/apps/desktop/src/app/settings/memory/connect.tsx @@ -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(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 ( {phase === 'idle' && connected && ( - {auth === 'apikey' ? 'api key set' : 'oauth set'} + {auth === 'apikey' ? copy.apiKeySet : copy.oauthSet} )} {phase === 'pending' ? ( <> - Waiting for browser consent… + {copy.waiting} ) : ( diff --git a/apps/desktop/src/app/settings/memory/field-control.tsx b/apps/desktop/src/app/settings/memory/field-control.tsx index d16a605..ddd45bf 100644 --- a/apps/desktop/src/app/settings/memory/field-control.tsx +++ b/apps/desktop/src/app/settings/memory/field-control.tsx @@ -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 }) { {field.label} - + ) @@ -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 && ( - set + {t.settings.runtime.memory.set} )} diff --git a/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx b/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx index 39054f6..9dcadfd 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-modal.test.tsx @@ -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', () => ({ diff --git a/apps/desktop/src/app/settings/memory/provider-config-modal.tsx b/apps/desktop/src/app/settings/memory/provider-config-modal.tsx index e3327c0..b83a0cd 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-modal.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-modal.tsx @@ -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 }) { + const { t, locale } = useI18n() + const copy = t.settings.runtime.memory const activeProfile = useStore($activeGatewayProfile) const [values, setValues] = useState>({}) const [seeded, setSeeded] = useState>({}) @@ -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({ - {config.label} — full configuration - - Every {config.label} option for the {profile ?? activeProfile} profile. - Blank fields fall back to the resolved host or built-in default. - + {copy.fullTitle(config.label)} + {copy.fullDescription(config.label, profile ?? activeProfile)} {config.docs_url && ( - {config.label} configuration reference + {copy.reference(config.label)} )}
- {groupFields(config.fields).map(([group, fields]) => ( -
-

- {group} -

-
- {fields.map(field => ( -
- setValues(current => ({ ...current, [field.key]: value }))} - value={values[field.key] ?? ''} - /> - } - description={field.description} - title={} - /> -
- ))} -
-
- ))} + {groupFields(config.fields.map(field => localizeMemoryField(config.name, field, locale))).map( + ([group, fields]) => ( +
+

+ {group === 'Other' ? copy.other : group} +

+
+ {fields.map(field => ( +
+ setValues(current => ({ ...current, [field.key]: value }))} + value={values[field.key] ?? ''} + /> + } + description={field.description} + title={} + /> +
+ ))} +
+
+ ) + )}
diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx index ac4fe9c..811da93 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.test.tsx @@ -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( + + + + ) + 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: [] }) diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index b4b5fcc..85b57e6 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -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 { } export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) { + const { t, locale } = useI18n() + const copy = t.settings.runtime.memory const [config, setConfig] = useState(null) const [loadError, setLoadError] = useState(null) const [values, setValues] = useState>({}) @@ -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 (
- Memory provider settings failed to load: {loadError} + {copy.loadFailed}: {loadError}
) } - return + return } - 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 > - {config.label} settings + {copy.settings(config.label)} {secretFields.map(field => ( - {field.is_set ? `${field.label} set` : `${field.label} not set`} + {copy.credentialStatus(field.label, field.is_set)} ))} {hasFullConfig && ( )} diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 06cd8a2..1f070cf 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -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 () => { diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index baed8f7..922dc9e 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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
- {slots.length} auxiliary task{slots.length === 1 ? '' : 's'} ({names}) still run on{' '} - {allSameProvider ? provider : 'other providers'}, not your main model. + {t.settings.runtime.auxiliaryWarning( + slots.length, + names, + allSameProvider ? provider : t.settings.runtime.otherProviders + )}
) @@ -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 && } - {activating ? 'Activating...' : 'Activate'} + {activating ? t.settings.runtime.activatingProvider : t.settings.runtime.activateProvider} ) : ( ) ) : ( @@ -1085,15 +1092,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting {moa && currentMoaPreset && (
- -

- Configure named presets that appear as models under the Mixture of Agents provider. The aggregator is the - acting model. -

+ +

{t.settings.runtime.moa.description}

- Default: {moa.default_preset} + {t.settings.runtime.moa.default} {moa.default_preset}
{currentMoaPreset.reference_models.map((slot, index) => ( @@ -1267,7 +1271,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting size="sm" variant="ghost" > - Remove + {t.common.remove}
} @@ -1278,7 +1282,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting } key={`${selectedMoaPreset}-${index}`} - title={`Reference ${index + 1}`} + title={t.settings.runtime.moa.reference(index + 1)} /> ))} } - title="Aggregator" + title={t.settings.runtime.moa.aggregator} />
diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx index 917e7e0..ad9b09f 100644 --- a/apps/desktop/src/app/settings/plugins-settings.test.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.test.tsx @@ -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 () => ({ diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 69598af..48ac899 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -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')) } } diff --git a/apps/desktop/src/app/settings/pool-limits-setting.tsx b/apps/desktop/src/app/settings/pool-limits-setting.tsx index 4db5ee8..5e298d1 100644 --- a/apps/desktop/src/app/settings/pool-limits-setting.tsx +++ b/apps/desktop/src/app/settings/pool-limits-setting.tsx @@ -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={
} - 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} /> ms
} - 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} /> ) diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 634158f..e900361 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -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() diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index a061330..6449103 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -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([]) const [openProvider, setOpenProvider] = useState(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 diff --git a/apps/desktop/src/app/settings/searchable-select.test.tsx b/apps/desktop/src/app/settings/searchable-select.test.tsx index 61f0d77..96b66be 100644 --- a/apps/desktop/src/app/settings/searchable-select.test.tsx +++ b/apps/desktop/src/app/settings/searchable-select.test.tsx @@ -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() diff --git a/apps/desktop/src/app/settings/searchable-select.tsx b/apps/desktop/src/app/settings/searchable-select.tsx index 8f679b4..4271c96 100644 --- a/apps/desktop/src/app/settings/searchable-select.tsx +++ b/apps/desktop/src/app/settings/searchable-select.tsx @@ -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