@@ -95,7 +102,7 @@ export function ChatList({
className="text-destructive focus:text-destructive"
>
- Delete
+ {t("chat.delete")}
diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx
index a810fbdd..68032d29 100644
--- a/webui/src/components/CodeBlock.tsx
+++ b/webui/src/components/CodeBlock.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
@@ -15,6 +16,7 @@ interface CodeBlockProps {
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
+ const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const onCopy = () => {
@@ -33,19 +35,21 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
return (
- {language || "code"}
+
+ {language || t("code.fallbackLanguage")}
+
= {
- idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
+const COPY: Record = {
+ idle: { color: "bg-card/40 text-muted-foreground" },
connecting: {
- label: "Connecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
open: {
- label: "Connected",
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
},
reconnecting: {
- label: "Reconnecting…",
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
closed: {
- label: "Disconnected",
color: "bg-card/40 text-muted-foreground",
},
error: {
- label: "Connection error",
color: "bg-destructive/10 text-destructive",
},
};
export function ConnectionBadge() {
+ const { t } = useTranslation();
const { client } = useClient();
const [status, setStatus] = useState(client.status);
@@ -53,7 +50,7 @@ export function ConnectionBadge() {
)}
- {meta.label}
+ {t(`connection.${status}`)}
);
}
diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx
index b95d0e73..3342bbd3 100644
--- a/webui/src/components/DeleteConfirm.tsx
+++ b/webui/src/components/DeleteConfirm.tsx
@@ -8,6 +8,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import { useTranslation } from "react-i18next";
interface DeleteConfirmProps {
open: boolean;
@@ -22,22 +23,27 @@ export function DeleteConfirm({
onCancel,
onConfirm,
}: DeleteConfirmProps) {
+ const { t } = useTranslation();
return (
(!o ? onCancel() : undefined)}>
- Delete “{title}”?
+
+ {t("deleteConfirm.title", { title })}
+
- The session file will be removed from disk. This cannot be undone.
+ {t("deleteConfirm.description")}
- Cancel
+
+ {t("deleteConfirm.cancel")}
+
- Delete
+ {t("deleteConfirm.confirm")}
diff --git a/webui/src/components/LanguageSwitcher.tsx b/webui/src/components/LanguageSwitcher.tsx
new file mode 100644
index 00000000..c778aa16
--- /dev/null
+++ b/webui/src/components/LanguageSwitcher.tsx
@@ -0,0 +1,67 @@
+import { Globe } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { setAppLanguage } from "@/i18n";
+import {
+ currentLocale,
+} from "@/i18n";
+import {
+ localeOption,
+ supportedLocales,
+ type SupportedLocale,
+} from "@/i18n/config";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function LanguageSwitcher() {
+ const { t } = useTranslation();
+ const locale = currentLocale();
+ const selected = localeOption(locale);
+
+ return (
+
+
+
+
+
+ {t("sidebar.language.label")}
+
+ {
+ void setAppLanguage(value as SupportedLocale);
+ }}
+ >
+ {supportedLocales.map((option) => (
+
+
+ {option.nativeLabel}
+ {option.nativeLabel !== option.label ? (
+
+ {option.label}
+
+ ) : null}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 559989ea..6d974046 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { ChevronRight, Wrench } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
@@ -63,9 +64,10 @@ export function MessageBubble({ message }: MessageBubbleProps) {
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
+ const { t } = useTranslation();
return (
@@ -111,6 +114,7 @@ interface TraceGroupProps {
* group down to a one-line summary so it never dominates the thread.
*/
function TraceGroup({ message, animClass }: TraceGroupProps) {
+ const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
const [open, setOpen] = useState(true);
@@ -127,7 +131,9 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
>
- {count === 1 ? "Using a tool" : `Used ${count} tools`}
+ {count === 1
+ ? t("message.toolSingle")
+ : t("message.toolMany", { count })}
- Recent
+ {t("sidebar.recent")}
@@ -79,8 +82,9 @@ export function Sidebar(props: SidebarProps) {
/>
-
+
+
);
diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index 41651db8..35350fef 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,13 +16,16 @@ interface ThreadComposerProps {
export function ThreadComposer({
onSend,
disabled,
- placeholder = "Type your message…",
+ placeholder,
modelLabel = null,
variant = "thread",
}: ThreadComposerProps) {
+ const { t } = useTranslation();
const [value, setValue] = useState("");
const textareaRef = useRef
(null);
const isHero = variant === "hero";
+ const resolvedPlaceholder =
+ placeholder ?? t("thread.composer.placeholderThread");
useEffect(() => {
if (disabled) return;
@@ -83,9 +87,9 @@ export function ThreadComposer({
onInput={onInput}
onKeyDown={onKeyDown}
rows={1}
- placeholder={placeholder}
+ placeholder={resolvedPlaceholder}
disabled={disabled}
- aria-label="Message input"
+ aria-label={t("thread.composer.inputAria")}
className={cn(
"w-full resize-none bg-transparent",
isHero
@@ -120,7 +124,7 @@ export function ThreadComposer({
) : null}
- Enter to send · Shift+Enter for newline
+ {t("thread.composer.sendHint")}
@@ -128,7 +132,7 @@ export function ThreadComposer({
type="submit"
size="icon"
disabled={disabled || !value.trim()}
- aria-label="Send message"
+ aria-label={t("thread.composer.send")}
className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx
index 1ec189e2..bdc00ac2 100644
--- a/webui/src/components/thread/ThreadHeader.tsx
+++ b/webui/src/components/thread/ThreadHeader.tsx
@@ -1,4 +1,5 @@
import { PanelLeftOpen } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -16,13 +17,14 @@ export function ThreadHeader({
onGoHome,
hideSidebarToggleOnDesktop = false,
}: ThreadHeaderProps) {
+ const { t } = useTranslation();
return (
- Loading conversation…
+ {t("thread.loadingConversation")}
) : (
@@ -120,7 +122,7 @@ export function ThreadShell({
nanobot
- Ask questions, continue local work, or start a new thread.
+ {t("thread.empty.description")}
);
@@ -142,7 +144,11 @@ export function ThreadShell({
@@ -150,7 +156,11 @@ export function ThreadShell({
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index b2978a18..4ad43282 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -1,5 +1,6 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
+import { useTranslation } from "react-i18next";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { Button } from "@/components/ui/button";
@@ -21,6 +22,7 @@ export function ThreadViewport({
composer,
emptyState,
}: ThreadViewportProps) {
+ const { t } = useTranslation();
const scrollRef = useRef(null);
const [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0;
@@ -104,7 +106,7 @@ export function ThreadViewport({
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
- aria-label="Scroll to bottom"
+ aria-label={t("thread.scrollToBottom")}
>
diff --git a/webui/src/components/ui/sheet.tsx b/webui/src/components/ui/sheet.tsx
index 1dc81ba3..e964a35f 100644
--- a/webui/src/components/ui/sheet.tsx
+++ b/webui/src/components/ui/sheet.tsx
@@ -58,12 +58,14 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
- VariantProps {}
+ VariantProps {
+ showCloseButton?: boolean;
+}
const SheetContent = React.forwardRef<
React.ElementRef,
SheetContentProps
->(({ side = "right", className, children, ...props }, ref) => (
+>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
{children}
-
-
- Close
-
+ {showCloseButton ? (
+
+
+ Close
+
+ ) : null}
));
diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts
index 2e90fe60..9e3901bd 100644
--- a/webui/src/hooks/useSessions.ts
+++ b/webui/src/hooks/useSessions.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
+import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
@@ -183,5 +184,8 @@ export function sessionTitle(
session: ChatSummary,
firstUserMessage?: string,
): string {
- return deriveTitle(firstUserMessage || session.preview, "New chat");
+ return deriveTitle(
+ firstUserMessage || session.preview,
+ i18n.t("chat.newChat"),
+ );
}
diff --git a/webui/src/i18n/config.ts b/webui/src/i18n/config.ts
new file mode 100644
index 00000000..f4c98bb3
--- /dev/null
+++ b/webui/src/i18n/config.ts
@@ -0,0 +1,93 @@
+export const LOCALE_STORAGE_KEY = "nanobot.locale";
+
+export const supportedLocales = [
+ { code: "en", label: "English", nativeLabel: "English" },
+ { code: "zh-CN", label: "Chinese (Simplified)", nativeLabel: "简体中文" },
+ { code: "zh-TW", label: "Chinese (Traditional)", nativeLabel: "繁體中文" },
+ { code: "fr", label: "French", nativeLabel: "Français" },
+ { code: "ja", label: "Japanese", nativeLabel: "日本語" },
+ { code: "ko", label: "Korean", nativeLabel: "한국어" },
+ { code: "es", label: "Spanish", nativeLabel: "Español" },
+ { code: "vi", label: "Vietnamese", nativeLabel: "Tiếng Việt" },
+ { code: "id", label: "Indonesian", nativeLabel: "Bahasa Indonesia" },
+] as const;
+
+export type SupportedLocale = (typeof supportedLocales)[number]["code"];
+
+export const defaultLocale: SupportedLocale = "en";
+export const fallbackLocale: SupportedLocale = "en";
+
+export function normalizeLocale(
+ input: string | null | undefined,
+): SupportedLocale {
+ if (!input) return defaultLocale;
+ const trimmed = input.trim();
+ if (!trimmed) return defaultLocale;
+
+ const exact = supportedLocales.find((locale) => locale.code === trimmed);
+ if (exact) return exact.code;
+
+ const lower = trimmed.toLowerCase();
+ if (lower === "zh" || lower.startsWith("zh-cn") || lower.startsWith("zh-sg")) {
+ return "zh-CN";
+ }
+ if (
+ lower.startsWith("zh-tw") ||
+ lower.startsWith("zh-hk") ||
+ lower.startsWith("zh-mo") ||
+ lower.startsWith("zh-hant")
+ ) {
+ return "zh-TW";
+ }
+
+ const base = lower.split("-")[0];
+ const baseMatch = supportedLocales.find(
+ (locale) => locale.code.toLowerCase() === base,
+ );
+ return baseMatch?.code ?? defaultLocale;
+}
+
+export function readStoredLocale(): SupportedLocale | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ return raw ? normalizeLocale(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function detectNavigatorLocale(): SupportedLocale {
+ if (typeof navigator === "undefined") return defaultLocale;
+ const candidates = [
+ ...(navigator.languages ?? []),
+ navigator.language,
+ ].filter(Boolean);
+ for (const locale of candidates) {
+ const normalized = normalizeLocale(locale);
+ if (normalized) return normalized;
+ }
+ return defaultLocale;
+}
+
+export function resolveInitialLocale(): SupportedLocale {
+ return readStoredLocale() ?? detectNavigatorLocale();
+}
+
+export function persistLocale(locale: SupportedLocale): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+ } catch {
+ // ignore storage errors
+ }
+}
+
+export function applyDocumentLocale(locale: SupportedLocale): void {
+ if (typeof document === "undefined") return;
+ document.documentElement.lang = locale;
+}
+
+export function localeOption(locale: SupportedLocale) {
+ return supportedLocales.find((entry) => entry.code === locale) ?? supportedLocales[0];
+}
diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts
new file mode 100644
index 00000000..64f713bb
--- /dev/null
+++ b/webui/src/i18n/index.ts
@@ -0,0 +1,72 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+
+import {
+ applyDocumentLocale,
+ defaultLocale,
+ fallbackLocale,
+ LOCALE_STORAGE_KEY,
+ normalizeLocale,
+ persistLocale,
+ resolveInitialLocale,
+ type SupportedLocale,
+} from "./config";
+
+import enCommon from "./locales/en/common.json";
+import zhCNCommon from "./locales/zh-CN/common.json";
+import zhTWCommon from "./locales/zh-TW/common.json";
+import frCommon from "./locales/fr/common.json";
+import jaCommon from "./locales/ja/common.json";
+import koCommon from "./locales/ko/common.json";
+import esCommon from "./locales/es/common.json";
+import viCommon from "./locales/vi/common.json";
+import idCommon from "./locales/id/common.json";
+
+export const resources = {
+ en: { common: enCommon },
+ "zh-CN": { common: zhCNCommon },
+ "zh-TW": { common: zhTWCommon },
+ fr: { common: frCommon },
+ ja: { common: jaCommon },
+ ko: { common: koCommon },
+ es: { common: esCommon },
+ vi: { common: viCommon },
+ id: { common: idCommon },
+} as const;
+
+export function currentLocale(): SupportedLocale {
+ return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
+}
+
+export async function setAppLanguage(locale: SupportedLocale): Promise {
+ await i18n.changeLanguage(locale);
+}
+
+if (!i18n.isInitialized) {
+ void i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ lng: resolveInitialLocale(),
+ fallbackLng: fallbackLocale,
+ defaultNS: "common",
+ ns: ["common"],
+ interpolation: {
+ escapeValue: false,
+ },
+ returnNull: false,
+ supportedLngs: Object.keys(resources),
+ });
+}
+
+const syncLocaleSideEffects = (language: string) => {
+ const locale = normalizeLocale(language);
+ applyDocumentLocale(locale);
+ persistLocale(locale);
+};
+
+syncLocaleSideEffects(currentLocale());
+i18n.on("languageChanged", syncLocaleSideEffects);
+
+export { LOCALE_STORAGE_KEY };
+export default i18n;
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
new file mode 100644
index 00000000..9fc01663
--- /dev/null
+++ b/webui/src/i18n/locales/en/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Connecting to nanobot…",
+ "boot": "Loading nanobot…"
+ },
+ "error": {
+ "title": "Couldn't reach nanobot",
+ "gatewayHint": "Make sure the gateway is running (`nanobot web`) and that this page is open on the same machine."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot web UI — chat with your nanobot workspace."
+ }
+ },
+ "sidebar": {
+ "collapse": "Collapse sidebar",
+ "toggleTheme": "Toggle theme",
+ "newChat": "New chat",
+ "recent": "Recent",
+ "refreshSessions": "Refresh sessions",
+ "language": {
+ "label": "Language",
+ "ariaLabel": "Change language"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Chat {{id}}",
+ "loading": "Loading…",
+ "noSessions": "No sessions yet.",
+ "actions": "Chat actions for {{title}}",
+ "delete": "Delete",
+ "newChat": "New chat"
+ },
+ "deleteConfirm": {
+ "title": "Delete “{{title}}”?",
+ "description": "The session file will be removed from disk. This cannot be undone.",
+ "cancel": "Cancel",
+ "confirm": "Delete"
+ },
+ "connection": {
+ "idle": "Idle",
+ "connecting": "Connecting…",
+ "open": "Connected",
+ "reconnecting": "Reconnecting…",
+ "closed": "Disconnected",
+ "error": "Connection error"
+ },
+ "thread": {
+ "loadingConversation": "Loading conversation…",
+ "empty": {
+ "description": "Ask questions, continue local work, or start a new thread."
+ },
+ "header": {
+ "toggleSidebar": "Toggle sidebar"
+ },
+ "composer": {
+ "placeholderThread": "Type your message…",
+ "placeholderHero": "What's on your mind?",
+ "placeholderOpening": "Opening a new chat…",
+ "inputAria": "Message input",
+ "sendHint": "Enter to send · Shift+Enter for newline",
+ "send": "Send message"
+ },
+ "scrollToBottom": "Scroll to bottom"
+ },
+ "message": {
+ "streaming": "streaming",
+ "assistantTyping": "Assistant is typing",
+ "toolSingle": "Using a tool",
+ "toolMany": "Used {{count}} tools"
+ },
+ "code": {
+ "fallbackLanguage": "code",
+ "copyAria": "Copy code",
+ "copy": "Copy",
+ "copied": "Copied"
+ }
+}
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
new file mode 100644
index 00000000..1c099364
--- /dev/null
+++ b/webui/src/i18n/locales/es/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Conectando con nanobot…",
+ "boot": "Cargando nanobot…"
+ },
+ "error": {
+ "title": "No se pudo conectar con nanobot",
+ "gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot web`) y de que esta página esté abierta en la misma máquina."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Interfaz web de nanobot: conversa con tu espacio de trabajo de nanobot."
+ }
+ },
+ "sidebar": {
+ "collapse": "Contraer barra lateral",
+ "toggleTheme": "Cambiar tema",
+ "newChat": "Nuevo chat",
+ "recent": "Recientes",
+ "refreshSessions": "Actualizar sesiones",
+ "language": {
+ "label": "Idioma",
+ "ariaLabel": "Cambiar idioma"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Chat {{id}}",
+ "loading": "Cargando…",
+ "noSessions": "Todavía no hay sesiones.",
+ "actions": "Acciones del chat {{title}}",
+ "delete": "Eliminar",
+ "newChat": "Nuevo chat"
+ },
+ "deleteConfirm": {
+ "title": "¿Eliminar “{{title}}”?",
+ "description": "El archivo de sesión se eliminará del disco. Esta acción no se puede deshacer.",
+ "cancel": "Cancelar",
+ "confirm": "Eliminar"
+ },
+ "connection": {
+ "idle": "Inactivo",
+ "connecting": "Conectando…",
+ "open": "Conectado",
+ "reconnecting": "Reconectando…",
+ "closed": "Desconectado",
+ "error": "Error de conexión"
+ },
+ "thread": {
+ "loadingConversation": "Cargando conversación…",
+ "empty": {
+ "description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
+ },
+ "header": {
+ "toggleSidebar": "Mostrar u ocultar la barra lateral"
+ },
+ "composer": {
+ "placeholderThread": "Escribe tu mensaje…",
+ "placeholderHero": "¿Qué tienes en mente?",
+ "placeholderOpening": "Abriendo un nuevo chat…",
+ "inputAria": "Entrada de mensaje",
+ "sendHint": "Enter para enviar · Shift+Enter para nueva línea",
+ "send": "Enviar mensaje"
+ },
+ "scrollToBottom": "Desplazarse al final"
+ },
+ "message": {
+ "streaming": "transmitiendo",
+ "assistantTyping": "El asistente está escribiendo",
+ "toolSingle": "Usando una herramienta",
+ "toolMany": "Se usaron {{count}} herramientas"
+ },
+ "code": {
+ "fallbackLanguage": "código",
+ "copyAria": "Copiar código",
+ "copy": "Copiar",
+ "copied": "Copiado"
+ }
+}
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
new file mode 100644
index 00000000..75e4753d
--- /dev/null
+++ b/webui/src/i18n/locales/fr/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Connexion à nanobot…",
+ "boot": "Chargement de nanobot…"
+ },
+ "error": {
+ "title": "Impossible de joindre nanobot",
+ "gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot web`) et que cette page est ouverte sur la même machine."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Interface web nanobot — discutez avec votre espace de travail nanobot."
+ }
+ },
+ "sidebar": {
+ "collapse": "Réduire la barre latérale",
+ "toggleTheme": "Changer de thème",
+ "newChat": "Nouvelle discussion",
+ "recent": "Récentes",
+ "refreshSessions": "Actualiser les sessions",
+ "language": {
+ "label": "Langue",
+ "ariaLabel": "Changer de langue"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Discussion {{id}}",
+ "loading": "Chargement…",
+ "noSessions": "Aucune session pour le moment.",
+ "actions": "Actions de la discussion {{title}}",
+ "delete": "Supprimer",
+ "newChat": "Nouvelle discussion"
+ },
+ "deleteConfirm": {
+ "title": "Supprimer « {{title}} » ?",
+ "description": "Le fichier de session sera supprimé du disque. Cette action est irréversible.",
+ "cancel": "Annuler",
+ "confirm": "Supprimer"
+ },
+ "connection": {
+ "idle": "Inactif",
+ "connecting": "Connexion…",
+ "open": "Connecté",
+ "reconnecting": "Reconnexion…",
+ "closed": "Déconnecté",
+ "error": "Erreur de connexion"
+ },
+ "thread": {
+ "loadingConversation": "Chargement de la conversation…",
+ "empty": {
+ "description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
+ },
+ "header": {
+ "toggleSidebar": "Afficher ou masquer la barre latérale"
+ },
+ "composer": {
+ "placeholderThread": "Saisissez votre message…",
+ "placeholderHero": "Qu’avez-vous en tête ?",
+ "placeholderOpening": "Ouverture d’une nouvelle discussion…",
+ "inputAria": "Champ de message",
+ "sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
+ "send": "Envoyer le message"
+ },
+ "scrollToBottom": "Faire défiler vers le bas"
+ },
+ "message": {
+ "streaming": "en cours de génération",
+ "assistantTyping": "L’assistant est en train d’écrire",
+ "toolSingle": "Utilisation d’un outil",
+ "toolMany": "{{count}} outils utilisés"
+ },
+ "code": {
+ "fallbackLanguage": "code",
+ "copyAria": "Copier le code",
+ "copy": "Copier",
+ "copied": "Copié"
+ }
+}
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
new file mode 100644
index 00000000..6085046e
--- /dev/null
+++ b/webui/src/i18n/locales/id/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Menghubungkan ke nanobot…",
+ "boot": "Memuat nanobot…"
+ },
+ "error": {
+ "title": "Tidak dapat menjangkau nanobot",
+ "gatewayHint": "Pastikan gateway sedang berjalan (`nanobot web`) dan halaman ini dibuka pada mesin yang sama."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "UI web nanobot — ngobrol dengan workspace nanobot Anda."
+ }
+ },
+ "sidebar": {
+ "collapse": "Ciutkan sidebar",
+ "toggleTheme": "Ganti tema",
+ "newChat": "Obrolan baru",
+ "recent": "Terbaru",
+ "refreshSessions": "Segarkan sesi",
+ "language": {
+ "label": "Bahasa",
+ "ariaLabel": "Ganti bahasa"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Obrolan {{id}}",
+ "loading": "Memuat…",
+ "noSessions": "Belum ada sesi.",
+ "actions": "Aksi obrolan untuk {{title}}",
+ "delete": "Hapus",
+ "newChat": "Obrolan baru"
+ },
+ "deleteConfirm": {
+ "title": "Hapus “{{title}}”?",
+ "description": "File sesi akan dihapus dari disk. Tindakan ini tidak dapat dibatalkan.",
+ "cancel": "Batal",
+ "confirm": "Hapus"
+ },
+ "connection": {
+ "idle": "Idle",
+ "connecting": "Menghubungkan…",
+ "open": "Terhubung",
+ "reconnecting": "Menyambung ulang…",
+ "closed": "Terputus",
+ "error": "Kesalahan koneksi"
+ },
+ "thread": {
+ "loadingConversation": "Memuat percakapan…",
+ "empty": {
+ "description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
+ },
+ "header": {
+ "toggleSidebar": "Tampilkan atau sembunyikan sidebar"
+ },
+ "composer": {
+ "placeholderThread": "Ketik pesan Anda…",
+ "placeholderHero": "Apa yang sedang Anda pikirkan?",
+ "placeholderOpening": "Membuka obrolan baru…",
+ "inputAria": "Input pesan",
+ "sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
+ "send": "Kirim pesan"
+ },
+ "scrollToBottom": "Gulir ke bawah"
+ },
+ "message": {
+ "streaming": "sedang mengalir",
+ "assistantTyping": "Asisten sedang mengetik",
+ "toolSingle": "Menggunakan sebuah alat",
+ "toolMany": "Menggunakan {{count}} alat"
+ },
+ "code": {
+ "fallbackLanguage": "kode",
+ "copyAria": "Salin kode",
+ "copy": "Salin",
+ "copied": "Tersalin"
+ }
+}
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
new file mode 100644
index 00000000..5f76ac0c
--- /dev/null
+++ b/webui/src/i18n/locales/ja/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "nanobot に接続中…",
+ "boot": "nanobot を読み込み中…"
+ },
+ "error": {
+ "title": "nanobot に接続できませんでした",
+ "gatewayHint": "gateway(`nanobot web`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI — nanobot ワークスペースと会話します。"
+ }
+ },
+ "sidebar": {
+ "collapse": "サイドバーを閉じる",
+ "toggleTheme": "テーマを切り替える",
+ "newChat": "新しいチャット",
+ "recent": "最近のチャット",
+ "refreshSessions": "セッションを更新",
+ "language": {
+ "label": "言語",
+ "ariaLabel": "言語を変更"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "チャット {{id}}",
+ "loading": "読み込み中…",
+ "noSessions": "まだセッションがありません。",
+ "actions": "「{{title}}」のチャット操作",
+ "delete": "削除",
+ "newChat": "新しいチャット"
+ },
+ "deleteConfirm": {
+ "title": "「{{title}}」を削除しますか?",
+ "description": "セッションファイルはディスクから削除されます。この操作は元に戻せません。",
+ "cancel": "キャンセル",
+ "confirm": "削除"
+ },
+ "connection": {
+ "idle": "待機中",
+ "connecting": "接続中…",
+ "open": "接続済み",
+ "reconnecting": "再接続中…",
+ "closed": "切断済み",
+ "error": "接続エラー"
+ },
+ "thread": {
+ "loadingConversation": "会話を読み込み中…",
+ "empty": {
+ "description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
+ },
+ "header": {
+ "toggleSidebar": "サイドバーを切り替える"
+ },
+ "composer": {
+ "placeholderThread": "メッセージを入力…",
+ "placeholderHero": "何を考えていますか?",
+ "placeholderOpening": "新しいチャットを開いています…",
+ "inputAria": "メッセージ入力欄",
+ "sendHint": "Enter で送信 · Shift+Enter で改行",
+ "send": "メッセージを送信"
+ },
+ "scrollToBottom": "一番下へスクロール"
+ },
+ "message": {
+ "streaming": "生成中",
+ "assistantTyping": "アシスタントが入力中",
+ "toolSingle": "ツールを使用中",
+ "toolMany": "{{count}} 個のツールを使用"
+ },
+ "code": {
+ "fallbackLanguage": "コード",
+ "copyAria": "コードをコピー",
+ "copy": "コピー",
+ "copied": "コピーしました"
+ }
+}
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
new file mode 100644
index 00000000..bc840764
--- /dev/null
+++ b/webui/src/i18n/locales/ko/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "nanobot에 연결 중…",
+ "boot": "nanobot 불러오는 중…"
+ },
+ "error": {
+ "title": "nanobot에 연결할 수 없습니다",
+ "gatewayHint": "gateway(`nanobot web`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot 웹 UI — nanobot 작업공간과 대화하세요."
+ }
+ },
+ "sidebar": {
+ "collapse": "사이드바 접기",
+ "toggleTheme": "테마 전환",
+ "newChat": "새 채팅",
+ "recent": "최근 대화",
+ "refreshSessions": "세션 새로고침",
+ "language": {
+ "label": "언어",
+ "ariaLabel": "언어 변경"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "채팅 {{id}}",
+ "loading": "불러오는 중…",
+ "noSessions": "아직 세션이 없습니다.",
+ "actions": "{{title}} 채팅 작업",
+ "delete": "삭제",
+ "newChat": "새 채팅"
+ },
+ "deleteConfirm": {
+ "title": "“{{title}}”을(를) 삭제할까요?",
+ "description": "세션 파일이 디스크에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.",
+ "cancel": "취소",
+ "confirm": "삭제"
+ },
+ "connection": {
+ "idle": "대기 중",
+ "connecting": "연결 중…",
+ "open": "연결됨",
+ "reconnecting": "재연결 중…",
+ "closed": "연결 끊김",
+ "error": "연결 오류"
+ },
+ "thread": {
+ "loadingConversation": "대화 불러오는 중…",
+ "empty": {
+ "description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
+ },
+ "header": {
+ "toggleSidebar": "사이드바 전환"
+ },
+ "composer": {
+ "placeholderThread": "메시지를 입력하세요…",
+ "placeholderHero": "무슨 생각을 하고 있나요?",
+ "placeholderOpening": "새 채팅을 여는 중…",
+ "inputAria": "메시지 입력",
+ "sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
+ "send": "메시지 보내기"
+ },
+ "scrollToBottom": "맨 아래로 스크롤"
+ },
+ "message": {
+ "streaming": "생성 중",
+ "assistantTyping": "도우미가 입력 중",
+ "toolSingle": "도구 사용 중",
+ "toolMany": "도구 {{count}}개 사용됨"
+ },
+ "code": {
+ "fallbackLanguage": "코드",
+ "copyAria": "코드 복사",
+ "copy": "복사",
+ "copied": "복사됨"
+ }
+}
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
new file mode 100644
index 00000000..d648f60e
--- /dev/null
+++ b/webui/src/i18n/locales/vi/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "Đang kết nối tới nanobot…",
+ "boot": "Đang tải nanobot…"
+ },
+ "error": {
+ "title": "Không thể kết nối tới nanobot",
+ "gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot web`) và trang này được mở trên cùng máy."
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "Giao diện web nanobot — trò chuyện với workspace nanobot của bạn."
+ }
+ },
+ "sidebar": {
+ "collapse": "Thu gọn thanh bên",
+ "toggleTheme": "Chuyển giao diện",
+ "newChat": "Cuộc trò chuyện mới",
+ "recent": "Gần đây",
+ "refreshSessions": "Làm mới phiên",
+ "language": {
+ "label": "Ngôn ngữ",
+ "ariaLabel": "Đổi ngôn ngữ"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "Trò chuyện {{id}}",
+ "loading": "Đang tải…",
+ "noSessions": "Chưa có phiên nào.",
+ "actions": "Tác vụ cho cuộc trò chuyện {{title}}",
+ "delete": "Xóa",
+ "newChat": "Cuộc trò chuyện mới"
+ },
+ "deleteConfirm": {
+ "title": "Xóa “{{title}}”?",
+ "description": "Tệp phiên sẽ bị xóa khỏi đĩa. Không thể hoàn tác thao tác này.",
+ "cancel": "Hủy",
+ "confirm": "Xóa"
+ },
+ "connection": {
+ "idle": "Rảnh",
+ "connecting": "Đang kết nối…",
+ "open": "Đã kết nối",
+ "reconnecting": "Đang kết nối lại…",
+ "closed": "Đã ngắt kết nối",
+ "error": "Lỗi kết nối"
+ },
+ "thread": {
+ "loadingConversation": "Đang tải cuộc trò chuyện…",
+ "empty": {
+ "description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
+ },
+ "header": {
+ "toggleSidebar": "Bật/tắt thanh bên"
+ },
+ "composer": {
+ "placeholderThread": "Nhập tin nhắn…",
+ "placeholderHero": "Bạn đang nghĩ gì?",
+ "placeholderOpening": "Đang mở cuộc trò chuyện mới…",
+ "inputAria": "Ô nhập tin nhắn",
+ "sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
+ "send": "Gửi tin nhắn"
+ },
+ "scrollToBottom": "Cuộn xuống cuối"
+ },
+ "message": {
+ "streaming": "đang truyền",
+ "assistantTyping": "Trợ lý đang nhập",
+ "toolSingle": "Đang dùng một công cụ",
+ "toolMany": "Đã dùng {{count}} công cụ"
+ },
+ "code": {
+ "fallbackLanguage": "mã",
+ "copyAria": "Sao chép mã",
+ "copy": "Sao chép",
+ "copied": "Đã sao chép"
+ }
+}
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
new file mode 100644
index 00000000..67a12f3f
--- /dev/null
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "正在连接 nanobot…",
+ "boot": "正在加载 nanobot…"
+ },
+ "error": {
+ "title": "无法连接到 nanobot",
+ "gatewayHint": "请确认 gateway 已启动(`nanobot web`),并且当前页面与 gateway 运行在同一台机器上。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI —— 与你的 nanobot 工作区对话。"
+ }
+ },
+ "sidebar": {
+ "collapse": "收起侧边栏",
+ "toggleTheme": "切换主题",
+ "newChat": "新建对话",
+ "recent": "最近对话",
+ "refreshSessions": "刷新会话",
+ "language": {
+ "label": "语言",
+ "ariaLabel": "切换语言"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "对话 {{id}}",
+ "loading": "加载中…",
+ "noSessions": "还没有会话。",
+ "actions": "“{{title}}” 的会话操作",
+ "delete": "删除",
+ "newChat": "新建对话"
+ },
+ "deleteConfirm": {
+ "title": "删除“{{title}}”?",
+ "description": "这个会话文件会从磁盘中删除,且无法撤销。",
+ "cancel": "取消",
+ "confirm": "删除"
+ },
+ "connection": {
+ "idle": "空闲",
+ "connecting": "连接中…",
+ "open": "已连接",
+ "reconnecting": "重连中…",
+ "closed": "已断开",
+ "error": "连接出错"
+ },
+ "thread": {
+ "loadingConversation": "正在加载对话…",
+ "empty": {
+ "description": "可以提问、继续本地工作,或者开启一个新线程。"
+ },
+ "header": {
+ "toggleSidebar": "切换侧边栏"
+ },
+ "composer": {
+ "placeholderThread": "输入消息…",
+ "placeholderHero": "你在想什么?",
+ "placeholderOpening": "正在打开新对话…",
+ "inputAria": "消息输入框",
+ "sendHint": "Enter 发送 · Shift+Enter 换行",
+ "send": "发送消息"
+ },
+ "scrollToBottom": "滚动到底部"
+ },
+ "message": {
+ "streaming": "流式输出中",
+ "assistantTyping": "助手正在输入",
+ "toolSingle": "正在使用工具",
+ "toolMany": "已使用 {{count}} 个工具"
+ },
+ "code": {
+ "fallbackLanguage": "代码",
+ "copyAria": "复制代码",
+ "copy": "复制",
+ "copied": "已复制"
+ }
+}
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
new file mode 100644
index 00000000..743ca587
--- /dev/null
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -0,0 +1,83 @@
+{
+ "app": {
+ "brand": "nanobot",
+ "loading": {
+ "connecting": "正在連線到 nanobot…",
+ "boot": "正在載入 nanobot…"
+ },
+ "error": {
+ "title": "無法連線到 nanobot",
+ "gatewayHint": "請確認 gateway 已啟動(`nanobot web`),並且目前頁面與 gateway 在同一台機器上開啟。"
+ },
+ "documentTitle": {
+ "base": "nanobot",
+ "chat": "{{title}} · nanobot"
+ },
+ "meta": {
+ "description": "nanobot Web UI —— 與你的 nanobot 工作區對話。"
+ }
+ },
+ "sidebar": {
+ "collapse": "收合側邊欄",
+ "toggleTheme": "切換主題",
+ "newChat": "新增對話",
+ "recent": "最近對話",
+ "refreshSessions": "重新整理會話",
+ "language": {
+ "label": "語言",
+ "ariaLabel": "切換語言"
+ }
+ },
+ "chat": {
+ "fallbackTitle": "對話 {{id}}",
+ "loading": "載入中…",
+ "noSessions": "目前還沒有會話。",
+ "actions": "「{{title}}」的會話操作",
+ "delete": "刪除",
+ "newChat": "新增對話"
+ },
+ "deleteConfirm": {
+ "title": "刪除「{{title}}」?",
+ "description": "這個會話檔案會從磁碟中移除,而且無法復原。",
+ "cancel": "取消",
+ "confirm": "刪除"
+ },
+ "connection": {
+ "idle": "閒置",
+ "connecting": "連線中…",
+ "open": "已連線",
+ "reconnecting": "重新連線中…",
+ "closed": "已中斷",
+ "error": "連線錯誤"
+ },
+ "thread": {
+ "loadingConversation": "正在載入對話…",
+ "empty": {
+ "description": "你可以提問、延續本地工作,或是開始新的執行緒。"
+ },
+ "header": {
+ "toggleSidebar": "切換側邊欄"
+ },
+ "composer": {
+ "placeholderThread": "輸入訊息…",
+ "placeholderHero": "你在想什麼?",
+ "placeholderOpening": "正在開啟新對話…",
+ "inputAria": "訊息輸入框",
+ "sendHint": "Enter 送出 · Shift+Enter 換行",
+ "send": "送出訊息"
+ },
+ "scrollToBottom": "捲動到底部"
+ },
+ "message": {
+ "streaming": "串流輸出中",
+ "assistantTyping": "助理正在輸入",
+ "toolSingle": "正在使用工具",
+ "toolMany": "已使用 {{count}} 個工具"
+ },
+ "code": {
+ "fallbackLanguage": "程式碼",
+ "copyAria": "複製程式碼",
+ "copy": "複製",
+ "copied": "已複製"
+ }
+}
diff --git a/webui/src/lib/format.ts b/webui/src/lib/format.ts
index 3fc5c962..fd5c43a9 100644
--- a/webui/src/lib/format.ts
+++ b/webui/src/lib/format.ts
@@ -1,3 +1,5 @@
+import i18n, { currentLocale } from "@/i18n";
+
/** Truncate the first user message into a chat title. */
export function deriveTitle(preview: string | undefined, fallback: string): string {
if (!preview) return fallback;
@@ -23,22 +25,53 @@ const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[Number.POSITIVE_INFINITY, "year"],
];
-const RTF = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+const relativeTimeFormatters = new Map();
+const dateTimeFormatters = new Map();
-export function relativeTime(value: string | number | null | undefined): string {
+function activeLocale(locale?: string): string {
+ return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
+}
+
+function relativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
+ const existing = relativeTimeFormatters.get(locale);
+ if (existing) return existing;
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
+ relativeTimeFormatters.set(locale, formatter);
+ return formatter;
+}
+
+function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
+ const existing = dateTimeFormatters.get(locale);
+ if (existing) return existing;
+ const formatter = new Intl.DateTimeFormat(locale, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+ dateTimeFormatters.set(locale, formatter);
+ return formatter;
+}
+
+export function relativeTime(
+ value: string | number | null | undefined,
+ locale?: string,
+): string {
const date = parseDate(value);
if (!date) return "";
let delta = (date.getTime() - Date.now()) / 1000;
+ const formatter = relativeTimeFormatter(activeLocale(locale));
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
- return RTF.format(Math.round(delta), unit);
+ return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
- return RTF.format(Math.round(delta), "year");
+ return formatter.format(Math.round(delta), "year");
}
-export function fmtDateTime(value: string | number | null | undefined): string {
+export function fmtDateTime(
+ value: string | number | null | undefined,
+ locale?: string,
+): string {
const date = parseDate(value);
- return date ? date.toLocaleString() : "";
+ return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
}
diff --git a/webui/src/main.tsx b/webui/src/main.tsx
index 81cab64b..ed79c766 100644
--- a/webui/src/main.tsx
+++ b/webui/src/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
+import "./i18n";
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index a7c3e00d..fd9756d1 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -144,5 +144,5 @@ describe("App layout", () => {
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
- });
+ }, 15_000);
});
diff --git a/webui/src/tests/format.i18n.test.ts b/webui/src/tests/format.i18n.test.ts
new file mode 100644
index 00000000..517b1953
--- /dev/null
+++ b/webui/src/tests/format.i18n.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { setAppLanguage } from "@/i18n";
+import { fmtDateTime, relativeTime } from "@/lib/format";
+
+describe("localized format helpers", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-04-18T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("formats relative time using the active locale", async () => {
+ const value = "2026-04-18T11:59:00Z";
+
+ await setAppLanguage("en");
+ const english = relativeTime(value);
+
+ await setAppLanguage("zh-CN");
+ const chinese = relativeTime(value);
+
+ expect(english).toBe(
+ new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
+ -1,
+ "minute",
+ ),
+ );
+ expect(chinese).toBe(
+ new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }).format(
+ -1,
+ "minute",
+ ),
+ );
+ expect(english).not.toBe(chinese);
+ });
+
+ it("formats date-time using the active locale", async () => {
+ const value = "2026-04-18T08:30:00Z";
+ const date = new Date(value);
+
+ await setAppLanguage("en");
+ const english = fmtDateTime(value);
+
+ await setAppLanguage("fr");
+ const french = fmtDateTime(value);
+
+ expect(english).toBe(
+ new Intl.DateTimeFormat("en", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date),
+ );
+ expect(french).toBe(
+ new Intl.DateTimeFormat("fr", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date),
+ );
+ expect(english).not.toBe(french);
+ });
+});
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
new file mode 100644
index 00000000..66b02957
--- /dev/null
+++ b/webui/src/tests/i18n.test.tsx
@@ -0,0 +1,44 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { ThreadComposer } from "@/components/thread/ThreadComposer";
+
+describe("webui i18n", () => {
+ it("switches UI copy and document locale through the language switcher", async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(
+ screen.getByPlaceholderText("Type your message…"),
+ ).toBeInTheDocument();
+ expect(document.documentElement.lang).toBe("en");
+
+ await user.click(screen.getByRole("button", { name: "Change language" }));
+ await user.click(screen.getByRole("menuitemradio", { name: /简体中文/i }));
+
+ await waitFor(() => {
+ expect(document.documentElement.lang).toBe("zh-CN");
+ });
+ expect(localStorage.getItem("nanobot.locale")).toBe("zh-CN");
+ expect(screen.getByPlaceholderText("输入消息…")).toBeInTheDocument();
+ });
+
+ it("updates the composer aria label when the language changes", async () => {
+ render();
+
+ await act(async () => {
+ const { setAppLanguage } = await import("@/i18n");
+ await setAppLanguage("ja");
+ });
+
+ expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument();
+ });
+});
diff --git a/webui/src/tests/setup.ts b/webui/src/tests/setup.ts
index 4da32b32..bc8ec9d3 100644
--- a/webui/src/tests/setup.ts
+++ b/webui/src/tests/setup.ts
@@ -1,4 +1,7 @@
import "@testing-library/jest-dom/vitest";
+import { beforeEach } from "vitest";
+
+import i18n from "@/i18n";
// happy-dom doesn't ship with ``crypto.randomUUID``; shim a tiny v4-ish helper.
if (!("randomUUID" in globalThis.crypto)) {
@@ -12,3 +15,10 @@ if (!("randomUUID" in globalThis.crypto)) {
configurable: true,
});
}
+
+beforeEach(async () => {
+ await i18n.changeLanguage("en");
+ document.documentElement.lang = "en";
+ document.title = "nanobot";
+ localStorage.setItem("nanobot.locale", "en");
+});