import { Picker, useDesignSystemComponent, } from "@agent-native/toolkit/design-system"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { IconCheck, IconChevronDown, IconLanguage } from "@tabler/icons-react"; import i18next, { type i18n as I18nInstance } from "i18next"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { I18nextProvider, initReactI18next, useTranslation, } from "react-i18next"; import defaultEnglishMessages from "../localization/default-messages.js"; import { DEFAULT_LOCALE, LOCALE_HYDRATION_GLOBAL, LOCALE_METADATA, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, localeDirection, normalizeLocalizationPreference, resolveLocaleFromCandidates, resolveLocaleFromPreference, type LocaleCode, type LocaleMetadata, type LocalePreference, type LocalizationPreference, } from "../localization/shared.js"; import { setClientAppState } from "./application-state.js"; import { callAction } from "./use-action.js"; import { cn } from "./utils.js"; export { DEFAULT_LOCALE, LOCALE_HYDRATION_GLOBAL, LOCALE_METADATA, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, localeDirection, normalizeLocaleCode, normalizeLocalePreference, normalizeLocalizationPreference, resolveLocaleFromCandidates, resolveLocaleFromPreference, type LocaleCode, type LocaleMetadata, type LocalePreference, type LocalizationPreference, } from "../localization/shared.js"; export { getLocaleInitScript } from "../localization/server.js"; export type LocaleMessages = Record; export interface LocaleHydrationPayload { locale?: LocaleCode; preference?: LocalizationPreference; dir?: "ltr" | "rtl"; messages?: LocaleMessages; } export interface AgentNativeI18nCatalog { namespace?: string; sourceLocale?: LocaleCode; messages?: LocaleMessages; loadMessages?: (locale: LocaleCode) => Promise; } export interface AgentNativeI18nProviderProps { children: React.ReactNode; catalog?: AgentNativeI18nCatalog; initialLocale?: LocaleCode; initialPreference?: LocalizationPreference | LocalePreference; initialMessages?: LocaleMessages | null; persistPreference?: boolean; } interface LocaleContextValue { locale: LocaleCode; sourceLocale: LocaleCode; preference: LocalePreference; dir: "ltr" | "rtl"; metadata: LocaleMetadata; setPreference: (preference: LocalePreference) => Promise; loading: boolean; } declare global { var __AGENT_NATIVE_LOCALE_CONTEXT__: | React.Context | undefined; interface Window { __AGENT_NATIVE_LOCALE__?: LocaleHydrationPayload; } } const LocaleContext = globalThis.__AGENT_NATIVE_LOCALE_CONTEXT__ ?? (globalThis.__AGENT_NATIVE_LOCALE_CONTEXT__ = createContext(null)); const LANGUAGE_PICKER_COPY: Record< LocaleCode, { label: string; system: string; systemDescription: string } > = { "en-US": { label: "Language", system: "System", systemDescription: "Use your browser language", }, "zh-CN": { label: "语言", system: "系统", systemDescription: "使用浏览器语言", }, "zh-TW": { label: "語言", system: "系統", systemDescription: "使用瀏覽器語言", }, "es-ES": { label: "Idioma", system: "Sistema", systemDescription: "Usar el idioma del navegador", }, "fr-FR": { label: "Langue", system: "Système", systemDescription: "Utiliser la langue du navigateur", }, "de-DE": { label: "Sprache", system: "System", systemDescription: "Browsersprache verwenden", }, "ja-JP": { label: "言語", system: "システム", systemDescription: "ブラウザーの言語を使用", }, "ko-KR": { label: "언어", system: "시스템", systemDescription: "브라우저 언어 사용", }, "pt-BR": { label: "Idioma", system: "Sistema", systemDescription: "Usar o idioma do navegador", }, "hi-IN": { label: "भाषा", system: "सिस्टम", systemDescription: "ब्राउज़र की भाषा का उपयोग करें", }, "ar-SA": { label: "اللغة", system: "النظام", systemDescription: "استخدام لغة المتصفح", }, }; function browserLanguageCandidates(): string[] { if (typeof navigator === "undefined") return []; return navigator.languages?.length ? [...navigator.languages] : navigator.language ? [navigator.language] : []; } function readStoredPreference(): LocalePreference | null { if (typeof window === "undefined") return null; try { return normalizeLocalizationPreference( window.localStorage.getItem(LOCALE_STORAGE_KEY), ).locale; } catch { return null; } } function writeStoredPreference(preference: LocalePreference) { if (typeof window === "undefined") return; try { window.localStorage.setItem(LOCALE_STORAGE_KEY, preference); } catch { // Ignore storage-denied browsers; the in-memory provider state still works. } } function readHydrationPayload(): LocaleHydrationPayload { if (typeof window === "undefined") return {}; return window[LOCALE_HYDRATION_GLOBAL] ?? {}; } function resolveInitialState(args: { initialLocale?: LocaleCode; initialPreference?: LocalizationPreference | LocalePreference; }): { locale: LocaleCode; preference: LocalePreference } { const hydration = readHydrationPayload(); const preference = normalizeLocalizationPreference( args.initialPreference ?? hydration.preference ?? readStoredPreference(), ).locale; const locale = args.initialLocale ?? hydration.locale ?? resolveLocaleFromPreference(preference, browserLanguageCandidates()); return { locale, preference }; } function normalizeLoadedMessages(value: unknown): LocaleMessages | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const maybeDefault = (value as { default?: unknown }).default; if ( maybeDefault && typeof maybeDefault === "object" && !Array.isArray(maybeDefault) ) { return maybeDefault as LocaleMessages; } return value as LocaleMessages; } function createI18nInstance(args: { namespace: string; sourceLocale: LocaleCode; messages: LocaleMessages; initialLocale: LocaleCode; initialMessages?: LocaleMessages | null; }): I18nInstance { const instance = i18next.createInstance(); const resources: Record> = { [args.sourceLocale]: { [args.namespace]: args.messages, }, }; if (args.initialLocale !== args.sourceLocale && args.initialMessages) { resources[args.initialLocale] = { [args.namespace]: args.initialMessages, }; } void instance.use(initReactI18next).init({ resources, lng: args.initialLocale, fallbackLng: args.sourceLocale, defaultNS: args.namespace, ns: [args.namespace], interpolation: { escapeValue: false }, react: { useSuspense: false }, returnNull: false, initAsync: false, }); return instance; } export function AgentNativeI18nProvider({ children, catalog, initialLocale, initialPreference, initialMessages, persistPreference = true, }: AgentNativeI18nProviderProps) { const namespace = catalog?.namespace ?? "translation"; const sourceLocale = catalog?.sourceLocale ?? DEFAULT_LOCALE; const sourceMessages = catalog?.messages ?? {}; const loadMessages = catalog?.loadMessages; const hydration = readHydrationPayload(); const initialState = useMemo( () => resolveInitialState({ initialLocale, initialPreference }), [initialLocale, initialPreference], ); const [preference, setPreferenceState] = useState( initialState.preference, ); const [locale, setLocale] = useState(initialState.locale); const [loading, setLoading] = useState(false); const i18nRef = useRef(null); if (!i18nRef.current) { const preloadedMessages = normalizeLoadedMessages( hydration.locale === initialState.locale && hydration.messages ? hydration.messages : initialMessages, ); i18nRef.current = createI18nInstance({ namespace, sourceLocale, messages: hydration.locale === sourceLocale && hydration.messages ? hydration.messages : sourceMessages, initialLocale: initialState.locale, initialMessages: preloadedMessages, }); } const i18n = i18nRef.current; useEffect(() => { if (i18n.hasResourceBundle(sourceLocale, namespace)) { i18n.addResourceBundle( sourceLocale, namespace, sourceMessages, true, true, ); } }, [i18n, namespace, sourceLocale, sourceMessages]); useEffect(() => { const nextLocale = preference === "system" ? resolveLocaleFromCandidates(browserLanguageCandidates()) : preference; setLocale(nextLocale); }, [preference]); useEffect(() => { let cancelled = false; async function applyLocale() { setLoading(true); try { if ( locale !== sourceLocale && !i18n.hasResourceBundle(locale, namespace) ) { const preloaded = hydration.locale === locale && hydration.messages ? hydration.messages : initialLocale === locale && initialMessages ? initialMessages : null; const loaded = preloaded ?? (await loadMessages?.(locale)); const messages = normalizeLoadedMessages(loaded); if (messages) { i18n.addResourceBundle(locale, namespace, messages, true, true); } } if (!cancelled) { await i18n.changeLanguage(locale); } } finally { if (!cancelled) setLoading(false); } } void applyLocale(); return () => { cancelled = true; }; }, [ hydration.locale, hydration.messages, i18n, initialLocale, initialMessages, loadMessages, locale, namespace, sourceLocale, ]); useEffect(() => { if (typeof document === "undefined") return; const dir = localeDirection(locale); const root = document.documentElement; root.setAttribute("lang", locale); root.setAttribute("dir", dir); root.setAttribute("data-locale", locale); }, [locale]); useEffect(() => { if (!persistPreference) return; let cancelled = false; callAction( "get-localization-preference", undefined, { method: "GET" }, ) .then((value) => { if (cancelled) return; const normalized = normalizeLocalizationPreference(value).locale; setPreferenceState(normalized); writeStoredPreference(normalized); }) .catch(() => { // Anonymous/public routes use localStorage/browser language only. }); return () => { cancelled = true; }; }, [persistPreference]); useEffect(() => { if (typeof window === "undefined") return; const onStorage = (event: StorageEvent) => { if (event.key !== LOCALE_STORAGE_KEY || event.newValue == null) return; setPreferenceState( normalizeLocalizationPreference(event.newValue).locale, ); }; window.addEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage); }, []); useEffect(() => { if (!persistPreference) return; void setClientAppState( "localization", { locale, preference, dir: localeDirection(locale), }, { requestSource: "localization" }, ).catch(() => { // Public/anonymous pages cannot write app-state; localization still works. }); }, [locale, persistPreference, preference]); const setPreference = useCallback( async (next: LocalePreference) => { const normalized = normalizeLocalizationPreference(next).locale; setPreferenceState(normalized); writeStoredPreference(normalized); if (!persistPreference) return; try { await callAction( "set-localization-preference", { locale: normalized, }, ); } catch (error) { const status = (error as { status?: unknown })?.status; if (status !== 401 && status !== 403) { throw error; } } }, [persistPreference], ); const context = useMemo( () => ({ locale, sourceLocale, preference, dir: localeDirection(locale), metadata: LOCALE_METADATA[locale], setPreference, loading, }), [loading, locale, preference, setPreference, sourceLocale], ); return ( {children} ); } export function useLocale(): LocaleContextValue { const value = useContext(LocaleContext); if (!value) { throw new Error("useLocale must be used within AgentNativeI18nProvider"); } return value; } export function useOptionalLocale(): LocaleContextValue | null { return useContext(LocaleContext); } const CORE_FALLBACK_MESSAGES: Record = { "runsTray.runs": "Runs", "runsTray.agentRuns": "Agent runs", "runsTray.activeRun_one": "{{count}} active run", "runsTray.activeRun_other": "{{count}} active runs", "runsTray.failedRun_one": "{{count}} failed run", "runsTray.failedRun_other": "{{count}} failed runs", "runsTray.recentRuns": "Recent runs", "runsTray.noRecentRuns": "No recent runs", "runsTray.ariaAgentRuns": "Agent runs, {{label}}", "runsTray.summaryRunning": "{{activeCount}} running", "runsTray.summaryRunningRecent": "{{activeCount}} running · {{terminalCount}} recent", "runsTray.summaryRecent_one": "{{count}} recent run", "runsTray.summaryRecent_other": "{{count}} recent runs", "runsTray.noTrackedWorkYet": "No tracked work yet", "runsTray.emptyDescription": "Background agent work will appear here while it runs and after it finishes.", "runsTray.open": "Open", "runsTray.stopRun": "Stop {{title}}", "runsTray.hideRun": "Hide {{title}}", "runsTray.statusRunning": "Running", "runsTray.statusDone": "Done", "runsTray.statusFailed": "Failed", "runsTray.statusStopped": "Stopped", "runsTray.updatedJustNow": "Updated just now", "runsTray.finishedJustNow": "Finished just now", "runsTray.updatedMinutes": "Updated {{count}}m ago", "runsTray.finishedMinutes": "Finished {{count}}m ago", "runsTray.updatedHours": "Updated {{count}}h ago", "runsTray.finishedHours": "Finished {{count}}h ago", "runsTray.updatedDate": "Updated {{date}}", "runsTray.finishedDate": "Finished {{date}}", "codeRequired.fallbackDetail": "Edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way you like.", "codeRequired.defaultFeature": "Make the requested code changes to this app", "codeRequired.branchError": "Failed to create branch", "codeRequired.title": "Code changes required", "codeRequired.subtitleWithFeature": '"{{feature}}" creates or modifies source code, which needs Desktop or Builder from this surface.', "codeRequired.subtitle": "This action creates or modifies source code, which needs Desktop or Builder from this surface.", "codeRequired.desktopTitle": "Use Agent Native Desktop", "codeRequired.desktopDescription": "Open the project in the desktop app to enable source edits and CLI access.", "codeRequired.builderAgentTitle": "Use Builder.io Agent", "codeRequired.builderAgentDescription": "Let our cloud agent make the changes for you. You'll get a link to preview and deploy.", "codeRequired.codeChangeTitle": "This requires a code change", "codeRequired.codeChangeBadge": "Code change", "codeRequired.connectBuilderTitle": "Connect Builder.io", "codeRequired.connectBuilderDescription": "Connect Builder to enable cloud-based code changes from this app.", "codeRequired.setupRequired": "Setup required", "codeRequired.branchCreated": "Branch created", "codeRequired.close": "Close", "agentPanel.useBuilder": "Use Builder", "agentPanel.openDesktopToEditCode": "Open Desktop to edit code", "agentPanel.codeUnavailableDescription": "Source-code changes and CLI access are available in the Agent Native Desktop app.", "agentPanel.downloadDesktop": "Download Desktop", "agentPanel.chatMode": "Chat mode", "agentPanel.chat": "Chat", "agentPanel.cliTerminalMode": "CLI terminal mode", "agentPanel.cli": "CLI", "agentPanel.workspaceMode": "Files, agents, skills, and tasks", "agentPanel.workspace": "Resources", "agentPanel.newChat": "New chat", "agentPanel.newTerminal": "New terminal", "agentPanel.panelOptions": "Agent panel options", "agentPanel.collapseSidebar": "Collapse sidebar", "agentPanel.hideChats": "Hide chats", "agentPanel.allChats": "All chats", "agentPanel.settings": "Settings", "agentPanel.feedback": "Feedback", "agentPanel.exitFullscreen": "Exit fullscreen", "agentPanel.fullscreen": "Fullscreen", "agentPanel.closeTab": "Close tab", "agentPanel.closeOtherTabs": "Close other tabs", "agentPanel.closeAllTabs": "Close all tabs", "agentPanel.clearChat": "Clear chat", "agentPanel.cliRequiresDevMode": "CLI requires dev mode", "agentPanel.cliRequiresDevModeDescription": "Run this app locally with pnpm dev or use Builder.io to access the CLI terminal.", "agentPanel.toggleAgent": "Toggle agent", }; function flattenMessages( value: unknown, prefix = "", out: Record = {}, ) { if (!value || typeof value !== "object" || Array.isArray(value)) return out; for (const [key, child] of Object.entries(value)) { const nextKey = prefix ? `${prefix}.${key}` : key; if (typeof child === "string") { out[nextKey] = child; } else { flattenMessages(child, nextKey, out); } } return out; } const DEFAULT_ENGLISH_MESSAGES = flattenMessages(defaultEnglishMessages); function interpolateFallbackMessage( template: string, options?: Record, ) { return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => { const value = options?.[name]; return value == null ? "" : String(value); }); } function humanizeFallbackKey(key: string) { const lastSegment = key.split(".").filter(Boolean).pop() ?? key; const words = lastSegment .replace(/[_-]+/g, " ") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .trim() .toLowerCase(); return words ? words.charAt(0).toUpperCase() + words.slice(1) : key; } function fallbackMessage(key: string, options?: Record) { const count = Number(options?.count); const pluralKey = Number.isFinite(count) && count === 1 ? `${key}_one` : `${key}_other`; const template = DEFAULT_ENGLISH_MESSAGES[pluralKey] ?? DEFAULT_ENGLISH_MESSAGES[key] ?? CORE_FALLBACK_MESSAGES[pluralKey] ?? CORE_FALLBACK_MESSAGES[key]; return template ? interpolateFallbackMessage(template, options) : humanizeFallbackKey(key); } export function useT() { const { i18n, t } = useTranslation(); const context = useContext(LocaleContext); const sourceLocale = context?.sourceLocale ?? DEFAULT_LOCALE; return useCallback( (key: string, options?: Record) => { const translated = t(key, options); if (translated !== key) return translated; const getFixedT = ( i18n as { getFixedT?: (locale: LocaleCode) => typeof t } ).getFixedT; const sourceFallback = getFixedT?.(sourceLocale)(key, options); if (sourceFallback && sourceFallback !== key) return sourceFallback; return fallbackMessage(key, options); }, [i18n, sourceLocale, t], ); } export function useFormatters() { const context = useContext(LocaleContext); const locale = context?.locale ?? DEFAULT_LOCALE; return useMemo( () => ({ formatDate( value: Date | number | string, options?: Intl.DateTimeFormatOptions, ) { return new Intl.DateTimeFormat(locale, options).format( value instanceof Date ? value : new Date(value), ); }, formatNumber(value: number, options?: Intl.NumberFormatOptions) { return new Intl.NumberFormat(locale, options).format(value); }, formatRelativeTime( value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions, ) { return new Intl.RelativeTimeFormat(locale, options).format(value, unit); }, formatList(value: string[], options?: Intl.ListFormatOptions) { return new Intl.ListFormat(locale, options).format(value); }, }), [locale], ); } export function LanguagePicker({ className, includeSystem = true, label, variant = "select", }: { className?: string; includeSystem?: boolean; label?: string; variant?: "select" | "icon" | "ghost-icon"; }) { const { locale, preference, setPreference } = useLocale(); const [open, setOpen] = useState(false); const copy = LANGUAGE_PICKER_COPY[locale] ?? LANGUAGE_PICKER_COPY[DEFAULT_LOCALE]; const resolvedLabel = label ?? copy.label; const options = [ ...(includeSystem ? [ { value: "system" as const, label: copy.system, description: copy.systemDescription, }, ] : []), ...SUPPORTED_LOCALES.map((code) => ({ value: code, label: `${LOCALE_METADATA[code].nativeName} (${code})`, description: code, })), ]; const selected = options.find((option) => option.value === preference); const selectedLabel = selected?.label ?? preference; const triggerLabel = `${resolvedLabel}: ${selectedLabel}`; const customPicker = useDesignSystemComponent("Picker"); function handleOptionClick(value: LocalePreference) { setOpen(false); void setPreference(normalizeLocalizationPreference(value).locale); } if (variant === "select" && customPicker) { return ( mode="select" options={options} value={preference} onChange={(value) => { if (value == null) return; void setPreference(normalizeLocalizationPreference(value).locale); }} placeholder={selectedLabel} aria-label={triggerLabel} className={className} /> ); } return (
{options.map((option) => { const optionSelected = option.value === preference; return ( ); })}
); }