diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 1efa49b9..d3cfeefc 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -13,6 +13,7 @@ const PreferencesSchema = z.object({ thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), showTimelineTools: z.boolean().default(true), lastUsedBinary: z.string().optional(), + locale: z.string().optional(), environmentVariables: z.record(z.string()).default({}), modelRecents: z.array(ModelPreferenceSchema).default([]), modelThinkingSelections: z.record(z.string(), z.string()).default({}), diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 14fd32eb..0ffb20d5 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -9,6 +9,7 @@ import VersionPill from "./version-pill" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { githubStars } from "../stores/github-stars" import { formatCompactCount } from "../lib/formatters" +import { useI18n } from "../lib/i18n" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -24,6 +25,7 @@ interface FolderSelectionViewProps { const FolderSelectionView: Component = (props) => { const { recentFolders, removeRecentFolder, preferences } = useConfig() + const { t } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") @@ -181,10 +183,10 @@ const FolderSelectionView: Component = (props) => { const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) - if (days > 0) return `${days}d ago` - if (hours > 0) return `${hours}h ago` - if (minutes > 0) return `${minutes}m ago` - return "just now" + if (days > 0) return t("time.relative.daysAgoShort", { count: days }) + if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours }) + if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes }) + return t("time.relative.justNow") } function handleFolderSelect(path: string) { @@ -203,7 +205,7 @@ const FolderSelectionView: Component = (props) => { if (nativeDialogsAvailable) { const fallbackPath = folders()[0]?.path const selected = await openNativeFolderDialog({ - title: "Select Workspace", + title: t("folderSelection.dialog.title"), defaultPath: fallbackPath, }) if (selected) { @@ -266,7 +268,7 @@ const FolderSelectionView: Component = (props) => {
- CodeNomad logo + {t("folderSelection.logoAlt")}

CodeNomad

@@ -275,8 +277,8 @@ const FolderSelectionView: Component = (props) => { target="_blank" rel="noreferrer" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" - aria-label="CodeNomad GitHub" - title="CodeNomad GitHub" + aria-label={t("folderSelection.links.github")} + title={t("folderSelection.links.github")} onClick={(event) => { event.preventDefault() openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") @@ -289,8 +291,8 @@ const FolderSelectionView: Component = (props) => { target="_blank" rel="noreferrer" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" - aria-label="CodeNomad GitHub Stars" - title="CodeNomad GitHub Stars" + aria-label={t("folderSelection.links.githubStars")} + title={t("folderSelection.links.githubStars")} onClick={(event) => { event.preventDefault() openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") @@ -306,8 +308,8 @@ const FolderSelectionView: Component = (props) => { target="_blank" rel="noreferrer" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" - aria-label="CodeNomad Discord" - title="CodeNomad Discord" + aria-label={t("folderSelection.links.discord")} + title={t("folderSelection.links.discord")} onClick={(event) => { event.preventDefault() openExternalLink( @@ -318,7 +320,7 @@ const FolderSelectionView: Component = (props) => {
-

Select a folder to start coding with AI

+

{t("folderSelection.tagline")}

@@ -332,16 +334,21 @@ const FolderSelectionView: Component = (props) => {
-

No Recent Folders

-

Browse for a folder to get started

+

{t("folderSelection.empty.title")}

+

{t("folderSelection.empty.description")}

} >
-

Recent Folders

+

{t("folderSelection.recent.title")}

- {folders().length} {folders().length === 1 ? "folder" : "folders"} available + {t( + folders().length === 1 + ? "folderSelection.recent.subtitle.one" + : "folderSelection.recent.subtitle.other", + { count: folders().length }, + )}

= (props) => { onClick={(e) => handleRemove(folder.path, e)} disabled={isLoading()} class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded" - title="Remove from recent" + title={t("folderSelection.recent.remove")} > @@ -411,8 +418,8 @@ const FolderSelectionView: Component = (props) => {
@@ -424,7 +431,11 @@ const FolderSelectionView: Component = (props) => { >
- {props.isLoading ? "Opening..." : "Browse Folders"} + + {props.isLoading + ? t("folderSelection.browse.buttonOpening") + : t("folderSelection.browse.button")} +
@@ -435,7 +446,7 @@ const FolderSelectionView: Component = (props) => { @@ -457,20 +468,20 @@ const FolderSelectionView: Component = (props) => {
- Navigate + {t("folderSelection.hints.navigate")}
Enter - Select + {t("folderSelection.hints.select")}
Del - Remove + {t("folderSelection.hints.remove")}
- Browse + {t("folderSelection.hints.browse")}
@@ -480,8 +491,8 @@ const FolderSelectionView: Component = (props) => {
-

Starting instance…

-

Hang tight while we prepare your workspace.

+

{t("folderSelection.loading.title")}

+

{t("folderSelection.loading.subtitle")}

@@ -497,8 +508,8 @@ const FolderSelectionView: Component = (props) => { setIsFolderBrowserOpen(false)} onSelect={handleBrowserSelect} /> diff --git a/packages/ui/src/lib/i18n/index.tsx b/packages/ui/src/lib/i18n/index.tsx new file mode 100644 index 00000000..c3dd7e2d --- /dev/null +++ b/packages/ui/src/lib/i18n/index.tsx @@ -0,0 +1,104 @@ +import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" +import type { ParentComponent } from "solid-js" +import { useConfig } from "../../stores/preferences" +import { enMessages } from "./messages/en" + +type Messages = Record + +export type Locale = "en" + +const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const + +const messagesByLocale: Record = { + en: enMessages, +} + +function normalizeLocaleTag(value: string): string { + return value.trim().replace(/_/g, "-") +} + +function matchSupportedLocale(value: string | undefined): Locale | null { + if (!value) return null + + const normalized = normalizeLocaleTag(value) + const lower = normalized.toLowerCase() + const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) + const exact = supportedLower.get(lower) + if (exact) return exact + + const base = lower.split("-")[0] + if (!base) return null + const baseMatch = supportedLower.get(base) + return baseMatch ?? null +} + +function detectNavigatorLocale(): Locale | null { + if (typeof navigator === "undefined") return null + + const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : navigator.language + ? [navigator.language] + : [] + + for (const candidate of candidates) { + const match = matchSupportedLocale(candidate) + if (match) return match + } + + return null +} + +function interpolate(template: string, params?: Record): string { + if (!params) return template + return template.replace(/\{(\w+)\}/g, (_match, key: string) => { + const value = params[key] + return value === undefined || value === null ? "" : String(value) + }) +} + +export interface I18nContextValue { + locale: () => Locale + t: (key: string, params?: Record) => string +} + +const I18nContext = createContext() + +export const I18nProvider: ParentComponent = (props) => { + const { preferences } = useConfig() + const [detectedLocale, setDetectedLocale] = createSignal("en") + + onMount(() => { + const detected = detectNavigatorLocale() + if (detected) setDetectedLocale(detected) + }) + + const locale = createMemo(() => { + const configured = matchSupportedLocale(preferences().locale) + return configured ?? detectedLocale() ?? "en" + }) + + const messages = createMemo(() => messagesByLocale[locale()]) + + function t(key: string, params?: Record): string { + const current = messages()[key] + const fallback = enMessages[key as keyof typeof enMessages] + const template = current ?? fallback ?? key + return interpolate(template, params) + } + + const value: I18nContextValue = { + locale, + t, + } + + return {props.children} +} + +export function useI18n(): I18nContextValue { + const context = useContext(I18nContext) + if (!context) { + throw new Error("useI18n must be used within I18nProvider") + } + return context +} diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts new file mode 100644 index 00000000..0e7799db --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -0,0 +1,39 @@ +export const enMessages = { + "folderSelection.logoAlt": "CodeNomad logo", + "folderSelection.tagline": "Select a folder to start coding with AI", + + "folderSelection.links.github": "CodeNomad GitHub", + "folderSelection.links.githubStars": "CodeNomad GitHub Stars", + "folderSelection.links.discord": "CodeNomad Discord", + + "folderSelection.empty.title": "No Recent Folders", + "folderSelection.empty.description": "Browse for a folder to get started", + + "folderSelection.recent.title": "Recent Folders", + "folderSelection.recent.subtitle.one": "{count} folder available", + "folderSelection.recent.subtitle.other": "{count} folders available", + "folderSelection.recent.remove": "Remove from recent", + + "folderSelection.browse.title": "Browse for Folder", + "folderSelection.browse.subtitle": "Select any folder on your computer", + "folderSelection.browse.button": "Browse Folders", + "folderSelection.browse.buttonOpening": "Opening...", + + "folderSelection.advancedSettings": "Advanced Settings", + + "folderSelection.hints.navigate": "Navigate", + "folderSelection.hints.select": "Select", + "folderSelection.hints.remove": "Remove", + "folderSelection.hints.browse": "Browse", + + "folderSelection.loading.title": "Starting instance...", + "folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.", + + "folderSelection.dialog.title": "Select Workspace", + "folderSelection.dialog.description": "Select workspace to start coding.", + + "time.relative.justNow": "just now", + "time.relative.daysAgoShort": "{count}d ago", + "time.relative.hoursAgoShort": "{count}h ago", + "time.relative.minutesAgoShort": "{count}m ago", +} as const diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 6f74c473..16aa1866 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" import { runtimeEnv } from "./lib/runtime-env" +import { I18nProvider } from "./lib/i18n" import "./index.css" import "@git-diff-view/solid/styles/diff-view-pure.css" @@ -22,9 +23,11 @@ render( () => ( - - - + + + + + ), diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 9b48b53b..6ed3377f 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -37,6 +37,7 @@ export interface Preferences { thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean lastUsedBinary?: string + locale?: string environmentVariables: Record modelRecents: ModelPreference[] modelThinkingSelections: Record @@ -114,6 +115,7 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion, showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools, lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, + locale: sanitized.locale ?? defaultPreferences.locale, environmentVariables, modelRecents, modelThinkingSelections, @@ -578,4 +580,3 @@ export { recordWorkspaceLaunch, } -