diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 25167bf18737..b585e18d4b0a 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -70,7 +70,9 @@ "express": "^5.2.1", "framer-motion": "^12.34.3", "goose-acp-types": "file:../acp", + "i18next": "^24.0.0", "katex": "^0.16.33", + "react-i18next": "^15.4.0", "lodash": "^4.17.23", "lucide-react": "^0.575.0", "qrcode.react": "^4.2.0", diff --git a/ui/desktop/pnpm-lock.yaml b/ui/desktop/pnpm-lock.yaml index c52665780465..ad6e49cf9f1c 100644 --- a/ui/desktop/pnpm-lock.yaml +++ b/ui/desktop/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: goose-acp-types: specifier: file:../acp version: '@block/goose-acp@file:../acp(@agentclientprotocol/sdk@0.15.0(zod@3.25.76))' + i18next: + specifier: ^24.0.0 + version: 24.2.3(typescript@5.9.3) katex: specifier: ^0.16.33 version: 0.16.33 @@ -117,6 +120,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^15.4.0 + version: 15.7.4(i18next@24.2.3(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.2.4) @@ -4030,6 +4036,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4068,6 +4077,14 @@ packages: engines: {node: '>=18'} hasBin: true + i18next@24.2.3: + resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5351,6 +5368,22 @@ packages: peerDependencies: react: ^19.2.4 + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: ^19.2.4 + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-icons@5.5.0: resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: @@ -6308,6 +6341,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -10823,6 +10860,10 @@ snapshots: html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} http-cache-semantics@4.2.0: {} @@ -10875,6 +10916,12 @@ snapshots: husky@9.1.7: {} + i18next@24.2.3(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -12401,6 +12448,16 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-i18next@15.7.4(i18next@24.2.3(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 24.2.3(typescript@5.9.3) + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-icons@5.5.0(react@19.2.4): dependencies: react: 19.2.4 @@ -13500,6 +13557,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 7236e04ccf41..2f72b3d1cfb2 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Outlet, useLocation } from 'react-router-dom'; import { motion } from 'framer-motion'; import { Menu } from 'lucide-react'; @@ -20,6 +21,7 @@ interface AppLayoutContentProps { const AppLayoutContent: React.FC = ({ activeSessions }) => { const location = useLocation(); + const { t } = useTranslation(); const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin'; const chatContext = useChatContext(); const isOnPairRoute = location.pathname === '/pair'; @@ -123,7 +125,7 @@ const AppLayoutContent: React.FC = ({ activeSessions }) = className="no-drag hover:!bg-background-tertiary" variant="ghost" size="xs" - title={isNavExpanded ? 'Close navigation' : 'Open navigation'} + title={isNavExpanded ? t('nav.closeNavigation') : t('nav.openNavigation')} > diff --git a/ui/desktop/src/components/Layout/CondensedRenderer.tsx b/ui/desktop/src/components/Layout/CondensedRenderer.tsx index 9f1713acb41f..8b6427bb7b99 100644 --- a/ui/desktop/src/components/Layout/CondensedRenderer.tsx +++ b/ui/desktop/src/components/Layout/CondensedRenderer.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { GripVertical, ChevronDown, ChevronRight, Plus } from 'lucide-react'; import { motion } from 'framer-motion'; import { cn } from '../../utils'; @@ -27,6 +28,7 @@ export const CondensedRenderer: React.FC = ({ navFocusRef, }) => { const [chatPopoverOpen, setChatPopoverOpen] = useState(false); + const { t } = useTranslation(); const isVertical = navigationPosition === 'left' || navigationPosition === 'right'; const isTopPosition = navigationPosition === 'top'; @@ -176,7 +178,7 @@ export const CondensedRenderer: React.FC = ({ 'bg-background-tertiary hover:bg-background-inverse hover:text-text-inverse', 'flex items-center justify-center' )} - title="New Chat" + title={t('chat.newChat')} > diff --git a/ui/desktop/src/components/Layout/navigation/SessionsList.tsx b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx index 942473678a57..aa7309cbd314 100644 --- a/ui/desktop/src/components/Layout/navigation/SessionsList.tsx +++ b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { MessageSquare, ChefHat, Plus, History } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { SessionIndicators } from '../../SessionIndicators'; @@ -33,6 +34,7 @@ export const SessionsList: React.FC = ({ onShowAll, }) => { const [editingSessionId, setEditingSessionId] = useState(null); + const { t } = useTranslation(); const handleSaveSessionName = useCallback( async (sessionId: string, newName: string) => { @@ -68,7 +70,7 @@ export const SessionsList: React.FC = ({ >
- Start New Chat + {t('chat.startNewChat')}
)} @@ -105,7 +107,7 @@ export const SessionsList: React.FC = ({ handleSaveSessionName(session.id, newName)} - placeholder="Untitled session" + placeholder={t('chat.untitledSession')} disabled={isStreaming} singleClickEdit={false} className="truncate text-text-primary flex-1 !px-0 !py-0 hover:bg-transparent" @@ -134,7 +136,7 @@ export const SessionsList: React.FC = ({ >
- Show All + {t('chat.showAll')}
)} diff --git a/ui/desktop/src/components/SetupModal.tsx b/ui/desktop/src/components/SetupModal.tsx index cefb2c502053..6fa065ab5acb 100644 --- a/ui/desktop/src/components/SetupModal.tsx +++ b/ui/desktop/src/components/SetupModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from './ui/button'; interface SetupModalProps { @@ -20,6 +21,8 @@ export function SetupModal({ autoClose, onClose, }: SetupModalProps) { + const { t } = useTranslation(); + useEffect(() => { if (autoClose && onClose) { const timer = window.setTimeout(() => { @@ -45,7 +48,7 @@ export function SetupModal({ {onClose && (

@@ -53,7 +56,7 @@ export function SetupModal({ {showRetry && onRetry && ( )} diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx index 45756f52a11b..27bc45e6c700 100644 --- a/ui/desktop/src/components/TelemetryOptOutModal.tsx +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { BaseModal } from './ui/BaseModal'; import { Button } from './ui/button'; import { Goose } from './icons/Goose'; @@ -15,6 +16,7 @@ type TelemetryOptOutModalProps = export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { const { read, upsert } = useConfig(); + const { t } = useTranslation(); const isControlled = props.controlled; const controlledIsOpen = isControlled ? props.isOpen : undefined; const onClose = isControlled ? props.onClose : undefined; @@ -41,15 +43,15 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { } catch (error) { console.error('Failed to check telemetry config:', error); toastService.error({ - title: 'Configuration Error', - msg: 'Failed to check telemetry configuration.', + title: t('telemetry.configErrorTitle'), + msg: t('telemetry.configErrorMessage'), traceback: error instanceof Error ? error.stack || '' : '', }); } }; checkTelemetryChoice(); - }, [isControlled, read]); + }, [isControlled, read, t]); const handleChoice = async (enabled: boolean) => { setIsLoading(true); @@ -88,7 +90,7 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { disabled={isLoading} className="w-full h-[44px] rounded-lg" > - Yes, share anonymous usage data + {t('telemetry.yesShare')} } @@ -106,25 +108,23 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) {

- Help improve goose + {t('telemetry.title')}

- Would you like to help improve goose by sharing anonymous usage data? This helps us - understand how goose is used and identify areas for improvement. + {t('telemetry.description')}

-

What we collect:

+

{t('telemetry.whatWeCollectTitle')}

    -
  • Operating system, version, and architecture
  • -
  • goose version and install method
  • -
  • Provider and model used
  • -
  • Extensions and tool usage counts (names only)
  • -
  • Session metrics (duration, interaction count, token usage)
  • -
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
  • {t('telemetry.collectItems.os')}
  • +
  • {t('telemetry.collectItems.version')}
  • +
  • {t('telemetry.collectItems.provider')}
  • +
  • {t('telemetry.collectItems.extensions')}
  • +
  • {t('telemetry.collectItems.sessionMetrics')}
  • +
  • {t('telemetry.collectItems.errorTypes')}

- We never collect your conversations, code, tool arguments, error messages, or any - personal data. You can change this setting anytime in Settings → App. + {t('telemetry.privacyNote')}

diff --git a/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx b/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx index 5dd3ee208a61..543123f47432 100644 --- a/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx +++ b/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx @@ -1,4 +1,5 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; +import { useTranslation } from 'react-i18next'; interface PrivacyInfoModalProps { isOpen: boolean; @@ -6,30 +7,30 @@ interface PrivacyInfoModalProps { } export default function PrivacyInfoModal({ isOpen, onClose }: PrivacyInfoModalProps) { + const { t } = useTranslation(); + return ( !open && onClose()}> - Privacy details + {t('privacy.title')}

- Anonymous usage data helps us understand how goose is used and identify areas for - improvement. + {t('privacy.description')}

-

What we collect:

+

{t('privacy.whatWeCollectTitle')}

    -
  • Operating system, version, and architecture
  • -
  • goose version and install method
  • -
  • Provider and model used
  • -
  • Extensions and tool usage counts (names only)
  • -
  • Session metrics (duration, interaction count, token usage)
  • -
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
  • {t('telemetry.collectItems.os')}
  • +
  • {t('telemetry.collectItems.version')}
  • +
  • {t('telemetry.collectItems.provider')}
  • +
  • {t('telemetry.collectItems.extensions')}
  • +
  • {t('telemetry.collectItems.sessionMetrics')}
  • +
  • {t('telemetry.collectItems.errorTypes')}

- We never collect your conversations, code, tool arguments, error messages, or any - personal data. You can change this setting anytime in Settings. + {t('privacy.privacyNote')}

diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 5e56d80abff5..984e5191d94e 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; +import { Select } from '../../ui/Select'; import { Settings, ChevronDown, ChevronUp } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; import UpdateSection from './UpdateSection'; @@ -17,6 +19,8 @@ import { NavigationStyleSelector } from './NavigationStyleSelector'; import { NavigationPositionSelector } from './NavigationPositionSelector'; import { NavigationCustomizationSettings } from './NavigationCustomizationSettings'; import { NavigationProvider, useNavigationContextSafe } from '../../Layout/NavigationContext'; +import { applyLanguagePreference } from '../../../i18n'; +import type { LanguagePreference } from '../../../utils/settings'; interface AppSettingsSectionProps { scrollToSection?: string; @@ -91,6 +95,7 @@ const NavigationSettingsCard: React.FC = () => { }; export default function AppSettingsSection({ scrollToSection }: AppSettingsSectionProps) { + const { t } = useTranslation(); const [menuBarIconEnabled, setMenuBarIconEnabled] = useState(true); const [dockIconEnabled, setDockIconEnabled] = useState(true); const [wakelockEnabled, setWakelockEnabled] = useState(true); @@ -99,6 +104,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [showNotificationModal, setShowNotificationModal] = useState(false); const [showPricing, setShowPricing] = useState(true); const [isDarkMode, setIsDarkMode] = useState(false); + const [languagePreference, setLanguagePreference] = useState('system'); const updateSectionRef = useRef(null); const shouldShowUpdates = !window.appConfig.get('GOOSE_VERSION'); @@ -126,6 +132,17 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti window.electron.getSetting('showPricing').then(setShowPricing); }, []); + useEffect(() => { + window.electron + .getSetting('language') + .then((value) => { + if (value) setLanguagePreference(value); + }) + .catch((error) => { + console.warn('[Settings] Failed to load language setting:', error); + }); + }, []); + useEffect(() => { if (scrollToSection === 'update' && updateSectionRef.current) { setTimeout(() => { @@ -209,6 +226,23 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti window.dispatchEvent(new CustomEvent('showPricingChanged')); }; + const languageOptions = [ + { value: 'system', label: t('settings.language.system') }, + { value: 'en', label: t('settings.language.en') }, + { value: 'zh-CN', label: t('settings.language.zhCN') }, + ]; + + const selectedLanguageOption = languageOptions.find( + (option) => option.value === languagePreference + ); + + const handleLanguageChange = (option: { value: LanguagePreference; label: string } | null) => { + const nextLanguage = option?.value ?? 'system'; + setLanguagePreference(nextLanguage); + void window.electron.setSetting('language', nextLanguage); + applyLanguagePreference(nextLanguage); + }; + return (
@@ -249,6 +283,23 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
+
+
+

{t('settings.language.label')}

+

+ {t('settings.language.description')} +

+
+
+