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
This commit is contained in:
Jinjing
2026-10-21 19:43:22 -07:00
committed by GitHub
parent 95d9682b0c
commit a53c2a2a72
44 changed files with 2396 additions and 114 deletions
@@ -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
}
]
+22 -1
View File
@@ -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<void>((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([
+8 -9
View File
@@ -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<string>()
@@ -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<I18nInstance> {
@@ -83,6 +80,7 @@ export async function ensureMainI18n(): Promise<I18nInstance> {
}
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
const request = ++languageGeneration
await ensureMainI18n()
const selectedLocale = resolveUiLocale(
language,
@@ -92,9 +90,10 @@ export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
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
+84
View File
@@ -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 })
})
+24
View File
@@ -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
}
+78 -1
View File
@@ -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<typeof AppRelaunch>()),
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<string>((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)
+21 -1
View File
@@ -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())
@@ -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<typeof crypto>()),
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()
})
})
@@ -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<typeof crypto>()),
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)
})
})
@@ -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:
@@ -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)
}
}
)
})
+5
View File
@@ -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)
+4
View File
@@ -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<UiLanguageDetection | null>
/** A local profile storage operation is delayed and holds up subsequent saves. */
isProfileStateSaveDelayed: () => Promise<boolean>
onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void) => () => void
+2
View File
@@ -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<boolean> =>
ipcRenderer.invoke('app:isProfileStateSaveDelayed'),
onProfileStateSaveDelayChanged: (callback: (delayed: boolean) => void): (() => void) => {
+12 -28
View File
@@ -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 (
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
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.'
)}
>
<OnboardingFlow
onboarding={onboardingGate.onboarding}
onOnboardingChange={onboardingGate.setOnboarding}
/>
{languageReady ? (
<OnboardingFlow
onboarding={onboardingGate.onboarding}
onOnboardingChange={onboardingGate.setOnboarding}
/>
) : (
<OnboardingLanguageLoading />
)}
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
@@ -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 (
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
export function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
@@ -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 (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
@@ -226,16 +231,22 @@ export default function OnboardingFlow({
)}
>
<div className="relative flex h-full min-h-0 flex-col px-6 pb-6 pt-8 sm:px-8 sm:pb-8 sm:pt-9">
<div className="flex items-center gap-3 text-base font-semibold tracking-tight">
<img
src={logo}
alt=""
aria-hidden="true"
className="h-7 w-auto shrink-0 invert dark:invert-0"
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-base font-semibold tracking-tight">
<img
src={logo}
alt=""
aria-hidden="true"
className="h-7 w-auto shrink-0 invert dark:invert-0"
/>
<span>
{translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')}
</span>
</div>
<OnboardingLanguageSwitcher
open={languagePickerOpen}
onOpenChange={setLanguagePickerOpen}
/>
<span>
{translate('auto.components.onboarding.OnboardingFlow.a249f81538', 'Orca')}
</span>
</div>
<div className="mt-10 flex items-center gap-2 transition-[margin-top] duration-[760ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none">
@@ -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(<OnboardingLanguageLoading />)
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()
}
})
@@ -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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background text-foreground"
aria-busy={settings !== null || retrying}
>
{settings === null && !retrying ? (
<div className="space-y-3 text-center">
<p className="text-sm">
{translate('onboarding.language.settingsError', 'Could not load your settings.')}
</p>
<Button
disabled={retrying}
onClick={() => {
setRetrying(true)
void fetchSettings({ deferOwnerWorktreeVisibilityDefaults: true }).finally(() =>
setRetrying(false)
)
}}
>
{translate('onboarding.language.retry', 'Retry')}
</Button>
</div>
) : (
<div role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<LoadingSpinner className="size-4" />
{settings === null
? translate('onboarding.language.loadingSettings', 'Loading settings…')
: translate('onboarding.language.loading', 'Loading language…')}
</div>
)}
</div>
)
}
@@ -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<void> {
render(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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(<OnboardingLanguageSwitcher open={false} onOpenChange={vi.fn()} />)
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<GlobalSettings>) => {
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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 (
<OnboardingLanguageSwitcher
open={open}
onOpenChange={(next) => {
setOpen(next)
onOpenChange(next)
}}
/>
)
}
const user = userEvent.setup()
render(<Picker />)
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(
<I18nProvider>
<OnboardingLanguageSwitcher open onOpenChange={onOpenChange} />
</I18nProvider>
)
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<ReturnType<typeof useAppStore.getState>['updateSettingsOrThrow']>(
async (change) => {
await new Promise<void>((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 <OnboardingLanguageSwitcher open={open} onOpenChange={setOpen} />
}
const user = userEvent.setup()
render(<Picker />)
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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<typeof newerSettings>((resolve) => {
finishSave = resolve
})
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { feedback: { submit }, telemetryTrack, settings: { set: save } }
})
render(
<I18nProvider>
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
</I18nProvider>
)
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(
<I18nProvider>
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
</I18nProvider>
)
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
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(<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />)
expect(screen.getAllByRole('radio')).toHaveLength(7)
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
expect(save).not.toHaveBeenCalled()
})
})
@@ -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<string>())
const contentRef = useRef<HTMLDivElement>(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<void> => {
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<void> => {
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="shrink-0" lang="en">
<Globe />
<span>Switch language</span>
<ChevronDown className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent
ref={contentRef}
align="end"
aria-label={translate('onboarding.language.title', 'Display language')}
className="z-[110] w-80 max-w-[calc(100vw-2rem)]"
wheelScroll
onOpenAutoFocus={(event) => {
event.preventDefault()
const content = contentRef.current
const selected =
content?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]') ??
content?.querySelector<HTMLElement>('[role="radio"]')
selected?.focus()
}}
>
<div className="scrollbar-sleek max-h-[var(--radix-popover-content-available-height)] overflow-y-auto p-4">
{requestView ? (
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
void submitRequest()
}}
>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => {
setRequestView(false)
requestAnimationFrame(() =>
contentRef.current
?.querySelector<HTMLElement>('[role="radio"][data-state="checked"]')
?.focus()
)
}}
>
<ArrowLeft />
{translate('onboarding.language.back', 'Languages')}
</Button>
<Label htmlFor="onboarding-language-request">
{translate(
'onboarding.language.requestPrompt',
'Which language would you like Orca to support?'
)}
</Label>
<Input
id="onboarding-language-request"
autoFocus
maxLength={80}
value={draft}
disabled={busy || requestState === 'pending'}
onChange={(event) => {
setDraft(event.target.value)
setRequestState('idle')
}}
/>
{requestState === 'success' ? (
<p role="status" className="text-xs text-muted-foreground">
{translate(
'onboarding.language.requestSuccess',
'Language request recorded. You can continue onboarding.'
)}
</p>
) : (
<>
{requestState === 'error' && (
<p role="alert" className="text-xs text-destructive">
{translate(
'onboarding.language.requestError',
'Could not record your request. Try again.'
)}
</p>
)}
<Button
type="submit"
size="sm"
disabled={
!draft.trim() || draft.trim().length > 80 || requestState === 'pending'
}
>
{requestState === 'error'
? translate('onboarding.language.retry', 'Retry')
: translate('onboarding.language.requestAction', 'Request language')}
</Button>
</>
)}
</form>
) : (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">
{translate('onboarding.language.title', 'Display language')}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<RadioGroup
value={language === 'system' ? detection.locale : language}
aria-busy={busy}
aria-disabled={busy}
onKeyDownCapture={(event) => {
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) => (
<div key={locale} className="rounded-md px-2 py-2 hover:bg-accent">
<Label className="cursor-pointer">
<RadioGroupItem
value={locale}
onClick={() => {
if (language === 'system' && locale === detection.locale) {
void chooseLanguage(locale)
}
}}
/>
<span>{getUiLanguageNativeLabel(locale)}</span>
</Label>
</div>
))}
</RadioGroup>
<p role="status" className="h-4 text-xs text-muted-foreground">
{selectionPhase === 'save'
? translate('onboarding.language.saving', 'Saving language…')
: busy
? translate('onboarding.language.loading', 'Loading language…')
: null}
</p>
{(selectionFailure !== null || catalogError) && (
<div role="alert" className="space-y-2">
<p className="text-xs text-destructive">
{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.'
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
if (selectionFailure) {
void chooseLanguage(selectionFailure.language)
} else {
useLanguageReadiness.getState().retryLanguageLoad()
}
}}
>
{translate('onboarding.language.retry', 'Retry')}
</Button>
</div>
)}
{canRequestLanguage && (
<div className="border-t border-border pt-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
disabled={busy}
onClick={() => setRequestView(true)}
>
{translate('onboarding.language.other', 'Other language…')}
</Button>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -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<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-2', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'size-4 shrink-0 rounded-full border border-input text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="size-2 fill-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+138 -2
View File
@@ -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<void> {
@@ -37,6 +46,13 @@ async function renderProvider(): Promise<void> {
}
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<string>((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')
})
+45 -26
View File
@@ -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<string | null>(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 <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
}
+15 -3
View File
@@ -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<void> {
export async function loadRendererUiLanguage(language: UiLanguage): Promise<string> {
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<void> {
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)
}
}
@@ -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<string, unknown> }) => void = () => {}
const promise = new Promise<{ default: Record<string, unknown> }>((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 ? <p>Onboarding available</p> : <OnboardingLanguageLoading />
}
render(
<I18nProvider>
<OnboardingGate />
</I18nProvider>
)
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')
})
@@ -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<ReturnType<typeof loadLanguageRuntime>>
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<UiLanguageDetection>((resolve) => {
finishDetection = resolve
})
let settings = getDefaultSettings('/tmp')
const save = vi.fn(async (updates: Partial<GlobalSettings>) => {
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 ? (
<>
<p>Onboarding available</p>
<OnboardingLanguageSwitcher open onOpenChange={vi.fn()} />
</>
) : (
<p>Waiting for language</p>
)
}
render(
<I18nProvider>
<OnboardingGate />
</I18nProvider>
)
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)
}
)
@@ -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<unknown>
): 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'
})
})
@@ -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<UiLanguageDetection> | undefined
function publishLanguageDetection(detection: UiLanguageDetection): UiLanguageDetection {
setRendererLanguageDetection(detection)
useLanguageReadiness.setState({ detection })
return detection
}
export function initializeLanguageDetection(): Promise<UiLanguageDetection> {
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<UiLanguageDetection | null>(
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
}
+21
View File
@@ -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…"
}
}
}
+21
View File
@@ -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…"
}
}
}
+21
View File
@@ -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…"
}
}
}
+21
View File
@@ -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": "言語を保存中…"
}
}
}
+21
View File
@@ -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": "언어 저장 중…"
}
}
}
+21
View File
@@ -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…"
}
}
}
+21
View File
@@ -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": "正在保存语言…"
}
}
}
@@ -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]
}
+3 -9
View File
@@ -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'
+2 -1
View File
@@ -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<typeof commonPropsSchema>
+8
View File
@@ -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,
@@ -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()
+10
View File
@@ -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'
}
+48 -1
View File
@@ -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')
})
})
+82 -17
View File
@@ -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 {