From 33d0c5735a981d004ec5b2d143bad4d70ddc17bd Mon Sep 17 00:00:00 2001 From: ZytriuNks <875003720@qq.com> Date: Mon, 14 Sep 2026 23:07:24 +0800 Subject: [PATCH] feat(web): add Simplified Chinese localization --- apps/web/src/AppRoot.tsx | 15 +- apps/web/src/clientPersistenceStorage.test.ts | 12 ++ apps/web/src/components/CommandPalette.tsx | 6 +- apps/web/src/components/DiffPanel.tsx | 46 +++--- apps/web/src/components/chat/ChatComposer.tsx | 8 +- .../chat/ComposerPrimaryActions.tsx | 14 +- .../src/components/chat/MessagesTimeline.tsx | 24 ++- .../settings/ConnectionsSettings.tsx | 51 ++++-- .../settings/ProviderSettingsPanel.tsx | 7 +- .../components/settings/SettingsPanels.tsx | 53 +++++++ .../settings/SettingsSidebarNav.tsx | 30 +++- .../settings/settingsSearch.test.ts | 2 + .../src/components/settings/settingsSearch.ts | 6 + .../src/components/sidebar/SidebarChrome.tsx | 10 +- apps/web/src/i18n/I18nProvider.test.tsx | 58 +++++++ apps/web/src/i18n/I18nProvider.tsx | 74 +++++++++ apps/web/src/i18n/locale.test.ts | 41 +++++ apps/web/src/i18n/locale.ts | 39 +++++ apps/web/src/i18n/messages.test.ts | 29 ++++ apps/web/src/i18n/messages.ts | 145 ++++++++++++++++++ apps/web/src/main.tsx | 2 + apps/web/src/timestampFormat.test.ts | 14 ++ apps/web/src/timestampFormat.ts | 139 ++++++++++++----- .../src/connection/presentation.test.ts | 24 +++ .../src/connection/presentation.ts | 54 +++++-- packages/contracts/src/settings.test.ts | 18 +++ packages/contracts/src/settings.ts | 8 + 27 files changed, 809 insertions(+), 120 deletions(-) create mode 100644 apps/web/src/i18n/I18nProvider.test.tsx create mode 100644 apps/web/src/i18n/I18nProvider.tsx create mode 100644 apps/web/src/i18n/locale.test.ts create mode 100644 apps/web/src/i18n/locale.ts create mode 100644 apps/web/src/i18n/messages.test.ts create mode 100644 apps/web/src/i18n/messages.ts diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 857125c9fdaf..ebd22792b44a 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -4,6 +4,7 @@ import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; +import { I18nProvider } from "./i18n/I18nProvider"; import type { AppRouter } from "./router"; /** @@ -13,11 +14,13 @@ import type { AppRouter } from "./router"; */ export function AppRoot({ router }: { readonly router: AppRouter }) { return ( - - - - - - + + + + + + + + ); } diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 17e531918ab1..36184c0f1695 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -114,6 +114,18 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("diffWordWrap"); }); + it("keeps the language across reloads and defaults it to the system", async () => { + const testWindow = getTestWindow(); + const { readBrowserClientSettings, writeBrowserClientSettings } = + await import("./clientPersistenceStorage"); + + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); + expect(readBrowserClientSettings()?.language).toBe("system"); + + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, language: "zh-CN" }); + expect(readBrowserClientSettings()?.language).toBe("zh-CN"); + }); + it("keeps the default diff file state across reloads and defaults it to expanded", async () => { const testWindow = getTestWindow(); const { readBrowserClientSettings, writeBrowserClientSettings } = diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f33a9cce63b0..c6fa4b570585 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -74,6 +74,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; +import { useConnectionStatusCopy } from "../i18n/I18nProvider"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; @@ -619,6 +620,7 @@ function OpenCommandPaletteDialog(props: { readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { + const connectionStatusCopy = useConnectionStatusCopy(); const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; @@ -874,7 +876,7 @@ function OpenCommandPaletteDialog(props: { isPrimary, machine: resolveEnvironmentMachineKind(environment.serverConfig), isConnected: canCreateProjectInEnvironment(environment.connection.phase), - status: connectionStatusText(environment.connection), + status: connectionStatusText(environment.connection, connectionStatusCopy), }; }); @@ -886,7 +888,7 @@ function OpenCommandPaletteDialog(props: { }); return options; - }, [environments]); + }, [connectionStatusCopy, environments]); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions.find((option) => option.isConnected)?.environmentId ?? null; const wslAddProjectEnvironmentOption = useMemo( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ca0bdaa7b4d5..d88479e113f7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -49,6 +49,7 @@ import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefres import { useProject, useThread } from "../state/entities"; import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { useI18n } from "../i18n/I18nProvider"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -110,6 +111,7 @@ export default function DiffPanel({ initialGitScope: initialGitScopeProp, workspaceMutationId, }: DiffPanelProps) { + const { language, t } = useI18n(); const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [initialGitScope] = useState(initialGitScopeProp); @@ -216,21 +218,21 @@ export default function DiffPanel({ const selectedScopeLabel = selectedTurnId === null ? selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes" + ? t("diff.scope.workingTree") + : t("diff.scope.branchChanges") : selectedTurn?.turnId === latestTurn?.turnId - ? "Latest turn" - : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + ? t("diff.scope.latestTurn") + : t("diff.scope.turn", { count: selectedCheckpointTurnCount ?? "?" }); const reviewSectionId = selectedTurn ? `turn:${selectedTurn.turnId}` : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; const reviewSectionTitle = selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + ? t("diff.scope.turn", { count: selectedCheckpointTurnCount ?? "?" }) : selectedGitScope === "unstaged" - ? "Working tree" - : "Branch changes"; + ? t("diff.scope.workingTree") + : t("diff.scope.branchChanges"); const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" @@ -558,7 +560,7 @@ export default function DiffPanel({ {selectedScopeLabel} @@ -572,7 +574,7 @@ export default function DiffPanel({ } onClick={() => selectGitScope("unstaged")} > - Working tree + {t("diff.scope.workingTree")} selectGitScope("branch")} > - Branch changes + {t("diff.scope.branchChanges")} - Latest turn + {t("diff.scope.latestTurn")} - Turn + {t("diff.scope.turns")} {orderedTurnDiffSummaries.map((summary) => { const turnCount = @@ -612,9 +614,13 @@ export default function DiffPanel({ } onClick={() => selectTurn(summary.turnId)} > - Turn {turnCount} + {t("diff.scope.turn", { count: turnCount })} - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + {formatShortTimestamp( + summary.completedAt, + settings.timestampFormat, + language, + )} ); @@ -895,15 +901,15 @@ export default function DiffPanel({ {!activeThread ? (
- Select a thread to inspect turn diffs. + {t("diff.empty.selectThread")}
) : !isGitRepo ? (
- Turn diffs are unavailable because this project is not a git repository. + {t("diff.empty.notRepository")}
) : selectedTurnId !== null && orderedTurnDiffSummaries.length === 0 ? (
- No completed turns yet. + {t("diff.empty.noCompletedTurns")}
) : ( <> @@ -932,11 +938,7 @@ export default function DiffPanel({ /> ) : (
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

+

{hasNoNetChanges ? t("diff.empty.noNetChanges") : t("diff.empty.noPatch")}

) ) : renderablePatch.kind === "files" ? ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fafdee59e042..9cb265d0fca2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -75,6 +75,7 @@ import { replaceTextRange, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; +import { useI18n } from "../../i18n/I18nProvider"; import { deriveComposerSendState, getAntigravitySendBlockReason, @@ -1430,6 +1431,7 @@ export interface ChatComposerProps { // -------------------------------------------------------------------------- export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + const { t } = useI18n(); const { composerDraftTarget, environmentId, @@ -6672,12 +6674,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired - ? "Choose a project above to start a thread" + ? t("composer.placeholder.chooseProject") : showProviderUnavailable - ? "Enable a provider in Settings to send a message" + ? t("composer.placeholder.enableProvider") : phase === "disconnected" ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : t("composer.placeholder.default") } disabled={ isConnecting || diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 91c54b75ed03..3a2b0059fc42 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -7,6 +7,7 @@ import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Spinner } from "../ui/spinner"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { useI18n } from "../../i18n/I18nProvider"; interface PendingActionState { questionIndex: number; @@ -81,6 +82,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ? { onPointerDown: preventPointerFocus } : undefined; const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const { t } = useI18n(); const isSendDisabled = sendDisabledReason !== null; const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", @@ -99,7 +101,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ )} {...pointerFocusProps} onClick={onInterrupt} - aria-label="Stop generation" + aria-label={t("composer.stopGeneration")} >
@@ -2022,10 +2033,15 @@ function AssistantMessageMeta({ {!message.streaming && ( }> - {formatDayAwareTimestamp(message.updatedAt, ctx.timestampFormat)} + {formatDayAwareTimestamp( + message.updatedAt, + ctx.timestampFormat, + undefined, + ctx.language, + )} - {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat)} + {formatChatTimestampTooltip(message.updatedAt, ctx.timestampFormat, ctx.language)} )} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f76e6ef10d64..ce1e2da8eadf 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -50,6 +50,8 @@ import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { useConnectionStatusCopy, useI18n } from "../../i18n/I18nProvider"; +import type { AppLanguage } from "../../i18n/locale"; import { cn } from "../../lib/utils"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; @@ -191,17 +193,23 @@ const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; -const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); +const accessTimestampFormatters: Record = { + en: new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), + "zh-CN": new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + }), +}; -function formatAccessTimestamp(value: string): string { +function formatAccessTimestamp(value: string, language: AppLanguage): string { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } - return accessTimestampFormatter.format(parsed); + return accessTimestampFormatters[language].format(parsed); } const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ @@ -597,6 +605,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { + const { language } = useI18n(); const nowMs = useRelativeTimeTick(1_000); const expiresAtMs = useMemo( () => new Date(pairingLink.expiresAt).getTime(), @@ -731,7 +740,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ if (credential) copyPairingValue(credential, "code"); }, [copyPairingValue, credential]); - const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt, language); const primaryLabel = pairingLink.label ?? "Pairing link"; const selectedQrOption = selectQrEndpointOption( @@ -754,7 +763,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

{primaryLabel}

@@ -762,7 +771,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

}> - {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} + {formatExpiresInLabel(pairingLink.expiresAt, nowMs, language)} {expiresAbsolute} @@ -978,16 +987,21 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ revokingClientSessionId, onRevokeSession, }: ConnectedClientListRowProps) { + const { language, t } = useI18n(); const nowMs = useRelativeTimeTick(1_000); const isLive = clientSession.current || clientSession.connected; const lastConnectedAt = clientSession.lastConnectedAt; const statusTooltip = isLive ? lastConnectedAt - ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}` - : "Connected" + ? t("connection.connectedFor", { + duration: formatElapsedDurationLabel(lastConnectedAt, nowMs, language), + }) + : t("connection.connected") : lastConnectedAt - ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` - : "Not connected yet."; + ? t("connection.lastConnectedAt", { + timestamp: formatAccessTimestamp(lastConnectedAt, language), + }) + : t("connection.notConnectedYet"); const deviceInfoBits = [ clientSession.client.deviceType !== "unknown" ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) @@ -1479,6 +1493,8 @@ function SavedBackendListRow({ onSetEnabled, onRemove, }: SavedBackendListRowProps) { + const connectionStatusCopy = useConnectionStatusCopy(); + const { t } = useI18n(); const environmentId = environment.environmentId; const enabled = environment.entry.enabled; const isConnected = environment.connection.phase === "connected"; @@ -1551,9 +1567,14 @@ function SavedBackendListRow({ {subtitleText} - {enabled ? connectionStatusText(environment.connection) : "Switched off"} + {enabled + ? connectionStatusText(environment.connection, connectionStatusCopy) + : t("connection.switchedOff")} {versionMismatch - ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}` + ? `\n${t("connection.updateAvailable", { + serverVersion: versionMismatch.serverVersion, + clientVersion: versionMismatch.clientVersion, + })}` : ""} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index f1966fe45474..189df64048c2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -30,6 +30,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; +import { useConnectionStatusCopy } from "../../i18n/I18nProvider"; import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, @@ -227,6 +228,7 @@ function EnvironmentUnavailablePlaceholder({ readonly access: Exclude; readonly deviceTabs?: ReactNode; }) { + const connectionStatusCopy = useConnectionStatusCopy(); const isLoading = access.kind === "loading"; const title = isLoading ? "Loading provider settings" @@ -239,7 +241,7 @@ function EnvironmentUnavailablePlaceholder({ ? access.reason === "permissions" ? "Checking what this session is allowed to change." : `Waiting for ${environment.label}'s configuration.` - : connectionStatusTitle(environment.connection); + : connectionStatusTitle(environment.connection, connectionStatusCopy); const error = isLoading ? null : environment.connection.error; // No spinner: this state can persist indefinitely for a wedged device, and a // continuously repainting animation would run the whole time. @@ -281,6 +283,7 @@ export function ProviderSettingsPanel(target: ProviderSettingsTarget) { } function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { + const connectionStatusCopy = useConnectionStatusCopy(); const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const searchTargetId = useSettingsSearchTargetId(); @@ -350,7 +353,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { {options.map((environment) => { const machine = resolveEnvironmentMachineKind(environment.serverConfig); const detail = providerEnvironmentDetail(environment); - const statusText = connectionStatusTitle(environment.connection); + const statusText = connectionStatusTitle(environment.connection, connectionStatusCopy); return ( = { artwork: "Artwork", @@ -537,6 +539,7 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode ? ["Environment identification"] : []), + ...(settings.language !== DEFAULT_UNIFIED_SETTINGS.language ? ["Language"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -663,6 +666,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, + settings.language, settings.timestampFormat, settings.notificationMode, settings.inAppNotificationsEnabled, @@ -738,6 +742,7 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, + language: DEFAULT_UNIFIED_SETTINGS.language, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, @@ -2083,6 +2088,7 @@ function LegacyFeaturesSection() { export function GeneralSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const { t } = useI18n(); const navigate = useNavigate(); const { scope, environment, connectedEnvironments } = useSettingsScope(); // The representative environment supplies the provider list for pickers; @@ -2094,6 +2100,11 @@ export function GeneralSettingsPanel() { const hasServerTargets = connectedEnvironments.length > 0; const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const [tokenStreamingWarningOpen, setTokenStreamingWarningOpen] = useState(false); + const languageLabels: Record = { + system: t("settings.language.system"), + en: t("settings.language.english"), + "zh-CN": t("settings.language.simplifiedChinese"), + }; const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), @@ -2297,6 +2308,48 @@ export function GeneralSettingsPanel() { /> } /> + updateSettings({ language: DEFAULT_UNIFIED_SETTINGS.language })} + /> + ) : null + } + control={ + + } + /> > = { + "/settings/projects": "settings.section.project", + "/settings/general": "settings.section.general", + "/settings/appearance": "settings.section.appearance", + "/settings/keybindings": "settings.section.keybindings", + "/settings/snap-shot": "settings.section.snapshots", + "/settings/providers": "settings.section.providers", + "/settings/integrations": "settings.section.integrations", + "/settings/source-control": "settings.section.sourceControl", + "/settings/connections": "settings.section.connections", + "/settings/archived": "settings.section.archive", +}; + function SettingsSectionIcon({ to }: { to: SettingsPath }) { const Icon = SETTINGS_SECTION_ICONS[to]; return ; } export function SettingsSidebarNav({ pathname }: { pathname: string }) { + const { t } = useI18n(); const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); const currentSearch = useLocation({ select: (location) => location.search }); @@ -246,8 +262,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setActiveResultIndex(0); }} onKeyDown={handleSearchKeyDown} - placeholder="Search" - aria-label="Search settings" + placeholder={t("settings.search.placeholder")} + aria-label={t("settings.search.label")} role="combobox" aria-autocomplete="list" aria-expanded={isSearching && hasResults} @@ -265,7 +281,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { size="icon-micro" variant="ghost" className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground" - aria-label="Clear settings search" + aria-label={t("settings.search.clear")} onClick={() => { clearSearch(); searchInputRef.current?.focus(); @@ -282,7 +298,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { role="status" className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" > - No settings found + {t("settings.search.empty")}

) : null} {isSearching ? ( @@ -290,7 +306,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { className="ps-px" id={hasResults ? "settings-search-results" : undefined} role={hasResults ? "listbox" : undefined} - aria-label={hasResults ? "Settings search results" : undefined} + aria-label={hasResults ? t("settings.search.results") : undefined} > {results.map((item, index) => ( @@ -311,7 +327,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { {item.title} - {SETTINGS_SECTION_LABELS[item.to]} + {t(SETTINGS_SECTION_MESSAGE_KEYS[item.to])} @@ -333,7 +349,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { onClick={() => handleSectionClick(item.to)} > - {item.label} + {t(SETTINGS_SECTION_MESSAGE_KEYS[item.to])} ); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ea9e1a88ec30..b2085820eaa4 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -218,6 +218,8 @@ describe("searchSettings", () => { }); it("serves anchor props to panels from the catalog", () => { + expect(searchableSetting("time-format")).toEqual({ id: "time-format", title: "Time format" }); + expect(searchableSetting("language")).toEqual({ id: "language", title: "Language" }); expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" }); expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" }); }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 19268e2cdaed..2b164d1232df 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -249,6 +249,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["notification toast popup completion input approval failure"], }, + { + id: "language", + title: "Language", + to: "/settings/general", + searchTerms: ["locale system english simplified chinese 中文 简体中文 语言"], + }, { id: "time-format", title: "Time format", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index afbbf7671dfc..c78b77e85d8d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -31,6 +31,7 @@ import { } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { useI18n } from "../../i18n/I18nProvider"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -132,6 +133,7 @@ function SidebarUtilityItem({ } export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { + const { t } = useI18n(); const navigate = useNavigate(); const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); @@ -192,26 +194,26 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { - Back + {t("navigation.back")} ) : ( <> } - label="Settings" + label={t("navigation.settings")} onClick={handleSettingsClick} /> {pullRequestsSupported ? ( } - label="Pull Requests" + label={t("navigation.pullRequests")} onClick={handlePullRequestsClick} /> ) : null} } - label="Usage" + label={t("navigation.usage")} onClick={handleUsageClick} /> diff --git a/apps/web/src/i18n/I18nProvider.test.tsx b/apps/web/src/i18n/I18nProvider.test.tsx new file mode 100644 index 000000000000..1518bba3dd0a --- /dev/null +++ b/apps/web/src/i18n/I18nProvider.test.tsx @@ -0,0 +1,58 @@ +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const settingsState = vi.hoisted(() => ({ language: "zh-CN" as "system" | "en" | "zh-CN" })); + +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: (selector: (settings: typeof settingsState) => T) => + selector(settingsState), +})); + +import { I18nProvider, useI18n } from "./I18nProvider"; + +let renderer: ReactTestRenderer | undefined; + +function Probe() { + const { language, t } = useI18n(); + return {`${language}:${t("settings.language.title")}`}; +} + +function renderProvider(children: ReactNode) { + return create({children}); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { documentElement: { lang: "en" } }); + settingsState.language = "zh-CN"; +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("I18nProvider", () => { + it("updates translated copy and the document language with the client preference", async () => { + await act(() => { + renderer = renderProvider(); + }); + + expect(renderer!.toJSON()).toMatchObject({ children: ["zh-CN:语言"] }); + expect(document.documentElement.lang).toBe("zh-CN"); + + settingsState.language = "en"; + await act(() => { + renderer!.update( + + + , + ); + }); + + expect(renderer!.toJSON()).toMatchObject({ children: ["en:Language"] }); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/apps/web/src/i18n/I18nProvider.tsx b/apps/web/src/i18n/I18nProvider.tsx new file mode 100644 index 000000000000..9f09777f62dd --- /dev/null +++ b/apps/web/src/i18n/I18nProvider.tsx @@ -0,0 +1,74 @@ +import type { ConnectionStatusCopy } from "@t3tools/client-runtime/connection"; +import type { LanguagePreference } from "@t3tools/contracts/settings"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from "react"; + +import { useClientSettings } from "../hooks/useSettings"; +import { resolveAppLanguage, resolveSystemLocale, type AppLanguage } from "./locale"; +import { translate, type MessageKey, type MessageValues } from "./messages"; + +export interface I18nContextValue { + readonly language: AppLanguage; + readonly preference: LanguagePreference; + readonly t: (key: MessageKey, values?: MessageValues) => string; +} + +const defaultValue: I18nContextValue = { + language: "en", + preference: "system", + t: (key, values) => translate("en", key, values), +}; + +const I18nContext = createContext(defaultValue); + +function readSystemLocale(): string | null { + if (typeof window === "undefined" || typeof navigator === "undefined") return null; + + return resolveSystemLocale({ + desktopLocale: window.desktopBridge?.getSystemLocale?.() ?? null, + browserLanguages: + navigator.languages.length > 0 + ? navigator.languages + : navigator.language + ? [navigator.language] + : [], + }); +} + +export function I18nProvider({ children }: { readonly children: ReactNode }) { + const preference = useClientSettings((settings) => settings.language); + const systemLocale = useMemo(() => readSystemLocale(), []); + const language = resolveAppLanguage(preference, systemLocale); + const t = useCallback( + (key: MessageKey, values?: MessageValues) => translate(language, key, values), + [language], + ); + const value = useMemo(() => ({ language, preference, t }), [language, preference, t]); + + useEffect(() => { + document.documentElement.lang = language; + }, [language]); + + return {children}; +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext); +} + +export function useConnectionStatusCopy(): ConnectionStatusCopy { + const { t } = useI18n(); + return useMemo( + () => ({ + available: t("connection.available"), + offline: t("connection.offline"), + connecting: t("connection.connecting"), + reconnecting: t("connection.reconnecting"), + reconnectingWithReason: (reason) => t("connection.reconnectingWithReason", { reason }), + connected: t("connection.connected"), + connectionFailed: t("connection.failed"), + connectionFailedWithReason: (reason) => t("connection.failedWithReason", { reason }), + reconnectingTitle: t("connection.reconnectingTitle"), + }), + [t], + ); +} diff --git a/apps/web/src/i18n/locale.test.ts b/apps/web/src/i18n/locale.test.ts new file mode 100644 index 000000000000..40e3226d9fc3 --- /dev/null +++ b/apps/web/src/i18n/locale.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAppLanguage, resolveSystemLocale } from "./locale"; + +describe("resolveAppLanguage", () => { + it.each(["zh-CN", "zh-SG", "zh-Hans", "zh"])( + "uses simplified Chinese for the supported system locale %s", + (systemLocale) => { + expect(resolveAppLanguage("system", systemLocale)).toBe("zh-CN"); + }, + ); + + it.each(["zh-TW", "zh-HK", "zh-Hant", "en-US", "de-DE", null, undefined])( + "falls back to English for the unsupported system locale %s", + (systemLocale) => { + expect(resolveAppLanguage("system", systemLocale)).toBe("en"); + }, + ); + + it("keeps an explicit language independent of the system locale", () => { + expect(resolveAppLanguage("en", "zh-CN")).toBe("en"); + expect(resolveAppLanguage("zh-CN", "en-US")).toBe("zh-CN"); + }); +}); + +describe("resolveSystemLocale", () => { + it("prefers the desktop host locale over the browser locale", () => { + expect( + resolveSystemLocale({ + desktopLocale: "zh-CN", + browserLanguages: ["en-US"], + }), + ).toBe("zh-CN"); + }); + + it("uses the first browser locale when the host reports none", () => { + expect(resolveSystemLocale({ desktopLocale: null, browserLanguages: ["zh-SG", "en-US"] })).toBe( + "zh-SG", + ); + }); +}); diff --git a/apps/web/src/i18n/locale.ts b/apps/web/src/i18n/locale.ts new file mode 100644 index 000000000000..85934871cd3a --- /dev/null +++ b/apps/web/src/i18n/locale.ts @@ -0,0 +1,39 @@ +import type { LanguagePreference } from "@t3tools/contracts/settings"; + +export type AppLanguage = Exclude; + +function isSimplifiedChineseLocale(locale: string | null | undefined): boolean { + const normalized = locale?.trim().replaceAll("_", "-").toLowerCase(); + if (!normalized) return false; + + return ( + normalized === "zh" || + normalized === "zh-cn" || + normalized.startsWith("zh-cn-") || + normalized === "zh-sg" || + normalized.startsWith("zh-sg-") || + normalized === "zh-hans" || + normalized.startsWith("zh-hans-") + ); +} + +export function resolveAppLanguage( + preference: LanguagePreference, + systemLocale: string | null | undefined, +): AppLanguage { + if (preference !== "system") return preference; + return isSimplifiedChineseLocale(systemLocale) ? "zh-CN" : "en"; +} + +export function resolveSystemLocale({ + desktopLocale, + browserLanguages, +}: { + readonly desktopLocale: string | null | undefined; + readonly browserLanguages: ReadonlyArray; +}): string | null { + const hostLocale = desktopLocale?.trim(); + if (hostLocale) return hostLocale; + + return browserLanguages.find((locale) => locale.trim().length > 0) ?? null; +} diff --git a/apps/web/src/i18n/messages.test.ts b/apps/web/src/i18n/messages.test.ts new file mode 100644 index 000000000000..8f5082577035 --- /dev/null +++ b/apps/web/src/i18n/messages.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EN_MESSAGES, ZH_CN_MESSAGES, translate } from "./messages"; + +describe("translation messages", () => { + it("keeps the English and Simplified Chinese dictionaries complete", () => { + expect(Object.keys(ZH_CN_MESSAGES).sort()).toEqual(Object.keys(EN_MESSAGES).sort()); + }); + + it("translates static copy", () => { + expect(translate("zh-CN", "settings.language.title")).toBe("语言"); + expect(translate("en", "settings.language.title")).toBe("Language"); + expect(translate("zh-CN", "connection.switchedOff")).toBe("已关闭"); + }); + + it("interpolates values without changing unknown user content", () => { + expect( + translate("zh-CN", "connection.reconnectingWithReason", { + reason: "Socket closed.", + }), + ).toBe("连接失败,正在重新连接… 原因:Socket closed."); + expect( + translate("zh-CN", "connection.updateAvailable", { + serverVersion: "1.2.3", + clientVersion: "1.2.4", + }), + ).toBe("有可用更新:1.2.3 → 1.2.4"); + }); +}); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts new file mode 100644 index 000000000000..c54275744276 --- /dev/null +++ b/apps/web/src/i18n/messages.ts @@ -0,0 +1,145 @@ +import type { AppLanguage } from "./locale"; + +export const EN_MESSAGES = { + "settings.language.title": "Language", + "settings.language.description": "Choose the language used by the interface on this device.", + "settings.language.system": "Follow system", + "settings.language.english": "English", + "settings.language.simplifiedChinese": "Simplified Chinese", + "connection.available": "Available", + "connection.offline": "Offline", + "connection.connecting": "Connecting...", + "connection.reconnecting": "Reconnecting...", + "connection.reconnectingWithReason": "Failed to connect. Reconnecting... Reason: {reason}", + "connection.connected": "Connected", + "connection.connectedFor": "Connected for {duration}", + "connection.lastConnectedAt": "Last connected at {timestamp}", + "connection.notConnectedYet": "Not connected yet.", + "connection.switchedOff": "Switched off", + "connection.updateAvailable": "Update available: {serverVersion} → {clientVersion}", + "connection.failed": "Connection failed", + "connection.failedWithReason": "Connection failed. Reason: {reason}", + "connection.reconnectingTitle": "Failed to connect. Reconnecting...", + "settings.search.placeholder": "Search", + "settings.search.label": "Search settings", + "settings.search.clear": "Clear settings search", + "settings.search.empty": "No settings found", + "settings.search.results": "Settings search results", + "settings.section.project": "Project", + "settings.section.general": "General", + "settings.section.appearance": "Appearance", + "settings.section.keybindings": "Keybindings", + "settings.section.snapshots": "SnapShots", + "settings.section.providers": "Providers", + "settings.section.integrations": "Integrations", + "settings.section.sourceControl": "Source Control", + "settings.section.connections": "Connections", + "settings.section.archive": "Archive", + "navigation.back": "Back", + "navigation.settings": "Settings", + "navigation.pullRequests": "Pull Requests", + "navigation.usage": "Usage", + "diff.scope.workingTree": "Working tree", + "diff.scope.branchChanges": "Branch changes", + "diff.scope.latestTurn": "Latest turn", + "diff.scope.turns": "Turn", + "diff.scope.turn": "Turn {count}", + "diff.scope.label": "Diff scope: {scope}", + "diff.empty.selectThread": "Select a thread to inspect turn diffs.", + "diff.empty.notRepository": + "Turn diffs are unavailable because this project is not a git repository.", + "diff.empty.noCompletedTurns": "No completed turns yet.", + "diff.empty.noNetChanges": "No net changes in this selection.", + "diff.empty.noPatch": "No patch available for this selection.", + "composer.stopGeneration": "Stop generation", + "composer.sendMessage": "Send message", + "composer.connecting": "Connecting", + "composer.sending": "Sending", + "composer.environmentDisconnected": "Environment disconnected", + "composer.preparingWorktree": "Preparing worktree", + "composer.placeholder.default": + "Ask anything, @tag files/folders, $use skills, or / for commands", + "composer.placeholder.chooseProject": "Choose a project above to start a thread", + "composer.placeholder.enableProvider": "Enable a provider in Settings to send a message", +} as const; + +export type MessageKey = keyof typeof EN_MESSAGES; +export type MessageValues = Readonly>; + +export const ZH_CN_MESSAGES = { + "settings.language.title": "语言", + "settings.language.description": "选择此设备上的界面语言。", + "settings.language.system": "跟随系统", + "settings.language.english": "English", + "settings.language.simplifiedChinese": "简体中文", + "connection.available": "可用", + "connection.offline": "离线", + "connection.connecting": "正在连接…", + "connection.reconnecting": "正在重新连接…", + "connection.reconnectingWithReason": "连接失败,正在重新连接… 原因:{reason}", + "connection.connected": "已连接", + "connection.connectedFor": "已连接 {duration}", + "connection.lastConnectedAt": "上次连接于 {timestamp}", + "connection.notConnectedYet": "尚未连接。", + "connection.switchedOff": "已关闭", + "connection.updateAvailable": "有可用更新:{serverVersion} → {clientVersion}", + "connection.failed": "连接失败", + "connection.failedWithReason": "连接失败。原因:{reason}", + "connection.reconnectingTitle": "连接失败,正在重新连接…", + "settings.search.placeholder": "搜索", + "settings.search.label": "搜索设置", + "settings.search.clear": "清除设置搜索", + "settings.search.empty": "未找到设置", + "settings.search.results": "设置搜索结果", + "settings.section.project": "项目", + "settings.section.general": "常规", + "settings.section.appearance": "外观", + "settings.section.keybindings": "快捷键", + "settings.section.snapshots": "截屏", + "settings.section.providers": "提供商", + "settings.section.integrations": "集成", + "settings.section.sourceControl": "源代码管理", + "settings.section.connections": "连接", + "settings.section.archive": "归档", + "navigation.back": "返回", + "navigation.settings": "设置", + "navigation.pullRequests": "拉取请求", + "navigation.usage": "用量", + "diff.scope.workingTree": "工作区", + "diff.scope.branchChanges": "分支更改", + "diff.scope.latestTurn": "最新轮次", + "diff.scope.turns": "轮次", + "diff.scope.turn": "第 {count} 轮", + "diff.scope.label": "差异范围:{scope}", + "diff.empty.selectThread": "请选择一个线程以查看轮次差异。", + "diff.empty.notRepository": "此项目不是 Git 仓库,无法查看轮次差异。", + "diff.empty.noCompletedTurns": "还没有已完成的轮次。", + "diff.empty.noNetChanges": "此选择中没有净更改。", + "diff.empty.noPatch": "此选择没有可用的补丁。", + "composer.stopGeneration": "停止生成", + "composer.sendMessage": "发送消息", + "composer.connecting": "正在连接", + "composer.sending": "正在发送", + "composer.environmentDisconnected": "环境已断开连接", + "composer.preparingWorktree": "正在准备工作树", + "composer.placeholder.default": + "输入问题,使用 @ 标记文件/文件夹,使用 $ 调用技能,或输入 / 查看命令", + "composer.placeholder.chooseProject": "请先在上方选择项目以开始线程", + "composer.placeholder.enableProvider": "请在设置中启用提供商后再发送消息", +} as const satisfies Record; + +const MESSAGES: Record> = { + en: EN_MESSAGES, + "zh-CN": ZH_CN_MESSAGES, +}; + +export function translate( + language: AppLanguage, + key: MessageKey, + values: MessageValues = {}, +): string { + return MESSAGES[language][key].replaceAll(/\{([^}]+)\}/g, (placeholder, name: string) => { + const value = values[name]; + return value === undefined ? placeholder : String(value); + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8cafbd5009b4..163a4fd55812 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import "./index.css"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; +import { ensureClientSettingsHydrated } from "./hooks/useSettings"; import { syncDocumentElectronPlatformClasses, syncDocumentWindowControlsOverlayClass, @@ -59,6 +60,7 @@ const managedAuthShellModule = export const startup = Promise.all([ managedAuthShellModule?.then((module) => module.default) ?? null, router.load(), + ensureClientSettingsHydrated().catch(() => undefined), ]) .then(([ManagedAuthShell]) => { // A route chunk failure still resolves router.load(): the error is parked in diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 8c6287010d8f..68e4e326f864 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -158,6 +158,20 @@ describe("formatDayAwareTimestamp", () => { vi.unstubAllGlobals(); }); + it("formats Simplified Chinese day and expiry labels", () => { + const yesterday = new Date(2026, 7, 13, 23, 30).toISOString(); + const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime(); + + expect(formatDayAwareTimestamp(yesterday, "24-hour", justPastMidnight, "zh-CN")).toBe( + `昨天 ${formatShortTimestamp(yesterday, "24-hour", "zh-CN")}`, + ); + const nowMs = new Date("2026-04-07T12:00:00.000Z").getTime(); + expect(formatElapsedDurationLabel("2026-04-07T11:45:00.000Z", nowMs, "zh-CN")).toBe("15 分钟"); + expect(formatExpiresInLabel("2026-04-07T12:04:12.000Z", nowMs, "zh-CN")).toBe( + "4 分 12 秒后过期", + ); + }); + it("returns an empty string for invalid input", () => { expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..644f1fe2041c 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,5 +1,7 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; +import type { AppLanguage } from "./i18n/locale"; + function getTimestampFormatOptions( timestampFormat: TimestampFormat, includeSeconds: boolean, @@ -53,20 +55,26 @@ function readHostSystemLocale(): string | null { const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); +function localeForLanguage(language: AppLanguage | undefined): string | undefined { + return language === "zh-CN" ? "zh-CN" : timestampLocale; +} + const timestampFormatterCache = new Map(); function getTimestampFormatter( timestampFormat: TimestampFormat, includeSeconds: boolean, + language?: AppLanguage, ): Intl.DateTimeFormat { - const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; + const locale = localeForLanguage(language); + const cacheKey = `${locale ?? "runtime"}:${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; const cachedFormatter = timestampFormatterCache.get(cacheKey); if (cachedFormatter) { return cachedFormatter; } const formatter = new Intl.DateTimeFormat( - timestampLocale, + locale, getTimestampFormatOptions(timestampFormat, includeSeconds), ); timestampFormatterCache.set(cacheKey, formatter); @@ -78,9 +86,6 @@ export function parseTimestampDate(isoDate: string): Date | null { return Number.isNaN(date.getTime()) ? null : date; } -// Deliberately not the host locale: the tooltip's ordinal suffix and -// day-before-month order below are English, so a localized month alone would -// read "4th Juni 2026". Localizing the whole label is a separate change. const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); function ordinalSuffix(day: number): string { @@ -105,31 +110,53 @@ function ordinalSuffix(day: number): string { export function formatChatTimestampTooltip( isoDate: string, timestampFormat: TimestampFormat, + language?: AppLanguage, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = formatShortTimestamp(isoDate, timestampFormat); + const time = formatShortTimestamp(isoDate, timestampFormat, language); const day = date.getDate(); - const month = monthNameFormatter.format(date); + const month = date.getMonth() + 1; const year = date.getFullYear(); - return `${time}, ${day}${ordinalSuffix(day)} ${month} ${year}`; + if (language === "zh-CN") { + return `${time},${year}年${month}月${day}日`; + } + return `${time}, ${day}${ordinalSuffix(day)} ${monthNameFormatter.format(date)} ${year}`; } -export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { +export function formatShortTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + language?: AppLanguage, +): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - return getTimestampFormatter(timestampFormat, false).format(date); + return getTimestampFormatter(timestampFormat, false, language).format(date); +} + +const numericDateFormatterCache = new Map(); + +function getNumericDateFormatter( + includeYear: boolean, + language?: AppLanguage, +): Intl.DateTimeFormat { + const locale = localeForLanguage(language); + const cacheKey = `${locale ?? "runtime"}:${includeYear ? "year" : "month-day"}`; + const cachedFormatter = numericDateFormatterCache.get(cacheKey); + if (cachedFormatter) return cachedFormatter; + + const formatter = new Intl.DateTimeFormat(locale, { + month: "numeric", + day: "numeric", + ...(includeYear ? { year: "numeric" as const } : {}), + }); + numericDateFormatterCache.set(cacheKey, formatter); + return formatter; } -const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", -}); -const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, { - month: "numeric", - day: "numeric", - year: "numeric", -}); +function formatNumericDate(date: Date, includeYear: boolean, language?: AppLanguage): string { + return getNumericDateFormatter(includeYear, language).format(date); +} /** * Chat timestamp that adds the date once the message is no longer from today: @@ -141,10 +168,11 @@ export function formatDayAwareTimestamp( isoDate: string, timestampFormat: TimestampFormat, nowMs: number = Date.now(), + language?: AppLanguage, ): string { const date = parseTimestampDate(isoDate); if (!date) return ""; - const time = getTimestampFormatter(timestampFormat, false).format(date); + const time = getTimestampFormatter(timestampFormat, false, language).format(date); const now = new Date(nowMs); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); @@ -153,10 +181,8 @@ export function formatDayAwareTimestamp( const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); if (dayDiff <= 0) return time; - if (dayDiff === 1) return `yesterday at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + if (dayDiff === 1) return language === "zh-CN" ? `昨天 ${time}` : `yesterday at ${time}`; + return `${formatNumericDate(date, date.getFullYear() !== now.getFullYear(), language)} ${time}`; } /** @@ -180,9 +206,7 @@ export function formatUpcomingTimestamp( if (dayDiff <= 0) return time; if (dayDiff === 1) return `tomorrow at ${time}`; - const dateFormatter = - date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; - return `${dateFormatter.format(date)} ${time}`; + return `${formatNumericDate(date, date.getFullYear() !== now.getFullYear())} ${time}`; } /** @@ -228,37 +252,80 @@ export function getRelativeTimeState(isoDate: string | null): RelativeTimeState * Relative elapsed duration since an ISO instant, without an "ago" suffix. * Useful for labels like "Connected for 3m". */ -export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { +export function formatElapsedDurationLabel( + isoDate: string, + nowMs: number = Date.now(), + language?: AppLanguage, +): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const diffMs = nowMs - date.getTime(); - if (diffMs <= 0) return "just now"; + const justNow = language === "zh-CN" ? "刚刚" : "just now"; + if (diffMs <= 0) return justNow; const seconds = Math.floor(diffMs / 1000); - if (seconds < 5) return "just now"; - if (seconds < 60) return `${seconds}s`; + if (seconds < 5) return justNow; + if (seconds < 60) return language === "zh-CN" ? `${seconds} 秒` : `${seconds}s`; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return language === "zh-CN" ? `${minutes} 分钟` : `${minutes}m`; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; + if (hours < 24) return language === "zh-CN" ? `${hours} 小时` : `${hours}h`; const days = Math.floor(hours / 24); - return `${days}d`; + return language === "zh-CN" ? `${days} 天` : `${days}d`; } /** * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. */ -export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { +export function formatExpiresInLabel( + isoDate: string, + nowMs: number = Date.now(), + language?: AppLanguage, +): string { const date = parseTimestampDate(isoDate); if (!date) return ""; const diffMs = date.getTime() - nowMs; - if (diffMs <= 0) return "Expired"; + if (diffMs <= 0) return language === "zh-CN" ? "已过期" : "Expired"; const totalSeconds = Math.floor(diffMs / 1000); + if (language === "zh-CN") { + if (totalSeconds < 5) return "即将过期"; + if (totalSeconds < 60) return `${totalSeconds} 秒后过期`; + + if (totalSeconds < 3600) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds === 0 ? `${minutes} 分后过期` : `${minutes} 分 ${seconds} 秒后过期`; + } + + if (totalSeconds < 86_400) { + const hours = Math.floor(totalSeconds / 3600); + const rem = totalSeconds % 3600; + const minutes = Math.floor(rem / 60); + const seconds = rem % 60; + const parts = [`${hours} 小时`]; + if (minutes > 0) parts.push(`${minutes} 分`); + if (seconds > 0) parts.push(`${seconds} 秒`); + return `${parts.join(" ")}后过期`; + } + + const days = Math.floor(totalSeconds / 86_400); + const remAfterDays = totalSeconds % 86_400; + const hours = Math.floor(remAfterDays / 3600); + const rem = remAfterDays % 3600; + const minutes = Math.floor(rem / 60); + const seconds = rem % 60; + const parts = [`${days} 天`]; + if (hours > 0) parts.push(`${hours} 小时`); + if (minutes > 0) parts.push(`${minutes} 分`); + if (seconds > 0) parts.push(`${seconds} 秒`); + return `${parts.join(" ")}后过期`; + } + if (totalSeconds < 5) return "Expires in a moment"; if (totalSeconds < 60) return `Expires in ${totalSeconds}s`; diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 979b6adb4003..ba7ad450b27a 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -131,6 +131,30 @@ describe("connection presentation", () => { expect(connectionStatusTitle(connection)).toBe("Failed to connect. Reconnecting..."); }); + it("accepts localized copy without translating the remote error detail", () => { + const connection = { + phase: "reconnecting", + error: "Relay request timed out.", + traceId: "trace-retry", + } as const; + const copy = { + available: "可用", + offline: "离线", + connecting: "正在连接…", + reconnecting: "正在重新连接…", + reconnectingWithReason: (reason: string) => `连接失败,正在重新连接… 原因:${reason}`, + connected: "已连接", + connectionFailed: "连接失败", + connectionFailedWithReason: (reason: string) => `连接失败。原因:${reason}`, + reconnectingTitle: "连接失败,正在重新连接…", + }; + + expect(connectionStatusText(connection, copy)).toBe( + "连接失败,正在重新连接… 原因:Relay request timed out.", + ); + expect(connectionStatusTitle(connection, copy)).toBe("连接失败,正在重新连接…"); + }); + it("presents the supervisor's offline state without consulting shell state", () => { expect( presentEnvironmentConnection( diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 4093167d333c..5d26d5244258 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -55,32 +55,60 @@ export function presentConnectionState( } } -export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { +export interface ConnectionStatusCopy { + readonly available: string; + readonly offline: string; + readonly connecting: string; + readonly reconnecting: string; + readonly reconnectingWithReason: (reason: string) => string; + readonly connected: string; + readonly connectionFailed: string; + readonly connectionFailedWithReason: (reason: string) => string; + readonly reconnectingTitle: string; +} + +export const DEFAULT_CONNECTION_STATUS_COPY: ConnectionStatusCopy = { + available: "Available", + offline: "Offline", + connecting: "Connecting...", + reconnecting: "Reconnecting...", + reconnectingWithReason: (reason) => `Failed to connect. Reconnecting... Reason: ${reason}`, + connected: "Connected", + connectionFailed: "Connection failed", + connectionFailedWithReason: (reason) => `Connection failed. Reason: ${reason}`, + reconnectingTitle: "Failed to connect. Reconnecting...", +}; + +export function connectionStatusText( + connection: EnvironmentConnectionPresentation, + copy: ConnectionStatusCopy = DEFAULT_CONNECTION_STATUS_COPY, +): string { switch (connection.phase) { case "available": - return "Available"; + return copy.available; case "offline": - return "Offline"; + return copy.offline; case "connecting": - return "Connecting..."; + return copy.connecting; case "reconnecting": - return connection.error - ? `Failed to connect. Reconnecting... Reason: ${connection.error}` - : "Reconnecting..."; + return connection.error ? copy.reconnectingWithReason(connection.error) : copy.reconnecting; case "connected": - return "Connected"; + return copy.connected; case "error": return connection.error - ? `Connection failed. Reason: ${connection.error}` - : "Connection failed"; + ? copy.connectionFailedWithReason(connection.error) + : copy.connectionFailed; } } -export function connectionStatusTitle(connection: EnvironmentConnectionPresentation): string { +export function connectionStatusTitle( + connection: EnvironmentConnectionPresentation, + copy: ConnectionStatusCopy = DEFAULT_CONNECTION_STATUS_COPY, +): string { if (connection.phase === "reconnecting" && connection.error) { - return "Failed to connect. Reconnecting..."; + return copy.reconnectingTitle; } - return connectionStatusText({ ...connection, error: null }); + return connectionStatusText({ ...connection, error: null }, copy); } export function presentEnvironmentConnection( diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b090b47fba5b..41ae90951158 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -164,6 +164,24 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings language", () => { + it("follows the system when existing settings omit the preference", () => { + expect(decodeClientSettings({}).language).toBe("system"); + expect(decodeClientSettingsPatch({})).not.toHaveProperty("language"); + }); + + it.each(["system", "en", "zh-CN"])("round-trips the %s preference", (language) => { + const settings = decodeClientSettings({ language }); + expect(encodeClientSettings(settings).language).toBe(language); + expect(decodeClientSettingsPatch({ language }).language).toBe(language); + }); + + it.each(["zh", "zh-TW", "auto", null])("rejects unsupported language %s", (language) => { + expect(() => decodeClientSettings({ language })).toThrow(); + expect(() => decodeClientSettingsPatch({ language })).toThrow(); + }); +}); + describe("ClientSettings notifications", () => { it("requires opt-in when existing settings omit notification preferences", () => { expect(decodeClientSettings({}).notificationMode).toBe("off"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7b3715d704be..7aedf9899f54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -42,6 +42,10 @@ import { PullRequestMergeMethod } from "./pullRequest.ts"; // ── Client Settings (local-only) ─────────────────────────────── +export const LanguagePreference = Schema.Literals(["system", "en", "zh-CN"]); +export type LanguagePreference = typeof LanguagePreference.Type; +const DEFAULT_LANGUAGE_PREFERENCE: LanguagePreference = "system"; + export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; @@ -453,6 +457,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + language: LanguagePreference.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_LANGUAGE_PREFERENCE)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1509,6 +1516,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + language: Schema.optionalKey(LanguagePreference), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean),