Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
e881f86309 fix(ui): translate hardcoded English strings in shared ui package
The four user-visible strings exposed by packages/ui rendered untranslated
on every page that used them:

- file-upload-button.tsx — "Attach file" aria-label/title
- sidebar.tsx — "Toggle Sidebar" sr-only label/aria-label/title
- pagination.tsx — "Go to previous/next page" aria-labels
- CodeBlock.tsx — "plain text" language fallback + "Copy code" aria-label/tooltip

Root cause: the package had no i18n hookup at all because the package
boundary rule forbids importing @multica/core. Replicating the pattern
five times would have been the same hack five times. Hooking up
react-i18next directly is the structurally clean fix — i18next is a
generic library, not business logic, and the upstream I18nextProvider
already exposes the instance via context.

To let packages/ui typecheck the selector form standalone (i.e. without
the views resource-types augmentation in scope), the augmentation is
split: views declares everything except the `ui` namespace on a new
global `I18nResources` interface, and packages/ui contributes the `ui`
slice via declaration merging in packages/ui/types/i18next.ts. Views'
resources-types side-effect-imports that file so both packages see the
merged shape during downstream typechecks.

Scope intentionally excludes:
- packages/ui/components/common/error-boundary.tsx — keeping its fallback
  in English so a render-time crash never depends on i18n being healthy.
- apps/desktop/src/renderer/src/components/update-notification.tsx —
  ships with the next desktop release, not via this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:21:44 +08:00
11 changed files with 125 additions and 35 deletions

View File

@@ -2,6 +2,7 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@multica/ui/lib/utils";
interface FileUploadButtonProps {
@@ -18,7 +19,9 @@ function FileUploadButton({
className,
size = "default",
}: FileUploadButtonProps) {
const { t } = useTranslation("ui");
const inputRef = useRef<HTMLInputElement>(null);
const attachLabel = t(($) => $.attach_file);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -36,8 +39,8 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label="Attach file"
title="Attach file"
aria-label={attachLabel}
title={attachLabel}
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,

View File

@@ -1,4 +1,5 @@
import * as React from "react"
import { useTranslation } from "react-i18next"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
@@ -67,9 +68,10 @@ function PaginationPrevious({
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label="Go to previous page"
aria-label={t(($) => $.pagination_previous)}
size="default"
className={cn("pl-1.5!", className)}
{...props}
@@ -85,9 +87,10 @@ function PaginationNext({
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label="Go to next page"
aria-label={t(($) => $.pagination_next)}
size="default"
className={cn("pr-1.5!", className)}
{...props}

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useTranslation } from "react-i18next"
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
import { cn } from "@multica/ui/lib/utils"
@@ -265,6 +266,7 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation("ui")
return (
<Button
@@ -280,13 +282,15 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t(($) => $.toggle_sidebar)}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
const { t } = useTranslation("ui")
const toggleLabel = t(($) => $.toggle_sidebar)
const didDragRef = React.useRef(false)
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
@@ -330,11 +334,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={toggleLabel}
tabIndex={-1}
onClick={handleClick}
onMouseDown={onMouseDown}
title="Toggle Sidebar"
title={toggleLabel}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",

View File

@@ -1,6 +1,7 @@
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { Copy, Check } from "lucide-react"
import { useTranslation } from "react-i18next"
import { Button } from "@multica/ui/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
import { cn } from '@multica/ui/lib/utils'
@@ -61,6 +62,7 @@ export function CodeBlock({
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const { t } = useTranslation("ui")
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
@@ -178,7 +180,7 @@ export function CodeBlock({
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
{resolvedLang !== 'text' ? resolvedLang : t(($) => $.plain_text)}
</span>
<Tooltip>
<TooltipTrigger
@@ -188,7 +190,7 @@ export function CodeBlock({
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
aria-label={t(($) => $.copy_code)}
>
{copied ? (
<Check className="size-3.5 text-success" />
@@ -198,7 +200,7 @@ export function CodeBlock({
</Button>
}
/>
<TooltipContent>Copy code</TooltipContent>
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
</Tooltip>
</div>

View File

@@ -17,6 +17,7 @@
"./hooks/*": "./hooks/*.ts",
"./lib/utils": "./lib/utils.ts",
"./lib/data-table": "./lib/data-table.ts",
"./i18n-types": "./types/i18next.ts",
"./styles/tokens.css": "./styles/tokens.css",
"./styles/base.css": "./styles/base.css"
},
@@ -52,8 +53,10 @@
"vaul": "^1.1.2"
},
"peerDependencies": {
"i18next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"react-i18next": "catalog:"
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",

View File

@@ -0,0 +1,35 @@
import "i18next";
// Local slice of the i18next augmentation that owns the `ui` namespace.
// The base augmentation lives in packages/views/i18n/resources-types.ts and
// declares everything else; this file contributes only the `ui` entry via
// declaration merging on the global `I18nResources` interface so
// packages/ui can typecheck the selector form standalone without depending
// on @multica/views.
//
// When both files are loaded together (in a consumer's typecheck program),
// the two augmentations compose: views contributes common/auth/... and ui
// contributes `ui`. No properties overlap, so the merge is conflict-free.
//
// The resource shape is mirrored from packages/views/locales/{en,zh-Hans}/ui.json.
// Drift between the JSON and these types is not caught by the locale parity
// test — if you add a key to ui.json, mirror it here.
declare global {
interface I18nResources {
ui: {
attach_file: string;
toggle_sidebar: string;
pagination_previous: string;
pagination_next: string;
copy_code: string;
plain_text: string;
};
}
}
declare module "i18next" {
interface CustomTypeOptions {
resources: I18nResources;
enableSelector: true;
}
}

View File

@@ -1,4 +1,8 @@
import "i18next";
// Pulls in the `ui` namespace augmentation owned by packages/ui — see
// packages/ui/types/i18next.ts. Side-effect import is required for views'
// typecheck program to see ui's contribution to `I18nResources`.
import "@multica/ui/i18n-types";
import type common from "../locales/en/common.json";
import type auth from "../locales/en/auth.json";
import type settings from "../locales/en/settings.json";
@@ -30,33 +34,43 @@ import type usage from "../locales/en/usage.json";
// Adding a namespace: drop a JSON file under en/ and zh-Hans/, then add
// the matching `import type` + entry below. Type inference on `t($ => $)`
// follows automatically.
//
// The resource shape lives on a global `I18nResources` interface (not a
// type literal inside CustomTypeOptions) so other packages can contribute
// namespaces via declaration merging. See packages/ui/types/i18next.d.ts —
// it adds the `ui` namespace there, which lets packages/ui typecheck the
// selector form standalone without depending on @multica/views.
declare global {
interface I18nResources {
common: typeof common;
auth: typeof auth;
settings: typeof settings;
issues: typeof issues;
agents: typeof agents;
editor: typeof editor;
onboarding: typeof onboarding;
invite: typeof invite;
labels: typeof labels;
members: typeof members;
"my-issues": typeof myIssues;
search: typeof search;
inbox: typeof inbox;
workspace: typeof workspace;
projects: typeof projects;
autopilots: typeof autopilots;
skills: typeof skills;
chat: typeof chat;
modals: typeof modals;
runtimes: typeof runtimes;
layout: typeof layout;
usage: typeof usage;
}
}
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "common";
resources: {
common: typeof common;
auth: typeof auth;
settings: typeof settings;
issues: typeof issues;
agents: typeof agents;
editor: typeof editor;
onboarding: typeof onboarding;
invite: typeof invite;
labels: typeof labels;
members: typeof members;
"my-issues": typeof myIssues;
search: typeof search;
inbox: typeof inbox;
workspace: typeof workspace;
projects: typeof projects;
autopilots: typeof autopilots;
skills: typeof skills;
chat: typeof chat;
modals: typeof modals;
runtimes: typeof runtimes;
layout: typeof layout;
usage: typeof usage;
};
resources: I18nResources;
enableSelector: true;
}
}

View File

@@ -0,0 +1,8 @@
{
"attach_file": "Attach file",
"toggle_sidebar": "Toggle Sidebar",
"pagination_previous": "Go to previous page",
"pagination_next": "Go to next page",
"copy_code": "Copy code",
"plain_text": "plain text"
}

View File

@@ -21,6 +21,7 @@ import enModals from "./en/modals.json";
import enRuntimes from "./en/runtimes.json";
import enLayout from "./en/layout.json";
import enUsage from "./en/usage.json";
import enUi from "./en/ui.json";
import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
@@ -43,6 +44,7 @@ import zhHansModals from "./zh-Hans/modals.json";
import zhHansRuntimes from "./zh-Hans/runtimes.json";
import zhHansLayout from "./zh-Hans/layout.json";
import zhHansUsage from "./zh-Hans/usage.json";
import zhHansUi from "./zh-Hans/ui.json";
// Single source of truth for the resource bundle. Both apps (web layout +
// desktop App.tsx) import from here so adding a locale or namespace happens
@@ -71,6 +73,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
runtimes: enRuntimes,
layout: enLayout,
usage: enUsage,
ui: enUi,
},
"zh-Hans": {
common: zhHansCommon,
@@ -95,5 +98,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
runtimes: zhHansRuntimes,
layout: zhHansLayout,
usage: zhHansUsage,
ui: zhHansUi,
},
};

View File

@@ -0,0 +1,8 @@
{
"attach_file": "添加附件",
"toggle_sidebar": "切换侧边栏",
"pagination_previous": "上一页",
"pagination_next": "下一页",
"copy_code": "复制代码",
"plain_text": "纯文本"
}

6
pnpm-lock.yaml generated
View File

@@ -591,6 +591,9 @@ importers:
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
i18next:
specifier: 'catalog:'
version: 26.0.8(typescript@5.9.3)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -615,6 +618,9 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.2.3(react@19.2.3)
react-i18next:
specifier: 'catalog:'
version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.3)