diff --git a/app/src/App.tsx b/app/src/App.tsx index 82a7df97b..9387b847e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -19,6 +19,7 @@ import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import AppWalkthrough from './components/walkthrough/AppWalkthrough'; import { MascotFrameProducer } from './features/meet/MascotFrameProducer'; +import { I18nProvider } from './lib/i18n/I18nContext'; // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // import { isWelcomeLocked } from './lib/coreState/store'; import { startNativeNotificationsService } from './lib/nativeNotifications'; @@ -58,24 +59,26 @@ function App() { )}> } persistor={persistor}> - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 895c12fad..2d03cd522 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -13,6 +13,7 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; +import { useT } from '../../lib/i18n/I18nContext'; import { bootCheckTransport } from '../../services/bootCheckService'; import { clearCoreRpcTokenCache, @@ -82,6 +83,7 @@ type TestStatus = const DESKTOP_DOWNLOAD_URL = 'https://github.com/tinyhumansai/openhuman/releases/latest'; function ModePicker({ onConfirm }: PickerProps) { + const { t } = useT(); // Web build cannot spawn a local sidecar, so the only viable choice is // cloud. Default the selection accordingly and hide the local option in // the render path below. @@ -108,13 +110,13 @@ function ModePicker({ onConfirm }: PickerProps) { const validateInputs = (): { url: string; token: string } | null => { const trimmedUrl = cloudUrl.trim(); if (!trimmedUrl) { - setUrlError('Please enter a core URL.'); + setUrlError(t('bootCheck.invalidUrl')); return null; } try { const parsed = new URL(trimmedUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - setUrlError('URL must start with http:// or https://'); + setUrlError(t('bootCheck.urlMustStartWith')); return null; } if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) { @@ -124,14 +126,14 @@ function ModePicker({ onConfirm }: PickerProps) { return null; } } catch { - setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)'); + setUrlError(t('bootCheck.validUrlRequired')); return null; } setUrlError(null); const trimmedToken = cloudToken.trim(); if (!trimmedToken) { - setTokenError('Please enter the core auth token.'); + setTokenError(t('bootCheck.tokenRequired')); return null; } setTokenError(null); @@ -198,25 +200,23 @@ function ModePicker({ onConfirm }: PickerProps) { return (

- {isDesktop ? 'Choose core mode' : 'Connect to your core'} + {isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}

- {isDesktop - ? 'OpenHuman needs a running core to operate. Choose how you want to connect.' - : 'OpenHuman on the web connects to a remote core you control. Enter its URL and auth token, or install the desktop app to run one locally.'} + {isDesktop ? t('bootCheck.desktopDescription') : t('bootCheck.webDescription')}

{!isDesktop && (
- Prefer to run everything on your own device?{' '} + {t('bootCheck.preferDesktop')}{' '} - Download the desktop app + {t('bootCheck.downloadDesktop')} .
@@ -233,10 +233,8 @@ function ModePicker({ onConfirm }: PickerProps) { ? 'border-ocean-500 bg-ocean-500/10 text-white' : 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800' }`}> -
Local (recommended)
-
- Embedded core runs on this device — fastest, no configuration required. -
+
{t('bootCheck.localRecommended')}
+
{t('bootCheck.localDescription')}
)} @@ -250,20 +248,20 @@ function ModePicker({ onConfirm }: PickerProps) { ? 'border-ocean-500 bg-ocean-500/10 text-white' : 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800' }`}> -
Cloud
-
- Connect to a remote core at a custom URL. -
+
{t('bootCheck.cloudMode')}
+
{t('bootCheck.cloudDescription')}
)} {selected === 'cloud' && (
- + { setCloudUrl(e.target.value); @@ -276,13 +274,14 @@ function ModePicker({ onConfirm }: PickerProps) {
{ setCloudToken(e.target.value); @@ -293,8 +292,7 @@ function ModePicker({ onConfirm }: PickerProps) { /> {tokenError &&

{tokenError}

}

- Stored on this device only. Required for remote cores — the desktop sends it as{' '} - Authorization: Bearer … on every RPC. + {t('bootCheck.storedLocally')} Authorization: Bearer … on every RPC.

@@ -304,21 +302,23 @@ function ModePicker({ onConfirm }: PickerProps) { onClick={handleTestConnection} disabled={testStatus.kind === 'testing'} className="rounded-lg border border-stone-600 px-3 py-1.5 text-xs text-stone-100 hover:bg-stone-800 disabled:opacity-60"> - {testStatus.kind === 'testing' ? 'Testing…' : 'Test connection'} + {testStatus.kind === 'testing' + ? t('bootCheck.testing') + : t('bootCheck.testConnection')} {testStatus.kind === 'ok' && ( - Connected ✓ + {t('bootCheck.connectedOk')} )} {testStatus.kind === 'auth' && ( - Auth failed — check the token (got 401/403). + {t('bootCheck.authFailed')} )} {testStatus.kind === 'unreachable' && ( - Unreachable: {testStatus.reason} + {t('bootCheck.unreachablePrefix')} {testStatus.reason} )}
@@ -331,7 +331,7 @@ function ModePicker({ onConfirm }: PickerProps) { type="button" onClick={handleContinue} className="rounded-lg bg-ocean-500 px-5 py-2 text-sm font-medium text-white hover:bg-ocean-600"> - Continue + {t('common.continue')}
@@ -343,11 +343,12 @@ function ModePicker({ onConfirm }: PickerProps) { // --------------------------------------------------------------------------- function CheckingScreen() { + const { t } = useT(); return (
-

Checking core…

+

{t('bootCheck.checkingCore')}

); @@ -376,14 +377,15 @@ function ResultScreen({ actionError, onAction, }: ResultScreenProps) { + const { t } = useT(); if (result.kind === 'match') return null; if (result.kind === 'unreachable') { return ( -

Could not reach core

+

{t('bootCheck.cannotReach')}

- {result.reason || 'The core process is unreachable. Try switching to a different mode.'} + {result.reason || t('bootCheck.cannotReachDesc')}

{actionError &&

{actionError}

}
@@ -392,19 +394,19 @@ function ResultScreen({ onClick={onRetry} disabled={actionBusy} className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60"> - Retry + {t('common.retry')}
@@ -414,11 +416,8 @@ function ResultScreen({ if (result.kind === 'daemonDetected') { return ( -

Legacy background core detected

-

- A separately-installed OpenHuman daemon is running on this device. It must be removed - before the embedded core can take over. -

+

{t('bootCheck.legacyDetected')}

+

{t('bootCheck.legacyDescription')}

{actionError &&

{actionError}

}
@@ -443,11 +442,8 @@ function ResultScreen({ if (result.kind === 'outdatedLocal') { return ( -

Local core needs a restart

-

- The local core version does not match this app build. Restarting it will load the correct - version. -

+

{t('bootCheck.localNeedsRestart')}

+

{t('bootCheck.localNeedsRestartDesc')}

{actionError &&

{actionError}

}
@@ -472,11 +468,8 @@ function ResultScreen({ if (result.kind === 'outdatedCloud') { return ( -

Cloud core needs an update

-

- The cloud core version does not match this app build. Run the core updater to resolve the - mismatch. -

+

{t('bootCheck.cloudNeedsUpdate')}

+

{t('bootCheck.cloudNeedsUpdateDesc')}

{actionError &&

{actionError}

}
@@ -501,11 +494,8 @@ function ResultScreen({ // noVersionMethod — treat like outdated, user picks which flavor of action return ( -

Core version check failed

-

- The core is running but does not expose a version endpoint. It may be outdated. Restart or - update the core to continue. -

+

{t('bootCheck.versionCheckFailed')}

+

{t('bootCheck.versionCheckFailedDesc')}

{actionError &&

{actionError}

}
@@ -536,6 +526,7 @@ interface BootCheckGateProps { } export default function BootCheckGate({ children }: BootCheckGateProps) { + const { t } = useT(); const dispatch = useAppDispatch(); const coreMode = useAppSelector(state => state.coreMode.mode); @@ -582,7 +573,7 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { setPhase('result'); setResult({ kind: 'unreachable', - reason: err instanceof Error ? err.message : 'Unexpected boot-check error', + reason: err instanceof Error ? err.message : t('bootCheck.unexpectedError'), }); } finally { runningRef.current = false; @@ -701,7 +692,7 @@ export default function BootCheckGate({ children }: BootCheckGateProps) { } } catch (err) { logError('[boot-check] gate — action error: %o', err); - setActionError(err instanceof Error ? err.message : 'Action failed — please try again.'); + setActionError(err instanceof Error ? err.message : t('bootCheck.actionFailed')); } finally { setActionBusy(false); } diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index 626d7c3b5..3aee21871 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useT } from '../lib/i18n/I18nContext'; // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // import { isWelcomeLocked } from '../lib/coreState/store'; import { useCoreState } from '../providers/CoreStateProvider'; @@ -8,10 +9,10 @@ import { useAppSelector } from '../store/hooks'; import { selectUnreadCount } from '../store/notificationSlice'; import { isAccountsFullscreen } from '../utils/accountsFullscreen'; -const tabs = [ +const makeTabs = (t: (key: string) => string) => [ { id: 'home', - label: 'Home', + label: t('nav.home'), path: '/home', icon: ( @@ -26,7 +27,7 @@ const tabs = [ }, { id: 'human', - label: 'Human', + label: t('nav.human'), path: '/human', icon: ( @@ -41,7 +42,7 @@ const tabs = [ }, { id: 'chat', - label: 'Chat', + label: t('nav.chat'), path: '/chat', icon: ( @@ -56,7 +57,7 @@ const tabs = [ }, { id: 'skills', - label: 'Connections', + label: t('nav.connections'), path: '/skills', icon: ( @@ -71,7 +72,7 @@ const tabs = [ }, { id: 'intelligence', - label: 'Memory', + label: t('nav.memory'), path: '/intelligence', icon: ( @@ -86,7 +87,7 @@ const tabs = [ }, { id: 'notifications', - label: 'Alerts', + label: t('nav.alerts'), path: '/notifications', icon: ( @@ -101,7 +102,7 @@ const tabs = [ }, { id: 'rewards', - label: 'Rewards', + label: t('nav.rewards'), path: '/rewards', icon: ( @@ -116,7 +117,7 @@ const tabs = [ }, { id: 'settings', - label: 'Settings', + label: t('nav.settings'), path: '/settings', icon: ( @@ -138,6 +139,8 @@ const tabs = [ ]; const BottomTabBar = () => { + const { t } = useT(); + const tabs = useMemo(() => makeTabs(t), [t]); const location = useLocation(); const navigate = useNavigate(); const { snapshot } = useCoreState(); @@ -230,7 +233,7 @@ const BottomTabBar = () => { }`} aria-label={ tab.id === 'notifications' && unreadCount > 0 - ? `${tab.label} (${unreadCount} unread)` + ? `${tab.label} (${unreadCount} ${t('alerts.unread')})` : tab.label }> diff --git a/app/src/components/channels/ChannelSetupModal.tsx b/app/src/components/channels/ChannelSetupModal.tsx index 8c34565e9..536f50ec2 100644 --- a/app/src/components/channels/ChannelSetupModal.tsx +++ b/app/src/components/channels/ChannelSetupModal.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { useT } from '../../lib/i18n/I18nContext'; import type { ChannelDefinition, ChannelType } from '../../types/channels'; import DiscordConfig from './DiscordConfig'; import TelegramConfig from './TelegramConfig'; @@ -21,6 +22,7 @@ interface ChannelSetupModalProps { } function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) { + const { t } = useT(); const channelId = definition.id as ChannelType; switch (channelId) { case 'telegram': @@ -30,13 +32,14 @@ function ChannelConfigContent({ definition }: { definition: ChannelDefinition }) default: return (

- Configuration for {definition.display_name} is not available yet. + {t('channels.configNotAvailable')} {definition.display_name}

); } } export default function ChannelSetupModal({ definition, onClose }: ChannelSetupModalProps) { + const { t } = useT(); const modalRef = useRef(null); useEffect(() => { @@ -88,7 +91,7 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM {definition.display_name} - channel + {t('channels.channel')}

{definition.description}

diff --git a/app/src/components/chat/TokenUsagePill.tsx b/app/src/components/chat/TokenUsagePill.tsx index c8cba56ec..def9a8b80 100644 --- a/app/src/components/chat/TokenUsagePill.tsx +++ b/app/src/components/chat/TokenUsagePill.tsx @@ -1,4 +1,5 @@ import { useUsageState } from '../../hooks/useUsageState'; +import { useT } from '../../lib/i18n/I18nContext'; import { useAppSelector } from '../../store/hooks'; import { BILLING_DASHBOARD_URL } from '../../utils/links'; import { openUrl } from '../../utils/openUrl'; @@ -42,6 +43,7 @@ function severityFromPct(pct: number): PillSeverity { } const TokenUsagePill = () => { + const { t } = useT(); const sessionTokens = useAppSelector(state => state.chatRuntime.sessionTokenUsage); const { usagePct10h, usagePct7d, isAtLimit, isNearLimit, currentTier, teamUsage } = useUsageState(); @@ -54,9 +56,9 @@ const TokenUsagePill = () => { const showPlanPill = teamUsage !== null; const planTitle = (() => { - if (isAtLimit) return 'Usage limit reached — click to top up'; - if (isNearLimit) return 'Approaching usage limit'; - return `${currentTier.toLowerCase()} plan — click for details`; + if (isAtLimit) return t('token.usageLimitReached'); + if (isNearLimit) return t('token.approachingLimit'); + return `${currentTier.toLowerCase()} ${t('token.planClickForDetails')}`; })(); if (!showSessionCounter && !showPlanPill) return null; @@ -66,7 +68,10 @@ const TokenUsagePill = () => { {showSessionCounter ? ( + title={t('token.sessionTokens') + .replace('{in}', sessionTokens.inputTokens.toLocaleString()) + .replace('{out}', sessionTokens.outputTokens.toLocaleString()) + .replace('{turns}', String(sessionTokens.turns))}> { }} title={planTitle} className={`inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium ring-1 transition-colors ${planSeverity.bg} ${planSeverity.text} ${planSeverity.ring} hover:opacity-80`}> - {isAtLimit ? 'Limit' : planSeverity.label} + {isAtLimit ? t('token.limit') : planSeverity.label} ) : null} diff --git a/app/src/components/intelligence/ActionableCard.tsx b/app/src/components/intelligence/ActionableCard.tsx index fb262c86a..629a9d745 100644 --- a/app/src/components/intelligence/ActionableCard.tsx +++ b/app/src/components/intelligence/ActionableCard.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { useT } from '../../lib/i18n/I18nContext'; import type { ActionableItem, SnoozeOption } from '../../types/intelligence'; interface ActionableCardProps { @@ -207,6 +208,7 @@ export function ActionableCard({ onSnooze, className = '', }: ActionableCardProps) { + const { t } = useT(); const [showSnoozeMenu, setShowSnoozeMenu] = useState(false); const [isAnimatingOut, setIsAnimatingOut] = useState(false); const snoozeButtonRef = useRef(null); @@ -284,7 +286,7 @@ export function ActionableCard({ - New + {t('actionable.new')} )} diff --git a/app/src/components/intelligence/ConfirmationModal.tsx b/app/src/components/intelligence/ConfirmationModal.tsx index cb5a36383..001ce131a 100644 --- a/app/src/components/intelligence/ConfirmationModal.tsx +++ b/app/src/components/intelligence/ConfirmationModal.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { useT } from '../../lib/i18n/I18nContext'; import type { ConfirmationModal as ConfirmationModalType } from '../../types/intelligence'; interface ConfirmationModalProps { @@ -8,6 +9,7 @@ interface ConfirmationModalProps { } export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) { + const { t } = useT(); const [dontShowAgain, setDontShowAgain] = useState(false); if (!modal.isOpen) return null; @@ -73,7 +75,7 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) { onChange={e => setDontShowAgain(e.target.checked)} className="rounded border-stone-300 bg-stone-100 text-primary-500 focus:ring-primary-500 focus:ring-offset-0" /> - Don't show similar suggestions + {t('modal.dontShowAgain')} )} @@ -83,7 +85,7 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) { diff --git a/app/src/components/intelligence/IntelligenceCallsTab.tsx b/app/src/components/intelligence/IntelligenceCallsTab.tsx index 261f3276f..b12408628 100644 --- a/app/src/components/intelligence/IntelligenceCallsTab.tsx +++ b/app/src/components/intelligence/IntelligenceCallsTab.tsx @@ -1,6 +1,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { useEffect, useState } from 'react'; +import { useT } from '../../lib/i18n/I18nContext'; import { closeMeetCall, joinMeetCall } from '../../services/meetCallService'; type ActiveCall = { requestId: string; meetUrl: string; displayName: string }; @@ -25,6 +26,7 @@ const PLACEHOLDER_URL = 'https://meet.google.com/abc-defg-hij'; * tracks active calls so the user can close them from the same surface. */ export default function IntelligenceCallsTab({ onToast }: Props) { + const { t } = useT(); const [meetUrl, setMeetUrl] = useState(''); const [displayName, setDisplayName] = useState('OpenHuman Agent'); const [submitting, setSubmitting] = useState(false); @@ -70,13 +72,13 @@ export default function IntelligenceCallsTab({ onToast }: Props) { setMeetUrl(''); onToast?.({ type: 'success', - title: 'Joining call', - message: 'Opening the Meet window — admit the agent from the host side.', + title: t('calls.joiningCall'), + message: t('calls.meetWindowOpening'), }); } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to start Meet call.'; + const message = err instanceof Error ? err.message : t('calls.failedToStart'); setError(message); - onToast?.({ type: 'error', title: 'Could not start call', message }); + onToast?.({ type: 'error', title: t('calls.couldNotStart'), message }); } finally { setSubmitting(false); } @@ -92,25 +94,22 @@ export default function IntelligenceCallsTab({ onToast }: Props) { setActiveCalls(prev => prev.filter(call => call.requestId !== requestId)); } } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to close call.'; - onToast?.({ type: 'error', title: 'Could not close call', message }); + const message = err instanceof Error ? err.message : t('calls.failedToClose'); + onToast?.({ type: 'error', title: t('calls.couldNotClose'), message }); } }; return (
-

Join a Google Meet call

-

- Paste a Meet link and the agent will join the call as a named guest in a separate window. - The host needs to admit the agent from the Meet waiting room. -

+

{t('calls.joinMeet')}

+

{t('calls.joinMeetDescription')}

@@ -136,6 +145,7 @@ function SourceCard({ status }: SourceCardProps) { } export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsProps) { + const { t } = useT(); const [statuses, setStatuses] = useState([]); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(true); @@ -177,8 +187,8 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP if (loading) { return (
-

Memory sources

-

Loading…

+

{t('sync.memorySources')}

+

{t('common.loading')}

); } @@ -186,9 +196,9 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP if (loadError) { return (
-

Memory sources

+

{t('sync.memorySources')}

- Failed to load sync status: {loadError} + {t('sync.failedToLoad')}: {loadError}

); @@ -197,17 +207,15 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP if (statuses.length === 0) { return (
-

Memory sources

-

- No content has been synced into memory yet. Connect an integration to start. -

+

{t('sync.memorySources')}

+

{t('sync.noContent')}

); } return (
-

Memory sources

+

{t('sync.memorySources')}

{statuses.map(s => ( diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index cefb674f6..39f4e3ba6 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -27,6 +27,7 @@ */ import { useCallback, useEffect, useState } from 'react'; +import { useT } from '../../lib/i18n/I18nContext'; import type { ToastNotification } from '../../types/intelligence'; import { openUrl } from '../../utils/openUrl'; import { @@ -81,6 +82,7 @@ async function openVaultInObsidian(contentRootAbs: string): Promise { } export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { + const { t } = useT(); const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [building, setBuilding] = useState(false); @@ -119,10 +121,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const handleWipe = useCallback(async () => { // Two-step confirm so accidental clicks can't nuke a workspace. - const ok = window.confirm( - 'This deletes every chunk, summary, and raw markdown file in this workspace. ' + - 'Re-syncing afterwards will re-ingest from upstream. Continue?' - ); + const ok = window.confirm(t('workspace.wipeConfirm')); if (!ok) return; setWiping(true); try { @@ -157,12 +156,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { }, [onToast, mode]); const handleResetTree = useCallback(async () => { - const ok = window.confirm( - 'This deletes every summary, buffer, and tree job — but keeps chunks ' + - 'and raw markdown intact. Every chunk gets re-queued through extraction ' + - 'and the tree rebuilds from scratch on the *current* summariser. ' + - 'No upstream re-fetch. Continue?' - ); + const ok = window.confirm(t('workspace.resetTreeConfirm')); if (!ok) return; setResetting(true); try { @@ -259,14 +253,14 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { text-coral-700 shadow-sm transition-colors hover:bg-coral-50 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-coral-200" - title="Delete every chunk, summary, and raw file in this workspace"> + title={t('workspace.wipeTitle')}> {wiping ? ( <> - Resetting… + {t('workspace.resetting')} ) : ( <> - Reset memory + {t('workspace.resetMemory')} )} @@ -280,14 +274,14 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { text-amber-800 shadow-sm transition-colors hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-amber-200" - title="Wipe summaries + buffers and re-summarise existing chunks (no upstream re-fetch)"> + title={t('workspace.resetTreeTitle')}> {resetting ? ( <> - Rebuilding… + {t('workspace.rebuilding')} ) : ( <> - Reset memory tree + {t('workspace.resetMemoryTree')} )} @@ -303,11 +297,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { focus:outline-none focus:ring-2 focus:ring-primary-200"> {building ? ( <> - Building… + {t('workspace.building')} ) : ( <> - Build summary trees + {t('workspace.buildSummaryTrees')} )} @@ -322,7 +316,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { focus:outline-none focus:ring-2 focus:ring-violet-300" title={`obsidian://open?path=${graph.content_root_abs}`}> - View vault in Obsidian + {t('workspace.viewVault')} )}
@@ -330,11 +324,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { {error ? (
- Failed to load memory graph: {error} + {t('workspace.graphLoadFailed')}: {error}
) : !graph ? (
- Loading graph… + {t('workspace.loadingGraph')}
) : ( ); diff --git a/app/src/components/intelligence/SubconsciousReflectionCards.tsx b/app/src/components/intelligence/SubconsciousReflectionCards.tsx index 703dd4c99..19868d62e 100644 --- a/app/src/components/intelligence/SubconsciousReflectionCards.tsx +++ b/app/src/components/intelligence/SubconsciousReflectionCards.tsx @@ -15,6 +15,7 @@ */ import { useCallback, useEffect, useState } from 'react'; +import { useT } from '../../lib/i18n/I18nContext'; import { actOnReflection, dismissReflection, @@ -42,7 +43,7 @@ interface SubconsciousReflectionCardsProps { initialReflections?: Reflection[]; } -const KIND_LABEL: Record = { +const KIND_LABEL: Partial> = { hotness_spike: 'Hotness spike', cross_source_pattern: 'Cross-source pattern', daily_digest: 'Daily digest', @@ -51,6 +52,10 @@ const KIND_LABEL: Record = { opportunity: 'Opportunity', }; +function kindLabel(kind: ReflectionKind, _t: (key: string) => string): string { + return KIND_LABEL[kind] ?? kind; +} + /** * Render a `created_at` (epoch seconds, as Rust serializes `f64` from * `subconscious_reflections.created_at`) into a short relative-time @@ -58,14 +63,17 @@ const KIND_LABEL: Record = { * than ~7 days falls back to a fixed `MMM D` so cards aren't ambiguous * when the user scrolls into older reflections. */ -function formatRelativeTime(epochSeconds: number): string { +function formatRelativeTime(epochSeconds: number, t: (key: string) => string): string { const nowMs = Date.now(); const tsMs = epochSeconds * 1000; - const diffSec = Math.max(0, Math.round((nowMs - tsMs) / 1000)); - if (diffSec < 45) return 'Just now'; - if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`; - if (diffSec < 86_400) return `${Math.round(diffSec / 3600)}h ago`; - if (diffSec < 604_800) return `${Math.round(diffSec / 86_400)}d ago`; + const diffSec = Math.max(0, Math.floor((nowMs - tsMs) / 1000)); + if (diffSec < 45) return t('notifications.justNow'); + if (diffSec < 3600) + return t('notifications.minAgo').replace('{n}', String(Math.floor(diffSec / 60))); + if (diffSec < 86_400) + return t('notifications.hrAgo').replace('{n}', String(Math.floor(diffSec / 3600))); + if (diffSec < 604_800) + return t('notifications.dayAgo').replace('{n}', String(Math.floor(diffSec / 86_400))); return new Date(tsMs).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } @@ -79,6 +87,7 @@ export default function SubconsciousReflectionCards({ pollIntervalMs = 0, initialReflections, }: SubconsciousReflectionCardsProps) { + const { t } = useT(); const [reflections, setReflections] = useState(initialReflections ?? []); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [loading, setLoading] = useState(initialReflections === undefined); @@ -165,7 +174,7 @@ export default function SubconsciousReflectionCards({ if (loading) { return (
- Loading reflections… + {t('reflections.loading')}
); } @@ -173,7 +182,7 @@ export default function SubconsciousReflectionCards({ if (visible.length === 0 && !error) { return (
- No proactive observations yet — they appear after each subconscious tick. + {t('reflections.empty')}
); } @@ -188,7 +197,7 @@ export default function SubconsciousReflectionCards({

- Reflections + {t('reflections.title')} {visible.length} @@ -225,19 +234,19 @@ export default function SubconsciousReflectionCards({
- {KIND_LABEL[r.kind] ?? r.kind} + {kindLabel(r.kind, t)} - {formatRelativeTime(r.created_at)} + {formatRelativeTime(r.created_at, t)}

{r.body}

{r.proposed_action && (

- Proposed action: {r.proposed_action} + {t('reflections.proposedAction')}: {r.proposed_action}

)}
@@ -247,14 +256,14 @@ export default function SubconsciousReflectionCards({ data-testid={`reflection-act-${r.id}`} onClick={() => void handleAct(r)} className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors"> - Act + {t('reflections.act')} )}

diff --git a/app/src/components/intelligence/WhatsAppMemorySection.tsx b/app/src/components/intelligence/WhatsAppMemorySection.tsx index 874c258d1..569a439eb 100644 --- a/app/src/components/intelligence/WhatsAppMemorySection.tsx +++ b/app/src/components/intelligence/WhatsAppMemorySection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; +import { useT } from '../../lib/i18n/I18nContext'; import { whatsappListChats } from '../../utils/tauriCommands/memory'; interface WhatsAppMemorySectionProps { @@ -7,6 +8,7 @@ interface WhatsAppMemorySectionProps { } export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemorySectionProps) { + const { t } = useT(); const [chatCount, setChatCount] = useState(null); const [lastSyncTs, setLastSyncTs] = useState(null); const [syncing, setSyncing] = useState(false); @@ -52,8 +54,9 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory WhatsApp - {chatCount.toLocaleString()} chat{chatCount !== 1 ? 's' : ''} synced - {lastSyncTs !== null && <> · {relativeTime(lastSyncTs)}} + {chatCount.toLocaleString()}{' '} + {chatCount !== 1 ? t('whatsapp.chatsSynced') : t('whatsapp.chatSynced')} + {lastSyncTs !== null && <> · {relativeTime(lastSyncTs, t)}} ); } -function relativeTime(secs: number): string { +function relativeTime(secs: number, t: (key: string) => string): string { const delta = Date.now() / 1000 - secs; - if (delta < 60) return 'just now'; - if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; - if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; - return `${Math.floor(delta / 86400)}d ago`; + if (delta < 60) return t('notifications.justNow'); + if (delta < 3600) return t('notifications.minAgo').replace('{n}', String(Math.floor(delta / 60))); + if (delta < 86400) + return t('notifications.hrAgo').replace('{n}', String(Math.floor(delta / 3600))); + return t('notifications.dayAgo').replace('{n}', String(Math.floor(delta / 86400))); } function WhatsAppIcon() { diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index c08fbf92e..273f5204c 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -18,7 +18,7 @@ import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab'; const mockDispatch = vi.fn(); const mockNavigate = vi.fn(); -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })); +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch, useSelector: () => 'en' })); vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate })); diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 856bdd08d..69f113fb8 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -1,7 +1,11 @@ import { ReactNode, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useT } from '../../lib/i18n/I18nContext'; +import type { Locale } from '../../lib/i18n/types'; import { useCoreState } from '../../providers/CoreStateProvider'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { setLocale } from '../../store/localeSlice'; import { clearAllAppData } from '../../utils/clearAllAppData'; import { BILLING_DASHBOARD_URL } from '../../utils/links'; import { openUrl } from '../../utils/openUrl'; @@ -20,8 +24,9 @@ interface SettingsItem { title: string; description: string; icon: ReactNode; - onClick: () => void; + onClick?: () => void; dangerous?: boolean; + rightElement?: ReactNode; } // Subtle uppercase section header label separating settings groups @@ -37,6 +42,9 @@ const SettingsHome = () => { const navigate = useNavigate(); const { navigateToSettings } = useSettingsNavigation(); const { clearSession, snapshot } = useCoreState(); + const { t } = useT(); + const dispatch = useAppDispatch(); + const currentLocale = useAppSelector(state => state.locale.current); const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -46,7 +54,7 @@ const SettingsHome = () => { await clearSession(); } catch (err) { console.warn('[Settings] Rust logout failed:', err); - setError('Failed to log out. Please try again.'); + setError(t('clearData.failedLogout')); } }; @@ -57,7 +65,7 @@ const SettingsHome = () => { const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app } catch (_error) { - setError('Failed to clear data and logout. Please try again.'); + setError(t('clearData.failed')); } finally { setIsLoading(false); } @@ -65,12 +73,12 @@ const SettingsHome = () => { const settingsSections: SettingsSection[] = [ { - label: 'General', + label: t('settings.general'), items: [ { id: 'account', - title: 'Account', - description: 'Recovery phrase, team, connections, and privacy', + title: t('settings.account'), + description: t('settings.accountDesc'), icon: ( { }, { id: 'notifications', - title: 'Notifications', - description: 'Do Not Disturb and per-account notification controls', + title: t('settings.notifications'), + description: t('settings.notificationsDesc'), icon: ( { ), onClick: () => navigateToSettings('notifications'), }, + { + id: 'language', + title: t('settings.language'), + description: t('settings.languageDesc'), + icon: ( + + + + ), + rightElement: ( + + ), + }, { id: 'mascot', title: 'Mascot', @@ -118,12 +151,12 @@ const SettingsHome = () => { ], }, { - label: 'Features & AI', + label: t('settings.featuresAndAI'), items: [ { id: 'features', - title: 'Features', - description: 'Screen awareness, messaging, and tools', + title: t('settings.features'), + description: t('settings.featuresDesc'), icon: ( { }, { id: 'ai', - title: 'AI', - description: 'Cloud providers, local Ollama models, and per-workload routing', + title: t('settings.ai'), + description: t('settings.aiDesc'), icon: ( { ], }, { - label: 'Billing & Rewards', + label: t('settings.billingAndRewards'), items: [ { id: 'billing', - title: 'Billing & Usage', - description: 'Subscription plan, credits, and payment methods', + title: t('settings.billingUsage'), + description: t('settings.billingUsageDesc'), icon: ( { }, { id: 'rewards', - title: 'Rewards', - description: 'Referrals, coupons, and earned credits', + title: t('settings.rewards'), + description: t('settings.rewardsDesc'), icon: ( { ], }, { - label: 'Support', + label: t('settings.support'), items: [ { id: 'restart-tour', - title: 'Restart Tour', - description: 'Replay the product walkthrough from the beginning', + title: t('settings.restartTour'), + description: t('settings.restartTourDesc'), icon: ( { }, { id: 'about', - title: 'About', - description: 'App version and software updates', + title: t('settings.about'), + description: t('settings.aboutDesc'), icon: ( { ], }, { - label: 'Advanced', + label: t('settings.advanced'), items: [ { id: 'developer-options', - title: 'Developer Options', - description: 'Diagnostics, debug panels, webhooks, and memory inspection', + title: t('settings.developerOptions'), + description: t('settings.developerOptionsDesc'), icon: ( { const destructiveItems: SettingsItem[] = [ { id: 'logout-and-clear', - title: 'Clear App Data', - description: 'Sign out and permanently clear all local app data', + title: t('settings.clearAppData'), + description: t('settings.clearAppDataDesc'), icon: ( { }, { id: 'logout', - title: 'Log out', - description: 'Sign out of your account', + title: t('settings.logOut'), + description: t('settings.logOutDesc'), icon: ( { dangerous={item.dangerous} isFirst={index === 0} isLast={index === section.items.length - 1} + rightElement={item.rightElement} /> ))} ))} {/* Danger Zone */} - + {destructiveItems.map((item, index) => ( {
-

Clear App Data

+

{t('clearData.title')}

-

This will sign you out and permanently delete local app data including:

+

{t('clearData.warning')}

    -
  • App settings and conversations
  • -
  • All local integration cache data
  • -
  • Workspace data
  • -
  • All other local data
  • +
  • {t('clearData.bulletSettings')}
  • +
  • {t('clearData.bulletCache')}
  • +
  • {t('clearData.bulletWorkspace')}
  • +
  • {t('clearData.bulletOther')}
-

This action cannot be undone.

+

{t('clearData.irreversible')}

{error && ( @@ -387,7 +421,7 @@ const SettingsHome = () => { }} disabled={isLoading} className="flex-1 px-4 py-2 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-100 transition-colors disabled:opacity-50"> - Cancel + {t('common.cancel')}
diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index c2432d845..2e2edee9b 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -1,10 +1,17 @@ +import { configureStore } from '@reduxjs/toolkit'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import localeReducer from '../../../store/localeSlice'; import SettingsHome from '../SettingsHome'; +function makeTestStore() { + return configureStore({ reducer: { locale: localeReducer } }); +} + // --- hoisted mocks --- const { mockNavigate, mockNavigateToSettings } = vi.hoisted(() => ({ @@ -53,9 +60,11 @@ vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() } function renderSettingsHome() { return render( - - - + + + + + ); } diff --git a/app/src/components/settings/components/SettingsHeader.tsx b/app/src/components/settings/components/SettingsHeader.tsx index b200b956d..65aecb10f 100644 --- a/app/src/components/settings/components/SettingsHeader.tsx +++ b/app/src/components/settings/components/SettingsHeader.tsx @@ -1,3 +1,5 @@ +import { useT } from '../../../lib/i18n/I18nContext'; + interface BreadcrumbItem { label: string; onClick?: () => void; @@ -13,11 +15,13 @@ interface SettingsHeaderProps { const SettingsHeader = ({ className = '', - title = 'Settings', + title, showBackButton = false, onBack, breadcrumbs, }: SettingsHeaderProps) => { + const { t } = useT(); + return (
@@ -26,7 +30,7 @@ const SettingsHeader = ({
); diff --git a/app/src/components/settings/components/SettingsMenuItem.tsx b/app/src/components/settings/components/SettingsMenuItem.tsx index dc3ed2dcc..660303391 100644 --- a/app/src/components/settings/components/SettingsMenuItem.tsx +++ b/app/src/components/settings/components/SettingsMenuItem.tsx @@ -4,10 +4,11 @@ interface SettingsMenuItemProps { icon: ReactNode; title: string; description?: string; - onClick: () => void; + onClick?: () => void; dangerous?: boolean; isFirst?: boolean; isLast?: boolean; + rightElement?: ReactNode; } const SettingsMenuItem = ({ @@ -18,6 +19,7 @@ const SettingsMenuItem = ({ dangerous = false, isFirst = false, isLast = false, + rightElement, }: SettingsMenuItemProps) => { // Color variations for dangerous items (like logout/delete) const titleColor = dangerous ? 'text-amber-600' : 'text-stone-900'; @@ -28,16 +30,33 @@ const SettingsMenuItem = ({ const borderClasses = isLast ? '' : `border-b ${borderColor}`; const roundedClasses = isFirst ? 'first:rounded-t-3xl' : isLast ? 'last:rounded-b-3xl' : ''; - return ( - + {rightElement &&
{rightElement}
} + + ); + + if (onClick) { + return ( + + ); + } + + return ( +
+ {content} +
); }; diff --git a/app/src/components/settings/panels/AboutPanel.tsx b/app/src/components/settings/panels/AboutPanel.tsx index 8dac52266..cdf078687 100644 --- a/app/src/components/settings/panels/AboutPanel.tsx +++ b/app/src/components/settings/panels/AboutPanel.tsx @@ -9,12 +9,14 @@ import { useState } from 'react'; import { useAppUpdate } from '../../../hooks/useAppUpdate'; +import { useT } from '../../../lib/i18n/I18nContext'; import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config'; import { openUrl } from '../../../utils/openUrl'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const AboutPanel = () => { + const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); // The auto-cadence is already running via the global ; // disable it here so opening the panel doesn't double-trigger probes. @@ -22,7 +24,7 @@ const AboutPanel = () => { const [lastCheckedAt, setLastCheckedAt] = useState(null); const isChecking = phase === 'checking'; - const summary = summaryFor(phase, info, error); + const summary = summaryFor(phase, info, error, t); const handleCheck = async () => { console.debug('[app-update] AboutPanel: manual check'); @@ -33,7 +35,7 @@ const AboutPanel = () => { return (
{
-
Version
+
{t('settings.about.version')}
v{APP_VERSION}
{info?.available && info.available_version && (
- v{info.available_version} is available + v{info.available_version} {t('settings.about.updateAvailable')}
)}
@@ -53,11 +55,13 @@ const AboutPanel = () => {
-
Software updates
+
+ {t('settings.about.softwareUpdates')} +
{summary}
{lastCheckedAt && (
- Last checked {formatRelative(lastCheckedAt)} + {t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
)}
@@ -66,15 +70,15 @@ const AboutPanel = () => { onClick={handleCheck} disabled={isChecking} className="shrink-0 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-400 text-white text-xs font-medium transition-colors disabled:opacity-50"> - {isChecking ? 'Checking…' : 'Check for updates'} + {isChecking ? t('settings.about.checking') : t('settings.about.checkForUpdates')}
-
Releases
+
{t('settings.about.releases')}

- Browse release notes and earlier builds on GitHub. + {t('settings.about.releasesDesc')}

@@ -93,41 +97,42 @@ const AboutPanel = () => { function summaryFor( phase: ReturnType['phase'], info: ReturnType['info'], - error: string | null + error: string | null, + t: (key: string) => string ): string { switch (phase) { case 'checking': - return 'Contacting the update server…'; + return t('about.update.status.checking'); case 'available': return info?.available_version - ? `Version ${info.available_version} found — downloading in the background…` - : 'A new version was found — downloading…'; + ? t('about.update.status.available').replace('{version}', info.available_version) + : t('about.update.status.availableNoVersion'); case 'downloading': - return 'Downloading the latest version in the background…'; + return t('about.update.status.downloading'); case 'ready_to_install': return info?.available_version - ? `Version ${info.available_version} is downloaded and ready. Use the prompt at the bottom right to restart.` - : 'A new version is downloaded and ready. Restart to apply.'; + ? t('about.update.status.readyToInstall').replace('{version}', info.available_version) + : t('about.update.status.readyToInstallNoVersion'); case 'installing': - return 'Installing the update…'; + return t('about.update.status.installing'); case 'restarting': - return 'Relaunching with the new version…'; + return t('about.update.status.restarting'); case 'up_to_date': - return 'You are running the latest version.'; + return t('about.update.status.upToDate'); case 'error': - return error ?? 'Last update check failed. Try again in a moment.'; + return error ?? t('about.update.status.error'); default: - return 'Click "Check for updates" to look for a newer version.'; + return t('about.update.status.default'); } } -function formatRelative(date: Date): string { +function formatRelative(date: Date, t: (key: string) => string): string { const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); - if (seconds < 60) return 'just now'; + if (seconds < 60) return t('notifications.justNow'); const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes} min ago`; + if (minutes < 60) return t('notifications.minAgo').replace('{n}', String(minutes)); const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return t('notifications.hrAgo').replace('{n}', String(hours)); return date.toLocaleString(); } diff --git a/app/src/components/settings/panels/AgentChatPanel.tsx b/app/src/components/settings/panels/AgentChatPanel.tsx index 386411b2c..311ed3f93 100644 --- a/app/src/components/settings/panels/AgentChatPanel.tsx +++ b/app/src/components/settings/panels/AgentChatPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { useT } from '../../../lib/i18n/I18nContext'; import { openhumanAgentChat } from '../../../utils/tauriCommands'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -9,6 +10,7 @@ type ChatMessage = { role: 'user' | 'agent'; text: string }; const STORAGE_KEY = 'openhuman.settings.agentChat.history'; const AgentChatPanel = () => { + const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); @@ -74,7 +76,7 @@ const AgentChatPanel = () => { return (
{
-

Overrides

-

- Inference uses your OpenHuman backend (config API URL and session). Optional model and - temperature override the defaults for this panel only. -

+

{t('chat.overrides')}

+

{t('chat.agentChatDesc')}

-

Conversation

+

{t('chat.conversation')}

{error && (
{error} @@ -118,12 +117,12 @@ const AgentChatPanel = () => { )}
{messages.length === 0 && ( -
Start a conversation with the agent.
+
{t('chat.startAgentConversation')}
)} {messages.map((message, index) => (
- {message.role === 'user' ? 'You' : 'Agent'} + {message.role === 'user' ? t('chat.you') : t('chat.agent')}
{