feat(webui): add i18n support and locale switcher

This commit is contained in:
Xubin Ren
2026-04-19 06:39:06 +00:00
parent be10ba1f0d
commit 4650b23d75
34 changed files with 6654 additions and 65 deletions
+14 -7
View File
@@ -1,4 +1,5 @@
import { MoreHorizontal, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
@@ -19,10 +20,10 @@ interface ChatListProps {
loading?: boolean;
}
function titleFor(s: ChatSummary): string {
function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = s.preview?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return `Chat ${s.chatId.slice(0, 6)}`;
return fallbackTitle;
}
export function ChatList({
@@ -32,16 +33,19 @@ export function ChatList({
onRequestDelete,
loading,
}: ChatListProps) {
const { t } = useTranslation();
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">Loading</div>
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-xs text-muted-foreground">
No sessions yet.
{t("chat.noSessions")}
</div>
);
}
@@ -51,7 +55,10 @@ export function ChatList({
<ul className="space-y-0.5 px-2 py-1">
{sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(s);
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
return (
<li key={s.key}>
<div
@@ -80,7 +87,7 @@ export function ChatList({
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={`Chat actions for ${title}`}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
@@ -95,7 +102,7 @@ export function ChatList({
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+7 -3
View File
@@ -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 (
<div className={cn("overflow-hidden rounded-lg", className)}>
<div className="flex items-center justify-between bg-zinc-900 px-4 py-1.5 text-xs font-medium text-zinc-200">
<span className="lowercase">{language || "code"}</span>
<span className="lowercase">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
aria-label="Copy code"
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? "Copied" : "Copy"}</span>
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<SyntaxHighlighter
+5 -8
View File
@@ -1,34 +1,31 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { label: string; color: string }> = {
idle: { label: "Idle", color: "bg-card/40 text-muted-foreground" },
const COPY: Record<ConnectionStatus, { color: string }> = {
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<ConnectionStatus>(client.status);
@@ -53,7 +50,7 @@ export function ConnectionBadge() {
)}
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
{meta.label}
{t(`connection.${status}`)}
</span>
);
}
+10 -4
View File
@@ -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 (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {title}?</AlertDialogTitle>
<AlertDialogTitle>
{t("deleteConfirm.title", { title })}
</AlertDialogTitle>
<AlertDialogDescription>
The session file will be removed from disk. This cannot be undone.
{t("deleteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={onCancel}>
{t("deleteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+67
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={t("sidebar.language.ariaLabel")}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<Globe className="h-3.5 w-3.5" />
<span className="max-w-[7rem] truncate">{selected.nativeLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("sidebar.language.label")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={locale}
onValueChange={(value) => {
void setAppLanguage(value as SupportedLocale);
}}
>
{supportedLocales.map((option) => (
<DropdownMenuRadioItem key={option.code} value={option.code}>
<span className="flex min-w-0 items-center gap-2">
<span>{option.nativeLabel}</span>
{option.nativeLabel !== option.label ? (
<span className="truncate text-xs text-muted-foreground">
{option.label}
</span>
) : null}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+9 -3
View File
@@ -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 (
<span
aria-label="streaming"
aria-label={t("message.streaming")}
className={cn(
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
"rounded-sm bg-foreground/70 animate-pulse",
@@ -76,9 +78,10 @@ function StreamCursor() {
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
const { t } = useTranslation();
return (
<span
aria-label="Assistant is typing"
aria-label={t("message.assistantTyping")}
className="inline-flex items-center gap-1 py-1"
>
<Dot delay="0ms" />
@@ -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) {
>
<Wrench className="h-3.5 w-3.5" aria-hidden />
<span className="font-medium">
{count === 1 ? "Using a tool" : `Used ${count} tools`}
{count === 1
? t("message.toolSingle")
: t("message.toolMany", { count })}
</span>
<ChevronRight
aria-hidden
+10 -6
View File
@@ -1,7 +1,9 @@
import { Moon, PanelLeftClose, Plus, RefreshCcw, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ChatSummary } from "@/lib/types";
@@ -20,13 +22,14 @@ interface SidebarProps {
}
export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
<div className="flex items-center justify-between px-2 py-2">
<Button
variant="ghost"
size="icon"
aria-label="Collapse sidebar"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
@@ -35,7 +38,7 @@ export function Sidebar(props: SidebarProps) {
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
aria-label={t("sidebar.toggleTheme")}
onClick={props.onToggleTheme}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
@@ -53,18 +56,18 @@ export function Sidebar(props: SidebarProps) {
variant="outline"
>
<Plus className="h-3.5 w-3.5" />
New chat
{t("sidebar.newChat")}
</Button>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-[11px] font-medium text-muted-foreground">
<span>Recent</span>
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label="Refresh sessions"
aria-label={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
@@ -79,8 +82,9 @@ export function Sidebar(props: SidebarProps) {
/>
</div>
<Separator className="bg-sidebar-border/70" />
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs">
<ConnectionBadge />
<LanguageSwitcher />
</div>
</aside>
);
@@ -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<HTMLTextAreaElement>(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({
</span>
) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
Enter to send · Shift+Enter for newline
{t("thread.composer.sendHint")}
</span>
</div>
<span className="sm:hidden" aria-hidden />
@@ -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",
+3 -1
View File
@@ -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 (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Toggle sidebar"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
+14 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
@@ -33,6 +34,7 @@ export function ThreadShell({
onNewChat,
hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading } = useSessionHistory(historyKey);
@@ -105,7 +107,7 @@ export function ThreadShell({
const emptyState = loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading conversation
{t("thread.loadingConversation")}
</div>
) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
@@ -120,7 +122,7 @@ export function ThreadShell({
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
Ask questions, continue local work, or start a new thread.
{t("thread.empty.description")}
</p>
</div>
);
@@ -142,7 +144,11 @@ export function ThreadShell({
<ThreadComposer
onSend={send}
disabled={!chatId}
placeholder={showHeroComposer ? "What's on your mind?" : "Type your message…"}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
@@ -150,7 +156,11 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
placeholder={booting ? "Opening a new chat…" : "What's on your mind?"}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
@@ -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<HTMLDivElement>(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")}
>
<ArrowDown className="h-4 w-4" />
</Button>
+10 -6
View File
@@ -58,12 +58,14 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
showCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
@@ -77,10 +79,12 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</SheetPortal>
));