From a53c2a2a7267940d6f1ec2708f225c64f751f07c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:43:22 -0700 Subject: [PATCH] Add onboarding language selection (#31784) * Add onboarding language selection and system locale detection - Detect and synchronize native/browser language preferences safely - Load catalogs before rendering onboarding and support recovery - Let users choose a supported language or request another language * Add onboarding language experiment cohorts and detection recovery * Stabilize onboarding language detection tests * Test persisted onboarding language cohort behavior --- config/localization-coverage-allowlist.json | 7 + src/main/i18n/main-i18n-lazy-locale.test.ts | 23 +- src/main/i18n/main-i18n.ts | 17 +- src/main/i18n/system-language.test.ts | 84 +++ src/main/i18n/system-language.ts | 24 + src/main/ipc/app.test.ts | 79 ++- src/main/ipc/app.ts | 22 +- ...istence-onboarding-language-cohort.test.ts | 80 +++ .../loaded-cohort-migrations.test.ts | 43 ++ .../loading-store/loaded-cohort-migrations.ts | 10 +- .../client-onboarding-language-cohort.test.ts | 50 ++ src/main/telemetry/client.ts | 5 + src/preload/api/app-api.ts | 4 + src/preload/api/app-bridge.ts | 2 + .../src/app-shell/AppRootSurfaces.tsx | 40 +- .../app-shell/app-root-surface-boundaries.tsx | 25 + .../components/onboarding/OnboardingFlow.tsx | 39 +- .../OnboardingLanguageLoading.test.tsx | 53 ++ .../onboarding/OnboardingLanguageLoading.tsx | 43 ++ .../OnboardingLanguageSwitcher.test.tsx | 482 ++++++++++++++++++ .../onboarding/OnboardingLanguageSwitcher.tsx | 357 +++++++++++++ .../src/components/ui/radio-group.tsx | 39 ++ src/renderer/src/i18n/I18nProvider.test.tsx | 140 ++++- src/renderer/src/i18n/I18nProvider.tsx | 71 ++- src/renderer/src/i18n/i18n.ts | 18 +- .../i18n/language-catalog-timeout.test.tsx | 73 +++ ...language-detection-reconciliation.test.tsx | 139 +++++ .../src/i18n/language-readiness.test.ts | 124 +++++ src/renderer/src/i18n/language-readiness.ts | 68 +++ src/renderer/src/i18n/locales/en.json | 21 + src/renderer/src/i18n/locales/es.json | 21 + src/renderer/src/i18n/locales/fr.json | 21 + src/renderer/src/i18n/locales/ja.json | 21 + src/renderer/src/i18n/locales/ko.json | 21 + src/renderer/src/i18n/locales/pt.json | 21 + src/renderer/src/i18n/locales/zh.json | 21 + src/renderer/src/i18n/supported-languages.ts | 4 + src/shared/global-settings-types.ts | 12 +- src/shared/telemetry-event-classification.ts | 3 +- src/shared/telemetry-event-registry.ts | 8 + .../telemetry-onboarding-language-schemas.ts | 17 + src/shared/telemetry-settings-types.ts | 10 + src/shared/ui-locale.test.ts | 49 +- src/shared/ui-locale.ts | 99 +++- 44 files changed, 2396 insertions(+), 114 deletions(-) create mode 100644 src/main/i18n/system-language.test.ts create mode 100644 src/main/i18n/system-language.ts create mode 100644 src/main/persistence-onboarding-language-cohort.test.ts create mode 100644 src/main/persistence/loading-store/loaded-cohort-migrations.test.ts create mode 100644 src/main/telemetry/client-onboarding-language-cohort.test.ts create mode 100644 src/renderer/src/app-shell/app-root-surface-boundaries.tsx create mode 100644 src/renderer/src/components/onboarding/OnboardingLanguageLoading.test.tsx create mode 100644 src/renderer/src/components/onboarding/OnboardingLanguageLoading.tsx create mode 100644 src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.test.tsx create mode 100644 src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx create mode 100644 src/renderer/src/components/ui/radio-group.tsx create mode 100644 src/renderer/src/i18n/language-catalog-timeout.test.tsx create mode 100644 src/renderer/src/i18n/language-detection-reconciliation.test.tsx create mode 100644 src/renderer/src/i18n/language-readiness.test.ts create mode 100644 src/renderer/src/i18n/language-readiness.ts create mode 100644 src/shared/telemetry-onboarding-language-schemas.ts create mode 100644 src/shared/telemetry-settings-types.ts diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 93d984594af..f13ffd0c55f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -75,5 +75,12 @@ "text": "sidebar", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx", + "kind": "jsx-text", + "text": "Switch language", + "dynamic": false, + "count": 1 } ] diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 28a076ad5db..d1a712cd0da 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Why: main-i18n avoids bundling locale catalogs on the cold-start path. // English comes from translateMain() fallbacks; non-English catalogs load @@ -20,6 +20,7 @@ import { } from '../../shared/ui-language' import { ensureMainI18n, + mainI18n, setMainPluginLanguagePacks, setMainUiLanguage, translateMain @@ -27,6 +28,7 @@ import { import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact' describe('main-i18n lazy locale loading', () => { + afterEach(() => vi.restoreAllMocks()) beforeEach(async () => { await ensureMainI18n() setMainPluginLanguagePacks([]) @@ -75,6 +77,25 @@ describe('main-i18n lazy locale loading', () => { expect(translateMain('menu.file', 'File')).toBe('File') }) + it('keeps a newer explicit language when system fallback catalog loading finishes late', async () => { + const { getSystemLanguageDetection } = await import('./system-language') + expect(getSystemLanguageDetection(['fr-FR'])?.locale).toBe('fr') + let finishLoad: () => void = () => {} + const load = vi.spyOn(mainI18n, 'loadLanguages').mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoad = resolve + }) + ) + const fallback = setMainUiLanguage('system') + await vi.waitFor(() => expect(load).toHaveBeenCalledWith('fr')) + await setMainUiLanguage('en') + finishLoad() + await fallback + expect(mainI18n.language).toBe('en') + expect(translateMain('menu.file', 'File')).toBe('File') + }) + it('loads a contributed catalog for native menus and dialogs', async () => { const id = 'plugin:orca-samples.portuguese/pt-BR' as const setMainPluginLanguagePacks([ diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 60abe61faa1..8a01f9d5b75 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -1,4 +1,4 @@ -import { app } from 'electron' +import { getSystemLanguageDetection } from './system-language' import i18next, { type BackendModule, type i18n as I18nInstance, @@ -14,6 +14,7 @@ import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin export const mainI18n: I18nInstance = i18next.createInstance() let initialized = false +let languageGeneration = 0 let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] const registeredPluginLanguages = new Set() @@ -51,11 +52,7 @@ const lazyLocaleBackend: BackendModule = { } export function getMainSystemLocale(): string { - try { - return app.getLocale() - } catch { - return DEFAULT_UI_LOCALE - } + return getSystemLanguageDetection()?.locale ?? DEFAULT_UI_LOCALE } export async function ensureMainI18n(): Promise { @@ -83,6 +80,7 @@ export async function ensureMainI18n(): Promise { } export async function setMainUiLanguage(language: UiLanguage): Promise { + const request = ++languageGeneration await ensureMainI18n() const selectedLocale = resolveUiLocale( language, @@ -92,9 +90,10 @@ export async function setMainUiLanguage(language: UiLanguage): Promise { pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ?? (selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale) if (mainI18n.language !== locale) { - // changeLanguage triggers the lazy backend load for non-English locales and - // resolves once the catalog is in memory, so callers that await this have - // the translations ready before they render menus/dialogs. + await mainI18n.loadLanguages(locale) + if (request !== languageGeneration) { + return mainI18n.language + } await mainI18n.changeLanguage(locale) } return locale diff --git a/src/main/i18n/system-language.test.ts b/src/main/i18n/system-language.test.ts new file mode 100644 index 00000000000..1ff7731b533 --- /dev/null +++ b/src/main/i18n/system-language.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPreferredSystemLanguages, isReady } = vi.hoisted(() => ({ + getPreferredSystemLanguages: vi.fn(), + isReady: vi.fn() +})) +vi.mock('electron', () => ({ app: { getPreferredSystemLanguages, isReady } })) + +beforeEach(() => { + vi.resetModules() + isReady.mockReturnValue(true) + getPreferredSystemLanguages.mockReturnValue(['fr-FR', 'en-US']) +}) + +describe('native language snapshot', () => { + it('reads preferences once after app readiness and shares the ordered result', async () => { + const { getSystemLanguageDetection } = await import('./system-language') + isReady.mockReturnValue(false) + expect(getSystemLanguageDetection()).toBeNull() + expect(getPreferredSystemLanguages).not.toHaveBeenCalled() + isReady.mockReturnValue(true) + expect(getSystemLanguageDetection()).toMatchObject({ + detectedTag: 'fr-FR', + locale: 'fr', + source: 'native' + }) + getPreferredSystemLanguages.mockReturnValue(['en-US']) + expect(getSystemLanguageDetection()?.locale).toBe('fr') + expect(getPreferredSystemLanguages).toHaveBeenCalledTimes(1) + }) + + it('keeps a C-only native result unknown so Chromium cannot override it', async () => { + getPreferredSystemLanguages.mockReturnValue(['C.UTF-8', 'POSIX']) + const { getSystemLanguageDetection } = await import('./system-language') + expect(getSystemLanguageDetection()).toMatchObject({ status: 'unknown', source: 'native' }) + }) + + it.each(['empty', 'failed'])( + 'permits uncertain browser fallback for %s native lookup', + async (kind) => { + getPreferredSystemLanguages.mockImplementation(() => { + if (kind === 'failed') { + throw new Error('Unavailable') + } + return [] + }) + const { getSystemLanguageDetection } = await import('./system-language') + expect(getSystemLanguageDetection()).toBeNull() + const fallback = getSystemLanguageDetection(['fr-FR']) + expect(fallback).toMatchObject({ locale: 'fr', source: 'browser-fallback' }) + expect(getSystemLanguageDetection(['ja-JP'])).toBe(fallback) + const { getMainSystemLocale, setMainUiLanguage, mainI18n } = await import('./main-i18n') + expect(getMainSystemLocale()).toBe('fr') + await setMainUiLanguage('system') + expect(mainI18n.language).toBe('fr') + await setMainUiLanguage('en') + expect(mainI18n.language).toBe('en') + } + ) + + it('never replaces a successful native snapshot with browser fallback hints', async () => { + const { getSystemLanguageDetection } = await import('./system-language') + const native = getSystemLanguageDetection() + expect(getSystemLanguageDetection(['ja-JP'])).toBe(native) + expect(native).toMatchObject({ locale: 'fr', source: 'native' }) + }) +}) + +it.each([ + { platform: 'Windows', preferences: ['ja-JP', 'en-US'], locale: 'ja', status: 'supported' }, + { platform: 'macOS', preferences: ['fr-US', 'en-US'], locale: 'fr', status: 'supported' }, + { + platform: 'Linux', + preferences: ['zh_CN.UTF-8', 'zh_CN', 'zh', 'C'], + locale: 'zh', + status: 'supported' + }, + { platform: 'Linux C-only', preferences: ['C.UTF-8', 'C'], locale: 'en', status: 'unknown' }, + { platform: 'unsupported', preferences: ['de-DE', 'fr-FR'], locale: 'en', status: 'unsupported' } +])('handles $platform preference lists', async ({ preferences, locale, status }) => { + getPreferredSystemLanguages.mockReturnValue(preferences) + const { getSystemLanguageDetection } = await import('./system-language') + expect(getSystemLanguageDetection()).toMatchObject({ locale, status }) +}) diff --git a/src/main/i18n/system-language.ts b/src/main/i18n/system-language.ts new file mode 100644 index 00000000000..d348486613e --- /dev/null +++ b/src/main/i18n/system-language.ts @@ -0,0 +1,24 @@ +import { app } from 'electron' +import { detectUiLanguage, type UiLanguageDetection } from '../../shared/ui-locale' + +let snapshot: UiLanguageDetection | null | undefined + +export function getSystemLanguageDetection( + browserPreferences?: readonly string[] +): UiLanguageDetection | null { + if (snapshot === undefined) { + try { + if (!app.isReady()) { + return null + } + const preferences = app.getPreferredSystemLanguages() + snapshot = preferences.length ? detectUiLanguage(preferences, 'native') : null + } catch { + snapshot = null + } + } + if (snapshot === null && browserPreferences) { + snapshot = detectUiLanguage(browserPreferences, 'browser-fallback') + } + return snapshot +} diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e9c684e0dc6..092970beed7 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AppRelaunch from '../app-relaunch' +import { detectUiLanguage } from '../../shared/ui-locale' const { handlers, @@ -9,6 +10,10 @@ const { appRelaunchMock, spawnMock, destroySystemTrayMock, + rebuildSystemTrayMenuMock, + rebuildAppMenuMock, + getSystemLanguageDetectionMock, + setMainUiLanguageMock, relaunchAppMock, showOpenDialogMock, trustFloatingWorkspaceDirectoryMock, @@ -22,6 +27,10 @@ const { appRelaunchMock: vi.fn(), spawnMock: vi.fn(), destroySystemTrayMock: vi.fn(), + rebuildSystemTrayMenuMock: vi.fn(), + rebuildAppMenuMock: vi.fn(), + getSystemLanguageDetectionMock: vi.fn(), + setMainUiLanguageMock: vi.fn(), relaunchAppMock: vi.fn(), showOpenDialogMock: vi.fn(), trustFloatingWorkspaceDirectoryMock: vi.fn(), @@ -97,9 +106,16 @@ vi.mock('@electron-toolkit/utils', () => ({ })) vi.mock('../tray/system-tray', () => ({ - destroySystemTray: destroySystemTrayMock + destroySystemTray: destroySystemTrayMock, + rebuildSystemTrayMenu: rebuildSystemTrayMenuMock })) +vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: rebuildAppMenuMock })) +vi.mock('../i18n/system-language', () => ({ + getSystemLanguageDetection: getSystemLanguageDetectionMock +})) +vi.mock('../i18n/main-i18n', () => ({ setMainUiLanguage: setMainUiLanguageMock })) + vi.mock('../app-relaunch', async (importOriginal) => ({ ...(await importOriginal()), relaunchApp: relaunchAppMock @@ -158,6 +174,10 @@ describe('registerAppHandlers', () => { appRelaunchMock.mockReset() spawnMock.mockReset() destroySystemTrayMock.mockReset() + rebuildSystemTrayMenuMock.mockReset() + rebuildAppMenuMock.mockReset() + getSystemLanguageDetectionMock.mockReset() + setMainUiLanguageMock.mockReset() relaunchAppMock.mockReset() relaunchAppMock.mockImplementation(() => appRelaunchMock()) prepareWindowsAdministratorRestartMock.mockReset() @@ -191,6 +211,63 @@ describe('registerAppHandlers', () => { expect(registerMacKeyboardLayoutChangeNotificationsMock).toHaveBeenCalledOnce() }) + it('returns fallback detection before catalog loading and then refreshes native menus', async () => { + const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') + getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) + let finishLoad: (locale: string) => void = () => {} + setMainUiLanguageMock.mockReturnValue( + new Promise((resolve) => { + finishLoad = resolve + }) + ) + const store = { getSettings: () => ({ uiLanguage: 'system' }) } + // SAFETY: the language handler only reads the uiLanguage setting. + registerAppHandlers(store as never) + + expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection) + expect(getSystemLanguageDetectionMock).toHaveBeenLastCalledWith(['fr-FR']) + expect(rebuildAppMenuMock).not.toHaveBeenCalled() + expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled() + finishLoad('fr') + await vi.advanceTimersByTimeAsync(0) + expect(rebuildAppMenuMock).toHaveBeenCalledOnce() + expect(rebuildSystemTrayMenuMock).toHaveBeenCalledOnce() + }) + + it('keeps an explicit language while accepting browser fallback detection', () => { + const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') + getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) + const store = { getSettings: () => ({ uiLanguage: 'en' }) } + // SAFETY: the language handler only reads the uiLanguage setting. + registerAppHandlers(store as never) + + expect(handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe(detection) + expect(setMainUiLanguageMock).not.toHaveBeenCalled() + }) + + it('preserves detection if the asynchronous native menu refresh fails', async () => { + const detection = detectUiLanguage(['fr-FR'], 'browser-fallback') + getSystemLanguageDetectionMock.mockReturnValueOnce(null).mockReturnValue(detection) + const failure = new Error('Catalog unavailable') + setMainUiLanguageMock.mockRejectedValue(failure) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = { getSettings: () => ({ uiLanguage: 'system' }) } + // SAFETY: the language handler only reads the uiLanguage setting. + registerAppHandlers(store as never) + + try { + expect(await handlers.get('app:getSystemLanguageDetection')?.(null, ['fr-FR'])).toBe( + detection + ) + await vi.advanceTimersByTimeAsync(0) + expect(warn).toHaveBeenCalledWith('[app] failed to refresh system language menus:', failure) + expect(rebuildAppMenuMock).not.toHaveBeenCalled() + expect(rebuildSystemTrayMenuMock).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + it('checkpoints before scheduling the native administrator restart and quits normally', async () => { const plan = { execPath: 'C:\\Orca\\resources\\bin\\orca.exe', args: ['restart'] } prepareWindowsAdministratorRestartMock.mockReturnValue(plan) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index b5f7afc92ac..16346ff6e40 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,3 +1,6 @@ +import { getSystemLanguageDetection } from '../i18n/system-language' +import { setMainUiLanguage } from '../i18n/main-i18n' +import { rebuildAppMenu } from '../menu/register-app-menu' import { existsSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' @@ -15,7 +18,7 @@ import { isPwshAvailableAsync } from '../pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl' import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' -import { destroySystemTray } from '../tray/system-tray' +import { destroySystemTray, rebuildSystemTrayMenu } from '../tray/system-tray' import { ensureDefaultFloatingWorkspacePath, trustFloatingWorkspaceDirectory, @@ -125,6 +128,23 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ) registerMacKeyboardLayoutChangeNotifications() ipcMain.handle('app:isProfileStateSaveDelayed', isProfileStateSaveDelayed) + ipcMain.handle('app:getSystemLanguageDetection', (_event, preferences: unknown) => { + const previous = getSystemLanguageDetection() + const detection = getSystemLanguageDetection( + Array.isArray(preferences) && preferences.every((tag) => typeof tag === 'string') + ? preferences + : undefined + ) + if (detection !== previous && store.getSettings().uiLanguage === 'system') { + void setMainUiLanguage('system') + .then(() => { + rebuildAppMenu() + rebuildSystemTrayMenu() + }) + .catch((error) => console.warn('[app] failed to refresh system language menus:', error)) + } + return detection + }) ipcMain.handle('app:getWindowsAdministratorStatus', getWindowsAdministratorStatus) ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) diff --git a/src/main/persistence-onboarding-language-cohort.test.ts b/src/main/persistence-onboarding-language-cohort.test.ts new file mode 100644 index 00000000000..5575c7d60c2 --- /dev/null +++ b/src/main/persistence-onboarding-language-cohort.test.ts @@ -0,0 +1,80 @@ +import { + closeTestStores, + createStore, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as crypto from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('node:crypto', async (importOriginal) => ({ + ...(await importOriginal()), + randomInt: vi.fn() +})) + +beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-onboarding-language-cohort-')) +}) +afterEach(async () => { + await closeTestStores() + vi.resetAllMocks() + rmSync(testState.dir, { recursive: true, force: true }) +}) + +describe('persisted onboarding language cohorts', () => { + it.each([ + { draw: 0, cohort: 'control', language: 'en' }, + { draw: 1, cohort: 'treatment', language: 'system' } + ])('retains $cohort through settings changes and restart', async ({ draw, cohort, language }) => { + vi.mocked(crypto.randomInt).mockImplementation(() => draw) + const store = createStore() + const telemetry = store.getSettings().telemetry + if (!telemetry) { + throw new Error('Fresh profile has no telemetry settings') + } + expect(telemetry.onboardingLanguageCohort).toBe(cohort) + expect(store.getSettings().uiLanguage).toBe(language) + store.flush() + expect(readDataFile()).toHaveProperty('settings.telemetry.onboardingLanguageCohort', cohort) + + store.updateSettings({ uiLanguage: 'fr' }) + store.updateSettings({ telemetry: { ...telemetry, optedIn: false } }) + store.flush() + await closeTestStores() + + const reopened = createStore() + expect(reopened.getSettings().telemetry).toEqual({ ...telemetry, optedIn: false }) + expect(reopened.getSettings().uiLanguage).toBe('fr') + expect(crypto.randomInt).toHaveBeenCalledExactlyOnceWith(2) + }) + + it('keeps a previously migrated profile excluded through restart', async () => { + writeDataFile({ + settings: { + uiLanguage: 'ja', + telemetry: { + optedIn: false, + installId: 'existing-profile', + existedBeforeTelemetryRelease: false + } + } + }) + const store = createStore() + store.flush() + await closeTestStores() + const reopened = createStore() + expect(reopened.getSettings().telemetry?.onboardingLanguageCohort).toBeUndefined() + expect(reopened.getSettings().telemetry?.optedIn).toBe(false) + expect(reopened.getSettings().uiLanguage).toBe('ja') + expect(crypto.randomInt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.test.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.test.ts new file mode 100644 index 00000000000..8a1eab30684 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as crypto from 'node:crypto' +import { getDefaultPersistedState } from '../../../shared/constants' +import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' + +vi.mock('node:crypto', async (importOriginal) => ({ + ...(await importOriginal()), + randomInt: vi.fn() +})) + +afterEach(() => vi.resetAllMocks()) + +describe('onboarding language experiment', () => { + it.each([ + { draw: 0, cohort: 'control', language: 'en' }, + { draw: 1, cohort: 'treatment', language: 'system' } + ])( + 'assigns and freezes the $cohort experience for a new install', + ({ draw, cohort, language }) => { + vi.mocked(crypto.randomInt).mockImplementation(() => draw) + const runtime = { loadNeedsSave: false } + const migration = new LoadedCohortMigrationOperations(runtime) + const fresh = migration.migrateTelemetry(getDefaultPersistedState('/test'), false) + expect(fresh.settings.telemetry?.onboardingLanguageCohort).toBe(cohort) + expect(fresh.settings.uiLanguage).toBe(language) + expect(runtime.loadNeedsSave).toBe(true) + const reopened = migration.migrateTelemetry(JSON.parse(JSON.stringify(fresh)), true) + expect(reopened).toEqual(fresh) + expect(crypto.randomInt).toHaveBeenCalledExactlyOnceWith(2) + } + ) + + it('excludes existing profiles and preserves their language', () => { + const migration = new LoadedCohortMigrationOperations({ loadNeedsSave: false }) + const profile = getDefaultPersistedState('/test') + profile.settings.uiLanguage = 'fr' + const upgraded = migration.migrateTelemetry(profile, true) + expect(upgraded.settings.telemetry?.onboardingLanguageCohort).toBeUndefined() + expect(upgraded.settings.uiLanguage).toBe('fr') + expect(crypto.randomInt).not.toHaveBeenCalled() + expect(migration.migrateTelemetry(upgraded, true)).toEqual(upgraded) + }) +}) diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts index 4084ca206a4..27e7608caa6 100644 --- a/src/main/persistence/loading-store/loaded-cohort-migrations.ts +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { randomInt, randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { createNewProfileNativeChatUpgradeTipAudience, @@ -86,12 +86,20 @@ export class LoadedCohortMigrationOperations { typeof existing?.existedBeforeTelemetryRelease === 'boolean' ? existing.existedBeforeTelemetryRelease : fileExistedOnLoad + const onboardingLanguageCohort = !fileExistedOnLoad + ? randomInt(2) === 0 + ? 'control' + : 'treatment' + : undefined + this.runtime.loadNeedsSave = true return { ...state, settings: { ...state.settings, + ...(onboardingLanguageCohort === 'control' ? { uiLanguage: 'en' as const } : {}), telemetry: { ...existing, + ...(onboardingLanguageCohort ? { onboardingLanguageCohort } : {}), existedBeforeTelemetryRelease: resolvedExistedBefore, // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. optedIn: diff --git a/src/main/telemetry/client-onboarding-language-cohort.test.ts b/src/main/telemetry/client-onboarding-language-cohort.test.ts new file mode 100644 index 00000000000..1625c039149 --- /dev/null +++ b/src/main/telemetry/client-onboarding-language-cohort.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { initTelemetry, track } from './client' +import { cleanupTelemetryClientTest, setupTelemetryClientTest } from './client-test-harness' + +const { capture } = vi.hoisted(() => { + vi.stubGlobal('ORCA_BUILD_IDENTITY', 'stable') + vi.stubGlobal('ORCA_POSTHOG_WRITE_KEY', 'test-key') + return { capture: vi.fn() } +}) + +vi.mock('posthog-node', () => ({ + PostHog: class { + capture = capture + } +})) + +afterEach(() => capture.mockClear()) + +describe('onboarding language cohort on analytics', () => { + it.each(['control', 'treatment', undefined] as const)( + 'attaches %s to completion and activation events without changing consent', + (cohort) => { + const state = setupTelemetryClientTest({ + optedIn: true, + installId: 'test-install', + existedBeforeTelemetryRelease: false, + ...(cohort ? { onboardingLanguageCohort: cohort } : {}) + }) + try { + initTelemetry(state.store) + track('onboarding_completed', { path: 'open_folder', total_duration_ms: 1000 }) + track('activation_checklist_item_completed', { + item: 'ranFirstAgent', + time_since_completed_ms: 2000 + }) + expect(capture).toHaveBeenCalledTimes(2) + for (const [event] of capture.mock.calls) { + expect(event.properties.onboarding_language_v1).toBe(cohort) + } + if (state.settings.telemetry) { + state.settings.telemetry.optedIn = false + } + track('app_opened', {}) + expect(capture).toHaveBeenCalledTimes(2) + } finally { + cleanupTelemetryClientTest(state.envStash) + } + } + ) +}) diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index da9c7eb41fd..defd7e83657 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -91,6 +91,11 @@ export function initTelemetry(store: Store): void { BUILD_IDENTITY as 'stable' | 'rc' ) + const languageCohort = settings.telemetry?.onboardingLanguageCohort + if (languageCohort === 'control' || languageCohort === 'treatment') { + commonProps.onboarding_language_v1 = languageCohort + } + // Fail-closed: a bad `install_id` (e.g. empty from a migration bug) would collapse all events into one distinct_id. // Validated once here (not per `track()`): `commonProps` is a session-lifetime singleton that can't drift. const parsedCommon = commonPropsSchema.safeParse(commonProps) diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 7e7decb8316..51055642eb2 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -1,3 +1,4 @@ +import type { UiLanguageDetection } from '../../shared/ui-locale' import type { AppIdentity } from '../../shared/app-identity' import type { E2EConfig } from '../../shared/e2e-config' import type { ExecutionHostId } from '../../shared/execution-host' @@ -14,6 +15,9 @@ import type { KeyboardLayoutSnapshot } from '../../shared/keyboard-layout-snapsh import type { KeyboardLayoutChangeEvent } from '../../shared/keyboard-layout-events' export type AppApi = { + getSystemLanguageDetection?: ( + browserPreferences?: readonly string[] + ) => Promise /** A local profile storage operation is delayed and holds up subsequent saves. */ isProfileStateSaveDelayed: () => Promise onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void) => () => void diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 01d8a9c8313..4e482a1c08b 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -13,6 +13,8 @@ import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../prelo import type { PreloadApi } from '../api-types' export const appApi = { + getSystemLanguageDetection: (browserPreferences?: readonly string[]) => + ipcRenderer.invoke('app:getSystemLanguageDetection', browserPreferences), isProfileStateSaveDelayed: (): Promise => ipcRenderer.invoke('app:isProfileStateSaveDelayed'), onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void): (() => void) => { diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 042b10565f0..ea532b7104f 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -1,3 +1,6 @@ +import { ModalBoundary, OverlayBoundary } from './app-root-surface-boundaries' +import { OnboardingLanguageLoading } from '../components/onboarding/OnboardingLanguageLoading' +import { useLanguageReadiness } from '../i18n/language-readiness' import { NotificationCardStack } from '../components/NotificationCardStack' import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' @@ -93,30 +96,6 @@ const PetOverlay = lazy(() => import('../components/pet/PetOverlay')) // Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow')) -type BoundaryProps = { - boundaryId: string - resetKey?: string | number | boolean | null - title?: string - description?: string - children: React.ReactNode -} - -function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { - return ( - - {children} - - ) -} - -function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { - return ( - - {children} - - ) -} - function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean { if (status.state === 'idle') { return false @@ -136,6 +115,7 @@ export function AppRootSurfaces(props: { onboardingGate: OnboardingGate }): React.JSX.Element { const { floatingWorkspace, onboardingGate } = props + const languageReady = useLanguageReadiness((state) => state.ready) const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts() usePreloadWorktreeJumpPalette() const activeView = useAppStore((s) => s.activeView) @@ -382,10 +362,14 @@ export function AppRootSurfaces(props: { 'Retry onboarding or close it and continue in the app.' )} > - + {languageReady ? ( + + ) : ( + + )} ) : null} diff --git a/src/renderer/src/app-shell/app-root-surface-boundaries.tsx b/src/renderer/src/app-shell/app-root-surface-boundaries.tsx new file mode 100644 index 00000000000..05d2803f85f --- /dev/null +++ b/src/renderer/src/app-shell/app-root-surface-boundaries.tsx @@ -0,0 +1,25 @@ +import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' + +type BoundaryProps = { + boundaryId: string + resetKey?: string | number | boolean | null + title?: string + description?: string + children: React.ReactNode +} + +export function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { + return ( + + {children} + + ) +} + +export function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index 85318e51355..9388b215048 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -1,3 +1,4 @@ +import { OnboardingLanguageSwitcher } from './OnboardingLanguageSwitcher' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { isEditableTarget } from '@/lib/editable-target' @@ -138,6 +139,7 @@ export default function OnboardingFlow({ (currentStep.id === 'notifications' ? translate('components.onboarding.flow.actions.addFirstProject', 'Add your first project') : translate('components.onboarding.flow.actions.continue', 'Continue')) + const [languagePickerOpen, setLanguagePickerOpen] = useState(false) const [skipConfirmOpen, setSkipConfirmOpen] = useState(false) const skipConfirmAdvancedViaRef = useRef<'button' | 'keyboard'>('button') const { next: flowNext, dismissOnboarding: flowDismissOnboarding } = flow @@ -146,13 +148,13 @@ export default function OnboardingFlow({ (advancedVia: 'button' | 'keyboard') => { // Why: click-off / Escape dismissal stays available on every step, // including the final notifications step, so the modal never feels stuck. - if (busyLabel || skipConfirmOpen) { + if (busyLabel || skipConfirmOpen || languagePickerOpen) { return } skipConfirmAdvancedViaRef.current = advancedVia setSkipConfirmOpen(true) }, - [busyLabel, skipConfirmOpen] + [busyLabel, skipConfirmOpen, languagePickerOpen] ) const confirmSkipOnboarding = useCallback(() => { @@ -165,6 +167,9 @@ export default function OnboardingFlow({ // re-bind on every render of the parent (flow object identity changes). useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { + if (languagePickerOpen || skipConfirmOpen) { + return + } // Why: don't hijack Enter / Cmd+Enter while the user is typing into the // clone-URL input or any other editable field on a step. if (isEditableTarget(event.target)) { @@ -180,11 +185,11 @@ export default function OnboardingFlow({ } addAppKeyListener('keydown', onKeyDown) return () => removeAppKeyListener('keydown', onKeyDown) - }, [flowNext]) + }, [flowNext, languagePickerOpen, skipConfirmOpen]) useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || skipConfirmOpen) { + if (event.key !== 'Escape' || skipConfirmOpen || languagePickerOpen) { return } event.preventDefault() @@ -192,7 +197,7 @@ export default function OnboardingFlow({ } addAppKeyListener('keydown', onKeyDown) return () => removeAppKeyListener('keydown', onKeyDown) - }, [requestSkipConfirmation, skipConfirmOpen]) + }, [requestSkipConfirmation, skipConfirmOpen, languagePickerOpen]) return ( @@ -226,16 +231,22 @@ export default function OnboardingFlow({ )} >
-
- +
+ + + {translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')} + +
+ - - {translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')} -
diff --git a/src/renderer/src/components/onboarding/OnboardingLanguageLoading.test.tsx b/src/renderer/src/components/onboarding/OnboardingLanguageLoading.test.tsx new file mode 100644 index 00000000000..8a831dc0f14 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingLanguageLoading.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import { i18n } from '@/i18n/i18n' +import { OnboardingLanguageLoading } from './OnboardingLanguageLoading' + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + vi.unstubAllGlobals() +}) + +it('releases settings recovery while remote owner hydration is still pending', async () => { + await i18n.changeLanguage('en') + useAppStore.setState(useAppStore.getInitialState(), true) + markRuntimeEnvironmentCompatible('language-retry-owner') + let finishOwnerRead: (value: unknown) => void = () => {} + const ownerRead = new Promise((resolve) => { + finishOwnerRead = resolve + }) + const call = vi.fn().mockReturnValue(ownerRead) + const settings = { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'language-retry-owner' + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + settings: { get: vi.fn().mockResolvedValue(settings) }, + runtimeEnvironments: { call, list: vi.fn().mockResolvedValue([]) } + } + }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(screen.getByRole('status').textContent).toContain('Loading settings') + try { + await waitFor(() => expect(call).toHaveBeenCalled()) + expect(useAppStore.getState().settings).toBe(settings) + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(screen.getByRole('status').textContent).toContain('Loading language') + } finally { + finishOwnerRead({ + ok: true, + result: { settings: {} }, + _meta: { runtimeId: 'language-retry-runtime' } + }) + await useAppStore.getState().awaitOwnerWorktreeVisibilityDefaultsHydration() + } +}) diff --git a/src/renderer/src/components/onboarding/OnboardingLanguageLoading.tsx b/src/renderer/src/components/onboarding/OnboardingLanguageLoading.tsx new file mode 100644 index 00000000000..3d356fc3066 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingLanguageLoading.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { LoadingSpinner } from '@/components/LoadingSpinner' + +export function OnboardingLanguageLoading(): React.JSX.Element { + const settings = useAppStore((state) => state.settings) + const fetchSettings = useAppStore((state) => state.fetchSettings) + const [retrying, setRetrying] = useState(false) + return ( +
+ {settings === null && !retrying ? ( +
+

+ {translate('onboarding.language.settingsError', 'Could not load your settings.')} +

+ +
+ ) : ( +
+ + {settings === null + ? translate('onboarding.language.loadingSettings', 'Loading settings…') + : translate('onboarding.language.loading', 'Loading language…')} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.test.tsx b/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.test.tsx new file mode 100644 index 00000000000..cc0a949a5ea --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.test.tsx @@ -0,0 +1,482 @@ +// @vitest-environment happy-dom +import { act, useState } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { detectUiLanguage, setRendererLanguageDetection } from '../../../../shared/ui-locale' +import { useAppStore } from '@/store' +import { i18n } from '@/i18n/i18n' +import * as rendererI18n from '@/i18n/i18n' +import { useLanguageReadiness } from '@/i18n/language-readiness' +import { I18nProvider } from '@/i18n/I18nProvider' +import * as languageReadiness from '@/i18n/language-readiness' +import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' +import { OnboardingLanguageSwitcher } from './OnboardingLanguageSwitcher' + +const { submit, telemetryTrack } = vi.hoisted(() => ({ submit: vi.fn(), telemetryTrack: vi.fn() })) + +beforeEach(async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ settings: getDefaultSettings('/tmp') }) + useLanguageReadiness.setState({ ...useLanguageReadiness.getInitialState(), ready: true }) + usePluginLanguagePackStore.setState({ entries: [], loaded: true }) + vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockResolvedValue( + detectUiLanguage(['de-DE'], 'native') + ) + setRendererLanguageDetection(detectUiLanguage(['de-DE'], 'native')) + Object.defineProperty(window, 'api', { + configurable: true, + value: { feedback: { submit }, telemetryTrack } + }) + submit.mockReset() + telemetryTrack.mockReset().mockResolvedValue(undefined) + await i18n.changeLanguage('en') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +async function openRequest(): Promise { + render() + fireEvent.click(screen.getByRole('button', { name: 'Other language…' })) + fireEvent.change(screen.getByRole('textbox'), { target: { value: ' Deutsch ' } }) +} + +describe('onboarding language request', () => { + it.each(['control', 'treatment'] as const)('gates the picker for the %s cohort', (cohort) => { + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp'), + telemetry: { + optedIn: true, + installId: 'test-install', + existedBeforeTelemetryRelease: false, + onboardingLanguageCohort: cohort + } + } + }) + render() + expect(screen.queryByRole('button', { name: 'Switch language' }) !== null).toBe( + cohort === 'treatment' + ) + }) + + it('acknowledges only delivered feedback, disables duplicate sends and preserves language', async () => { + let finish: (value: { ok: true }) => void = () => {} + submit.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await openRequest() + fireEvent.click(screen.getByRole('button', { name: 'Request language' })) + expect(screen.queryByRole('status')).toBeNull() + expect(screen.getByRole('button', { name: 'Request language' }).hasAttribute('disabled')).toBe( + true + ) + expect(submit).toHaveBeenCalledWith({ + feedback: 'Language request: Deutsch', + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + await act(async () => finish({ ok: true })) + expect(screen.getByRole('status').textContent).toContain('recorded') + expect(useAppStore.getState().settings?.uiLanguage).toBe('system') + expect(telemetryTrack).toHaveBeenCalledWith( + 'onboarding_language_requested', + expect.not.objectContaining({ requested_language: expect.anything() }) + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'deutsch' } }) + fireEvent.click(screen.getByRole('button', { name: 'Request language' })) + expect(submit).toHaveBeenCalledTimes(1) + expect(screen.getByRole('status').textContent).toContain('recorded') + }) + + it('retains a failed draft and retries without claiming success', async () => { + submit + .mockResolvedValueOnce({ ok: false, status: null, error: 'Offline' }) + .mockResolvedValueOnce({ ok: true }) + await openRequest() + fireEvent.click(screen.getByRole('button', { name: 'Request language' })) + await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Could not record')) + expect(screen.queryByRole('status')).toBeNull() + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'onboarding_language_requested', + expect.anything() + ) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status').textContent).toContain('recorded')) + expect(submit).toHaveBeenCalledTimes(2) + }) + + it('allows language selection after leaving a pending request and still prevents duplicate requests', async () => { + let finishRequest: (value: { ok: true }) => void = () => {} + submit.mockImplementation( + () => + new Promise((resolve) => { + finishRequest = resolve + }) + ) + let settings = getDefaultSettings('/tmp') + const save = vi.fn(async (updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { feedback: { submit }, telemetryTrack, settings: { set: save } } + }) + await openRequest() + fireEvent.click(screen.getByRole('button', { name: 'Request language' })) + fireEvent.click(screen.getByRole('button', { name: 'Languages' })) + + expect(screen.getByRole('radiogroup').getAttribute('aria-disabled')).toBe('false') + fireEvent.click(screen.getByRole('radio', { name: 'Français' })) + + await waitFor(() => + expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe( + 'checked' + ) + ) + expect(useAppStore.getState().settings?.uiLanguage).toBe('fr') + await waitFor(() => + expect(screen.getByRole('button', { name: 'Other language…' }).hasAttribute('disabled')).toBe( + false + ) + ) + fireEvent.click(screen.getByRole('button', { name: 'Other language…' })) + const form = screen.getByRole('textbox').closest('form') + if (!form) { + throw new Error('Request form missing') + } + fireEvent.submit(form) + expect(submit).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('status')).toBeNull() + await act(async () => finishRequest({ ok: true })) + expect(screen.getByRole('status').textContent).toContain('recorded') + }) + + it('rejects empty and oversized input before transport', async () => { + await openRequest() + fireEvent.change(screen.getByRole('textbox'), { target: { value: ' ' } }) + const form = screen.getByRole('textbox').closest('form') + if (!form) { + throw new Error('Request form missing') + } + fireEvent.submit(form) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x'.repeat(81) } }) + fireEvent.submit(form) + expect(submit).not.toHaveBeenCalled() + }) + + it('freezes switcher eligibility after selecting English', async () => { + const save = vi.fn(async () => { + const settings = useAppStore.getState().settings + if (settings) { + useAppStore.setState({ settings: { ...settings, uiLanguage: 'en' } }) + } + }) + useAppStore.setState({ updateSettingsOrThrow: save }) + render() + fireEvent.click(screen.getByRole('radio', { name: 'English' })) + await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'en' })) + expect(screen.getByRole('button', { name: 'Switch language' })).toBeTruthy() + }) + + it('lets an open picker close after late native English detection removes its eligibility', async () => { + useLanguageReadiness.setState({ detection: detectUiLanguage(['en-US'], 'browser-fallback') }) + const onOpenChange = vi.fn() + function Picker(): React.JSX.Element { + const [open, setOpen] = useState(false) + return ( + { + setOpen(next) + onOpenChange(next) + }} + /> + ) + } + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Switch language' })) + await act(async () => { + useLanguageReadiness.setState({ detection: detectUiLanguage(['en-US'], 'native') }) + }) + + expect(screen.getByRole('radiogroup')).toBeTruthy() + await user.keyboard('{Escape}') + + expect(onOpenChange).toHaveBeenLastCalledWith(false) + expect(screen.queryByRole('radiogroup')).toBeNull() + expect(screen.queryByRole('button', { name: 'Switch language' })).toBeNull() + }) + it('keeps the previous selection and offers retry when persistence fails', async () => { + const save = vi + .fn() + .mockRejectedValueOnce(new Error('Disk unavailable')) + .mockImplementationOnce(async () => { + const settings = useAppStore.getState().settings + if (settings) { + useAppStore.setState({ settings: { ...settings, uiLanguage: 'fr' } }) + } + }) + const onOpenChange = vi.fn() + useAppStore.setState({ updateSettingsOrThrow: save }) + render( + + + + ) + await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(false)) + fireEvent.click(screen.getByRole('radio', { name: 'Français' })) + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain( + 'Could not save your language preference' + ) + ) + expect(useAppStore.getState().settings?.uiLanguage).toBe('system') + expect(screen.getByRole('radio', { name: 'English' }).getAttribute('data-state')).toBe( + 'checked' + ) + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'onboarding_language_changed', + expect.anything() + ) + expect(onOpenChange).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(i18n.language).toBe('fr')) + expect(onOpenChange).not.toHaveBeenCalled() + expect(save).toHaveBeenNthCalledWith(2, { uiLanguage: 'fr' }) + expect(i18n.language).toBe('fr') + expect(telemetryTrack).toHaveBeenCalledWith( + 'onboarding_language_changed', + expect.objectContaining({ selected_language: 'fr', effective_language: 'fr' }) + ) + }) + + it('guards rapid choices while saving, preserves keyboard focus, and closes on Escape', async () => { + let finishSave: () => void = () => {} + const save = vi.fn['updateSettingsOrThrow']>( + async (change) => { + await new Promise((resolve) => { + finishSave = resolve + }) + const settings = useAppStore.getState().settings + if (settings && (change.uiLanguage === 'zh' || change.uiLanguage === 'ko')) { + useAppStore.setState({ settings: { ...settings, uiLanguage: change.uiLanguage } }) + } + } + ) + useAppStore.setState({ updateSettingsOrThrow: save }) + function Picker(): React.JSX.Element { + const [open, setOpen] = useState(false) + return + } + const user = userEvent.setup() + render() + const trigger = screen.getByRole('button', { name: 'Switch language' }) + await user.click(trigger) + const english = screen.getByRole('radio', { name: 'English' }) + await waitFor(() => expect(document.activeElement).toBe(english)) + + for (const name of ['中文(简体)', '한국어']) { + await user.keyboard('{ArrowDown>}') + const radio = screen.getByRole('radio', { name }) + await waitFor(() => expect(save).toHaveBeenCalledTimes(name === '中文(简体)' ? 1 : 2)) + await user.keyboard('{/ArrowDown}') + expect(document.activeElement).toBe(radio) + expect(radio.hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('radiogroup').getAttribute('aria-disabled')).toBe('true') + expect(screen.getByRole('status').textContent).toContain('Saving language') + await user.keyboard('{ArrowDown}') + expect(document.activeElement).toBe(radio) + expect(save).toHaveBeenCalledTimes(name === '中文(简体)' ? 1 : 2) + await act(async () => finishSave()) + await waitFor(() => expect(radio.getAttribute('data-state')).toBe('checked')) + expect(radio.hasAttribute('disabled')).toBe(false) + expect(document.activeElement).toBe(radio) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + } + + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('radiogroup')).toBeNull()) + expect(document.activeElement).toBe(trigger) + expect(useAppStore.getState().settings?.uiLanguage).toBe('ko') + }) + + it('uses detection only as a hint and saves only explicit built-in selections', async () => { + setRendererLanguageDetection(detectUiLanguage(['fr-FR'], 'native')) + const save = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ updateSettingsOrThrow: save }) + render() + expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe( + 'checked' + ) + expect(screen.getAllByRole('radio')).toHaveLength(7) + expect(save).not.toHaveBeenCalled() + expect(useAppStore.getState().settings?.uiLanguage).toBe('system') + fireEvent.click(screen.getByRole('radio', { name: 'English' })) + await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'en' })) + }) + + it('keeps unsupported-system copy accurate after a manual non-English choice', async () => { + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } }) + render() + expect( + screen.getByText('System language: de-DE. This language is not supported yet.') + ).toBeTruthy() + expect(screen.queryByText(/Orca uses English/)).toBeNull() + expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe( + 'checked' + ) + }) + + it('uses the newer authoritative setting when a delayed picker save resolves', async () => { + const settings = { ...getDefaultSettings('/tmp'), uiLanguage: 'en' as const } + const newerSettings = { ...settings, uiLanguage: 'ja' as const } + useAppStore.setState({ settings }) + let finishSave: (value: typeof newerSettings) => void = () => {} + const save = vi.fn( + () => + new Promise((resolve) => { + finishSave = resolve + }) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { feedback: { submit }, telemetryTrack, settings: { set: save } } + }) + render( + + + + ) + await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(false)) + fireEvent.click(screen.getByRole('radio', { name: 'Français' })) + await waitFor(() => expect(save).toHaveBeenCalledWith({ uiLanguage: 'fr' })) + await act(async () => useAppStore.setState({ settings: newerSettings })) + await waitFor(() => expect(i18n.language).toBe('ja')) + + await act(async () => finishSave(newerSettings)) + + expect(useAppStore.getState().settings?.uiLanguage).toBe('ja') + expect(i18n.language).toBe('ja') + expect(screen.getByRole('radio', { name: '日本語' }).getAttribute('data-state')).toBe('checked') + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'onboarding_language_changed', + expect.objectContaining({ selected_language: 'fr' }) + ) + }) + + it.each(['fr', 'system'] as const)( + 'retries the saved %s preference without writing settings', + async (uiLanguage) => { + setRendererLanguageDetection(detectUiLanguage(['fr-FR'], 'native')) + const settings = { ...getDefaultSettings('/tmp'), uiLanguage } + useAppStore.setState({ settings }) + let finishLoad: (locale: string) => void = () => {} + vi.spyOn(rendererI18n, 'loadRendererUiLanguage') + .mockRejectedValueOnce(new Error('Offline')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishLoad = resolve + }) + ) + const save = vi.fn() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + feedback: { submit }, + telemetryTrack, + settings: { set: save } + } + }) + render( + + + + ) + await waitFor(() => expect(useLanguageReadiness.getState().error).toBe(true)) + expect(i18n.language).toBe('en') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + await waitFor(() => expect(useLanguageReadiness.getState().loading).toBe(true)) + expect(screen.getByRole('button', { name: 'Retry' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('status').textContent).toContain('Loading language') + expect(screen.getByRole('radio', { name: 'Français' })).toBeTruthy() + expect(useLanguageReadiness.getState().ready).toBe(true) + await act(async () => finishLoad('fr')) + await waitFor(() => expect(i18n.language).toBe('fr')) + expect(useLanguageReadiness.getState().error).toBe(false) + expect(useAppStore.getState().settings?.uiLanguage).toBe(uiLanguage) + expect(screen.queryByRole('alert')).toBeNull() + expect(save).not.toHaveBeenCalled() + expect(telemetryTrack).not.toHaveBeenCalledWith( + 'onboarding_language_changed', + expect.anything() + ) + } + ) + + it('keeps the saved preference when an explicit selection cannot load its catalog', async () => { + const save = vi.fn() + useAppStore.setState({ updateSettingsOrThrow: save }) + vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockRejectedValue(new Error('Offline')) + render() + + fireEvent.click(screen.getByRole('radio', { name: 'Français' })) + + await waitFor(() => + expect(screen.getByRole('alert').textContent).toContain('Could not load this language') + ) + expect(useAppStore.getState().settings?.uiLanguage).toBe('system') + expect(save).not.toHaveBeenCalled() + expect(screen.getByRole('radio', { name: 'English' }).getAttribute('data-state')).toBe( + 'checked' + ) + }) + + it('omits unavailable language requests when using the actual web adapter', async () => { + const { installWebPreloadApi } = await import('@/web/web-preload-api') + const originalPath = window.location.href + window.history.replaceState(null, '', '/web-index.html') + try { + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: window.api + }) + installWebPreloadApi() + render() + expect(screen.getAllByRole('radio')).toHaveLength(7) + expect(screen.queryByRole('button', { name: 'Other language…' })).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() + } finally { + window.history.replaceState(null, '', originalPath) + Reflect.deleteProperty(window, '__ORCA_WEB_CLIENT__') + } + }) + + it('does not offer or retry a saved plugin language', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'plugin:example.missing/de' } + }) + const save = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ updateSettingsOrThrow: save }) + render() + expect(screen.getAllByRole('radio')).toHaveLength(7) + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() + expect(save).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx b/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx new file mode 100644 index 00000000000..5def3df44d1 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingLanguageSwitcher.tsx @@ -0,0 +1,357 @@ +import { useRef, useState } from 'react' +import { ArrowLeft, ChevronDown, Globe } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { i18n, translate, loadRendererUiLanguage } from '@/i18n/i18n' +import { getUiLanguageNativeLabel } from '@/i18n/supported-languages' +import { useLanguageReadiness } from '@/i18n/language-readiness' +import { track } from '@/lib/telemetry' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useAppStore } from '@/store' +import { + SUPPORTED_UI_LOCALES, + type SupportedUiLocale, + getRendererLanguageDetection, + shouldShowLanguageSwitcher +} from '../../../../shared/ui-locale' + +export function OnboardingLanguageSwitcher({ + open, + onOpenChange +}: { + open: boolean + onOpenChange: (open: boolean) => void +}): React.JSX.Element | null { + const cohort = useAppStore((state) => state.settings?.telemetry?.onboardingLanguageCohort) + const language = useAppStore((state) => state.settings?.uiLanguage ?? 'system') + const updateSettings = useAppStore((state) => state.updateSettingsOrThrow) + const catalogError = useLanguageReadiness((state) => state.error) + const catalogLoading = useLanguageReadiness((state) => state.loading) + const detection = + useLanguageReadiness((state) => state.detection) ?? getRendererLanguageDetection() + const [recoveryVisible] = useState(catalogError) + const [requestView, setRequestView] = useState(false) + const [draft, setDraft] = useState('') + const [requestState, setRequestState] = useState<'idle' | 'pending' | 'error' | 'success'>('idle') + const [selectionPhase, setSelectionPhase] = useState<'load' | 'save' | null>(null) + const [selectionFailure, setSelectionFailure] = useState<{ + language: SupportedUiLocale + stage: 'load' | 'save' + } | null>(null) + const selectionInFlight = useRef(false) + const requestInFlight = useRef(false) + const successfulRequests = useRef(new Set()) + const contentRef = useRef(null) + const busy = selectionPhase !== null || catalogLoading + const canRequestLanguage = !isWebClientLocation() + const metadata = { + system_language: detection.detectedTag, + system_language_supported: detection.status === 'supported', + detection_source: detection.source, + effective_language: i18n.language + } + const setOpen = (next: boolean): void => { + if (next) { + setRequestView(false) + track('onboarding_language_picker_opened', metadata) + } + onOpenChange(next) + } + + const chooseLanguage = async (next: SupportedUiLocale): Promise => { + if (selectionInFlight.current || catalogLoading) { + return + } + selectionInFlight.current = true + setSelectionPhase('load') + setSelectionFailure(null) + let stage: 'load' | 'save' = 'load' + try { + const locale = await loadRendererUiLanguage(next) + stage = 'save' + setSelectionPhase('save') + await updateSettings({ uiLanguage: next }) + if (useAppStore.getState().settings?.uiLanguage !== next) { + return + } + if (catalogError && language === next) { + useLanguageReadiness.getState().retryLanguageLoad() + } + track('onboarding_language_changed', { + ...metadata, + selected_language: next, + effective_language: locale + }) + } catch { + setSelectionFailure({ language: next, stage }) + } finally { + selectionInFlight.current = false + setSelectionPhase(null) + } + } + + const submitRequest = async (): Promise => { + const request = draft.trim() + if (!canRequestLanguage || !request || request.length > 80 || requestInFlight.current) { + return + } + const normalized = request.normalize('NFKC').toLowerCase() + if (successfulRequests.current.has(normalized)) { + setRequestState('success') + return + } + requestInFlight.current = true + setRequestState('pending') + try { + const result = await window.api.feedback.submit({ + feedback: `Language request: ${request}`, + submitAnonymously: true, + githubLogin: null, + githubEmail: null + }) + if (!result.ok) { + throw new Error(result.error) + } + successfulRequests.current.add(normalized) + setRequestState('success') + track('onboarding_language_requested', metadata) + } catch { + setRequestState('error') + } finally { + requestInFlight.current = false + } + } + + if ( + cohort === 'control' || + (!open && !shouldShowLanguageSwitcher(detection) && !catalogError && !recoveryVisible) + ) { + return null + } + + return ( + + + + + { + event.preventDefault() + const content = contentRef.current + const selected = + content?.querySelector('[role="radio"][data-state="checked"]') ?? + content?.querySelector('[role="radio"]') + selected?.focus() + }} + > +
+ {requestView ? ( +
{ + event.preventDefault() + void submitRequest() + }} + > + + + { + setDraft(event.target.value) + setRequestState('idle') + }} + /> + {requestState === 'success' ? ( +

+ {translate( + 'onboarding.language.requestSuccess', + 'Language request recorded. You can continue onboarding.' + )} +

+ ) : ( + <> + {requestState === 'error' && ( +

+ {translate( + 'onboarding.language.requestError', + 'Could not record your request. Try again.' + )} +

+ )} + + + )} +
+ ) : ( +
+
+

+ {translate('onboarding.language.title', 'Display language')} +

+

+ {detection.status === 'supported' + ? translate('onboarding.language.device', 'System language: {{language}}', { + language: getUiLanguageNativeLabel(detection.locale) + }) + : detection.status === 'unsupported' + ? translate( + 'onboarding.language.unsupported', + 'System language: {{language}}. This language is not supported yet.', + { language: detection.detectedTag } + ) + : translate( + 'onboarding.language.unknown', + 'Your system language could not be detected. Choose a language below.' + )} +

+
+ { + if ( + busy && + [ + 'ArrowDown', + 'ArrowUp', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + ' ', + 'Enter' + ].includes(event.key) + ) { + event.preventDefault() + event.stopPropagation() + } + }} + onValueChange={(value) => { + const locale = SUPPORTED_UI_LOCALES.find((entry) => entry === value) + if (locale) { + void chooseLanguage(locale) + } + }} + aria-label={translate('onboarding.language.title', 'Display language')} + > + {SUPPORTED_UI_LOCALES.map((locale) => ( +
+ +
+ ))} +
+

+ {selectionPhase === 'save' + ? translate('onboarding.language.saving', 'Saving language…') + : busy + ? translate('onboarding.language.loading', 'Loading language…') + : null} +

+ {(selectionFailure !== null || catalogError) && ( +
+

+ {selectionFailure?.stage === 'save' + ? translate( + 'onboarding.language.saveError', + 'Could not save your language preference. Try again.' + ) + : translate( + 'onboarding.language.loadError', + 'Could not load this language. Try again.' + )} +

+ +
+ )} + {canRequestLanguage && ( +
+ +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/ui/radio-group.tsx b/src/renderer/src/components/ui/radio-group.tsx new file mode 100644 index 00000000000..c07e3e5aaf0 --- /dev/null +++ b/src/renderer/src/components/ui/radio-group.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import * as RadioGroupPrimitive from 'radix-ui/radio-group' +import { Circle } from 'lucide-react' +import { cn } from '@/lib/utils' + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/renderer/src/i18n/I18nProvider.test.tsx b/src/renderer/src/i18n/I18nProvider.test.tsx index 13ec0d144a5..a8156686be8 100644 --- a/src/renderer/src/i18n/I18nProvider.test.tsx +++ b/src/renderer/src/i18n/I18nProvider.test.tsx @@ -6,8 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' import { UI_LANGUAGE_ENGLISH, UI_LANGUAGE_SPANISH } from '../../../shared/ui-language' +import { + getBrowserLanguageDetection, + setRendererLanguageDetection +} from '../../../shared/ui-locale' import { useAppStore } from '@/store' import { i18n } from './i18n' +import * as rendererI18n from './i18n' +import { useLanguageReadiness } from './language-readiness' +import * as languageReadiness from './language-readiness' +import { usePluginLanguagePackStore } from '../store/plugin-language-packs' import { I18nProvider } from './I18nProvider' // Why: settings arrive async over IPC after first render. The provider used to @@ -24,6 +32,7 @@ const roots: Root[] = [] const ORIGINAL_SYSTEM_LOCALE = navigator.language function stubSystemLocale(tag: string): void { Object.defineProperty(navigator, 'language', { value: tag, configurable: true }) + Object.defineProperty(navigator, 'languages', { value: [tag], configurable: true }) } async function renderProvider(): Promise { @@ -37,6 +46,13 @@ async function renderProvider(): Promise { } beforeEach(() => { + useLanguageReadiness.setState(useLanguageReadiness.getInitialState(), true) + vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation(async () => { + const detection = getBrowserLanguageDetection() + setRendererLanguageDetection(detection) + useLanguageReadiness.setState({ detection }) + return detection + }) useAppStore.setState(initialAppState, true) }) @@ -53,6 +69,67 @@ afterEach(async () => { }) describe('I18nProvider startup language', () => { + it.each(['loaded', 'failed'] as const)( + 'starts an explicit catalog before detection finishes and waits for detection when %s', + async (outcome) => { + const detection = getBrowserLanguageDetection() + let finishDetection: () => void = () => {} + vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation( + () => + new Promise((resolve) => { + finishDetection = () => resolve(detection) + }) + ) + const load = vi.spyOn(rendererI18n, 'loadRendererUiLanguage') + if (outcome === 'loaded') { + load.mockResolvedValue('fr') + } else { + load.mockRejectedValue(new Error('Catalog offline')) + } + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } }) + + await renderProvider() + + expect(load).toHaveBeenCalledWith('fr') + expect(changeLanguage).not.toHaveBeenCalled() + expect(useLanguageReadiness.getState()).toMatchObject({ ready: false, loading: true }) + + await act(async () => finishDetection()) + + expect(changeLanguage).toHaveBeenCalledWith(outcome === 'loaded' ? 'fr' : 'en') + await vi.waitFor(() => + expect(useLanguageReadiness.getState()).toMatchObject({ + ready: true, + loading: false, + error: outcome === 'failed' + }) + ) + } + ) + + it('waits for detection before resolving the system language catalog', async () => { + const detection = getBrowserLanguageDetection() + let finishDetection: () => void = () => {} + vi.spyOn(languageReadiness, 'initializeLanguageDetection').mockImplementation( + () => + new Promise((resolve) => { + finishDetection = () => resolve(detection) + }) + ) + const load = vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockResolvedValue('en') + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'system' } }) + + await renderProvider() + + expect(load).not.toHaveBeenCalled() + + await act(async () => finishDetection()) + + expect(load).toHaveBeenCalledWith('system') + expect(useLanguageReadiness.getState().ready).toBe(true) + }) + it('does not apply any language while settings are still loading', async () => { const changeLanguage = vi.spyOn(i18n, 'changeLanguage') @@ -71,7 +148,7 @@ describe('I18nProvider startup language', () => { }) }) - expect(changeLanguage).toHaveBeenCalledWith('es') + await vi.waitFor(() => expect(changeLanguage).toHaveBeenCalledWith('es')) }) it('applies persisted English even if i18n reports it as already active', async () => { @@ -108,6 +185,7 @@ describe('I18nProvider startup language', () => { }) // English wins and the Spanish OS locale is never requested. + expect(useLanguageReadiness.getState().detection?.locale).toBe('es') expect(changeLanguage).toHaveBeenCalledWith('en') expect(changeLanguage).not.toHaveBeenCalledWith('es') }) @@ -127,6 +205,64 @@ describe('I18nProvider startup language', () => { }) }) - expect(changeLanguage).toHaveBeenLastCalledWith('es') + await vi.waitFor(() => expect(changeLanguage).toHaveBeenLastCalledWith('es')) }) }) + +it('does not apply a delayed catalog after a newer saved preference', async () => { + let finishSpanish: (locale: string) => void = () => {} + const load = vi + .spyOn(rendererI18n, 'loadRendererUiLanguage') + .mockImplementation(async (language) => { + if (language === 'es') { + return new Promise((resolve) => { + finishSpanish = resolve + }) + } + return 'en' + }) + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + await renderProvider() + await act(async () => { + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'es' } }) + }) + await vi.waitFor(() => expect(load).toHaveBeenCalledWith('es')) + await act(async () => { + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } }) + }) + await vi.waitFor(() => expect(changeLanguage).toHaveBeenCalledWith('en')) + await act(async () => finishSpanish('es')) + expect(changeLanguage).not.toHaveBeenCalledWith('es') + expect(i18n.language).toBe('en') +}) + +it('settles catalog failure to English with recovery while preserving the saved language', async () => { + vi.spyOn(rendererI18n, 'loadRendererUiLanguage').mockRejectedValue(new Error('Catalog offline')) + await renderProvider() + await act(async () => { + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } }) + }) + await vi.waitFor(() => + expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: true }) + ) + expect(i18n.language).toBe('en') + expect(useAppStore.getState().settings?.uiLanguage).toBe('fr') +}) + +it('continues in English while plugin discovery is pending without changing the saved preference', async () => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { plugins: { listLanguagePacks: () => new Promise(() => {}) } } + }) + usePluginLanguagePackStore.setState({ entries: [], loaded: false }) + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'plugin:example.missing/de' } + }) + await renderProvider() + await vi.waitFor(() => + expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: false }) + ) + expect(i18n.language).toBe('en') + expect(usePluginLanguagePackStore.getState().loaded).toBe(false) + expect(useAppStore.getState().settings?.uiLanguage).toBe('plugin:example.missing/de') +}) diff --git a/src/renderer/src/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index 4eab2d543ee..76e70798768 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -1,42 +1,61 @@ import { useEffect, useRef, type ReactNode } from 'react' import { I18nextProvider } from 'react-i18next' - import { useAppStore } from '../store' -import { i18n, setRendererPluginLanguagePacks } from './i18n' -import { resolveUiLocale } from './supported-languages' -import { isPluginUiLanguage } from '../../../shared/ui-language' +import { i18n, setRendererPluginLanguagePacks, loadRendererUiLanguage } from './i18n' import { usePluginLanguagePacks } from '../store/plugin-language-packs' +import { initializeLanguageDetection, useLanguageReadiness } from './language-readiness' export function I18nProvider({ children }: { children: ReactNode }): React.JSX.Element { - // Why: settings arrive async over IPC; until they load we must not apply any - // language. Falling back to 'system' here used to kick off an OS-locale - // changeLanguage that raced with (and could permanently override) the - // persisted preference applied moments later. const uiLanguage = useAppStore((state) => state.settings?.uiLanguage ?? null) - const pluginLanguagePacks = usePluginLanguagePacks() - const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) - const locale = - uiLanguage === null - ? null - : (selectedPluginLanguage?.resourceLanguage ?? - (isPluginUiLanguage(uiLanguage) ? 'en' : resolveUiLocale(uiLanguage))) - const requestedLocale = useRef(null) + const packs = usePluginLanguagePacks() + const retryCount = useLanguageReadiness((state) => state.retryCount) + const systemLocale = useLanguageReadiness((state) => + uiLanguage === 'system' ? state.detection?.locale : null + ) + const generation = useRef(0) useEffect(() => { - setRendererPluginLanguagePacks(pluginLanguagePacks) - requestedLocale.current = null - }, [pluginLanguagePacks]) + setRendererPluginLanguagePacks(packs) + }, [packs]) useEffect(() => { - // Why: track the last *requested* locale instead of checking i18n.language — - // an in-flight lazy catalog load leaves i18n.language stale, which made the - // guard skip corrections back to the persisted language. - if (locale === null || requestedLocale.current === locale) { + if (uiLanguage === null) { return } - requestedLocale.current = locale - void i18n.changeLanguage(locale) - }, [locale, pluginLanguagePacks]) + const request = ++generation.current + let cancelled = false + useLanguageReadiness.setState({ loading: true }) + void (async () => { + const detection = initializeLanguageDetection() + try { + if (uiLanguage === 'system') { + await detection + if (cancelled || request !== generation.current) { + return + } + } + const [, locale] = await Promise.all([detection, loadRendererUiLanguage(uiLanguage)]) + if (cancelled || request !== generation.current) { + return + } + await i18n.changeLanguage(locale) + if (!cancelled && request === generation.current) { + useLanguageReadiness.setState({ ready: true, error: false, loading: false }) + } + } catch { + await detection + if (!cancelled && request === generation.current) { + await i18n.changeLanguage('en') + if (!cancelled && request === generation.current) { + useLanguageReadiness.setState({ ready: true, error: true, loading: false }) + } + } + } + })() + return () => { + cancelled = true + } + }, [uiLanguage, packs, retryCount, systemLocale]) return {children} } diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 080ee0eb4fe..fae597a0386 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -13,6 +13,7 @@ import { DEFAULT_LOCALE, resolveUiLocale } from './supported-languages' import type { SupportedUiLocale } from '../../../shared/ui-locale' import { isPluginUiLanguage, type UiLanguage } from '../../../shared/ui-language' import type { PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export const i18n: I18nInstance = i18next.createInstance() @@ -93,16 +94,27 @@ export function translate(key: string, fallback: string, options?: TOptions): st return isPseudoLocalizationLocale(i18n.language) ? pseudoLocalizeString(value) : value } -export async function setRendererUiLanguage(language: UiLanguage): Promise { +export async function loadRendererUiLanguage(language: UiLanguage): Promise { const resolved = resolveUiLocale(language) const resourceLanguage = resolveRendererResourceLanguage(resolved) const locale = isPluginUiLanguage(language) && resourceLanguage === resolved ? DEFAULT_LOCALE : resourceLanguage + const loader = Object.entries(NON_DEFAULT_LOCALE_LOADERS).find(([name]) => name === locale)?.[1] + if (loader && !i18n.hasResourceBundle(locale, 'translation')) { + const catalog = await withTimeout(loader(), 5000, null) + if (catalog === null) { + throw new Error('Language catalog unavailable') + } + i18n.addResourceBundle(locale, 'translation', catalog.default, true, true) + } + return locale +} + +export async function setRendererUiLanguage(language: UiLanguage): Promise { + const locale = await loadRendererUiLanguage(language) if (i18n.language !== locale) { - // changeLanguage triggers the lazy backend load for non-English locales and - // resolves once the catalog is in memory. await i18n.changeLanguage(locale) } } diff --git a/src/renderer/src/i18n/language-catalog-timeout.test.tsx b/src/renderer/src/i18n/language-catalog-timeout.test.tsx new file mode 100644 index 00000000000..8a41a644edf --- /dev/null +++ b/src/renderer/src/i18n/language-catalog-timeout.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '@/store' +import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' +import { OnboardingLanguageLoading } from '@/components/onboarding/OnboardingLanguageLoading' +import { I18nProvider } from './I18nProvider' +import { i18n } from './i18n' +import { useLanguageReadiness } from './language-readiness' + +const { catalog } = vi.hoisted(() => { + let finish: (value: { default: Record }) => void = () => {} + const promise = new Promise<{ default: Record }>((resolve) => { + finish = resolve + }) + return { catalog: { promise, finish } } +}) +vi.mock('./locales/fr.json', () => catalog.promise) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('releases onboarding in English when a catalog stalls, ignores its late result, and can retry', async () => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + Object.defineProperty(window, 'api', { configurable: true, value: {} }) + usePluginLanguagePackStore.setState({ entries: [], loaded: true }) + useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'fr' } }) + useLanguageReadiness.setState({ ready: false, error: false }) + function OnboardingGate(): React.JSX.Element { + const ready = useLanguageReadiness((state) => state.ready) + return ready ?

Onboarding available

: + } + render( + + + + ) + expect(screen.getByRole('status').textContent).toContain('Loading language') + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(screen.getByText('Onboarding available')).toBeTruthy() + expect(useLanguageReadiness.getState()).toMatchObject({ ready: true, error: true }) + expect(useAppStore.getState().settings?.uiLanguage).toBe('fr') + expect(i18n.language).toBe('en') + await act(async () => { + catalog.finish({ default: { menu: { file: 'Fichier' } } }) + await import('./locales/fr.json') + await vi.advanceTimersByTimeAsync(0) + }) + expect(i18n.language).toBe('en') + expect(useLanguageReadiness.getState().error).toBe(true) + + await act(async () => { + useLanguageReadiness.getState().retryLanguageLoad() + }) + await act(async () => { + await vi.waitFor(() => expect(i18n.language).toBe('fr')) + }) + + expect(i18n.language).toBe('fr') + expect(useLanguageReadiness.getState().error).toBe(false) + expect(i18n.t('menu.file')).toBe('Fichier') +}) diff --git a/src/renderer/src/i18n/language-detection-reconciliation.test.tsx b/src/renderer/src/i18n/language-detection-reconciliation.test.tsx new file mode 100644 index 00000000000..358beccf736 --- /dev/null +++ b/src/renderer/src/i18n/language-detection-reconciliation.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { UiLanguageDetection } from '../../../shared/ui-locale' + +async function loadLanguageRuntime() { + const [constants, locale, store, packs, readiness, provider, rendererI18n, onboarding] = + await Promise.all([ + import('../../../shared/constants'), + import('../../../shared/ui-locale'), + import('@/store'), + import('@/store/plugin-language-packs'), + import('./language-readiness'), + import('./I18nProvider'), + import('./i18n'), + import('@/components/onboarding/OnboardingLanguageSwitcher') + ]) + return { + getDefaultSettings: constants.getDefaultSettings, + detectUiLanguage: locale.detectUiLanguage, + useAppStore: store.useAppStore, + usePluginLanguagePackStore: packs.usePluginLanguagePackStore, + useLanguageReadiness: readiness.useLanguageReadiness, + I18nProvider: provider.I18nProvider, + i18n: rendererI18n.i18n, + loadRendererUiLanguage: rendererI18n.loadRendererUiLanguage, + OnboardingLanguageSwitcher: onboarding.OnboardingLanguageSwitcher + } +} + +let runtime: Awaited> + +beforeEach(async () => { + vi.resetModules() + runtime = await loadLanguageRuntime() + await Promise.all([runtime.loadRendererUiLanguage('fr'), runtime.loadRendererUiLanguage('ja')]) + await runtime.i18n.changeLanguage('en') +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it.each(['system', 'explicit'] as const)( + 'reconciles late native detection while preserving the %s preference', + async (preference) => { + const { + getDefaultSettings, + detectUiLanguage, + useAppStore, + usePluginLanguagePackStore, + useLanguageReadiness, + I18nProvider, + i18n, + OnboardingLanguageSwitcher + } = runtime + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + Object.defineProperty(navigator, 'languages', { configurable: true, value: ['fr-FR'] }) + let finishDetection: (value: UiLanguageDetection) => void = () => {} + const nativeDetection = new Promise((resolve) => { + finishDetection = resolve + }) + let settings = getDefaultSettings('/tmp') + const save = vi.fn(async (updates: Partial) => { + settings = { ...settings, ...updates } + return settings + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { getSystemLanguageDetection: () => nativeDetection }, + settings: { set: save }, + telemetryTrack: vi.fn().mockResolvedValue(undefined) + } + }) + useAppStore.setState({ settings }) + usePluginLanguagePackStore.setState({ entries: [], loaded: true }) + function OnboardingGate(): React.JSX.Element { + const ready = useLanguageReadiness((state) => state.ready) + return ready ? ( + <> +

Onboarding available

+ + + ) : ( +

Waiting for language

+ ) + } + render( + + + + ) + + expect(screen.getByText('Waiting for language')).toBeTruthy() + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + + expect(screen.getByText('Onboarding available')).toBeTruthy() + expect(i18n.language).toBe('fr') + expect(screen.getByRole('radio', { name: 'Français' }).getAttribute('data-state')).toBe( + 'checked' + ) + if (preference === 'explicit') { + await act(async () => { + fireEvent.click(screen.getByRole('radio', { name: 'English' })) + }) + expect(useAppStore.getState().settings?.uiLanguage).toBe('en') + expect(i18n.language).toBe('en') + } + + await act(async () => finishDetection(detectUiLanguage(['ja-JP'], 'native'))) + + const expectedLocale = preference === 'system' ? 'ja' : 'en' + expect(i18n.language).toBe(expectedLocale) + expect(useAppStore.getState().settings?.uiLanguage).toBe( + preference === 'system' ? 'system' : 'en' + ) + expect(useLanguageReadiness.getState()).toMatchObject({ + ready: true, + error: false, + loading: false, + detection: { locale: 'ja', source: 'native' } + }) + expect( + screen + .getByRole('radio', { name: preference === 'system' ? '日本語' : 'English' }) + .getAttribute('data-state') + ).toBe('checked') + expect(screen.getByText(/日本語/, { selector: 'p' })).toBeTruthy() + expect(save).toHaveBeenCalledTimes(preference === 'system' ? 0 : 1) + } +) diff --git a/src/renderer/src/i18n/language-readiness.test.ts b/src/renderer/src/i18n/language-readiness.test.ts new file mode 100644 index 00000000000..dcb512bb2d4 --- /dev/null +++ b/src/renderer/src/i18n/language-readiness.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { isWebClientLocation } = vi.hoisted(() => ({ isWebClientLocation: vi.fn() })) +vi.mock('../lib/web-client-location', () => ({ isWebClientLocation })) + +beforeEach(() => { + vi.resetModules() + isWebClientLocation.mockReturnValue(false) + Object.defineProperty(navigator, 'languages', { configurable: true, value: ['fr-FR', 'en-US'] }) +}) + +function installBridge( + getSystemLanguageDetection?: (browserPreferences?: readonly string[]) => Promise +): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { app: { getSystemLanguageDetection } } + }) +} + +afterEach(() => vi.useRealTimers()) + +describe('client-local startup language', () => { + it('uses the local native snapshot over Chromium preferences and freezes it for startup', async () => { + const probe = vi.fn().mockResolvedValue({ + detectedTag: 'de-DE', + status: 'unsupported', + locale: 'en', + source: 'native' + }) + installBridge(probe) + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ + detectedTag: 'de-DE', + source: 'native' + }) + await initializeLanguageDetection() + expect(probe).toHaveBeenCalledTimes(1) + expect(probe).toHaveBeenCalledWith(['fr-FR']) + }) + + it('does not replace a C-only native result with browser English', async () => { + installBridge( + vi + .fn() + .mockResolvedValue({ detectedTag: null, status: 'unknown', locale: 'en', source: 'native' }) + ) + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ + status: 'unknown', + source: 'native' + }) + }) + + it('uses uncertain browser fallback when an older bridge lacks the optional snapshot', async () => { + installBridge() + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ + detectedTag: 'fr-FR', + locale: 'fr', + source: 'browser-fallback' + }) + }) + + it('shares the main-owned browser fallback when native detection is unavailable', async () => { + const probe = vi.fn().mockResolvedValue({ + detectedTag: 'ja-JP', + status: 'supported', + locale: 'ja', + source: 'browser-fallback' + }) + installBridge(probe) + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ + detectedTag: 'ja-JP', + locale: 'ja', + source: 'browser-fallback' + }) + expect(probe).toHaveBeenCalledWith(['fr-FR']) + }) + + it('uses browser preferences on web/mobile without asking the server for its locale', async () => { + isWebClientLocation.mockReturnValue(true) + const probe = vi.fn() + installBridge(probe) + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ locale: 'fr', source: 'browser' }) + expect(probe).not.toHaveBeenCalled() + }) +}) + +it('bounds startup with browser fallback and reconciles a late native result', async () => { + vi.useFakeTimers() + let finish: (value: unknown) => void = () => {} + installBridge( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const { initializeLanguageDetection, useLanguageReadiness } = await import('./language-readiness') + const detection = initializeLanguageDetection() + await vi.advanceTimersByTimeAsync(1000) + expect(await detection).toMatchObject({ locale: 'fr', source: 'browser-fallback' }) + finish({ detectedTag: 'ja-JP', locale: 'ja', status: 'supported', source: 'native' }) + await Promise.resolve() + expect(useLanguageReadiness.getState().detection).toMatchObject({ + locale: 'ja', + source: 'native' + }) + const { getRendererLanguageDetection } = await import('../../../shared/ui-locale') + expect(getRendererLanguageDetection().locale).toBe('ja') + expect(await initializeLanguageDetection()).toMatchObject({ locale: 'ja', source: 'native' }) +}) + +it('uses browser fallback when native IPC rejects', async () => { + installBridge(() => Promise.reject(new Error('Native locale API unavailable'))) + const { initializeLanguageDetection } = await import('./language-readiness') + expect(await initializeLanguageDetection()).toMatchObject({ + locale: 'fr', + source: 'browser-fallback' + }) +}) diff --git a/src/renderer/src/i18n/language-readiness.ts b/src/renderer/src/i18n/language-readiness.ts new file mode 100644 index 00000000000..49a45123c6d --- /dev/null +++ b/src/renderer/src/i18n/language-readiness.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand' +import { + getBrowserLanguageDetection, + setRendererLanguageDetection, + type UiLanguageDetection +} from '../../../shared/ui-locale' +import { isWebClientLocation } from '../lib/web-client-location' +import { withTimeout } from '../../../shared/promise-timeout-fallback' + +export const useLanguageReadiness = create<{ + ready: boolean + error: boolean + loading: boolean + detection: UiLanguageDetection | null + retryCount: number + retryLanguageLoad: () => void +}>()((set) => ({ + ready: false, + error: false, + loading: false, + detection: null, + retryCount: 0, + retryLanguageLoad: () => + set((state) => (state.loading ? state : { loading: true, retryCount: state.retryCount + 1 })) +})) + +let detectionPromise: Promise | undefined + +function publishLanguageDetection(detection: UiLanguageDetection): UiLanguageDetection { + setRendererLanguageDetection(detection) + useLanguageReadiness.setState({ detection }) + return detection +} + +export function initializeLanguageDetection(): Promise { + const currentDetection = useLanguageReadiness.getState().detection + if (currentDetection) { + return Promise.resolve(currentDetection) + } + detectionPromise ??= (async () => { + let detection: UiLanguageDetection | null = null + const browserClient = isWebClientLocation() + const browserDetection = getBrowserLanguageDetection( + browserClient ? 'browser' : 'browser-fallback' + ) + if (!browserClient) { + try { + const snapshot = window.api?.app?.getSystemLanguageDetection?.( + browserDetection.detectedTag ? [browserDetection.detectedTag] : [] + ) + if (snapshot) { + detection = await withTimeout( + snapshot.then((nativeDetection) => + nativeDetection ? publishLanguageDetection(nativeDetection) : null + ), + 1000, + null + ) + } + } catch { + // Older local bridges do not provide the optional snapshot. + } + } + detection ??= useLanguageReadiness.getState().detection ?? browserDetection + return publishLanguageDetection(detection) + })() + return detectionPromise +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7f5a2b06097..95f72406d7c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20969,5 +20969,26 @@ "unavailable": "This workspace is protected or unavailable." }, "rowActions": "Actions for {{name}}" + }, + "onboarding": { + "language": { + "back": "Languages", + "requestPrompt": "Which language would you like Orca to support?", + "requestSuccess": "Language request recorded. You can continue onboarding.", + "requestError": "Could not record your request. Try again.", + "retry": "Retry", + "requestAction": "Request language", + "title": "Display language", + "device": "System language: {{language}}", + "unsupported": "System language: {{language}}. This language is not supported yet.", + "unknown": "Your system language could not be detected. Choose a language below.", + "loadError": "Could not load this language. Try again.", + "saveError": "Could not save your language preference. Try again.", + "other": "Other language…", + "settingsError": "Could not load your settings.", + "loadingSettings": "Loading settings…", + "loading": "Loading language…", + "saving": "Saving language…" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 950f526047c..3857cbe56f7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -20623,5 +20623,26 @@ "truncated": "No se pudieron mostrar algunos favoritos. Gestiona tus favoritos en Jira para acortar la lista.", "unsupported": "Actualiza el host de Jira para usar los favoritos." } + }, + "onboarding": { + "language": { + "back": "Idiomas", + "requestPrompt": "¿Qué idioma te gustaría que Orca admitiera?", + "requestSuccess": "Solicitud de idioma registrada. Puedes continuar la configuración.", + "requestError": "No se pudo registrar tu solicitud. Inténtalo de nuevo.", + "retry": "Reintentar", + "requestAction": "Solicitar idioma", + "title": "Idioma de la interfaz", + "device": "Idioma del sistema: {{language}}", + "unsupported": "Idioma del sistema: {{language}}. Este idioma aún no es compatible.", + "unknown": "No se pudo detectar el idioma del sistema. Elige uno a continuación.", + "loadError": "No se pudo cargar este idioma. Inténtalo de nuevo.", + "saveError": "No se pudo guardar tu preferencia de idioma. Inténtalo de nuevo.", + "other": "Otro idioma…", + "settingsError": "No se pudo cargar tu configuración.", + "loadingSettings": "Cargando configuración…", + "loading": "Cargando idioma…", + "saving": "Guardando idioma…" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2f92ea67b69..a4b34c15432 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -20635,5 +20635,26 @@ "preparingLabel": "Préparation de la mise à jour", "preparingAriaLabel": "Préparation de la mise à jour. Cliquez pour développer." } + }, + "onboarding": { + "language": { + "back": "Langues", + "requestPrompt": "Quelle langue aimeriez-vous voir prise en charge par Orca ?", + "requestSuccess": "Demande de langue enregistrée. Vous pouvez poursuivre la configuration.", + "requestError": "Impossible d’enregistrer votre demande. Réessayez.", + "retry": "Réessayer", + "requestAction": "Demander une langue", + "title": "Langue d’affichage", + "device": "Langue du système : {{language}}", + "unsupported": "Langue du système : {{language}}. Cette langue n’est pas encore prise en charge.", + "unknown": "La langue du système n’a pas pu être détectée. Choisissez une langue ci-dessous.", + "loadError": "Impossible de charger cette langue. Réessayez.", + "saveError": "Impossible d’enregistrer votre préférence de langue. Réessayez.", + "other": "Autre langue…", + "settingsError": "Impossible de charger vos paramètres.", + "loadingSettings": "Chargement des paramètres…", + "loading": "Chargement de la langue…", + "saving": "Enregistrement de la langue…" + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1d47775a7dd..519bc6872a5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20635,5 +20635,26 @@ "preparingLabel": "更新を準備中", "preparingAriaLabel": "更新を準備中。クリックして展開します。" } + }, + "onboarding": { + "language": { + "back": "言語", + "requestPrompt": "Orcaに対応してほしい言語は何ですか?", + "requestSuccess": "言語のリクエストを記録しました。設定を続けられます。", + "requestError": "リクエストを記録できませんでした。再試行してください。", + "retry": "再試行", + "requestAction": "言語をリクエスト", + "title": "表示言語", + "device": "システム言語:{{language}}", + "unsupported": "システム言語:{{language}}。この言語はまだサポートされていません。", + "unknown": "システム言語を検出できませんでした。以下から選択してください。", + "loadError": "この言語を読み込めませんでした。再試行してください。", + "saveError": "言語設定を保存できませんでした。再試行してください。", + "other": "その他の言語…", + "settingsError": "設定を読み込めませんでした。", + "loadingSettings": "設定を読み込み中…", + "loading": "言語を読み込み中…", + "saving": "言語を保存中…" + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5bb9bbd4837..fd8d9392801 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20635,5 +20635,26 @@ "preparingLabel": "업데이트 준비 중", "preparingAriaLabel": "업데이트 준비 중입니다. 클릭하여 펼치세요." } + }, + "onboarding": { + "language": { + "back": "언어", + "requestPrompt": "Orca에서 어떤 언어를 지원하면 좋을까요?", + "requestSuccess": "언어 요청이 기록되었습니다. 설정을 계속할 수 있습니다.", + "requestError": "요청을 기록하지 못했습니다. 다시 시도하세요.", + "retry": "다시 시도", + "requestAction": "언어 지원 요청", + "title": "표시 언어", + "device": "시스템 언어: {{language}}", + "unsupported": "시스템 언어: {{language}}. 이 언어는 아직 지원되지 않습니다.", + "unknown": "시스템 언어를 감지하지 못했습니다. 아래에서 언어를 선택하세요.", + "loadError": "이 언어를 불러오지 못했습니다. 다시 시도하세요.", + "saveError": "언어 설정을 저장하지 못했습니다. 다시 시도하세요.", + "other": "다른 언어…", + "settingsError": "설정을 불러오지 못했습니다.", + "loadingSettings": "설정 불러오는 중…", + "loading": "언어 불러오는 중…", + "saving": "언어 저장 중…" + } } } diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 35fe1d1e256..27196782406 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -20888,5 +20888,26 @@ "unavailable": "Este espaço de trabalho está protegido ou indisponível." }, "rowActions": "Ações para {{name}}" + }, + "onboarding": { + "language": { + "back": "Idiomas", + "requestPrompt": "Qual idioma você gostaria que o Orca oferecesse?", + "requestSuccess": "Solicitação de idioma registrada. Você pode continuar a configuração.", + "requestError": "Não foi possível registrar sua solicitação. Tente novamente.", + "retry": "Tentar novamente", + "requestAction": "Solicitar idioma", + "title": "Idioma de exibição", + "device": "Idioma do sistema: {{language}}", + "unsupported": "Idioma do sistema: {{language}}. Este idioma ainda não é compatível.", + "unknown": "Não foi possível detectar o idioma do sistema. Escolha um idioma abaixo.", + "loadError": "Não foi possível carregar este idioma. Tente novamente.", + "saveError": "Não foi possível salvar sua preferência de idioma. Tente novamente.", + "other": "Outro idioma…", + "settingsError": "Não foi possível carregar suas configurações.", + "loadingSettings": "Carregando configurações…", + "loading": "Carregando idioma…", + "saving": "Salvando idioma…" + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c83cda6b237..7346dc7b66e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20635,5 +20635,26 @@ "preparingLabel": "正在准备更新", "preparingAriaLabel": "正在准备更新。点击展开。" } + }, + "onboarding": { + "language": { + "back": "语言", + "requestPrompt": "你希望 Orca 支持哪种语言?", + "requestSuccess": "语言请求已记录。你可以继续设置。", + "requestError": "无法记录你的请求。请重试。", + "retry": "重试", + "requestAction": "请求语言支持", + "title": "显示语言", + "device": "系统语言:{{language}}", + "unsupported": "系统语言:{{language}}。暂不支持此语言。", + "unknown": "无法检测系统语言。请在下方选择语言。", + "loadError": "无法加载此语言。请重试。", + "saveError": "无法保存你的语言偏好设置。请重试。", + "other": "其他语言…", + "settingsError": "无法加载你的设置。", + "loadingSettings": "正在加载设置…", + "loading": "正在加载语言…", + "saving": "正在保存语言…" + } } } diff --git a/src/renderer/src/i18n/supported-languages.ts b/src/renderer/src/i18n/supported-languages.ts index d07bd4d49aa..c1ccd2ca8ed 100644 --- a/src/renderer/src/i18n/supported-languages.ts +++ b/src/renderer/src/i18n/supported-languages.ts @@ -53,3 +53,7 @@ export function getUiLanguageChoiceLabel( export function resolveUiLocale(language: UiLanguage): string { return resolveRendererUiLocale(language) } + +export function getUiLanguageNativeLabel(language: BuiltInUiLanguage): string { + return UI_LANGUAGE_CHOICE_FALLBACKS[language] +} diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 46380e7eaa4..0b249e37c53 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -1,3 +1,4 @@ +import type { TelemetrySettings } from './telemetry-settings-types' import type { GlobalSettingsBase } from './global-settings-base' import type { ExecutionHostId } from './execution-host' import type { OrcaWorkspaceLayout } from './orca-workspace-layout' @@ -525,15 +526,8 @@ export type GlobalSettings = GlobalSettingsBase & { /** GitLab project preferences (pinned + recent paths). Optional for pre-GitLab profiles; persistence merge fills the default. */ gitlabProjects?: GitLabProjectSettings /** Anonymous product-telemetry state; optional until the one-shot Store.load() migration populates it. - * Holds only consent + identity, not volatile counters — those would amplify the debounced settings write. */ - telemetry?: { - /** New users: true at install. Existing users: null until they resolve the first-launch banner. */ - optedIn: boolean | null - /** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */ - installId: string - /** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */ - existedBeforeTelemetryRelease: boolean - } + * Holds consent, identity, and frozen experiments, not volatile counters — those would amplify the debounced settings write. */ + telemetry?: TelemetrySettings /** One-shot cohort marker for the tab-switch keybinding swap. 'pending' = * pre-existing install (seed pins old chords, then flips to 'done'); 'done' = fresh install. */ tabSwitchKeybindingSeed?: 'pending' | 'done' diff --git a/src/shared/telemetry-event-classification.ts b/src/shared/telemetry-event-classification.ts index cc932fe9411..f2d18783e1f 100644 --- a/src/shared/telemetry-event-classification.ts +++ b/src/shared/telemetry-event-classification.ts @@ -129,7 +129,8 @@ export const commonPropsSchema = z // `.min(1)`: an empty install_id/session_id would collapse unrelated events into one synthetic user/session, corrupting analytics. install_id: z.string().min(1).max(64), session_id: z.string().min(1).max(64), - orca_channel: z.enum(['stable', 'rc']) + orca_channel: z.enum(['stable', 'rc']), + onboarding_language_v1: z.enum(['control', 'treatment']).optional() }) .strict() export type CommonProps = z.infer diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 53623c232ef..395c1f5f6d7 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -1,3 +1,8 @@ +import { + onboardingLanguagePickerOpenedSchema, + onboardingLanguageChangedSchema, + onboardingLanguageRequestedSchema +} from './telemetry-onboarding-language-schemas' import { agentTokenUsageSchema } from './telemetry-agent-token-usage-schema' import { agentErrorSchema, @@ -169,6 +174,9 @@ export const eventSchemas = { feature_wall_feature_selected: featureWallFeatureSelectedSchema, feature_wall_docs_clicked: featureWallDocsClickedSchema, + onboarding_language_picker_opened: onboardingLanguagePickerOpenedSchema, + onboarding_language_changed: onboardingLanguageChangedSchema, + onboarding_language_requested: onboardingLanguageRequestedSchema, onboarding_started: onboardingStartedSchema, onboarding_step_viewed: onboardingStepViewedSchema, onboarding_step_completed: onboardingStepCompletedSchema, diff --git a/src/shared/telemetry-onboarding-language-schemas.ts b/src/shared/telemetry-onboarding-language-schemas.ts new file mode 100644 index 00000000000..9c840ec62de --- /dev/null +++ b/src/shared/telemetry-onboarding-language-schemas.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +const properties = { + system_language: z.string().max(128).nullable(), + system_language_supported: z.boolean(), + detection_source: z.enum(['native', 'browser', 'browser-fallback']), + effective_language: z.string().max(256) +} + +export const onboardingLanguagePickerOpenedSchema = z.object(properties).strict() +export const onboardingLanguageChangedSchema = z + .object({ + ...properties, + selected_language: z.string().max(256) + }) + .strict() +export const onboardingLanguageRequestedSchema = z.object(properties).strict() diff --git a/src/shared/telemetry-settings-types.ts b/src/shared/telemetry-settings-types.ts new file mode 100644 index 00000000000..919244ece20 --- /dev/null +++ b/src/shared/telemetry-settings-types.ts @@ -0,0 +1,10 @@ +export type TelemetrySettings = { + /** New users: true at install. Existing users: null until they resolve the first-launch banner. */ + optedIn: boolean | null + /** Anonymous UUID v4. Generated on first run. Stable across launches; not surfaced in the UI. */ + installId: string + /** Cohort marker: true for pre-existing profiles (gates the opt-in banner), false for fresh installs. */ + existedBeforeTelemetryRelease: boolean + /** Frozen 50/50 assignment for fresh installs; absent for profiles predating the experiment. */ + onboardingLanguageCohort?: 'control' | 'treatment' +} diff --git a/src/shared/ui-locale.test.ts b/src/shared/ui-locale.test.ts index 7d231ad52d3..310d6cfe748 100644 --- a/src/shared/ui-locale.test.ts +++ b/src/shared/ui-locale.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { normalizeSupportedUiLocale, resolveUiLocale } from './ui-locale' +import { + normalizeSupportedUiLocale, + resolveUiLocale, + detectUiLanguage, + normalizeUiLocaleTag, + shouldShowLanguageSwitcher +} from './ui-locale' import { UI_LANGUAGE_CHINESE, UI_LANGUAGE_ENGLISH, @@ -79,3 +85,44 @@ describe('ui-locale', () => { expect(resolveUiLocale(UI_LANGUAGE_SYSTEM, 'pt-BR')).toBe('pt') }) }) + +describe('system language detection', () => { + it.each([ + ['en-US', 'en', 'supported'], + ['fr_CA.UTF-8', 'fr', 'supported'], + ['pt-PT', 'pt', 'supported'], + ['zh-Hans-TW', 'zh', 'supported'], + ['zh-Hant-CN', 'en', 'unsupported'], + ['zh-Latn-CN', 'en', 'unsupported'], + ['zh-MO', 'en', 'unsupported'], + ['zh_CN.UTF-8@hant', 'en', 'unsupported'], + ['zh_TW.UTF-8@hans', 'zh', 'supported'], + ['de-DE', 'en', 'unsupported'], + ['en--US', 'en', 'unknown'], + ['C.UTF-8', 'en', 'unknown'], + ['POSIX', 'en', 'unknown'] + ])('resolves %s without confusing a fallback with detected English', (tag, locale, status) => { + expect(detectUiLanguage([tag], 'native')).toMatchObject({ locale, status }) + }) + + it('retains an unsupported primary instead of using a supported secondary', () => { + expect(detectUiLanguage(['de-DE', 'fr-FR', 'C'], 'native')).toMatchObject({ + detectedTag: 'de-DE', + locale: 'en', + status: 'unsupported' + }) + }) + + it('hides only confident English and respects language order', () => { + const english = detectUiLanguage(['en-GB', 'zh-CN'], 'native') + expect(shouldShowLanguageSwitcher(english)).toBe(false) + expect(shouldShowLanguageSwitcher(detectUiLanguage(['en'], 'browser-fallback'))).toBe(true) + expect(shouldShowLanguageSwitcher(detectUiLanguage(['C', 'garbage!'], 'native'))).toBe(true) + expect(shouldShowLanguageSwitcher(detectUiLanguage(['zh-CN', 'en-GB'], 'native'))).toBe(true) + }) + + it('preserves script modifiers while normalizing POSIX locales', () => { + expect(normalizeUiLocaleTag('sr_RS.UTF-8@latin')).toBe('sr-Latn-RS') + expect(normalizeUiLocaleTag('sr_RS@cyrillic')).toBe('sr-Cyrl-RS') + }) +}) diff --git a/src/shared/ui-locale.ts b/src/shared/ui-locale.ts index 5a5f0b7f888..091a33cc451 100644 --- a/src/shared/ui-locale.ts +++ b/src/shared/ui-locale.ts @@ -16,22 +16,90 @@ export type SupportedUiLocale = (typeof SUPPORTED_UI_LOCALES)[number] export const DEFAULT_UI_LOCALE: SupportedUiLocale = 'en' -function normalizeLocaleTag(locale: string | undefined): string { - return (locale ?? DEFAULT_UI_LOCALE).trim().toLowerCase().replace(/_/g, '-') +export type UiLanguageDetection = { + detectedTag: string | null + status: 'supported' | 'unsupported' | 'unknown' + locale: SupportedUiLocale + source: 'native' | 'browser' | 'browser-fallback' +} + +export function normalizeUiLocaleTag(value: string | undefined): string | null { + if (!value || value.length > 128) { + return null + } + const [rawTag, modifier] = value.trim().split('@') + const tag = rawTag.replace(/\.[A-Za-z0-9-]+$/, '').replace(/_/g, '-') + if (/^(c|posix)$/i.test(tag)) { + return null + } + try { + const locale = new Intl.Locale(tag) + const script = Object.entries({ + hans: 'Hans', + hant: 'Hant', + latin: 'Latn', + cyrillic: 'Cyrl' + }).find(([name]) => name === modifier?.toLowerCase())?.[1] + return new Intl.Locale(locale, script ? { script } : undefined).baseName + } catch { + return null + } +} + +function matchUiLocale(tag: string): SupportedUiLocale | null { + const locale = new Intl.Locale(tag) + if (locale.language === 'zh') { + if (locale.script) { + return locale.script === 'Hans' ? 'zh' : null + } + return ['TW', 'HK', 'MO'].includes(locale.region ?? '') ? null : 'zh' + } + return SUPPORTED_UI_LOCALES.find((supported) => supported === locale.language) ?? null +} + +export function detectUiLanguage( + preferences: readonly string[], + source: UiLanguageDetection['source'] +): UiLanguageDetection { + const detectedTag = preferences.map(normalizeUiLocaleTag).find((tag) => tag !== null) ?? null + const match = detectedTag ? matchUiLocale(detectedTag) : null + return { + detectedTag, + status: detectedTag ? (match ? 'supported' : 'unsupported') : 'unknown', + locale: match ?? DEFAULT_UI_LOCALE, + source + } } export function normalizeSupportedUiLocale(locale: string | undefined): SupportedUiLocale { - const tag = normalizeLocaleTag(locale) - const primary = tag.split('-')[0] - if (primary === 'zh') { - if (tag.startsWith('zh-tw') || tag.startsWith('zh-hk') || tag.startsWith('zh-hant')) { - return DEFAULT_UI_LOCALE - } - return 'zh' - } - return SUPPORTED_UI_LOCALES.includes(primary as SupportedUiLocale) - ? (primary as SupportedUiLocale) - : DEFAULT_UI_LOCALE + return detectUiLanguage(locale ? [locale] : [], 'browser').locale +} + +let rendererDetection: UiLanguageDetection | null = null + +export function setRendererLanguageDetection(detection: UiLanguageDetection): void { + rendererDetection = detection +} + +export function getBrowserLanguageDetection( + source: UiLanguageDetection['source'] = 'browser' +): UiLanguageDetection { + return detectUiLanguage( + typeof navigator === 'undefined' ? [] : [...(navigator.languages ?? []), navigator.language], + source + ) +} + +export function getRendererLanguageDetection(): UiLanguageDetection { + return rendererDetection ?? getBrowserLanguageDetection() +} + +export function shouldShowLanguageSwitcher(detection: UiLanguageDetection): boolean { + return ( + detection.source === 'browser-fallback' || + detection.status !== 'supported' || + detection.locale !== 'en' + ) } export function resolveUiLocale( @@ -66,10 +134,7 @@ export function resolveUiLocale( } export function getRendererSystemLocale(): string { - if (typeof navigator !== 'undefined' && navigator.language) { - return navigator.language - } - return DEFAULT_UI_LOCALE + return getRendererLanguageDetection().locale } export function resolveRendererUiLocale(language: UiLanguage): string {