feat(i18n): translate layout namespace (sidebar nav, help, loader)

Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
  autopilots / agents / runtimes / skills / settings) — driven by
  labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
  / Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip

Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).

Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-30 16:07:00 +08:00
parent a0c13b6b9f
commit 2c0a362692
8 changed files with 106 additions and 25 deletions

View File

@@ -82,6 +82,9 @@ const TRANSLATED_FILES = [
"runtimes/components/runtime-detail-page.tsx",
"runtimes/components/runtime-detail.tsx",
"runtimes/components/shared.tsx",
"layout/app-sidebar.tsx",
"layout/help-launcher.tsx",
"layout/workspace-loader.tsx",
];
export default [

View File

@@ -19,6 +19,7 @@ import type skills from "../locales/en/skills.json";
import type chat from "../locales/en/chat.json";
import type modals from "../locales/en/modals.json";
import type runtimes from "../locales/en/runtimes.json";
import type layout from "../locales/en/layout.json";
// Module augmentation enables i18next v26 selector API across the monorepo:
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
@@ -52,6 +53,7 @@ declare module "i18next" {
chat: typeof chat;
modals: typeof modals;
runtimes: typeof runtimes;
layout: typeof layout;
};
enableSelector: true;
}

View File

@@ -76,6 +76,7 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
import type { PinnedItem } from "@multica/core/types";
import { useLogout } from "../auth";
import { ProjectIcon } from "../projects/components/project-icon";
import { useT } from "../i18n";
// Top-level nav items stay active when the user is on a child route
// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
@@ -109,22 +110,34 @@ type NavKey =
| "skills"
| "settings";
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "inbox", label: "Inbox", icon: Inbox },
{ key: "myIssues", label: "My Issues", icon: CircleUser },
// Static schema (key + icon) — labels resolved at render via useT("layout").
type NavLabelKey =
| "inbox"
| "my_issues"
| "issues"
| "projects"
| "autopilots"
| "agents"
| "runtimes"
| "skills"
| "settings";
const personalNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "inbox", labelKey: "inbox", icon: Inbox },
{ key: "myIssues", labelKey: "my_issues", icon: CircleUser },
];
const workspaceNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "issues", label: "Issues", icon: ListTodo },
{ key: "projects", label: "Projects", icon: FolderKanban },
{ key: "autopilots", label: "Autopilot", icon: Zap },
{ key: "agents", label: "Agents", icon: Bot },
const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "issues", labelKey: "issues", icon: ListTodo },
{ key: "projects", labelKey: "projects", icon: FolderKanban },
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
{ key: "agents", labelKey: "agents", icon: Bot },
];
const configureNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "runtimes", label: "Runtimes", icon: Monitor },
{ key: "skills", label: "Skills", icon: BookOpenText },
{ key: "settings", label: "Settings", icon: Settings },
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
{ key: "runtimes", labelKey: "runtimes", icon: Monitor },
{ key: "skills", labelKey: "skills", icon: BookOpenText },
{ key: "settings", labelKey: "settings", icon: Settings },
];
function DraftDot() {
@@ -154,6 +167,7 @@ function SortablePinItem({
label: string;
iconNode: React.ReactNode;
}) {
const { t } = useT("layout");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
@@ -208,7 +222,7 @@ function SortablePinItem({
>
<X className="size-1" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>Unpin</TooltipContent>
<TooltipContent side="top" sideOffset={4}>{t(($) => $.sidebar.unpin_tooltip)}</TooltipContent>
</Tooltip>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -308,6 +322,7 @@ interface AppSidebarProps {
}
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { t } = useT("layout");
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
@@ -589,8 +604,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
{item.label === "Inbox" && unreadCount > 0 && (
<span>{t(($) => $.nav[item.labelKey])}</span>
{item.key === "inbox" && unreadCount > 0 && (
<span className="ml-auto text-xs">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
@@ -653,7 +668,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
<span>{t(($) => $.nav[item.labelKey])}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -677,8 +692,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
{item.label === "Runtimes" && hasRuntimeUpdates && (
<span>{t(($) => $.nav[item.labelKey])}</span>
{item.key === "runtimes" && hasRuntimeUpdates && (
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
)}
</SidebarMenuButton>

View File

@@ -8,16 +8,18 @@ import {
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useModalStore } from "@multica/core/modals";
import { useT } from "../i18n";
const DOCS_URL = "https://multica.ai/docs";
const CHANGELOG_URL = "https://multica.ai/changelog";
export function HelpLauncher() {
const { t } = useT("layout");
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Help"
title="Help"
aria-label={t(($) => $.help.trigger)}
title={t(($) => $.help.trigger)}
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-foreground data-popup-open:bg-accent data-popup-open:text-foreground"
>
<CircleHelp className="size-4" />
@@ -34,7 +36,7 @@ export function HelpLauncher() {
}
>
<BookOpen className="h-3.5 w-3.5" />
Docs
{t(($) => $.help.docs)}
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/50" />
</DropdownMenuItem>
<DropdownMenuItem
@@ -47,14 +49,14 @@ export function HelpLauncher() {
}
>
<History className="h-3.5 w-3.5" />
Change log
{t(($) => $.help.changelog)}
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/50" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => useModalStore.getState().open("feedback")}
>
<MessageCircle className="h-3.5 w-3.5" />
Feedback
{t(($) => $.help.feedback)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,6 +1,7 @@
"use client";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { useT } from "../i18n";
/**
* Full-screen workspace loader. Renders IN PLACE OF the dashboard during:
@@ -13,6 +14,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
* workspace have been freshly fetched.
*/
export function WorkspaceLoader({ name }: { name?: string | null }) {
const { t } = useT("layout");
return (
<div
className="flex h-svh w-full items-center justify-center bg-background"
@@ -23,10 +25,11 @@ export function WorkspaceLoader({ name }: { name?: string | null }) {
<MulticaIcon className="size-8 animate-pulse" />
{name ? (
<p className="text-sm text-muted-foreground">
Loading <span className="font-medium text-foreground">{name}</span>
{t(($) => $.workspace_loader.loading_named_prefix)}{" "}
<span className="font-medium text-foreground">{name}</span>
</p>
) : (
<p className="text-sm text-muted-foreground">Loading workspace</p>
<p className="text-sm text-muted-foreground">{t(($) => $.workspace_loader.loading_workspace)}</p>
)}
</div>
</div>

View File

@@ -0,0 +1,26 @@
{
"nav": {
"inbox": "Inbox",
"my_issues": "My Issues",
"issues": "Issues",
"projects": "Projects",
"autopilots": "Autopilot",
"agents": "Agents",
"runtimes": "Runtimes",
"skills": "Skills",
"settings": "Settings"
},
"help": {
"trigger": "Help",
"docs": "Docs",
"changelog": "Change log",
"feedback": "Feedback"
},
"workspace_loader": {
"loading_workspace": "Loading workspace…",
"loading_named_prefix": "Loading"
},
"sidebar": {
"unpin_tooltip": "Unpin"
}
}

View File

@@ -16,6 +16,7 @@ import enSkills from "./en/skills.json";
import enChat from "./en/chat.json";
import enModals from "./en/modals.json";
import enRuntimes from "./en/runtimes.json";
import enLayout from "./en/layout.json";
import zhHansCommon from "./zh-Hans/common.json";
import zhHansAuth from "./zh-Hans/auth.json";
import zhHansSettings from "./zh-Hans/settings.json";
@@ -33,6 +34,7 @@ import zhHansSkills from "./zh-Hans/skills.json";
import zhHansChat from "./zh-Hans/chat.json";
import zhHansModals from "./zh-Hans/modals.json";
import zhHansRuntimes from "./zh-Hans/runtimes.json";
import zhHansLayout from "./zh-Hans/layout.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
@@ -56,6 +58,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
chat: enChat,
modals: enModals,
runtimes: enRuntimes,
layout: enLayout,
},
"zh-Hans": {
common: zhHansCommon,
@@ -75,5 +78,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
chat: zhHansChat,
modals: zhHansModals,
runtimes: zhHansRuntimes,
layout: zhHansLayout,
},
};

View File

@@ -0,0 +1,26 @@
{
"nav": {
"inbox": "收件箱",
"my_issues": "我的 issue",
"issues": "issue",
"projects": "project",
"autopilots": "autopilot",
"agents": "智能体",
"runtimes": "运行时",
"skills": "skill",
"settings": "设置"
},
"help": {
"trigger": "帮助",
"docs": "文档",
"changelog": "更新日志",
"feedback": "反馈"
},
"workspace_loader": {
"loading_workspace": "正在加载工作区…",
"loading_named_prefix": "正在加载"
},
"sidebar": {
"unpin_tooltip": "取消固定"
}
}