From 1ad0bb60198ad33128c70a90a0b822b5daa9d550 Mon Sep 17 00:00:00 2001 From: Sam Wong Date: Thu, 6 Aug 2026 10:35:32 -0700 Subject: [PATCH] feat(desktop): add i18n infrastructure with a Language setting (#2929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires react-i18next into the desktop app with `en` as default and fallback, adds a Language selector in Settings persisted through the existing settings mechanism, and extracts a bounded first slice of 140 keys across four high-traffic surfaces (Settings, sidebar, composer/message actions, agents). Everything not yet extracted stays hardcoded and renders in English, so no surface regresses while extraction continues surface by surface. A big-bang pass over all ~520 desktop `.tsx` files would not be reviewable. `desktop/src/locales/STRINGS-MANIFEST.md` lists every key with its English source and one line of UI context, so a human translator can work without reading the code. Empty locale values fall back to English (`returnEmptyString: false`), so a locale can land partially translated and ship the rest later. Machine translation is deliberately not a build step — translation quality stays a human decision per locale. `AppSidebar.tsx` and `MessageComposer.tsx` are left untouched: both sit at the 1000-line desktop file-size ratchet, and extraction cannot grow them. Their strings fall back to English and can be extracted when those files are split. Verification: `just desktop-check` clean (tsc, biome, file-size ratchet), `just desktop-test` 4394 passed / 0 failed, including a fallback test in `desktop/src/i18n.test.mjs`. Signed-off-by: Sam Wong --- desktop/package.json | 2 + desktop/src/features/agents/ui/AgentsView.tsx | 20 +- .../agents/ui/UnifiedAgentsSection.tsx | 30 +-- .../communities/ui/CommunitySwitcher.tsx | 46 +++-- .../messages/ui/ComposerReplyEditBanner.tsx | 10 +- .../ui/DeleteMessageConfirmDialog.tsx | 10 +- .../messages/ui/FormattingToolbar.tsx | 30 +-- .../messages/ui/MentionAutocomplete.tsx | 28 ++- .../features/messages/ui/MessageActionBar.tsx | 39 ++-- .../messages/ui/MessageComposerToolbar.tsx | 38 ++-- .../features/settings/ui/LanguageSetting.tsx | 40 ++++ .../features/settings/ui/SettingsPanels.tsx | 86 +++++---- .../src/features/settings/ui/SettingsView.tsx | 39 ++-- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 26 ++- .../sidebar/ui/ChannelContextMenu.tsx | 34 ++-- .../features/sidebar/ui/SidebarSection.tsx | 13 +- desktop/src/i18n.test.mjs | 58 ++++++ desktop/src/i18n.ts | 77 ++++++++ desktop/src/locales/STRINGS-MANIFEST.md | 148 ++++++++++++++ desktop/src/locales/en.json | 180 ++++++++++++++++++ desktop/src/locales/zh-Hans.json | 180 ++++++++++++++++++ desktop/src/locales/zh-Hant.json | 180 ++++++++++++++++++ desktop/src/main.tsx | 1 + pnpm-lock.yaml | 57 ++++++ 24 files changed, 1195 insertions(+), 177 deletions(-) create mode 100644 desktop/src/features/settings/ui/LanguageSetting.tsx create mode 100644 desktop/src/i18n.test.mjs create mode 100644 desktop/src/i18n.ts create mode 100644 desktop/src/locales/STRINGS-MANIFEST.md create mode 100644 desktop/src/locales/en.json create mode 100644 desktop/src/locales/zh-Hans.json create mode 100644 desktop/src/locales/zh-Hant.json diff --git a/desktop/package.json b/desktop/package.json index a1fd2e919d..c3f9de8f62 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -64,6 +64,7 @@ "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", + "i18next": "^25.10.5", "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", "motion": "^12.38.0", @@ -72,6 +73,7 @@ "react": "^19.1.0", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", + "react-i18next": "^16.6.2", "react-markdown": "^10.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f9ada91c2f..f80a08d1fd 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, @@ -39,6 +40,7 @@ import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; export function AgentsView() { + const { t } = useTranslation(); const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const { globalConfig } = useGlobalAgentConfig(); const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); @@ -150,8 +152,8 @@ export function AgentsView() { > {hasSavedAgentDefaults - ? "Agent defaults" - : "Set agent defaults"} + ? t("agents.defaults") + : t("agents.setDefaults")} {runningAgentCount > 0 ? ( ) : null} @@ -171,7 +173,7 @@ export function AgentsView() { {canInvite && onInvite ? ( ) : null} @@ -362,8 +364,8 @@ export function CommunitySwitcher({ diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index afe2cb22a5..94912c56de 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -13,6 +13,7 @@ import { SquareCode, Strikethrough, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -129,6 +130,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ disabled = false, onLinkButton, }: FormattingToolbarProps) { + const { t } = useTranslation(); const pendingSelectionRef = React.useRef( null, ); @@ -254,18 +256,18 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ const hasSelection = from !== to; if (hasSelection) { - const url = window.prompt("Enter URL:"); + const url = window.prompt(t("composer.format.enterUrl")); if (url) { editor.chain().focus().setLink({ href: url }).run(); } } else { - const url = window.prompt("Enter URL:"); + const url = window.prompt(t("composer.format.enterUrl")); if (url) { - const label = window.prompt("Link text:", url) || url; + const label = window.prompt(t("composer.format.linkText"), url) || url; editor.chain().focus().insertContent(`[${label}](${url})`).run(); } } - }, [editor, formattingChain, onLinkButton, restorePendingSelection]); + }, [editor, formattingChain, onLinkButton, restorePendingSelection, t]); const toggleBulletList = React.useCallback(() => { formattingChain() @@ -320,66 +322,66 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ const items = [ { icon: Bold, - label: "Bold", + label: t("composer.format.bold"), shortcut: "⌘B", action: toggleBold, active: activeStates.bold, }, { icon: Italic, - label: "Italic", + label: t("composer.format.italic"), shortcut: "⌘I", action: toggleItalic, active: activeStates.italic, }, { icon: Strikethrough, - label: "Strikethrough", + label: t("composer.format.strikethrough"), shortcut: "⌘⇧X", action: toggleStrike, active: activeStates.strike, }, { icon: Code, - label: "Code", + label: t("composer.format.code"), shortcut: "⌘E", action: toggleCode, active: activeStates.code, }, { icon: SquareCode, - label: "Code block", + label: t("composer.format.codeBlock"), action: toggleCodeBlock, active: activeStates.codeBlock, }, { icon: Link, - label: "Link", + label: t("composer.format.link"), shortcut: "⌘K", action: toggleLink, active: activeStates.link, }, { icon: List, - label: "Bullet list", + label: t("composer.format.bulletList"), action: toggleBulletList, active: activeStates.bulletList, }, { icon: ListOrdered, - label: "Ordered list", + label: t("composer.format.orderedList"), action: toggleOrderedList, active: activeStates.orderedList, }, { icon: Quote, - label: "Quote", + label: t("composer.format.quote"), action: toggleBlockquote, active: activeStates.blockquote, }, { icon: HatGlasses, - label: "Spoiler", + label: t("composer.format.spoiler"), action: toggleSpoiler, active: activeStates.spoiler, }, diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..58c4910d51 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { Bot, Users } from "lucide-react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; @@ -42,6 +43,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ onSelect, position = "above", }: MentionAutocompleteProps) { + const { t } = useTranslation(); const listRef = React.useRef(null); React.useEffect(() => { @@ -100,7 +102,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ (suggestion.personaId ? `persona-${suggestion.personaId}` : null) ?? (suggestion.teamId ? `team-${suggestion.teamId}` : null) ?? suggestion.displayName; - const agentLabel = "agent"; + const agentLabel = t("composer.mention.agent"); const hasNameCollision = (nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1; const collisionNpub = @@ -160,7 +162,9 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ {suggestion.kind === "team" ? ( ) : suggestion.isAgent ? ( @@ -184,17 +188,25 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ className="min-w-0 truncate" title={ suggestion.ownerLabel && suggestion.notInChannel - ? `managed by ${suggestion.ownerLabel} · not in channel` + ? t("composer.mention.managedByNotInChannel", { + owner: suggestion.ownerLabel, + }) : suggestion.ownerLabel - ? `managed by ${suggestion.ownerLabel}` - : "not in channel" + ? t("composer.mention.managedBy", { + owner: suggestion.ownerLabel, + }) + : t("composer.mention.notInChannel") } > {suggestion.ownerLabel && suggestion.notInChannel - ? `managed by ${suggestion.ownerLabel} · not in channel` + ? t("composer.mention.managedByNotInChannel", { + owner: suggestion.ownerLabel, + }) : suggestion.ownerLabel - ? `managed by ${suggestion.ownerLabel}` - : "not in channel"} + ? t("composer.mention.managedBy", { + owner: suggestion.ownerLabel, + }) + : t("composer.mention.notInChannel")} ) : null} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 967e50f5d2..e9d78a09e4 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -14,6 +14,7 @@ import { Trash2, } from "lucide-react"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; @@ -82,6 +83,7 @@ function MoreActionsMenu({ isFollowingThread?: boolean; isUnread?: boolean; }) { + const { t } = useTranslation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false); // Set true the moment the user picks "Edit message". The @@ -112,7 +114,7 @@ function MoreActionsMenu({ - Reply + {t("composer.actions.reply")} ) : null} diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index efb0d3b3b9..bfc46b1d0a 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -52,6 +53,7 @@ export const MessageComposerToolbar = React.memo( onPaperclip: () => void; sendDisabled: boolean; }) { + const { t } = useTranslation(); return (
- Formatting + + {t("composer.toolbar.formatting")} + - Close formatting + + {t("composer.toolbar.closeFormatting")} +
@@ -162,7 +168,7 @@ export const MessageComposerToolbar = React.memo( - Mention someone + + {t("composer.toolbar.mention")} + - Attach file + + {t("composer.toolbar.attachFile")} + - Formatting + + {t("composer.toolbar.formatting")} + @@ -232,7 +244,11 @@ export const MessageComposerToolbar = React.memo(
{extraActions} @@ -777,9 +794,9 @@ function ThreadLayoutSetting() { value={option.value} > - {option.label} + {t(option.labelKey)} - {option.description} + {t(option.descriptionKey)} @@ -802,9 +819,12 @@ function AccentPickerContent({ isDark: boolean; setAccentColor: (value: string) => void; }) { + const { t } = useTranslation(); return (
-

Accent color

+

+ {t("settings.appearance.accentColor")} +

{ACCENT_COLORS.map((color) => { const isNeutral = color.value === NEUTRAL_ACCENT; diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 991029dca7..6d6dc2b1c3 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import { @@ -49,11 +50,11 @@ type SettingsViewProps = SettingsPanelProps & { }; const settingsNavGroups: Array<{ - label: string; + labelKey: string; sections: SettingsSection[]; }> = [ { - label: "Personal", + labelKey: "settings.nav.groups.personal", sections: [ "profile", "appearance", @@ -66,11 +67,11 @@ const settingsNavGroups: Array<{ ], }, { - label: "Communities", + labelKey: "settings.nav.groups.communities", sections: ["hosted-communities", "community-members"], }, { - label: "App", + labelKey: "settings.nav.groups.app", sections: ["agents", "compute", "experimental", "mobile", "updates"], }, ]; @@ -85,6 +86,8 @@ function SettingsSectionButton({ section: (typeof settingsSections)[number]; }) { const Icon = section.icon; + const { t } = useTranslation(); + const label = t(section.labelKey); return ( @@ -93,7 +96,7 @@ function SettingsSectionButton({ data-testid={`settings-nav-${section.value}`} isActive={active} onClick={() => onSelect(section.value)} - tooltip={section.label} + tooltip={label} type="button" > - {section.label} + {label} ); @@ -127,6 +130,7 @@ export function SettingsView({ onSetSoundForSlot, section, }: SettingsViewProps) { + const { t } = useTranslation(); const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); const featureState = useFeatureSnapshot(); @@ -227,11 +231,11 @@ export function SettingsView({ - Back to app + {t("settings.nav.backToApp")} @@ -244,7 +248,7 @@ export function SettingsView({ data-testid="community-access-loading" > - Checking invite permissions… + {t("settings.access.checking")}
) : null} {myMembershipQuery.isError ? ( @@ -254,7 +258,7 @@ export function SettingsView({ >
- Invite settings could not be checked. + {t("settings.access.error")}
) : null} @@ -272,15 +276,18 @@ export function SettingsView({ data-testid="community-access-snapshot-missing" > - Invite settings are unavailable. Relay recovery may still be in - progress. + {t("settings.access.unavailable")}
) : null} {visibleNavGroups.map((group) => ( - - {group.label} + + {t(group.labelKey)} - + {group.sections.map((entry) => ( - Inbox + {t("sidebar.primary.inbox")} {homeBadgeCount > 0 ? ( @@ -130,11 +132,11 @@ export function AppSidebarPrimaryMenu({ data-testid="open-pulse-view" isActive={selectedView === "pulse"} onClick={onSelectPulse} - tooltip="Pulse" + tooltip={t("sidebar.primary.pulse")} type="button" > - Pulse + {t("sidebar.primary.pulse")} @@ -144,11 +146,13 @@ export function AppSidebarPrimaryMenu({ data-testid="open-projects-view" isActive={selectedView === "projects"} onClick={onSelectProjects} - tooltip="Projects" + tooltip={t("sidebar.primary.projects")} type="button" > - Projects + + {t("sidebar.primary.projects")} + @@ -158,7 +162,7 @@ export function AppSidebarPrimaryMenu({ data-testid="open-agents-view" isActive={selectedView === "agents"} onClick={onSelectAgents} - tooltip="Agents" + tooltip={t("sidebar.primary.agents")} type="button" > - Agents + {t("sidebar.primary.agents")} @@ -179,11 +183,13 @@ export function AppSidebarPrimaryMenu({ data-testid="open-workflows-view" isActive={selectedView === "workflows"} onClick={onSelectWorkflows} - tooltip="Workflows" + tooltip={t("sidebar.primary.workflows")} type="button" > - Workflows + + {t("sidebar.primary.workflows")} + diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 78f3929f19..2338ec6a6d 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -14,6 +14,7 @@ import { Trash2, TriangleAlert, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useAppShell } from "@/app/AppShellContext"; import { @@ -108,31 +109,29 @@ function MoveToSectionSubmenu({ * "Copy" submenu (channel name / channel ID). */ function CopyChannelSubmenu({ channel }: { channel: Channel }) { + const { t } = useTranslation(); return ( - Copy + {t("sidebar.channel.copy")} - copyTextToClipboard( - channel.name, - "Channel name copied to clipboard", - ) + copyTextToClipboard(channel.name, t("sidebar.channel.nameCopied")) } > - Copy channel name + {t("sidebar.channel.copyName")} - copyTextToClipboard(channel.id, "Channel ID copied to clipboard") + copyTextToClipboard(channel.id, t("sidebar.channel.idCopied")) } > - Copy channel ID + {t("sidebar.channel.copyId")} @@ -179,6 +178,7 @@ export function ChannelContextMenuItems({ onDeleteChannel?: (channel: Channel) => void; onLeaveChannel?: (channel: Channel) => void; }) { + const { t } = useTranslation(); const { feedItemState, hasSidebarUnreadProjections, @@ -261,7 +261,7 @@ export function ChannelContextMenuItems({ - Mark as read + {t("sidebar.channel.markAsRead")} ) : !hasProjectedUnread && onMarkChannelUnread ? ( - Mark unread + {t("sidebar.channel.markUnread")} ) : null} {showMuteToggle || showStar ? : null} @@ -286,7 +286,7 @@ export function ChannelContextMenuItems({ - Unmute channel + {t("sidebar.channel.unmute")} ) : ( - Mute channel + {t("sidebar.channel.mute")} ) ) : null} @@ -309,7 +309,7 @@ export function ChannelContextMenuItems({ - Unstar channel + {t("sidebar.channel.unstar")} ) : ( - Star channel + {t("sidebar.channel.star")} ) ) : null} @@ -331,7 +331,7 @@ export function ChannelContextMenuItems({ - Leave channel + {t("sidebar.channel.leave")} ) : null} {ownerActionsLoading ? ( @@ -358,7 +358,7 @@ export function ChannelContextMenuItems({ - Archive channel + {t("sidebar.channel.archive")} ) : null} {canDeleteChannel ? ( @@ -370,7 +370,7 @@ export function ChannelContextMenuItems({ - Delete channel + {t("sidebar.channel.delete")} ) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 56e12146b9..2622dc3daf 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,4 +1,5 @@ import type * as React from "react"; +import { useTranslation } from "react-i18next"; import { BellOff, ChevronDown, @@ -70,6 +71,7 @@ function UnreadCountBadge({ className?: string; count: number; }) { + const { t } = useTranslation(); return ( {formatUnreadCount(count)} - new comment{count === 1 ? "" : "s"} + + {" "} + {t("sidebar.channel.newComments", { count })} + ); } @@ -91,12 +96,13 @@ function UnreadDotBadge({ channelName: string; className?: string; }) { + const { t } = useTranslation(); return ( - unread + {t("sidebar.channel.unread")} ); } @@ -423,6 +429,7 @@ export function SidebarSection({ onUnmuteChannel?: (channelId: string) => void; sectionActionsOpen?: boolean; }) { + const { t } = useTranslation(); if (items.length === 0 && !action && !emptyState) { return null; } @@ -503,7 +510,7 @@ export function SidebarSection({ ) : null} {channel.channelType === "dm" && onHideDm ? (