feat(ui): add i18n scaffolding

Adds a minimal i18n provider with locale preference support and migrates folder selection copy to message keys.
This commit is contained in:
Shantur Rathore
2026-01-26 10:22:03 +00:00
parent 96f5a0ab44
commit 33939f4096
6 changed files with 193 additions and 34 deletions

View File

@@ -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({}),

View File

@@ -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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (props) => {
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
@@ -275,8 +277,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (props) => {
<DiscordSymbolIcon class="w-4 h-4" />
</a>
</div>
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
</div>
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
@@ -332,16 +334,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
@@ -393,7 +400,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
@@ -411,8 +418,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
</div>
<div class="panel-body">
@@ -424,7 +431,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
@@ -435,7 +446,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
@@ -457,20 +468,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
<span>{t("folderSelection.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
<span>{t("folderSelection.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Remove</span>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>
</div>
@@ -480,8 +491,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
</div>
</div>
</Show>
@@ -497,8 +508,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
title={t("folderSelection.dialog.title")}
description={t("folderSelection.dialog.description")}
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>

View File

@@ -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<string, string>
export type Locale = "en"
const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const
const messagesByLocale: Record<Locale, Messages> = {
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, unknown>): 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, unknown>) => string
}
const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
onMount(() => {
const detected = detectNavigatorLocale()
if (detected) setDetectedLocale(detected)
})
const locale = createMemo<Locale>(() => {
const configured = matchSupportedLocale(preferences().locale)
return configured ?? detectedLocale() ?? "en"
})
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
function t(key: string, params?: Record<string, unknown>): 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 <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}

View File

@@ -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

View File

@@ -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(
() => (
<ConfigProvider>
<InstanceConfigProvider>
<ThemeProvider>
<App />
</ThemeProvider>
<I18nProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</I18nProvider>
</InstanceConfigProvider>
</ConfigProvider>
),

View File

@@ -37,6 +37,7 @@ export interface Preferences {
thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean
lastUsedBinary?: string
locale?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
modelThinkingSelections: Record<string, string>
@@ -114,6 +115,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { 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,
}