diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 9e69c47..70a9909 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,7 +3,10 @@ import "./globals.css"
import { JetBrains_Mono } from "next/font/google"
import { NextIntlClientProvider } from "next-intl"
import { AppI18nProvider } from "@/components/i18n-provider"
+import { getMessagesForLocale } from "@/i18n/messages"
+import { resolveRequestLocale } from "@/i18n/resolve-request-locale"
import { ThemeProvider } from "@/components/theme-provider"
+import { toIntlLocale } from "@/lib/i18n"
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
@@ -15,16 +18,30 @@ export const metadata: Metadata = {
description: "AI Coding Agent Conversation Manager",
}
-export default function RootLayout({
+export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
+ const appLocale = await resolveRequestLocale()
+ const initialLocale = toIntlLocale(appLocale)
+ const initialMessages = await getMessagesForLocale(appLocale)
+
return (
-
+
-
-
+
+
(
- () => loadPersistedLanguageSettings() ?? DEFAULT_LANGUAGE_SETTINGS
- )
+ useState({
+ mode: "manual",
+ language: initialAppLocale,
+ })
const [languageSettingsLoaded, setLanguageSettingsLoaded] = useState(false)
const [messages, setMessages] = useState(
- getFallbackMessages()
+ initialMessages ?? getFallbackMessages()
+ )
+ const [messagesLocale, setMessagesLocale] = useState(
+ initialMessages ? initialAppLocale : "en"
)
const systemLocaleSnapshot = useSyncExternalStore(
@@ -137,15 +155,22 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
[languageSettings, systemLocaleCandidates]
)
- const intlLocale = APP_LOCALE_TO_INTL_LOCALE[appLocale]
+ useEffect(() => {
+ if (!languageSettingsLoaded) return
+ persistLanguageCookies(languageSettings, appLocale)
+ }, [appLocale, languageSettings, languageSettingsLoaded])
useEffect(() => {
+ if (appLocale === messagesLocale) {
+ return
+ }
let cancelled = false
getMessagesForLocale(appLocale)
.then((nextMessages) => {
if (!cancelled) {
setMessages(nextMessages)
+ setMessagesLocale(appLocale)
}
})
.catch((err) => {
@@ -155,11 +180,15 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
return () => {
cancelled = true
}
- }, [appLocale])
+ }, [appLocale, messagesLocale])
+
+ const localeReady = appLocale === messagesLocale
+ const appReady = languageSettingsLoaded && localeReady
+ const activeIntlLocale = toIntlLocale(messagesLocale)
useEffect(() => {
- document.documentElement.lang = intlLocale
- }, [intlLocale])
+ document.documentElement.lang = activeIntlLocale
+ }, [activeIntlLocale])
const contextValue = useMemo(
() => ({
@@ -173,8 +202,8 @@ export function AppI18nProvider({ children }: { children: React.ReactNode }) {
return (
-
- {children}
+
+ {appReady ? children : }
)
diff --git a/src/components/layout/app-boot-loading.tsx b/src/components/layout/app-boot-loading.tsx
new file mode 100644
index 0000000..a409b1c
--- /dev/null
+++ b/src/components/layout/app-boot-loading.tsx
@@ -0,0 +1,14 @@
+"use client"
+
+import { Loader2 } from "lucide-react"
+
+export function AppBootLoading() {
+ return (
+
+ )
+}
diff --git a/src/i18n/request.ts b/src/i18n/request.ts
index 431baed..4239fe6 100644
--- a/src/i18n/request.ts
+++ b/src/i18n/request.ts
@@ -1,7 +1,13 @@
import { getRequestConfig } from "next-intl/server"
-import enMessages from "@/i18n/messages/en.json"
+import { getMessagesForLocale } from "@/i18n/messages"
+import { resolveRequestLocale } from "@/i18n/resolve-request-locale"
+import { APP_LOCALE_TO_INTL_LOCALE } from "@/lib/i18n"
-export default getRequestConfig(async () => ({
- locale: "en",
- messages: enMessages,
-}))
+export default getRequestConfig(async () => {
+ const appLocale = await resolveRequestLocale()
+
+ return {
+ locale: APP_LOCALE_TO_INTL_LOCALE[appLocale],
+ messages: await getMessagesForLocale(appLocale),
+ }
+})
diff --git a/src/i18n/resolve-request-locale.ts b/src/i18n/resolve-request-locale.ts
new file mode 100644
index 0000000..c438696
--- /dev/null
+++ b/src/i18n/resolve-request-locale.ts
@@ -0,0 +1,51 @@
+import { cookies, headers } from "next/headers"
+import {
+ LANGUAGE_COOKIE_KEY,
+ LANGUAGE_MODE_COOKIE_KEY,
+ parseAcceptLanguageHeader,
+ parseLocaleFromCookieValue,
+ resolveSystemLocale,
+} from "@/lib/i18n"
+import type { AppLocale, LanguageMode } from "@/lib/types"
+
+const FALLBACK_LOCALE: AppLocale = "en"
+
+function parseLanguageModeCookie(value: string | undefined): LanguageMode {
+ return value === "manual" ? "manual" : "system"
+}
+
+export async function resolveRequestLocale(): Promise {
+ let configuredLocale: AppLocale | null = null
+ let languageMode: LanguageMode = "system"
+
+ try {
+ const cookieStore = await cookies()
+ configuredLocale = parseLocaleFromCookieValue(
+ cookieStore.get(LANGUAGE_COOKIE_KEY)?.value
+ )
+ languageMode = parseLanguageModeCookie(
+ cookieStore.get(LANGUAGE_MODE_COOKIE_KEY)?.value
+ )
+ } catch {
+ // Ignore when request cookies are unavailable (e.g. static export build).
+ }
+
+ if (configuredLocale && languageMode === "manual") {
+ return configuredLocale
+ }
+
+ try {
+ const headerStore = await headers()
+ const candidates = parseAcceptLanguageHeader(
+ headerStore.get("accept-language")
+ )
+ const fromHeader = resolveSystemLocale(candidates)
+ if (fromHeader) {
+ return fromHeader
+ }
+ } catch {
+ // Ignore when request headers are unavailable (e.g. static export build).
+ }
+
+ return configuredLocale ?? FALLBACK_LOCALE
+}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 805dbf9..839726e 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -3,6 +3,8 @@ import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
export const APP_LOCALES: readonly AppLocale[] = ["en", "zh_cn", "zh_tw"]
const FALLBACK_APP_LOCALE: AppLocale = "en"
export const LANGUAGE_SETTINGS_STORAGE_KEY = "codeg.system_language_settings"
+export const LANGUAGE_MODE_COOKIE_KEY = "codeg.language_mode"
+export const LANGUAGE_COOKIE_KEY = "codeg.locale"
export type IntlLocale = "en" | "zh-CN" | "zh-TW"
export const DEFAULT_LANGUAGE_SETTINGS: SystemLanguageSettings = {
@@ -16,10 +18,28 @@ export const APP_LOCALE_TO_INTL_LOCALE: Record = {
zh_tw: "zh-TW",
}
+export const INTL_LOCALE_TO_APP_LOCALE: Record = {
+ en: "en",
+ "zh-CN": "zh_cn",
+ "zh-TW": "zh_tw",
+}
+
export function isAppLocale(value: unknown): value is AppLocale {
return APP_LOCALES.includes(value as AppLocale)
}
+export function isIntlLocale(value: unknown): value is IntlLocale {
+ return value === "en" || value === "zh-CN" || value === "zh-TW"
+}
+
+export function toIntlLocale(locale: AppLocale): IntlLocale {
+ return APP_LOCALE_TO_INTL_LOCALE[locale]
+}
+
+export function fromIntlLocale(locale: IntlLocale): AppLocale {
+ return INTL_LOCALE_TO_APP_LOCALE[locale]
+}
+
export function normalizeLanguageSettings(
settings: Partial | null | undefined
): SystemLanguageSettings {
@@ -34,7 +54,7 @@ export function normalizeLanguageSettings(
}
}
-function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null {
+export function mapLocaleTagToAppLocale(localeTag: string): AppLocale | null {
const normalized = localeTag.trim().toLowerCase().replace(/_/g, "-")
if (!normalized) return null
@@ -54,6 +74,26 @@ function mapSystemLocaleToAppLocale(localeTag: string): AppLocale | null {
return null
}
+export function parseAcceptLanguageHeader(value: string | null): string[] {
+ if (!value) return []
+
+ return value
+ .split(",")
+ .map((entry) => entry.split(";")[0]?.trim())
+ .filter((entry): entry is string => Boolean(entry))
+}
+
+export function parseLocaleFromCookieValue(
+ value: string | undefined
+): AppLocale | null {
+ if (!value) return null
+
+ if (isAppLocale(value)) return value
+ if (isIntlLocale(value)) return fromIntlLocale(value)
+
+ return mapLocaleTagToAppLocale(value)
+}
+
export function getSystemLocaleCandidates(): string[] {
if (typeof navigator === "undefined") return []
@@ -67,7 +107,7 @@ export function getSystemLocaleCandidates(): string[] {
export function resolveSystemLocale(candidates: string[]): AppLocale | null {
for (const candidate of candidates) {
- const resolved = mapSystemLocaleToAppLocale(candidate)
+ const resolved = mapLocaleTagToAppLocale(candidate)
if (resolved) return resolved
}