feat: add Chinese (简体中文) i18n support (#1518)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
落叶
2026-05-15 16:11:40 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 3dfd2b28be
commit 02cf2cee3e
88 changed files with 3587 additions and 1193 deletions
+21 -18
View File
@@ -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() {
)}>
<Provider store={store}>
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
</BootCheckGate>
<I18nProvider>
<BootCheckGate>
<CoreStateProvider>
<SocketProvider>
<ChatRuntimeProvider>
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AppShell />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<AppUpdatePrompt />
</ServiceBlockingGate>
</CommandProvider>
</Router>
</ChatRuntimeProvider>
</SocketProvider>
</CoreStateProvider>
</BootCheckGate>
</I18nProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
@@ -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 (
<Panel>
<h2 className="text-xl font-semibold text-white">
{isDesktop ? 'Choose core mode' : 'Connect to your core'}
{isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}
</h2>
<p className="mt-2 text-sm text-stone-300">
{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')}
</p>
{!isDesktop && (
<div
className="mt-4 rounded-xl border border-stone-700 bg-stone-800/60 p-3 text-xs text-stone-300"
data-testid="web-download-cta">
Prefer to run everything on your own device?{' '}
{t('bootCheck.preferDesktop')}{' '}
<a
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noopener noreferrer"
className="text-ocean-400 underline hover:text-ocean-300">
Download the desktop app
{t('bootCheck.downloadDesktop')}
</a>
.
</div>
@@ -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'
}`}>
<div className="font-medium">Local (recommended)</div>
<div className="mt-0.5 text-xs text-stone-400">
Embedded core runs on this device fastest, no configuration required.
</div>
<div className="font-medium">{t('bootCheck.localRecommended')}</div>
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.localDescription')}</div>
</button>
)}
@@ -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'
}`}>
<div className="font-medium">Cloud</div>
<div className="mt-0.5 text-xs text-stone-400">
Connect to a remote core at a custom URL.
</div>
<div className="font-medium">{t('bootCheck.cloudMode')}</div>
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.cloudDescription')}</div>
</button>
)}
{selected === 'cloud' && (
<div className="mt-1 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-300">Core RPC URL</label>
<label className="text-xs font-medium text-stone-300">
{t('bootCheck.coreRpcUrl')}
</label>
<input
type="url"
placeholder="https://core.example.com/rpc"
placeholder={t('bootCheck.rpcUrlPlaceholder')}
value={cloudUrl}
onChange={e => {
setCloudUrl(e.target.value);
@@ -276,13 +274,14 @@ function ModePicker({ onConfirm }: PickerProps) {
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-stone-300">
Auth token (<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
{t('bootCheck.authToken')} (
<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
</label>
<input
type="password"
autoComplete="off"
spellCheck={false}
placeholder="Bearer token configured on the remote core"
placeholder={t('bootCheck.bearerTokenPlaceholder')}
value={cloudToken}
onChange={e => {
setCloudToken(e.target.value);
@@ -293,8 +292,7 @@ function ModePicker({ onConfirm }: PickerProps) {
/>
{tokenError && <p className="text-xs text-coral-400">{tokenError}</p>}
<p className="text-[11px] text-stone-500">
Stored on this device only. Required for remote cores the desktop sends it as{' '}
<code>Authorization: Bearer </code> on every RPC.
{t('bootCheck.storedLocally')} <code>Authorization: Bearer </code> on every RPC.
</p>
</div>
@@ -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')}
</button>
{testStatus.kind === 'ok' && (
<span className="text-xs text-emerald-400" data-testid="test-status-ok">
Connected
{t('bootCheck.connectedOk')}
</span>
)}
{testStatus.kind === 'auth' && (
<span className="text-xs text-coral-400" data-testid="test-status-auth">
Auth failed check the token (got 401/403).
{t('bootCheck.authFailed')}
</span>
)}
{testStatus.kind === 'unreachable' && (
<span className="text-xs text-coral-400" data-testid="test-status-unreachable">
Unreachable: {testStatus.reason}
{t('bootCheck.unreachablePrefix')} {testStatus.reason}
</span>
)}
</div>
@@ -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')}
</button>
</div>
</Panel>
@@ -343,11 +343,12 @@ function ModePicker({ onConfirm }: PickerProps) {
// ---------------------------------------------------------------------------
function CheckingScreen() {
const { t } = useT();
return (
<Panel>
<div className="flex flex-col items-center gap-4 py-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-600 border-t-ocean-500" />
<p className="text-sm text-stone-300">Checking core</p>
<p className="text-sm text-stone-300">{t('bootCheck.checkingCore')}</p>
</div>
</Panel>
);
@@ -376,14 +377,15 @@ function ResultScreen({
actionError,
onAction,
}: ResultScreenProps) {
const { t } = useT();
if (result.kind === 'match') return null;
if (result.kind === 'unreachable') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Could not reach core</h2>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.cannotReach')}</h2>
<p className="mt-2 text-sm text-stone-300">
{result.reason || 'The core process is unreachable. Try switching to a different mode.'}
{result.reason || t('bootCheck.cannotReachDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
@@ -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')}
</button>
<button
type="button"
onClick={onSwitchMode}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800">
Switch mode
{t('bootCheck.switchMode')}
</button>
<button
type="button"
onClick={onQuit}
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600">
Quit
{t('bootCheck.quit')}
</button>
</div>
</Panel>
@@ -414,11 +416,8 @@ function ResultScreen({
if (result.kind === 'daemonDetected') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Legacy background core detected</h2>
<p className="mt-2 text-sm text-stone-300">
A separately-installed OpenHuman daemon is running on this device. It must be removed
before the embedded core can take over.
</p>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.legacyDetected')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.legacyDescription')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -426,14 +425,14 @@ function ResultScreen({
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600 disabled:opacity-60">
{actionBusy ? 'Removing' : 'Remove and continue'}
{actionBusy ? t('bootCheck.removing') : t('bootCheck.removeContinue')}
</button>
<button
type="button"
onClick={onSwitchMode}
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">
Switch mode
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
@@ -443,11 +442,8 @@ function ResultScreen({
if (result.kind === 'outdatedLocal') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Local core needs a restart</h2>
<p className="mt-2 text-sm text-stone-300">
The local core version does not match this app build. Restarting it will load the correct
version.
</p>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.localNeedsRestart')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.localNeedsRestartDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -455,14 +451,14 @@ function ResultScreen({
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? 'Restarting' : 'Restart core'}
{actionBusy ? t('bootCheck.restarting') : t('bootCheck.restartCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
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">
Switch mode
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
@@ -472,11 +468,8 @@ function ResultScreen({
if (result.kind === 'outdatedCloud') {
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Cloud core needs an update</h2>
<p className="mt-2 text-sm text-stone-300">
The cloud core version does not match this app build. Run the core updater to resolve the
mismatch.
</p>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.cloudNeedsUpdate')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.cloudNeedsUpdateDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -484,14 +477,14 @@ function ResultScreen({
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? 'Updating' : 'Update cloud core'}
{actionBusy ? t('bootCheck.updating') : t('bootCheck.updateCloudCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
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">
Switch mode
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
@@ -501,11 +494,8 @@ function ResultScreen({
// noVersionMethod — treat like outdated, user picks which flavor of action
return (
<Panel>
<h2 className="text-xl font-semibold text-white">Core version check failed</h2>
<p className="mt-2 text-sm text-stone-300">
The core is running but does not expose a version endpoint. It may be outdated. Restart or
update the core to continue.
</p>
<h2 className="text-xl font-semibold text-white">{t('bootCheck.versionCheckFailed')}</h2>
<p className="mt-2 text-sm text-stone-300">{t('bootCheck.versionCheckFailedDesc')}</p>
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
<div className="mt-5 flex gap-3">
<button
@@ -513,14 +503,14 @@ function ResultScreen({
onClick={onAction}
disabled={actionBusy}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{actionBusy ? 'Working' : 'Restart / update core'}
{actionBusy ? t('bootCheck.working') : t('bootCheck.restartUpdateCore')}
</button>
<button
type="button"
onClick={onSwitchMode}
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">
Switch mode
{t('bootCheck.switchMode')}
</button>
</div>
</Panel>
@@ -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);
}
+14 -11
View File
@@ -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: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -26,7 +27,7 @@ const tabs = [
},
{
id: 'human',
label: 'Human',
label: t('nav.human'),
path: '/human',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -41,7 +42,7 @@ const tabs = [
},
{
id: 'chat',
label: 'Chat',
label: t('nav.chat'),
path: '/chat',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -56,7 +57,7 @@ const tabs = [
},
{
id: 'skills',
label: 'Connections',
label: t('nav.connections'),
path: '/skills',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -71,7 +72,7 @@ const tabs = [
},
{
id: 'intelligence',
label: 'Memory',
label: t('nav.memory'),
path: '/intelligence',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -86,7 +87,7 @@ const tabs = [
},
{
id: 'notifications',
label: 'Alerts',
label: t('nav.alerts'),
path: '/notifications',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -101,7 +102,7 @@ const tabs = [
},
{
id: 'rewards',
label: 'Rewards',
label: t('nav.rewards'),
path: '/rewards',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -116,7 +117,7 @@ const tabs = [
},
{
id: 'settings',
label: 'Settings',
label: t('nav.settings'),
path: '/settings',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -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
}>
<span className="relative inline-flex flex-shrink-0">
@@ -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 (
<p className="text-sm text-stone-400 py-4">
Configuration for {definition.display_name} is not available yet.
{t('channels.configNotAvailable')} {definition.display_name}
</p>
);
}
}
export default function ChannelSetupModal({ definition, onClose }: ChannelSetupModalProps) {
const { t } = useT();
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -88,7 +91,7 @@ export default function ChannelSetupModal({ definition, onClose }: ChannelSetupM
{definition.display_name}
</h2>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-md bg-primary-500/15 text-primary-600">
channel
{t('channels.channel')}
</span>
</div>
<p className="text-xs text-stone-500 mt-1.5">{definition.description}</p>
+10 -5
View File
@@ -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 ? (
<span
className="inline-flex items-center gap-1 rounded-full bg-stone-100 px-2 py-1 font-mono text-stone-600 ring-1 ring-stone-200/60"
title={`Session tokens: ${sessionTokens.inputTokens.toLocaleString()} in / ${sessionTokens.outputTokens.toLocaleString()} out across ${sessionTokens.turns} turn(s)`}>
title={t('token.sessionTokens')
.replace('{in}', sessionTokens.inputTokens.toLocaleString())
.replace('{out}', sessionTokens.outputTokens.toLocaleString())
.replace('{turns}', String(sessionTokens.turns))}>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -86,7 +91,7 @@ const TokenUsagePill = () => {
}}
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}
</button>
) : null}
</div>
@@ -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<HTMLButtonElement>(null);
@@ -284,7 +286,7 @@ export function ActionableCard({
<button
onClick={handleComplete}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
title="Complete">
title={t('actionable.complete')}>
<svg
className="w-3.5 h-3.5"
fill="none"
@@ -303,7 +305,7 @@ export function ActionableCard({
<button
onClick={handleDismiss}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
title="Dismiss">
title={t('actionable.dismiss')}>
<svg
className="w-3.5 h-3.5"
fill="none"
@@ -324,7 +326,7 @@ export function ActionableCard({
ref={snoozeButtonRef}
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
title="Snooze">
title={t('actionable.snooze')}>
<svg
className="w-3.5 h-3.5"
fill="none"
@@ -354,7 +356,7 @@ export function ActionableCard({
<>
<span className="text-xs text-stone-600"></span>
<span className="text-xs bg-sage-500 text-white px-1.5 py-0.5 rounded-sm font-medium">
New
{t('actionable.new')}
</span>
</>
)}
@@ -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')}
</label>
</div>
)}
@@ -83,7 +85,7 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-stone-600 hover:text-stone-900 rounded-lg hover:bg-stone-100 transition-colors">
{modal.cancelText || 'Cancel'}
{modal.cancelText || t('common.cancel')}
</button>
<button
onClick={handleConfirm}
@@ -95,7 +97,7 @@ export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
: 'bg-primary-500 hover:bg-primary-600 text-white'
}
`}>
{modal.confirmText || 'Confirm'}
{modal.confirmText || t('common.confirm')}
</button>
</div>
</div>
@@ -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 (
<div className="space-y-6">
<div>
<h2 className="text-base font-semibold text-stone-900">Join a Google Meet call</h2>
<p className="mt-1 text-sm text-stone-500">
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.
</p>
<h2 className="text-base font-semibold text-stone-900">{t('calls.joinMeet')}</h2>
<p className="mt-1 text-sm text-stone-500">{t('calls.joinMeetDescription')}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500">
Meet link
{t('calls.meetLink')}
</span>
<input
type="url"
@@ -127,7 +126,7 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500">
Display name
{t('calls.displayName')}
</span>
<input
type="text"
@@ -151,14 +150,14 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
type="submit"
disabled={submitting || !meetUrl.trim() || !displayName.trim()}
className="inline-flex items-center justify-center rounded-xl border border-primary-600 bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-soft transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? 'Opening Meet' : 'Join call'}
{submitting ? t('calls.openingMeet') : t('calls.joinCall')}
</button>
</form>
{activeCalls.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500">
Active calls
{t('calls.activeCalls')}
</h3>
<ul className="space-y-2">
{activeCalls.map(call => (
@@ -175,7 +174,7 @@ export default function IntelligenceCallsTab({ onToast }: Props) {
type="button"
onClick={() => handleClose(call.requestId)}
className="shrink-0 rounded-lg border border-stone-200 bg-white px-3 py-1 text-xs text-stone-600 hover:border-coral-300 hover:text-coral-600">
Leave
{t('calls.leave')}
</button>
</li>
))}
@@ -1,4 +1,7 @@
import { useT } from '../../lib/i18n/I18nContext';
export default function IntelligenceDreamsTab() {
const { t } = useT();
return (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-500/10">
@@ -17,13 +20,9 @@ export default function IntelligenceDreamsTab() {
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">Dreams</h2>
<p className="text-stone-400 text-sm mb-1">
Twice every day, OpenHuman will generate a dream (or a summary) based on everything that has
happened in your life today. These dreams are then indexed and can be used to influence
OpenHuman&apos;s behavior.
</p>
<p className="text-xs text-stone-500">Coming soon</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.tab.dreams')}</h2>
<p className="text-stone-400 text-sm mb-1">{t('dreams.description')}</p>
<p className="text-xs text-stone-500">{t('dreams.comingSoon')}</p>
</div>
);
}
@@ -1,3 +1,4 @@
import { useT } from '../../lib/i18n/I18nContext';
import type { ActionableItem, ActionableItemSource, TimeGroup } from '../../types/intelligence';
import { ActionableCard } from './ActionableCard';
@@ -32,17 +33,18 @@ export default function IntelligenceMemoryTab({
timeGroups,
usingMemoryData,
}: IntelligenceMemoryTabProps) {
const { t } = useT();
return (
<>
<div className="flex items-center gap-3 mb-6 animate-fade-up">
<div className="flex-1">
<label htmlFor="actionable-search" className="sr-only">
Search actionable items
{t('memory.searchAria')}
</label>
<input
id="actionable-search"
type="text"
placeholder="Search actionable items..."
placeholder={t('memory.search')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
@@ -56,14 +58,14 @@ export default function IntelligenceMemoryTab({
value={sourceFilter}
onChange={e => setSourceFilter(e.target.value as ActionableItemSource | 'all')}
className="px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">All Sources</option>
<option value="email">Email</option>
<option value="calendar">Calendar</option>
<option value="telegram">Telegram</option>
<option value="ai_insight">AI Insights</option>
<option value="system">System</option>
<option value="trading">Trading</option>
<option value="security">Security</option>
<option value="all">{t('memory.sourceFilter.all')}</option>
<option value="email">{t('memory.sourceFilter.email')}</option>
<option value="calendar">{t('memory.sourceFilter.calendar')}</option>
<option value="telegram">{t('memory.sourceFilter.telegram')}</option>
<option value="ai_insight">{t('memory.sourceFilter.aiInsight')}</option>
<option value="system">{t('memory.sourceFilter.system')}</option>
<option value="trading">{t('memory.sourceFilter.trading')}</option>
<option value="security">{t('memory.sourceFilter.security')}</option>
</select>
</div>
@@ -72,18 +74,16 @@ export default function IntelligenceMemoryTab({
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">Loading Intelligence...</h2>
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.loading')}</h2>
<p className="text-stone-400 text-sm">{t('memory.fetching')}</p>
</div>
) : isRunning && items.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-stone-900 mb-2">Analyzing your data</h2>
<p className="text-stone-400 text-sm">
The conscious loop is reviewing your connected skills
</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.analyzing')}</h2>
<p className="text-stone-400 text-sm">{t('memory.analyzingHint')}</p>
</div>
) : timeGroups.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
@@ -103,25 +103,27 @@ export default function IntelligenceMemoryTab({
</div>
{searchFilter || sourceFilter !== 'all' ? (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">No matches</h2>
<p className="text-stone-400 text-sm">No items match your current filters.</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">{t('memory.noMatches')}</h2>
<p className="text-stone-400 text-sm">{t('memory.noMatchesHint')}</p>
</>
) : usingMemoryData ? (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">All caught up!</h2>
<p className="text-stone-400 text-sm">No actionable items at the moment.</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">
{t('memory.allCaughtUp')}
</h2>
<p className="text-stone-400 text-sm">{t('memory.allCaughtUpHint')}</p>
</>
) : (
<>
<h2 className="text-lg font-semibold text-stone-900 mb-2">No analysis yet</h2>
<p className="text-stone-400 text-sm mb-4">
Run an analysis to extract actionable items from your connected skills.
</p>
<h2 className="text-lg font-semibold text-stone-900 mb-2">
{t('memory.noAnalysis')}
</h2>
<p className="text-stone-400 text-sm mb-4">{t('memory.noAnalysisHint')}</p>
<button
onClick={() => void handleAnalyzeNow()}
disabled={isRunning}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
Analyze Now
{t('memory.analyzeNow')}
</button>
</>
)}
@@ -131,7 +133,7 @@ export default function IntelligenceMemoryTab({
{isRunning && (
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
Analyzing your data
{t('memory.analyzing')}
</div>
)}
{timeGroups.map((group, groupIndex) => (
@@ -2,6 +2,7 @@ import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { setSelectedThread } from '../../store/threadSlice';
import type {
SubconsciousEscalation,
@@ -55,6 +56,7 @@ export default function IntelligenceSubconsciousTab({
triggerTick,
triggering,
}: IntelligenceSubconsciousTabProps) {
const { t } = useT();
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -176,19 +178,28 @@ export default function IntelligenceSubconsciousTab({
<div className="flex items-center gap-2 text-xs text-stone-400">
{status && (
<>
<span>{status.task_count} tasks</span>
<span>
{status.task_count} {t('subconscious.tasks')}
</span>
<span className="text-stone-300">|</span>
<span>{status.total_ticks} ticks</span>
<span>
{status.total_ticks} {t('subconscious.ticks')}
</span>
{status.last_tick_at && (
<>
<span className="text-stone-300">|</span>
<span>Last: {new Date(status.last_tick_at * 1000).toLocaleTimeString()}</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
</span>
</>
)}
{status.consecutive_failures > 0 && (
<>
<span className="text-stone-300">|</span>
<span className="text-coral-500">{status.consecutive_failures} failed</span>
<span className="text-coral-500">
{status.consecutive_failures} {t('subconscious.failed')}
</span>
</>
)}
</>
@@ -214,7 +225,7 @@ export default function IntelligenceSubconsciousTab({
// Config update would require restart — show as read-only for now
}}
disabled
title="Tick interval (change in Settings > Advanced)"
title={t('subconscious.tickInterval')}
className="text-xs bg-stone-50 border border-stone-200 rounded px-1.5 py-0.5 text-stone-500 cursor-not-allowed">
<option value={5}>5 min</option>
<option value={10}>10 min</option>
@@ -242,7 +253,7 @@ export default function IntelligenceSubconsciousTab({
/>
</svg>
)}
Run Now
{t('subconscious.runNow')}
</button>
</div>
</div>
@@ -256,7 +267,7 @@ export default function IntelligenceSubconsciousTab({
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
Approval Needed
{t('subconscious.approvalNeeded')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700">
{escalations.length}
</span>
@@ -280,7 +291,7 @@ export default function IntelligenceSubconsciousTab({
{esc.priority}
</span>
<span className="text-[10px] text-stone-400">
Requires your approval to proceed
{t('subconscious.requiresApproval')}
</span>
</div>
</div>
@@ -289,19 +300,19 @@ export default function IntelligenceSubconsciousTab({
<button
onClick={() => handleFixInSkills(esc.id)}
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
Fix in Connections
{t('subconscious.fixInConnections')}
</button>
) : (
<button
onClick={() => void handleApproveEscalation(esc.id)}
className="px-3 py-1.5 text-xs bg-sage-500 hover:bg-sage-600 text-white rounded-lg transition-colors">
Go ahead
{t('subconscious.goAhead')}
</button>
)}
<button
onClick={() => void handleDismissEscalation(esc.id)}
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
Skip
{t('common.skip')}
</button>
</div>
</div>
@@ -312,13 +323,15 @@ export default function IntelligenceSubconsciousTab({
)}
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3">Active Tasks</h3>
<h3 className="text-sm font-semibold text-stone-900 mb-3">
{t('subconscious.activeTasks')}
</h3>
{loading && tasks.length === 0 ? (
<div className="text-center py-4">
<div className="w-6 h-6 mx-auto border-2 border-stone-300 border-t-transparent rounded-full animate-spin" />
</div>
) : tasks.filter(t => !t.completed).length === 0 ? (
<p className="text-xs text-stone-400 py-3">No active tasks. Add one below.</p>
<p className="text-xs text-stone-400 py-3">{t('subconscious.noActiveTasks')}</p>
) : (
<div className="space-y-1.5">
{tasks
@@ -328,7 +341,7 @@ export default function IntelligenceSubconsciousTab({
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
<span className="text-sm text-stone-900 truncate flex-1">{task.title}</span>
<span className="text-[10px] text-stone-400 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100">
default
{t('subconscious.default')}
</span>
</div>
))}
@@ -342,7 +355,7 @@ export default function IntelligenceSubconsciousTab({
<button
type="button"
aria-pressed={task.enabled}
aria-label={`${task.enabled ? 'Disable' : 'Enable'} ${task.title}`}
aria-label={`${task.enabled ? t('common.disable') : t('common.enable')} ${task.title}`}
onClick={() => void handleToggleTask(task.id, !task.enabled, task.title)}
className={`relative w-7 h-4 rounded-full flex-shrink-0 transition-colors ${
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
@@ -360,7 +373,7 @@ export default function IntelligenceSubconsciousTab({
</div>
<button
type="button"
aria-label={`Remove ${task.title}`}
aria-label={`${t('common.remove')} ${task.title}`}
onClick={() => void handleRemoveTask(task.id, task.title)}
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 hover:text-coral-500 transition-all">
<svg
@@ -384,7 +397,7 @@ export default function IntelligenceSubconsciousTab({
<form onSubmit={e => void handleAddTask(e)} className="flex gap-2 mt-3">
<input
type="text"
placeholder="Add a task... (e.g. 'Check urgent emails')"
placeholder={t('subconscious.addTaskPlaceholder')}
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
@@ -393,15 +406,17 @@ export default function IntelligenceSubconsciousTab({
type="submit"
disabled={!newTaskTitle.trim()}
className="px-3 py-2 text-sm bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white rounded-lg transition-colors">
Add
{t('common.add')}
</button>
</form>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 mb-3">Activity Log</h3>
<h3 className="text-sm font-semibold text-stone-900 mb-3">
{t('subconscious.activityLog')}
</h3>
{logEntries.length === 0 ? (
<p className="text-xs text-stone-400 py-3">No activity yet. Run a tick to see results.</p>
<p className="text-xs text-stone-400 py-3">{t('subconscious.noActivity')}</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{logEntries.map(entry => (
@@ -454,19 +469,19 @@ export default function IntelligenceSubconsciousTab({
? `${entry.result.substring(0, 120)}...`
: entry.result
: entry.decision === 'noop'
? 'Nothing new'
? t('subconscious.decision.nothingNew')
: entry.decision === 'act'
? 'Completed'
? t('subconscious.decision.completed')
: entry.decision === 'in_progress'
? 'Evaluating...'
? t('subconscious.decision.evaluating')
: entry.decision === 'escalate'
? 'Waiting for approval'
? t('subconscious.decision.waitingApproval')
: entry.decision === 'failed'
? 'Failed'
? t('subconscious.decision.failed')
: entry.decision === 'cancelled'
? 'Cancelled'
? t('subconscious.decision.cancelled')
: entry.decision === 'dismissed'
? 'Skipped'
? t('subconscious.decision.skipped')
: entry.decision}
</span>
{entry.duration_ms != null && (
@@ -5,15 +5,14 @@
* forward is connecting an integration in Settings, so we point there in
* prose without an explicit link to keep the surface meditative.
*/
import { useT } from '../../lib/i18n/I18nContext';
export function MemoryEmptyPlaceholder() {
const { t } = useT();
return (
<div className="mw-detail-empty" data-testid="memory-empty-placeholder">
<h2 className="mw-empty-title">Nothing yet.</h2>
<p className="mw-empty-body">
Connect an integration in Settings to start
<br />
building your memory tree.
</p>
<h2 className="mw-empty-title">{t('memory.empty')}</h2>
<p className="mw-empty-body">{t('memory.emptyHint')}</p>
</div>
);
}
+36 -19
View File
@@ -20,6 +20,7 @@
*/
import { useMemo, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { openUrl } from '../../utils/openUrl';
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
@@ -193,6 +194,7 @@ function joinPath(root: string, rel: string): string {
}
export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: MemoryGraphProps) {
const { t } = useT();
const [hovered, setHovered] = useState<GraphNode | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
@@ -240,10 +242,7 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
<div
className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 bg-stone-50/40 text-sm text-stone-500"
data-testid="memory-graph-empty">
{emptyHint ??
(mode === 'contacts'
? 'No contact mentions yet — sync a source and rebuild trees first.'
: 'No memory yet — connect a source above to start ingesting.')}
{emptyHint ?? (mode === 'contacts' ? t('graph.noContactMentions') : t('graph.noMemory'))}
</div>
);
}
@@ -256,23 +255,31 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
? Array.from(new Set(nodes.map(n => n.tree_kind ?? '')))
.filter(Boolean)
.map(kind => ({
label: kind === 'source' ? 'Source' : kind === 'topic' ? 'Topic' : 'Global',
label:
kind === 'source'
? t('graph.source')
: kind === 'topic'
? t('graph.topic')
: t('graph.global'),
color: SUMMARY_TREE_COLOR[kind] ?? '#94a3b8',
}))
: [
{ label: 'Document', color: NODE_COLOR.chunk },
{ label: 'Contact', color: NODE_COLOR.contact },
{ label: t('graph.document'), color: NODE_COLOR.chunk },
{ label: t('graph.contact'), color: NODE_COLOR.contact },
];
return (
<div className="memory-graph rounded-lg border border-stone-100 bg-white">
<div className="flex items-center justify-between gap-4 border-b border-stone-100 px-4 py-2">
<div className="flex items-center gap-3 text-xs text-stone-500">
<span>{nodes.length} nodes</span>
<span>
{nodes.length} {t('graph.nodes')}
</span>
<span className="text-stone-300">·</span>
<span>
{sim.edges.length} {mode === 'tree' ? 'parent → child' : 'document ↔ contact'} link
{sim.edges.length === 1 ? '' : 's'}
{sim.edges.length}{' '}
{mode === 'tree' ? t('graph.parentChild') : t('graph.documentContact')}{' '}
{sim.edges.length === 1 ? t('graph.link') : t('graph.links')}
</span>
</div>
<div className="flex items-center gap-3">
@@ -321,7 +328,7 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
if (n.kind === 'summary') void openSummaryInObsidian(n, contentRootAbs);
}}
data-testid={`memory-graph-node-${n.id}`}>
<title>{tooltipFor(n)}</title>
<title>{tooltipFor(n, t)}</title>
</circle>
);
})}
@@ -339,20 +346,22 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
<span className="text-stone-400"> · </span>
<span>{hovered.tree_scope}</span>
<span className="text-stone-400"> · </span>
<span>{hovered.child_count ?? 0} children</span>
<span className="ml-3 text-stone-400">click to open in Obsidian</span>
<span>
{hovered.child_count ?? 0} {t('graph.children')}
</span>
<span className="ml-3 text-stone-400">{t('graph.clickToOpenObsidian')}</span>
</>
) : hovered.kind === 'contact' ? (
<>
<span className="font-medium text-violet-700">{hovered.label}</span>
<span className="ml-3 text-stone-400">
person · canonical id {hovered.id.slice(0, 12)}
{t('graph.person')} · canonical id {hovered.id.slice(0, 12)}
</span>
</>
) : (
<>
<span className="font-medium">{hovered.label || 'chunk'}</span>
<span className="ml-3 text-stone-400">document</span>
<span className="ml-3 text-stone-400">{t('graph.document')}</span>
</>
)}
</div>
@@ -361,10 +370,18 @@ export function MemoryGraph({ nodes, edges, mode, contentRootAbs, emptyHint }: M
);
}
function tooltipFor(n: GraphNode): string {
function tooltipFor(
n: GraphNode,
t: (key: string, params?: Record<string, string>) => string
): string {
if (n.kind === 'summary') {
return `L${n.level ?? 0} · ${n.tree_kind} · ${n.tree_scope ?? ''} · ${n.child_count ?? 0} children`;
return t('graph.tooltip.summary', {
level: String(n.level ?? 0),
kind: n.tree_kind ?? '',
scope: n.tree_scope ?? '',
children: String(n.child_count ?? 0),
});
}
if (n.kind === 'contact') return `${n.label} (person)`;
return n.label || 'document';
if (n.kind === 'contact') return t('graph.tooltip.contact', { label: n.label });
return n.label || t('graph.document');
}
@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
interface MemoryHeatmapProps {
/** Array of document/relation timestamps (unix epoch seconds). */
timestamps: number[];
@@ -9,7 +11,9 @@ interface MemoryHeatmapProps {
const MONTHS = 8;
const DAYS_PER_WEEK = 7;
const CELL_GAP = 2;
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
function dayLabels(t: (key: string) => string): string[] {
return ['', t('memory.day.mon'), '', t('memory.day.wed'), '', t('memory.day.fri'), ''];
}
const INTENSITY_COLORS = [
'rgba(255,255,255,0.04)', // 0 events
@@ -32,7 +36,7 @@ function dateToKey(date: Date): string {
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
@@ -41,6 +45,7 @@ function formatDate(date: Date): string {
}
export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
const { t } = useT();
const [hoveredCell, setHoveredCell] = useState<{
date: Date;
count: number;
@@ -99,7 +104,7 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
// Track month labels (on the first Sunday-row cell of each new month)
if (cellDate.getMonth() !== lastMonth && d === 0) {
lastMonth = cellDate.getMonth();
months.push({ label: cellDate.toLocaleDateString('en-US', { month: 'short' }), weekIdx });
months.push({ label: cellDate.toLocaleDateString(undefined, { month: 'short' }), weekIdx });
}
cursor.setDate(cursor.getDate() + 1);
@@ -124,7 +129,9 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
if (loading) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-3">Ingestion Activity</h3>
<h3 className="text-sm font-semibold text-stone-900 mb-3">
{t('memory.ingestionActivity')}
</h3>
<div className="h-28 rounded-lg bg-stone-200 animate-pulse" />
</div>
);
@@ -134,14 +141,21 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-stone-900">Ingestion Activity</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('memory.ingestionActivity')}</h3>
<p className="text-xs text-stone-500 mt-0.5">
{totalEvents} event{totalEvents !== 1 ? 's' : ''} over the last {MONTHS} months
{maxDailyCount > 0 && <> · peak: {maxDailyCount}/day</>}
{totalEvents} {totalEvents !== 1 ? t('memory.events') : t('memory.event')}{' '}
{t('memory.overTheLast')} {MONTHS} {t('memory.months')}
{maxDailyCount > 0 && (
<>
{' '}
· {t('memory.peak')}: {maxDailyCount}
{t('memory.perDay')}
</>
)}
</p>
</div>
<div className="flex items-center gap-1 text-[10px] text-stone-500">
<span>Less</span>
<span>{t('memory.less')}</span>
{INTENSITY_COLORS.map((color, i) => (
<div
key={i}
@@ -149,7 +163,7 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
style={{ backgroundColor: color }}
/>
))}
<span>More</span>
<span>{t('memory.more')}</span>
</div>
</div>
@@ -159,7 +173,7 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
preserveAspectRatio="xMinYMin meet"
className="block">
{/* Day labels */}
{DAY_LABELS.map((label, i) =>
{dayLabels(t).map((label, i) =>
label ? (
<text
key={i}
@@ -229,9 +243,11 @@ export function MemoryHeatmap({ timestamps, loading }: MemoryHeatmapProps) {
className="fixed z-50 px-2 py-1 rounded-md bg-white border border-stone-200 text-[11px] text-stone-900 shadow-lg pointer-events-none"
style={{ left: hoveredCell.x, top: hoveredCell.y - 32, transform: 'translateX(-50%)' }}>
<span className="font-medium">
{hoveredCell.count} event{hoveredCell.count !== 1 ? 's' : ''}
{hoveredCell.count} {hoveredCell.count !== 1 ? t('memory.events') : t('memory.event')}
</span>{' '}
<span className="text-stone-400">on {formatDate(hoveredCell.date)}</span>
<span className="text-stone-400">
{t('memory.on')} {formatDate(hoveredCell.date)}
</span>
</div>
)}
</div>
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { GraphRelation } from '../../utils/tauriCommands';
interface MemoryInsightsProps {
@@ -88,53 +89,53 @@ function categorize(predicate: string): InsightCategory {
return 'other';
}
const CATEGORY_CONFIG: Record<
InsightCategory,
{ label: string; icon: string; color: string; bgColor: string; borderColor: string }
> = {
facts: {
label: 'Known Facts',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200',
},
preferences: {
label: 'Preferences',
icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
color: 'text-rose-600',
bgColor: 'bg-rose-50',
borderColor: 'border-rose-200',
},
relationships: {
label: 'Relationships',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
color: 'text-primary-600',
bgColor: 'bg-primary-50',
borderColor: 'border-primary-200',
},
skills: {
label: 'Skills & Expertise',
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
color: 'text-amber-600',
bgColor: 'bg-amber-100',
borderColor: 'border-amber-200',
},
opinions: {
label: 'Opinions & Beliefs',
icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
color: 'text-lavender-600',
bgColor: 'bg-lavender-50',
borderColor: 'border-lavender-200',
},
other: {
label: 'Other Insights',
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
color: 'text-stone-600',
bgColor: 'bg-stone-100',
borderColor: 'border-stone-200',
},
};
function useInsightCategoryLabels() {
const { t } = useT();
return {
facts: {
label: t('insights.knownFacts'),
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200',
},
preferences: {
label: t('insights.preferences'),
icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
color: 'text-rose-600',
bgColor: 'bg-rose-50',
borderColor: 'border-rose-200',
},
relationships: {
label: t('insights.relationships'),
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
color: 'text-primary-600',
bgColor: 'bg-primary-50',
borderColor: 'border-primary-200',
},
skills: {
label: t('insights.skills'),
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
color: 'text-amber-600',
bgColor: 'bg-amber-100',
borderColor: 'border-amber-200',
},
opinions: {
label: t('insights.opinions'),
icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
color: 'text-lavender-600',
bgColor: 'bg-lavender-50',
borderColor: 'border-lavender-200',
},
other: {
label: t('insights.other'),
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
color: 'text-stone-600',
bgColor: 'bg-stone-100',
borderColor: 'border-stone-200',
},
} as const;
}
/** Small inline badge that displays an entity type (e.g. "person", "project"). */
function EntityTypeBadge({ type }: { type: string }) {
@@ -146,7 +147,9 @@ function EntityTypeBadge({ type }: { type: string }) {
}
export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
const { t } = useT();
const [expandedCategory, setExpandedCategory] = useState<InsightCategory | null>(null);
const config = useInsightCategoryLabels();
const groups = useMemo<InsightGroup[]>(() => {
const buckets = new Map<InsightCategory, InsightItem[]>();
@@ -184,13 +187,13 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
return categoryOrder
.filter(cat => (buckets.get(cat)?.length ?? 0) > 0)
.map(cat => ({ category: cat, ...CATEGORY_CONFIG[cat], items: buckets.get(cat)! }));
}, [relations]);
.map(cat => ({ category: cat, ...config[cat], items: buckets.get(cat)! }));
}, [relations, config]);
if (loading) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Intelligent Insights</h3>
<h3 className="text-sm font-semibold text-stone-900 mb-4">{t('insights.title')}</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-28 rounded-lg bg-stone-200 animate-pulse" />
@@ -203,10 +206,8 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
if (groups.length === 0) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<h3 className="text-sm font-semibold text-stone-900 mb-2">Intelligent Insights</h3>
<p className="text-sm text-stone-600">
No insights yet. Ingest documents to extract facts, preferences, and relationships.
</p>
<h3 className="text-sm font-semibold text-stone-900 mb-2">{t('insights.title')}</h3>
<p className="text-sm text-stone-600">{t('insights.empty')}</p>
</div>
);
}
@@ -215,9 +216,9 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
<div className="rounded-xl border border-stone-200 bg-stone-50 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Intelligent Insights</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('insights.title')}</h3>
<p className="text-xs text-stone-500 mt-0.5">
Extracted knowledge organized by type {relations.length} total relations
{t('insights.description').replace('{count}', String(relations.length))}
</p>
</div>
</div>
@@ -253,7 +254,9 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
</div>
<div className="min-w-0 flex-1">
<div className={`text-xs font-medium ${group.color}`}>{group.label}</div>
<div className="text-[10px] text-stone-500">{group.items.length} items</div>
<div className="text-[10px] text-stone-500">
{group.items.length} {t('insights.items')}
</div>
</div>
<svg
className={`w-3.5 h-3.5 text-stone-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
@@ -294,7 +297,7 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
))}
{!isExpanded && group.items.length > 3 && (
<div className="text-[10px] text-stone-500 pt-0.5">
+{group.items.length - 3} more
+{group.items.length - 3} {t('insights.more')}
</div>
)}
</div>
@@ -7,6 +7,7 @@
*/
import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands';
import { MemoryHeatmap } from './MemoryHeatmap';
@@ -93,6 +94,7 @@ export function MemoryNavigator({
searchQuery,
onSearchChange,
}: MemoryNavigatorProps) {
const { t } = useT();
const heatmapTimestamps = useMemo(
() => chunks.map(c => Math.floor(c.timestamp_ms / 1000)),
[chunks]
@@ -178,10 +180,10 @@ export function MemoryNavigator({
<input
type="text"
className="mw-search-input"
placeholder="search memory…"
placeholder={t('memory.searchPlaceholder')}
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
aria-label="Search memory"
aria-label={t('memory.search')}
/>
</div>
@@ -190,14 +192,21 @@ export function MemoryNavigator({
</div>
<div className="mw-pane-scroll">
<NavSection label="recent" defaultOpen>
<NavSection label={t('navigator.recent')} defaultOpen>
<div className="mw-recent-summary">
<span>today {todayCount}</span>
<span>this week {weekCount}</span>
<span>
{t('navigator.today')} {todayCount}
</span>
<span>
{t('navigator.thisWeek')} {weekCount}
</span>
</div>
</NavSection>
<NavSection label="sources" defaultOpen countSummary={String(sources.length)}>
<NavSection
label={t('navigator.sources')}
defaultOpen
countSummary={String(sources.length)}>
{sources.length === 0 ? (
<ul className="mw-list">
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}></li>
@@ -215,10 +224,10 @@ export function MemoryNavigator({
byKind.set(s.source_kind, arr);
}
const kindLabel: Record<string, string> = {
email: 'Email',
slack: 'Slack',
chat: 'Chat',
document: 'Documents',
email: t('navigator.email'),
slack: t('navigator.slack'),
chat: t('navigator.chat'),
document: t('navigator.documents'),
};
const kinds = Array.from(byKind.entries()).sort((a, b) => b[1].length - a[1].length);
return (
@@ -257,11 +266,17 @@ export function MemoryNavigator({
)}
</NavSection>
<NavSection label="people" defaultOpen countSummary={String(topPeople.length)}>
<NavSection
label={t('navigator.people')}
defaultOpen
countSummary={String(topPeople.length)}>
{renderEntityList(topPeople)}
</NavSection>
<NavSection label="topics" defaultOpen countSummary={String(topTopics.length)}>
<NavSection
label={t('navigator.topics')}
defaultOpen
countSummary={String(topTopics.length)}>
{renderEntityList(topTopics)}
</NavSection>
</div>
@@ -12,6 +12,7 @@
*/
import { useEffect, useMemo, useRef } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { Chunk } from '../../utils/tauriCommands';
interface MemoryResultListProps {
@@ -103,6 +104,7 @@ export function MemoryResultList({
selectedChunkId,
onSelectChunk,
}: MemoryResultListProps) {
const { t } = useT();
const groups = useMemo<Group[]>(() => {
const today = startOfLocalDay(new Date()).getTime();
const yesterday = today - DAY_MS;
@@ -132,7 +134,7 @@ export function MemoryResultList({
if (chunks.length === 0) {
return (
<section className="mw-pane-results" data-testid="memory-result-list">
<div className="mw-results-empty">No matching chunks.</div>
<div className="mw-results-empty">{t('memory.noResults')}</div>
</section>
);
}
@@ -27,6 +27,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { listConnections, syncConnection } from '../../lib/composio/composioApi';
import type { ComposioConnection } from '../../lib/composio/types';
import { useT } from '../../lib/i18n/I18nContext';
import {
type FreshnessLabel,
type MemorySyncStatus,
@@ -58,11 +59,10 @@ const TOOLKIT_LABEL: Record<string, string> = {
document: 'Document',
};
const FRESHNESS_LABEL: Record<FreshnessLabel, string> = {
active: 'Active',
recent: 'Recent',
idle: 'Idle',
};
function useFreshnessLabel() {
const { t } = useT();
return { active: t('sync.active'), recent: t('sync.recent'), idle: t('sync.idle') };
}
function freshnessBadge(label: FreshnessLabel): string {
switch (label) {
@@ -143,6 +143,7 @@ export function MemorySources({
pollIntervalMs = 5000,
onToast,
}: MemorySourcesProps) {
const { t } = useT();
const [connections, setConnections] = useState<ComposioConnection[]>([]);
const [statuses, setStatuses] = useState<MemorySyncStatus[]>([]);
const [loading, setLoading] = useState(true);
@@ -224,8 +225,8 @@ export function MemorySources({
<section
className="rounded-lg border border-stone-200 bg-white p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<p className="mt-2 text-xs text-stone-500">Loading</p>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('common.loading')}</p>
</section>
);
}
@@ -235,7 +236,7 @@ export function MemorySources({
<section
className="rounded-lg border border-stone-200 bg-white p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 break-words rounded-md bg-coral-50 p-2 text-xs text-coral-800">
{loadError}
</p>
@@ -248,11 +249,8 @@ export function MemorySources({
<section
className="rounded-lg border border-stone-200 bg-white p-4"
data-testid="memory-sources">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<p className="mt-2 text-xs text-stone-500">
No connected sources with a memory-tree sync provider yet. Connect Gmail (or another
supported integration) in the Chat tab to start ingesting.
</p>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('sync.noConnectedSources')}</p>
</section>
);
}
@@ -262,7 +260,7 @@ export function MemorySources({
className="rounded-lg border border-stone-200 bg-white p-4"
data-testid="memory-sources">
<header className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<span className="text-xs text-stone-400">
{rows.length} identit{rows.length === 1 ? 'y' : 'ies'}
</span>
@@ -288,6 +286,8 @@ interface SourceRowCardProps {
}
function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
const { t } = useT();
const freshnessLabels = useFreshnessLabel();
// `buildRows` already filtered down to (connected toolkit + syncable),
// so `connection` is non-null and `isSyncable` is always true here.
const { connection, status, title, toolkit } = row;
@@ -314,7 +314,7 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
<span
className={`rounded-md px-2 py-0.5 text-xs font-medium ${freshnessBadge(status.freshness)}`}
data-testid={`memory-source-freshness-${toolkit}`}>
{FRESHNESS_LABEL[status.freshness]}
{freshnessLabels[status.freshness]}
</span>
)}
{!isActive && (
@@ -325,10 +325,18 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500">
<span data-testid={`memory-source-chunks-${toolkit}`}>
{lifetime.toLocaleString()} chunks
{lifetime.toLocaleString()} {t('sync.chunks')}
</span>
{lastSync && <span>Last chunk {lastSync}</span>}
{pending > 0 && <span>{pending.toLocaleString()} pending</span>}
{lastSync && (
<span>
{t('sync.lastChunk')} {lastSync}
</span>
)}
{pending > 0 && (
<span>
{pending.toLocaleString()} {t('sync.pending')}
</span>
)}
</div>
{showProgress && (
<div className="mt-2 max-w-md" data-testid={`memory-source-progress-${toolkit}`}>
@@ -343,7 +351,8 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
/>
</div>
<div className="mt-1 text-xs text-stone-500">
{batchProcessed.toLocaleString()} of {batchTotal.toLocaleString()} processed
{batchProcessed.toLocaleString()} / {batchTotal.toLocaleString()}{' '}
{t('sync.processed')}
</div>
</div>
)}
@@ -361,11 +370,11 @@ function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
focus:outline-none focus:ring-2 focus:ring-primary-200">
{isSyncing ? (
<>
<Spinner /> Syncing
<Spinner /> {t('sync.syncing')}
</>
) : (
<>
<SyncIcon /> Sync
<SyncIcon /> {t('sync.sync')}
</>
)}
</button>
@@ -1,3 +1,5 @@
import { useT } from '../../lib/i18n/I18nContext';
interface MemoryStatsBarProps {
totalDocs: number;
totalFiles: number;
@@ -39,6 +41,7 @@ function formatNumber(value: number): string {
}
export function MemoryStatsBar(props: MemoryStatsBarProps) {
const { t } = useT();
const {
totalDocs,
totalFiles,
@@ -55,39 +58,41 @@ export function MemoryStatsBar(props: MemoryStatsBarProps) {
const stats = [
{
label: 'Storage',
label: t('stats.storage'),
value: estimatedStorageBytes > 0 ? formatBytes(estimatedStorageBytes) : '--',
sub: totalFiles > 0 ? `${formatNumber(totalFiles)} files` : undefined,
sub: totalFiles > 0 ? `${formatNumber(totalFiles)} ${t('stats.files')}` : undefined,
color: 'text-primary-500',
},
{
label: 'Documents',
label: t('stats.documents'),
value: formatNumber(totalDocs),
sub: docsToday > 0 ? `+${docsToday} today` : undefined,
sub: docsToday > 0 ? `+${docsToday} ${t('stats.today')}` : undefined,
color: 'text-emerald-600',
},
{
label: 'Namespaces',
label: t('stats.namespaces'),
value: formatNumber(totalNamespaces),
sub: undefined,
color: 'text-amber-600',
},
{
label: 'Relations',
label: t('stats.relations'),
value: formatNumber(totalRelations),
sub: undefined,
color: 'text-lavender-600',
},
{
label: 'First Memory',
label: t('stats.firstMemory'),
value: oldestDocTimestamp ? formatTimeAgo(oldestDocTimestamp) : '--',
sub: newestDocTimestamp ? `Latest: ${formatTimeAgo(newestDocTimestamp)}` : undefined,
sub: newestDocTimestamp
? `${t('stats.latest')}: ${formatTimeAgo(newestDocTimestamp)}`
: undefined,
color: 'text-sky-600',
},
{
label: 'Sessions',
label: t('stats.sessions'),
value: totalSessions !== null ? formatNumber(totalSessions) : '--',
sub: totalTokens !== null ? `${formatNumber(totalTokens)} tokens` : undefined,
sub: totalTokens !== null ? `${formatNumber(totalTokens)} ${t('stats.tokens')}` : undefined,
color: 'text-rose-600',
},
];
@@ -9,6 +9,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
type FreshnessLabel,
type MemorySyncStatus,
@@ -20,11 +21,10 @@ interface MemorySyncConnectionsProps {
pollIntervalMs?: number;
}
const FRESHNESS_LABEL: Record<FreshnessLabel, string> = {
active: 'Active',
recent: 'Recent',
idle: 'Idle',
};
function useFreshnessLabel() {
const { t } = useT();
return { active: t('sync.active'), recent: t('sync.recent'), idle: t('sync.idle') } as const;
}
const PROVIDER_LABEL: Record<string, string> = {
slack: 'Slack',
@@ -72,6 +72,7 @@ interface SourceCardProps {
}
function SourceCard({ status }: SourceCardProps) {
const { t } = useT();
const label = PROVIDER_LABEL[status.provider] ?? status.provider;
const lastSync = relativeTimestamp(status.last_chunk_at_ms);
const lifetime = status.chunks_synced;
@@ -97,14 +98,18 @@ function SourceCard({ status }: SourceCardProps) {
<span
className={`rounded-md px-2 py-0.5 text-xs font-medium ${freshnessBadgeClass(status.freshness)}`}
data-testid={`memory-sync-freshness-${status.provider}`}>
{FRESHNESS_LABEL[status.freshness]}
{useFreshnessLabel()[status.freshness]}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500">
<span data-testid={`memory-sync-chunks-${status.provider}`}>
{lifetime.toLocaleString()} chunks
{lifetime.toLocaleString()} {t('sync.chunks')}
</span>
{lastSync && <span>Last chunk {lastSync}</span>}
{lastSync && (
<span>
{t('sync.lastChunk')} {lastSync}
</span>
)}
</div>
</div>
</div>
@@ -123,10 +128,14 @@ function SourceCard({ status }: SourceCardProps) {
</div>
<div className="mt-1 text-xs text-stone-500">
<span data-testid={`memory-sync-pending-${status.provider}`}>
{batchProcessed.toLocaleString()} of {batchTotal.toLocaleString()} processed
{batchProcessed.toLocaleString()} / {batchTotal.toLocaleString()}{' '}
{t('sync.processed')}
</span>
{pending > 0 && (
<span className="text-stone-400"> · {pending.toLocaleString()} pending</span>
<span className="text-stone-400">
{' '}
· {pending.toLocaleString()} {t('sync.pending')}
</span>
)}
</div>
</div>
@@ -136,6 +145,7 @@ function SourceCard({ status }: SourceCardProps) {
}
export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsProps) {
const { t } = useT();
const [statuses, setStatuses] = useState<MemorySyncStatus[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -177,8 +187,8 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (loading) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<p className="mt-2 text-xs text-stone-500">Loading</p>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('common.loading')}</p>
</section>
);
}
@@ -186,9 +196,9 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (loadError) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 rounded-md bg-coral-50 p-2 text-xs text-coral-800 break-words">
Failed to load sync status: {loadError}
{t('sync.failedToLoad')}: {loadError}
</p>
</section>
);
@@ -197,17 +207,15 @@ export function MemorySyncConnections({ pollIntervalMs }: MemorySyncConnectionsP
if (statuses.length === 0) {
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<p className="mt-2 text-xs text-stone-500">
No content has been synced into memory yet. Connect an integration to start.
</p>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<p className="mt-2 text-xs text-stone-500">{t('sync.noContent')}</p>
</section>
);
}
return (
<section className="memory-sync-connections" data-testid="memory-sync-connections">
<h3 className="text-sm font-semibold text-stone-700">Memory sources</h3>
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
<div className="mt-2 space-y-2">
{statuses.map(s => (
<SourceCard key={s.provider} status={s} />
@@ -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<void> {
}
export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const { t } = useT();
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(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 ? (
<>
<Spinner /> Resetting
<Spinner /> {t('workspace.resetting')}
</>
) : (
<>
<TrashIcon /> Reset memory
<TrashIcon /> {t('workspace.resetMemory')}
</>
)}
</button>
@@ -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 ? (
<>
<Spinner /> Rebuilding
<Spinner /> {t('workspace.rebuilding')}
</>
) : (
<>
<RefreshIcon /> Reset memory tree
<RefreshIcon /> {t('workspace.resetMemoryTree')}
</>
)}
</button>
@@ -303,11 +297,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
focus:outline-none focus:ring-2 focus:ring-primary-200">
{building ? (
<>
<Spinner /> Building
<Spinner /> {t('workspace.building')}
</>
) : (
<>
<BrainIcon /> Build summary trees
<BrainIcon /> {t('workspace.buildSummaryTrees')}
</>
)}
</button>
@@ -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}`}>
<ExternalLinkIcon />
View vault in Obsidian
{t('workspace.viewVault')}
</button>
)}
</div>
@@ -330,11 +324,11 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
{error ? (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-800">
Failed to load memory graph: {error}
{t('workspace.graphLoadFailed')}: {error}
</div>
) : !graph ? (
<div className="flex h-[640px] items-center justify-center rounded-lg border border-stone-100 bg-stone-50/40 text-sm text-stone-500">
Loading graph
{t('workspace.loadingGraph')}
</div>
) : (
<MemoryGraph
@@ -354,6 +348,7 @@ interface ModeToggleProps {
}
function ModeToggle({ mode, onChange }: ModeToggleProps) {
const { t } = useT();
const baseBtn =
'px-3 py-1.5 text-xs font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200';
const active = 'bg-primary-500 text-white shadow-sm';
@@ -362,7 +357,7 @@ function ModeToggle({ mode, onChange }: ModeToggleProps) {
<div
className="inline-flex items-center gap-1 rounded-lg border border-stone-200 bg-stone-50 p-1"
role="tablist"
aria-label="Graph view mode"
aria-label={t('workspace.graphViewMode')}
data-testid="memory-graph-mode-toggle">
<button
type="button"
@@ -371,7 +366,7 @@ function ModeToggle({ mode, onChange }: ModeToggleProps) {
role="tab"
aria-selected={mode === 'tree'}
data-testid="memory-graph-mode-tree">
Trees
{t('workspace.trees')}
</button>
<button
type="button"
@@ -380,7 +375,7 @@ function ModeToggle({ mode, onChange }: ModeToggleProps) {
role="tab"
aria-selected={mode === 'contacts'}
data-testid="memory-graph-mode-contacts">
Contacts
{t('workspace.contacts')}
</button>
</div>
);
@@ -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<ReflectionKind, string> = {
const KIND_LABEL: Partial<Record<ReflectionKind, string>> = {
hotness_spike: 'Hotness spike',
cross_source_pattern: 'Cross-source pattern',
daily_digest: 'Daily digest',
@@ -51,6 +52,10 @@ const KIND_LABEL: Record<ReflectionKind, string> = {
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<ReflectionKind, string> = {
* 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<Reflection[]>(initialReflections ?? []);
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(initialReflections === undefined);
@@ -165,7 +174,7 @@ export default function SubconsciousReflectionCards({
if (loading) {
return (
<div data-testid="reflection-cards-loading" className="text-xs text-stone-400 py-2">
Loading reflections
{t('reflections.loading')}
</div>
);
}
@@ -173,7 +182,7 @@ export default function SubconsciousReflectionCards({
if (visible.length === 0 && !error) {
return (
<div data-testid="reflection-cards-empty" className="text-xs text-stone-400 py-3">
No proactive observations yet they appear after each subconscious tick.
{t('reflections.empty')}
</div>
);
}
@@ -188,7 +197,7 @@ export default function SubconsciousReflectionCards({
<div className="shrink-0 pb-3">
<h3 className="text-sm font-semibold text-stone-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary-400" />
Reflections
{t('reflections.title')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary-50 text-primary-700">
{visible.length}
</span>
@@ -225,19 +234,19 @@ export default function SubconsciousReflectionCards({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-stone-100 text-stone-600">
{KIND_LABEL[r.kind] ?? r.kind}
{kindLabel(r.kind, t)}
</span>
<span
data-testid={`reflection-timestamp-${r.id}`}
className="text-[10px] text-stone-400"
title={formatAbsoluteTime(r.created_at)}>
{formatRelativeTime(r.created_at)}
{formatRelativeTime(r.created_at, t)}
</span>
</div>
<p className="text-sm text-stone-900 whitespace-pre-line break-words">{r.body}</p>
{r.proposed_action && (
<p className="text-xs text-stone-500 mt-2">
<em>Proposed action:</em> {r.proposed_action}
<em>{t('reflections.proposedAction')}:</em> {r.proposed_action}
</p>
)}
</div>
@@ -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')}
</button>
)}
<button
data-testid={`reflection-dismiss-${r.id}`}
onClick={() => void handleDismiss(r.id)}
className="px-3 py-1.5 text-xs bg-stone-100 hover:bg-stone-200 text-stone-600 rounded-lg transition-colors">
Dismiss
{t('reflections.dismiss')}
</button>
</div>
</div>
@@ -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<number | null>(null);
const [lastSyncTs, setLastSyncTs] = useState<number | null>(null);
const [syncing, setSyncing] = useState(false);
@@ -52,8 +54,9 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory
<WhatsAppIcon />
<span className="text-sm font-semibold text-stone-800">WhatsApp</span>
<span className="text-xs text-stone-500">
{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)}</>}
</span>
</div>
<button
@@ -66,19 +69,20 @@ export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemory
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
<RefreshIcon spinning={syncing} />
{syncing ? 'Syncing' : 'Sync'}
{syncing ? t('sync.syncing') : t('sync.sync')}
</button>
</div>
</div>
);
}
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() {
@@ -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 }));
+74 -40
View File
@@ -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<string | null>(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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -85,8 +93,8 @@ const SettingsHome = () => {
},
{
id: 'notifications',
title: 'Notifications',
description: 'Do Not Disturb and per-account notification controls',
title: t('settings.notifications'),
description: t('settings.notificationsDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -99,6 +107,31 @@ const SettingsHome = () => {
),
onClick: () => navigateToSettings('notifications'),
},
{
id: 'language',
title: t('settings.language'),
description: t('settings.languageDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
),
rightElement: (
<select
value={currentLocale}
onChange={e => dispatch(setLocale(e.target.value as Locale))}
aria-label={t('settings.language')}
className="text-sm border border-stone-300 rounded-lg px-3 py-1.5 bg-white text-stone-700 focus:outline-none focus:ring-2 focus:ring-ocean-500/30 cursor-pointer">
<option value="en">English</option>
<option value="zh-CN"></option>
</select>
),
},
{
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -138,8 +171,8 @@ const SettingsHome = () => {
},
{
id: 'ai',
title: 'AI',
description: 'Cloud providers, local Ollama models, and per-workload routing',
title: t('settings.ai'),
description: t('settings.aiDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -155,12 +188,12 @@ const SettingsHome = () => {
],
},
{
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -177,8 +210,8 @@ const SettingsHome = () => {
},
{
id: 'rewards',
title: 'Rewards',
description: 'Referrals, coupons, and earned credits',
title: t('settings.rewards'),
description: t('settings.rewardsDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -194,12 +227,12 @@ const SettingsHome = () => {
],
},
{
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -217,8 +250,8 @@ const SettingsHome = () => {
},
{
id: 'about',
title: 'About',
description: 'App version and software updates',
title: t('settings.about'),
description: t('settings.aboutDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -234,12 +267,12 @@ const SettingsHome = () => {
],
},
{
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -260,8 +293,8 @@ const SettingsHome = () => {
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: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -277,8 +310,8 @@ const SettingsHome = () => {
},
{
id: 'logout',
title: 'Log out',
description: 'Sign out of your account',
title: t('settings.logOut'),
description: t('settings.logOutDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -315,13 +348,14 @@ const SettingsHome = () => {
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === section.items.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
))}
{/* Danger Zone */}
<SectionHeader label="Danger Zone" />
<SectionHeader label={t('settings.dangerZone')} />
{destructiveItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
@@ -356,20 +390,20 @@ const SettingsHome = () => {
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-stone-900">Clear App Data</h3>
<h3 className="text-lg font-semibold text-stone-900">{t('clearData.title')}</h3>
</div>
</div>
<div className="mb-6">
<div className="text-stone-700 text-sm leading-relaxed">
<p>This will sign you out and permanently delete local app data including:</p>
<p>{t('clearData.warning')}</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>App settings and conversations</li>
<li>All local integration cache data</li>
<li>Workspace data</li>
<li>All other local data</li>
<li>{t('clearData.bulletSettings')}</li>
<li>{t('clearData.bulletCache')}</li>
<li>{t('clearData.bulletWorkspace')}</li>
<li>{t('clearData.bulletOther')}</li>
</ul>
<p className="mt-3">This action cannot be undone.</p>
<p className="mt-3">{t('clearData.irreversible')}</p>
</div>
{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')}
</button>
<button
onClick={handleLogoutAndClearData}
@@ -410,7 +444,7 @@ const SettingsHome = () => {
/>
</svg>
)}
{isLoading ? 'Clearing App Data...' : 'Clear App Data'}
{isLoading ? t('clearData.clearing') : t('clearData.title')}
</button>
</div>
</div>
@@ -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(
<MemoryRouter>
<SettingsHome />
</MemoryRouter>
<Provider store={makeTestStore()}>
<MemoryRouter>
<SettingsHome />
</MemoryRouter>
</Provider>
);
}
@@ -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 (
<div className={`px-5 pt-5 pb-3 ${className}`}>
<div className="flex items-center">
@@ -26,7 +30,7 @@ const SettingsHeader = ({
<button
onClick={onBack}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 transition-colors mr-2"
aria-label="Go back">
aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500"
fill="none"
@@ -77,7 +81,7 @@ const SettingsHeader = ({
)}
{/* Title */}
<h2 className="text-sm font-semibold text-stone-900">{title}</h2>
<h2 className="text-sm font-semibold text-stone-900">{title ?? t('nav.settings')}</h2>
</div>
</div>
);
@@ -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 (
<button
onClick={onClick}
className={`w-full flex items-center justify-between py-3 px-4 bg-white ${borderClasses} hover:bg-stone-50 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
const content = (
<>
<div className={`w-5 h-5 opacity-60 flex-shrink-0 mr-3 ${iconColor}`}>{icon}</div>
<div className="flex-1">
<div className={`font-medium text-sm mb-1 ${titleColor}`}>{title}</div>
{description && <p className="opacity-70 text-xs">{description}</p>}
</div>
</button>
{rightElement && <div className="flex-shrink-0 ml-3">{rightElement}</div>}
</>
);
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={`w-full flex items-center justify-between py-3 px-4 bg-white ${borderClasses} hover:bg-stone-50 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
{content}
</button>
);
}
return (
<div
className={`w-full flex items-center justify-between py-3 px-4 bg-white ${borderClasses} ${roundedClasses}`}>
{content}
</div>
);
};
@@ -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 <AppUpdatePrompt />;
// disable it here so opening the panel doesn't double-trigger probes.
@@ -22,7 +24,7 @@ const AboutPanel = () => {
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(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 (
<div className="z-10 relative">
<SettingsHeader
title="About"
title={t('settings.about')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -41,11 +43,11 @@ const AboutPanel = () => {
<div className="p-4 space-y-4">
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-xs text-stone-500">Version</div>
<div className="text-xs text-stone-500">{t('settings.about.version')}</div>
<div className="mt-1 text-lg font-semibold text-stone-900">v{APP_VERSION}</div>
{info?.available && info.available_version && (
<div className="mt-1 text-xs text-primary-500">
v{info.available_version} is available
v{info.available_version} {t('settings.about.updateAvailable')}
</div>
)}
</div>
@@ -53,11 +55,13 @@ const AboutPanel = () => {
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900">Software updates</div>
<div className="text-sm font-medium text-stone-900">
{t('settings.about.softwareUpdates')}
</div>
<div className="mt-1 text-xs text-stone-500 leading-relaxed">{summary}</div>
{lastCheckedAt && (
<div className="mt-1 text-[11px] text-stone-400">
Last checked {formatRelative(lastCheckedAt)}
{t('settings.about.lastChecked')} {formatRelative(lastCheckedAt, t)}
</div>
)}
</div>
@@ -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')}
</button>
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-white p-4">
<div className="text-sm font-medium text-stone-900">Releases</div>
<div className="text-sm font-medium text-stone-900">{t('settings.about.releases')}</div>
<p className="mt-1 text-xs text-stone-500 leading-relaxed">
Browse release notes and earlier builds on GitHub.
{t('settings.about.releasesDesc')}
</p>
<button
type="button"
@@ -82,7 +86,7 @@ const AboutPanel = () => {
void openUrl(LATEST_APP_DOWNLOAD_URL);
}}
className="mt-3 px-3 py-1.5 rounded-lg border border-stone-200 text-stone-700 hover:bg-stone-100 text-xs transition-colors">
Open GitHub releases
{t('settings.about.openReleases')}
</button>
</div>
</div>
@@ -93,41 +97,42 @@ const AboutPanel = () => {
function summaryFor(
phase: ReturnType<typeof useAppUpdate>['phase'],
info: ReturnType<typeof useAppUpdate>['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();
}
@@ -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<ChatMessage[]>([]);
const [input, setInput] = useState('');
@@ -74,7 +76,7 @@ const AgentChatPanel = () => {
return (
<div>
<SettingsHeader
title="Agent Chat"
title={t('chat.agentChat')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -82,14 +84,11 @@ const AgentChatPanel = () => {
<div className="p-4 space-y-4">
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Overrides</h3>
<p className="text-sm text-stone-400">
Inference uses your OpenHuman backend (config API URL and session). Optional model and
temperature override the defaults for this panel only.
</p>
<h3 className="text-sm font-semibold text-stone-900">{t('chat.overrides')}</h3>
<p className="text-sm text-stone-400">{t('chat.agentChatDesc')}</p>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-2 text-sm text-stone-600">
Model
{t('chat.model')}
<input
className="input input-bordered w-full text-slate-900 bg-white"
placeholder="gpt-4o"
@@ -98,7 +97,7 @@ const AgentChatPanel = () => {
/>
</label>
<label className="space-y-2 text-sm text-stone-600">
Temperature
{t('chat.temperature')}
<input
className="input input-bordered w-full text-slate-900 bg-white"
placeholder="0.7"
@@ -110,7 +109,7 @@ const AgentChatPanel = () => {
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Conversation</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('chat.conversation')}</h3>
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
@@ -118,12 +117,12 @@ const AgentChatPanel = () => {
)}
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4 space-y-3">
{messages.length === 0 && (
<div className="text-sm text-stone-400">Start a conversation with the agent.</div>
<div className="text-sm text-stone-400">{t('chat.startAgentConversation')}</div>
)}
{messages.map((message, index) => (
<div key={`${message.role}-${index}`} className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-stone-500">
{message.role === 'user' ? 'You' : 'Agent'}
{message.role === 'user' ? t('chat.you') : t('chat.agent')}
</div>
<div
className={`text-sm whitespace-pre-wrap ${
@@ -137,12 +136,12 @@ const AgentChatPanel = () => {
<div className="space-y-2">
<textarea
className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white"
placeholder="Ask the agent anything..."
placeholder={t('chat.askAgent')}
value={input}
onChange={event => setInput(event.target.value)}
/>
<button className="btn btn-primary" onClick={sendMessage} disabled={sending}>
{sending ? 'Sending' : 'Send Message'}
{sending ? t('common.loading') : t('chat.sendMessage')}
</button>
</div>
</section>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AcceptedCompletion,
type AutocompleteConfig,
@@ -65,6 +66,7 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
};
const AutocompleteDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
// Status & loading
@@ -456,7 +458,7 @@ const AutocompleteDebugPanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Autocomplete Debug"
title={t('autocomplete.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AutocompleteConfig,
type AutocompleteStatus,
@@ -57,6 +58,7 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
};
const AutocompletePanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -70,8 +72,6 @@ const AutocompletePanel = () => {
);
const [acceptWithTab, setAcceptWithTab] = useState<boolean>(DEFAULT_CONFIG.accept_with_tab);
// Hold full config so we can pass through unchanged advanced values on save.
// configLoaded tracks whether we've received real config from the backend.
const fullConfigRef = useRef<AutocompleteConfig>(DEFAULT_CONFIG);
const [configLoaded, setConfigLoaded] = useState(false);
@@ -147,7 +147,7 @@ const AutocompletePanel = () => {
setStylePreset(response.result.config.style_preset);
setDisabledAppsText(response.result.config.disabled_apps.join('\n'));
setAcceptWithTab(response.result.config.accept_with_tab);
setMessage('Autocomplete settings saved.');
setMessage(t('autocomplete.settingsSaved'));
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save autocomplete settings');
@@ -166,9 +166,9 @@ const AutocompletePanel = () => {
});
await refreshStatus();
if (response.result.started) {
setMessage('Autocomplete started.');
setMessage(t('autocomplete.started'));
} else {
setMessage('Autocomplete did not start. Check if it is enabled.');
setMessage(t('autocomplete.didNotStart'));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start autocomplete');
@@ -182,7 +182,7 @@ const AutocompletePanel = () => {
try {
await openhumanAutocompleteStop({ reason: 'manual_stop_from_settings' });
await refreshStatus();
setMessage('Autocomplete stopped.');
setMessage(t('autocomplete.stopped'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop autocomplete');
}
@@ -191,7 +191,7 @@ const AutocompletePanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Autocomplete"
title={t('autocomplete.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -199,10 +199,10 @@ const AutocompletePanel = () => {
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Settings</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('autocomplete.settings')}</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Enabled</span>
<span className="text-sm text-stone-700">{t('common.enabled')}</span>
<input
type="checkbox"
checked={enabled}
@@ -211,7 +211,7 @@ const AutocompletePanel = () => {
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Accept With Tab</span>
<span className="text-sm text-stone-700">{t('autocomplete.acceptWithTab')}</span>
<input
type="checkbox"
checked={acceptWithTab}
@@ -220,23 +220,21 @@ const AutocompletePanel = () => {
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Style Preset</span>
<span className="text-sm text-stone-700">{t('autocomplete.stylePreset')}</span>
<select
value={stylePreset}
onChange={event => setStylePreset(event.target.value)}
className="rounded border border-stone-300 bg-white px-2 py-1 text-xs text-stone-700">
<option value="balanced">Balanced</option>
<option value="concise">Concise</option>
<option value="formal">Formal</option>
<option value="casual">Casual</option>
<option value="custom">Custom</option>
<option value="balanced">{t('autocomplete.style.balanced')}</option>
<option value="concise">{t('autocomplete.style.concise')}</option>
<option value="formal">{t('autocomplete.style.formal')}</option>
<option value="casual">{t('autocomplete.style.casual')}</option>
<option value="custom">{t('autocomplete.style.custom')}</option>
</select>
</label>
<div className="space-y-1">
<div className="text-xs text-stone-600">
Disabled Apps (one bundle/app token per line)
</div>
<div className="text-xs text-stone-600">{t('autocomplete.disabledApps')}</div>
<textarea
value={disabledAppsText}
onChange={event => setDisabledAppsText(event.target.value)}
@@ -250,15 +248,19 @@ const AutocompletePanel = () => {
onClick={() => void saveConfig()}
disabled={isSaving || !configLoaded}
className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isSaving ? 'Saving' : 'Save Settings'}
{isSaving ? t('autocomplete.saving') : t('autocomplete.saveSettings')}
</button>
</section>
<section className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Runtime</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('autocomplete.runtime')}</h3>
<div className="text-sm text-stone-600 space-y-1">
<div>Running: {status?.running ? 'yes' : 'no'}</div>
<div>Enabled: {status?.enabled ? 'yes' : 'no'}</div>
<div>
{t('autocomplete.running')}: {status?.running ? t('common.yes') : t('common.no')}
</div>
<div>
{t('common.enabled')}: {status?.enabled ? t('common.yes') : t('common.no')}
</div>
</div>
<div className="flex gap-2">
<button
@@ -266,14 +268,14 @@ const AutocompletePanel = () => {
onClick={() => void start()}
disabled={!configLoaded || !status?.platform_supported || Boolean(status?.running)}
className="rounded-lg border border-green-500/60 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
Start
{t('autocomplete.start')}
</button>
<button
type="button"
onClick={() => void stop()}
disabled={!status?.running}
className="rounded-lg border border-red-500/60 bg-red-50 px-3 py-2 text-sm text-red-600 disabled:opacity-50">
Stop
{t('autocomplete.stop')}
</button>
</div>
</section>
@@ -285,7 +287,7 @@ const AutocompletePanel = () => {
type="button"
onClick={() => navigateToSettings('autocomplete-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
{t('autocomplete.advancedSettings')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
@@ -1,6 +1,7 @@
import createDebug from 'debug';
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { BILLING_DASHBOARD_URL } from '../../../utils/links';
import { openUrl } from '../../../utils/openUrl';
import PageBackButton from '../components/PageBackButton';
@@ -9,6 +10,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = createDebug('openhuman:billing-panel');
const BillingPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<'opening' | 'idle' | 'error'>('opening');
@@ -41,7 +43,7 @@ const BillingPanel = () => {
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<PageBackButton
label="Back"
label={t('common.back')}
onClick={navigateBack}
trailingContent={
breadcrumbs.length > 0 ? (
@@ -64,12 +66,13 @@ const BillingPanel = () => {
<div className="max-w-xl space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
Billing moved to the web
{t('settings.billing.movedToWeb')}
</p>
<h1 className="mt-2 text-2xl font-semibold text-stone-900">Open billing dashboard</h1>
<h1 className="mt-2 text-2xl font-semibold text-stone-900">
{t('settings.billing.openDashboard')}
</h1>
<p className="mt-2 text-sm leading-6 text-stone-600">
Subscription changes, payment methods, credits, and invoices are now managed at
TinyHumans on the web.
{t('settings.billing.movedToWebDesc')}
</p>
</div>
@@ -80,28 +83,24 @@ const BillingPanel = () => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="inline-flex items-center rounded-full bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600">
Open dashboard
{t('settings.billing.openDashboard')}
</button>
<button
type="button"
onClick={navigateBack}
className="inline-flex items-center rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-50">
Back to settings
{t('settings.billing.backToSettings')}
</button>
</div>
{status === 'opening' && (
<p className="text-xs text-stone-500">Opening your browser</p>
<p className="text-xs text-stone-500">{t('settings.billing.openingBrowser')}</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500">
If your browser did not open, use the button above.
</p>
<p className="text-xs text-stone-500">{t('settings.billing.browserNotOpen')}</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600">
The browser could not be opened automatically. Use the button above.
</p>
<p className="text-xs text-coral-600">{t('settings.billing.browserOpenFailed')}</p>
)}
</div>
</div>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
openhumanGetComposioTriggerSettings,
openhumanUpdateComposioTriggerSettings,
@@ -8,6 +9,7 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ComposioTriagePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [triageDisabled, setTriageDisabled] = useState(false);
@@ -48,7 +50,7 @@ const ComposioTriagePanel = () => {
try {
const toolkitList = disabledToolkits
.split(',')
.map(t => t.trim().toLowerCase())
.map(e => e.trim().toLowerCase())
.filter(Boolean);
await openhumanUpdateComposioTriggerSettings({
triage_disabled: triageDisabled,
@@ -75,7 +77,7 @@ const ComposioTriagePanel = () => {
return (
<div>
<SettingsHeader
title="Integration Triggers"
title={t('composio.triageTitle')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -90,7 +92,7 @@ const ComposioTriagePanel = () => {
return (
<div>
<SettingsHeader
title="Integration Triggers"
title={t('composio.triageTitle')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -98,14 +100,11 @@ const ComposioTriagePanel = () => {
<div className="p-4 space-y-5">
<p className="text-sm text-stone-500">
When active, each incoming Composio trigger runs through an AI triage step that classifies
the event and may kick off automated actions one local LLM turn per trigger. Disable
globally or per integration if you prefer manual review. If the environment variable{' '}
<span className="font-mono">OPENHUMAN_TRIGGER_TRIAGE_DISABLED</span> is set, it overrides
these settings and disables triage for all triggers.
{t('composio.triageDesc')}{' '}
<span className="font-mono">OPENHUMAN_TRIGGER_TRIAGE_DISABLED</span>{' '}
{t('composio.envVarOverrides')}
</p>
{/* Global toggle */}
<div className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4 space-y-1">
<button
type="button"
@@ -116,11 +115,9 @@ const ComposioTriagePanel = () => {
className="w-full flex items-center justify-between">
<div className="text-left">
<span className="text-sm font-medium text-stone-900">
Disable AI triage for all triggers
{t('composio.disableAllTriage')}
</span>
<p className="text-xs text-stone-500 mt-0.5">
Triggers are still recorded to history no LLM turn is run.
</p>
<p className="text-xs text-stone-500 mt-0.5">{t('composio.triggersStillRecorded')}</p>
</div>
<div
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
@@ -135,10 +132,9 @@ const ComposioTriagePanel = () => {
</button>
</div>
{/* Per-toolkit list */}
<div className={`space-y-2 ${triageDisabled ? 'opacity-40 pointer-events-none' : ''}`}>
<label className="block text-sm font-medium text-stone-800" htmlFor="disabled-toolkits">
Disable AI triage for specific integrations
{t('composio.disableSpecificIntegrations')}
</label>
<p className="text-xs text-stone-500">
Comma-separated integration slugs, e.g. <span className="font-mono">gmail, slack</span>.
@@ -160,14 +156,14 @@ const ComposioTriagePanel = () => {
onClick={handleSave}
disabled={saving}
className="w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
{saving ? 'Saving' : 'Save'}
{saving ? t('common.loading') : t('common.save')}
</button>
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600">Settings saved</p>
<p className="text-xs text-center text-green-600">{t('composio.settingsSaved')}</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500">Failed to save. Try again.</p>
<p className="text-xs text-center text-red-500">{t('composio.saveFailed')}</p>
)}
</div>
</div>
@@ -5,6 +5,7 @@ import BinanceIcon from '../../../assets/icons/binance.svg';
import GoogleIcon from '../../../assets/icons/GoogleIcon';
import MetamaskIcon from '../../../assets/icons/metamask.svg';
import NotionIcon from '../../../assets/icons/notion.svg';
import { useT } from '../../../lib/i18n/I18nContext';
import { fetchWalletStatus, type WalletStatus } from '../../../services/walletApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -19,26 +20,24 @@ interface ConnectOption {
skillId?: string;
}
/**
* Renders a connection option row with its real-time status badge.
* Uses useSkillConnectionStatus hook for skill-backed connections.
*/
function ConnectionOptionRow({
option,
isFirst,
isLast,
onConnect,
t,
}: {
option: ConnectOption;
isFirst: boolean;
isLast: boolean;
onConnect: (option: ConnectOption) => void;
t: (key: string) => string;
}) {
const isDisabled = option.comingSoon;
const badge = option.comingSoon ? (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-stone-100 text-stone-500 border border-stone-200">
Coming soon
{t('connections.comingSoon')}
</span>
) : option.statusLabel ? (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-sage-50 text-sage-700 border border-sage-200">
@@ -46,7 +45,7 @@ function ConnectionOptionRow({
</span>
) : (
<span className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-primary-50 text-primary-600 border border-primary-100">
Set up
{t('connections.setUp')}
</span>
);
@@ -76,11 +75,8 @@ function ConnectionOptionRow({
);
}
// ---------------------------------------------------------------------------
// Main panel
// ---------------------------------------------------------------------------
const ConnectionsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const navigate = useNavigate();
const [walletStatus, setWalletStatus] = useState<WalletStatus | null>(null);
@@ -129,20 +125,20 @@ const ConnectionsPanel = () => {
id: 'wallet',
name: 'Web3 Wallet',
description: walletConfigured
? 'Local EVM, BTC, Solana, and Tron identities are configured from your recovery phrase.'
? t('connections.walletConfigured')
: walletReady
? 'Set up local EVM, BTC, Solana, and Tron identities from one recovery phrase.'
? t('connections.walletReady')
: walletStatusState === 'error'
? 'Could not check wallet status. Tap to retry from the Recovery Phrase panel.'
: 'Checking wallet status…',
? t('connections.walletError')
: t('connections.walletChecking'),
icon: <img src={MetamaskIcon} alt="Metamask" className="w-5 h-5" />,
statusLabel: walletConfigured
? 'Configured'
? t('connections.configured')
: walletReady
? undefined
: walletStatusState === 'error'
? 'Unavailable'
: 'Checking',
? t('connections.unavailable')
: t('connections.checking'),
},
{
id: 'exchange',
@@ -165,7 +161,7 @@ const ConnectionsPanel = () => {
return (
<div>
<SettingsHeader
title="Connections"
title={t('settings.account.connections')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -173,7 +169,6 @@ const ConnectionsPanel = () => {
<div>
<div className="p-4 space-y-4">
{/* Connection Options */}
<div className="rounded-2xl border border-stone-200 overflow-hidden bg-white">
{connectOptions.map((option, index) => (
<ConnectionOptionRow
@@ -182,6 +177,7 @@ const ConnectionsPanel = () => {
isFirst={index === 0}
isLast={index === connectOptions.length - 1}
onConnect={handleConnect}
t={t}
/>
))}
</div>
@@ -189,10 +185,10 @@ const ConnectionsPanel = () => {
{walletConfigured && walletStatus ? (
<div className="rounded-2xl border border-stone-200 bg-white p-4 space-y-3">
<div>
<p className="font-medium text-stone-900 text-sm">Wallet identities</p>
<p className="text-xs text-stone-500 mt-1">
Derived locally from your recovery phrase and stored as safe metadata only.
<p className="font-medium text-stone-900 text-sm">
{t('connections.walletIdentities')}
</p>
<p className="text-xs text-stone-500 mt-1">{t('connections.walletDerived')}</p>
</div>
<div className="grid gap-2">
{walletStatus.accounts.map(account => (
@@ -213,7 +209,6 @@ const ConnectionsPanel = () => {
</div>
) : null}
{/* Security notice — palette aligned with Privacy & Security panel for cross-surface trust coherence */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="flex items-start space-x-3">
<svg
@@ -227,10 +222,11 @@ const ConnectionsPanel = () => {
/>
</svg>
<div>
<p className="font-medium text-stone-900 text-sm">Privacy & Security</p>
<p className="font-medium text-stone-900 text-sm">
{t('connections.privacySecurity')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
All data and credentials are stored locally with zero-data retention policy. Your
information is encrypted and never shared with third parties.
{t('connections.privacySecurityDesc')}
</p>
</div>
</div>
@@ -1,6 +1,7 @@
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type CoreCronJob,
type CoreCronRun,
@@ -17,6 +18,7 @@ import CoreJobList from './cron/CoreJobList';
const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills');
const CronJobsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [loading, setLoading] = useState(true);
@@ -131,7 +133,7 @@ const CronJobsPanel = () => {
return (
<div>
<SettingsHeader
title="Cron Jobs"
title={t('cron.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -139,8 +141,8 @@ const CronJobsPanel = () => {
<div className="p-4 space-y-4">
<section className="space-y-1">
<h3 className="text-sm font-semibold text-stone-900">Scheduled Jobs</h3>
<p className="text-xs text-stone-400">Manage cron jobs from the core scheduler.</p>
<h3 className="text-sm font-semibold text-stone-900">{t('cron.scheduledJobs')}</h3>
<p className="text-xs text-stone-400">{t('cron.manageCronJobs')}</p>
</section>
{coreError && (
@@ -164,7 +166,7 @@ const CronJobsPanel = () => {
type="button"
className="btn btn-outline btn-sm"
onClick={() => void loadCoreCronJobsOnly()}>
Refresh Cron Jobs
{t('cron.refreshCronJobs')}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { triggerSentryTestEvent } from '../../../services/analytics';
import { useAppSelector } from '../../../store/hooks';
import { APP_ENVIRONMENT } from '../../../utils/config';
@@ -58,25 +59,6 @@ const developerItems = [
</svg>
),
},
// Screen Awareness Debug, Memory Data, and Memory Debug hidden — routes
// retained in `pages/Settings.tsx` for re-enable.
// {
// id: 'screen-awareness-debug',
// title: 'Screen Awareness Debug',
// description: 'FPS tuning, vision model config, capture tests, and session diagnostics',
// route: 'screen-awareness-debug',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M3 5h18v12H3zM8 21h8m-4-4v4"
// />
// </svg>
// ),
// },
// Autocomplete Debug + Voice Debug hidden per #717 (routes retained for re-enable).
{
id: 'local-model-debug',
title: 'Local Model Debug',
@@ -109,38 +91,6 @@ const developerItems = [
</svg>
),
},
// {
// id: 'memory-data',
// title: 'Memory Data',
// description: 'Knowledge graph, insights, activity heatmap, and file management',
// route: 'memory-data',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
// />
// </svg>
// ),
// },
// {
// id: 'memory-debug',
// title: 'Memory Debug',
// description: 'Inspect memory documents, namespaces, and test query/recall',
// route: 'memory-debug',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M9 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
// />
// </svg>
// ),
// },
{
id: 'intelligence',
title: 'Intelligence',
@@ -229,25 +179,15 @@ const developerItems = [
},
];
/**
* Small badge showing whether the desktop is talking to the embedded local
* core or a user-configured remote (cloud) core. Read straight from the
* `coreMode` Redux slice so it always reflects what `coreRpcClient` will
* resolve on the next call. For cloud mode also surfaces the (masked) URL
* + a "token set" indicator so users debugging a misconfigured cloud
* deployment can verify they actually entered both pieces in the picker.
*/
const CoreModeBadge = () => {
const { t } = useT();
const mode = useAppSelector(state => state.coreMode.mode);
if (mode.kind === 'unset') {
return (
<div className="px-4 py-3 mb-3 rounded-lg border border-coral-300 bg-coral-50">
<div className="text-sm font-semibold text-coral-900">Core mode: not set</div>
<div className="text-xs text-coral-800 mt-0.5">
The boot-check picker hasn&apos;t been confirmed yet. Use Switch mode on the picker to
choose Local or Cloud.
</div>
<div className="text-sm font-semibold text-coral-900">{t('devOptions.coreModeNotSet')}</div>
<div className="text-xs text-coral-800 mt-0.5">{t('devOptions.coreModeNotSetDesc')}</div>
</div>
);
}
@@ -257,37 +197,36 @@ const CoreModeBadge = () => {
<div className="px-4 py-3 mb-3 rounded-lg border border-ocean-300 bg-ocean-50">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-ocean-600 text-white text-[11px] font-medium">
Local
{t('devOptions.local')}
</span>
<span className="text-sm font-semibold text-ocean-900">
{t('devOptions.embeddedCoreSidecar')}
</span>
<span className="text-sm font-semibold text-ocean-900">Embedded core sidecar</span>
</div>
<div className="text-xs text-ocean-800 mt-1">
Spawned in-process by the Tauri shell on app launch.
</div>
<div className="text-xs text-ocean-800 mt-1">{t('devOptions.sidecarSpawned')}</div>
</div>
);
}
// Cloud — show URL + token status. Token value itself is never rendered.
return (
<div className="px-4 py-3 mb-3 rounded-lg border border-sage-300 bg-sage-50">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-full bg-sage-600 text-white text-[11px] font-medium">
Cloud
{t('devOptions.cloud')}
</span>
<span className="text-sm font-semibold text-sage-900">Remote core RPC</span>
<span className="text-sm font-semibold text-sage-900">{t('devOptions.remoteCoreRpc')}</span>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
<dt className="text-sage-700">URL:</dt>
<dd className="font-mono text-sage-900 truncate" title={mode.url}>
{mode.url}
</dd>
<dt className="text-sage-700">Token:</dt>
<dt className="text-sage-700">{t('devOptions.token')}:</dt>
<dd className="text-sage-900">
{mode.token ? (
<span className="font-mono">{mode.token.slice(-4)}</span>
) : (
<span className="text-coral-600">not set RPC will 401</span>
<span className="text-coral-600">{t('devOptions.tokenNotSet')}</span>
)}
</dd>
</dl>
@@ -301,9 +240,8 @@ type SentryTestStatus =
| { kind: 'sent'; eventId: string | undefined }
| { kind: 'error'; message: string };
// Staging-only Sentry pipeline check (issue #1072). Removed once the
// staging dashboard confirms events are landing with the right tags.
const SentryTestRow = () => {
const { t } = useT();
const [status, setStatus] = useState<SentryTestStatus>({ kind: 'idle' });
const onClick = async () => {
@@ -320,29 +258,24 @@ const SentryTestRow = () => {
<div className="px-4 py-3 mb-3 rounded-lg border border-amber-300 bg-amber-50">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-900">Trigger Sentry Test (staging)</div>
<div className="text-sm font-semibold text-amber-900">
{t('devOptions.triggerSentryTest')}
</div>
<div className="text-xs text-amber-800 mt-0.5">
Fires a tagged error to verify the Sentry pipeline. Issue #1072 remove after
verification.
{t('devOptions.triggerSentryTestDesc')}
</div>
</div>
<button
onClick={onClick}
disabled={status.kind === 'sending'}
className="shrink-0 px-3 py-1.5 rounded-md bg-amber-600 hover:bg-amber-500 text-white text-xs font-medium transition-colors disabled:opacity-60">
{status.kind === 'sending' ? 'Sending' : 'Send test event'}
{status.kind === 'sending' ? t('devOptions.sending') : t('devOptions.sendTestEvent')}
</button>
</div>
{/*
* Single live region so screen readers announce the result when
* status flips from `sending` to `sent` / `error`. `aria-live=polite`
* waits for any in-flight speech to finish; `aria-atomic` makes the
* reader re-read the whole region rather than only the diff.
*/}
<div role="status" aria-live="polite" aria-atomic="true" className="mt-2 text-xs">
{status.kind === 'sent' && (
<span className="text-amber-900">
Event sent.{' '}
{t('devOptions.eventSent')}.{' '}
{status.eventId ? (
<span className="font-mono">id: {status.eventId}</span>
) : (
@@ -351,19 +284,17 @@ const SentryTestRow = () => {
</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600">Failed: {status.message}</span>
<span className="text-coral-600">
{t('devOptions.failed')}: {status.message}
</span>
)}
</div>
</div>
);
};
// Surfaces the on-disk log folder so users running into "stuck on
// Initializing OpenHuman..." (and similar startup issues) can grab today's
// `openhuman-YYYY-MM-DD.log` and send it to support without hunting through
// `~/.openhuman/logs/`. Invokes the `reveal_logs_folder` Tauri command which
// `open`/`explorer`/`xdg-open`s the directory in the platform file manager.
const LogsFolderRow = () => {
const { t } = useT();
const [path, setPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -391,17 +322,14 @@ const LogsFolderRow = () => {
<div className="px-4 py-3 mb-3 rounded-lg border border-slate-200 bg-slate-50">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">App logs</div>
<div className="text-xs text-slate-700 mt-0.5">
Open the folder containing rolling daily log files. Attach the most recent file when
reporting an issue.
</div>
<div className="text-sm font-semibold text-slate-900">{t('devOptions.appLogs')}</div>
<div className="text-xs text-slate-700 mt-0.5">{t('devOptions.appLogsDesc')}</div>
{path && <div className="text-[11px] text-slate-500 mt-1 font-mono truncate">{path}</div>}
</div>
<button
onClick={onClick}
className="shrink-0 px-3 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium transition-colors">
Open logs folder
{t('devOptions.openLogsFolder')}
</button>
</div>
{error && (
@@ -414,13 +342,14 @@ const LogsFolderRow = () => {
};
const DeveloperOptionsPanel = () => {
const { t } = useT();
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
const showSentryTest = APP_ENVIRONMENT === 'staging';
return (
<div className="z-10 relative">
<SettingsHeader
title="Developer Options"
title={t('devOptions.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
formatBytes,
formatEta,
@@ -52,6 +53,7 @@ const statusTone = (state: string): string => {
};
const LocalModelDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [status, setStatus] = useState<LocalAiStatus | null>(null);
@@ -345,7 +347,7 @@ const LocalModelDebugPanel = () => {
return (
<div>
<SettingsHeader
title="Local Model Debug"
title={t('localModel.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../../types/intelligence';
import { MemoryWorkspace } from '../../intelligence/MemoryWorkspace';
import { ToastContainer } from '../../intelligence/Toast';
@@ -8,6 +9,7 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const MemoryDataPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
@@ -37,7 +39,7 @@ const MemoryDataPanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Memory Data"
title={t('memory.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
memoryClearNamespace,
type MemoryDebugDocument,
@@ -16,6 +17,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { normalizeMemoryDocuments } from './memoryDebugUtils';
const MemoryDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [documents, setDocuments] = useState<MemoryDebugDocument[]>([]);
const [documentsRaw, setDocumentsRaw] = useState<unknown>(null);
@@ -172,7 +174,7 @@ const MemoryDebugPanel = () => {
return (
<div>
<SettingsHeader
title="Memory Debug"
title={t('memory.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useChannelDefinitions } from '../../../hooks/useChannelDefinitions';
import { resolvePreferredAuthModeForChannel } from '../../../lib/channels/routing';
import { useT } from '../../../lib/i18n/I18nContext';
import { channelConnectionsApi } from '../../../services/api/channelConnectionsApi';
import { setDefaultMessagingChannel } from '../../../store/channelConnectionsSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -14,11 +15,7 @@ import ChannelSetupModal from '../../channels/ChannelSetupModal';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const CHANNEL_ICONS: Record<string, string> = {
telegram: '\u2708\uFE0F',
discord: '\uD83C\uDFAE',
web: '\uD83C\uDF10',
};
const CHANNEL_ICONS: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐' };
function statusDot(status: ChannelConnectionStatus): string {
switch (status) {
@@ -33,16 +30,16 @@ function statusDot(status: ChannelConnectionStatus): string {
}
}
function statusLabel(status: ChannelConnectionStatus): string {
function statusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
switch (status) {
case 'connected':
return 'Connected';
return t('channels.status.connected');
case 'connecting':
return 'Connecting';
return t('channels.status.connecting');
case 'error':
return 'Error';
return t('channels.status.error');
default:
return 'Not configured';
return t('channels.status.notConfigured');
}
}
@@ -60,6 +57,7 @@ function statusColor(status: ChannelConnectionStatus): string {
}
const MessagingPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const channelConnections = useAppSelector(state => state.channelConnections);
@@ -76,8 +74,10 @@ const MessagingPanel = () => {
const recommendedRoute = useMemo(() => {
const channel = channelConnections.defaultMessagingChannel;
const authMode = resolvePreferredAuthModeForChannel(channelConnections, channel);
return authMode ? `${channel} via ${authMode}` : 'No active route';
}, [channelConnections]);
return authMode
? t('channels.activeRouteValue').replace('{channel}', channel).replace('{authMode}', authMode)
: t('channels.noActiveRoute');
}, [channelConnections, t]);
const bestStatus = useCallback(
(channelId: ChannelType): ChannelConnectionStatus => {
@@ -107,7 +107,7 @@ const MessagingPanel = () => {
return (
<div>
<SettingsHeader
title="Messaging"
title={t('settings.features.messaging')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -116,7 +116,7 @@ const MessagingPanel = () => {
<div className="p-4 space-y-4">
{/* Default channel selector */}
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Default Messaging Channel</h3>
<h3 className="text-sm font-semibold text-stone-900">{t('channels.defaultMessaging')}</h3>
<div className="grid grid-cols-2 gap-2">
{definitions.map(def => {
const channelId = def.id as ChannelType;
@@ -139,7 +139,8 @@ const MessagingPanel = () => {
})}
</div>
<p className="text-xs text-stone-400">
Active route: <span className="text-primary-600">{recommendedRoute}</span>
{t('channels.activeRoute')}:{' '}
<span className="text-primary-600">{recommendedRoute}</span>
</p>
</section>
@@ -151,17 +152,17 @@ const MessagingPanel = () => {
{loading && (
<div className="rounded-xl border border-stone-200 bg-white p-4 text-sm text-stone-400">
Loading channel definitions...
{t('channels.loadingDefinitions')}
</div>
)}
{/* Channel cards — click to open the shared ChannelSetupModal */}
{!loading && (
<section className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Channel Connections</h3>
<p className="text-xs text-stone-400">
Configure auth modes for each messaging channel.
</p>
<h3 className="text-sm font-semibold text-stone-900">
{t('channels.channelConnections')}
</h3>
<p className="text-xs text-stone-400">{t('channels.configureAuthModes')}</p>
<div className="space-y-2">
{configurableChannels.map(def => {
const channelId = def.id as ChannelType;
@@ -185,7 +186,7 @@ const MessagingPanel = () => {
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDot(status)}`}
/>
<span className={`text-xs ${statusColor(status)}`}>
{statusLabel(status)}
{statusLabel(status, t)}
</span>
</div>
<p className="text-xs text-stone-500 mt-0.5">{def.description}</p>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
fetchNotificationStats,
getNotificationSettings,
@@ -18,6 +19,7 @@ const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp'];
* controls will populate here as providers are connected.
*/
const NotificationRoutingPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const providers = PROVIDERS;
const [stats, setStats] = useState<NotificationStats | null>(null);
@@ -103,7 +105,7 @@ const NotificationRoutingPanel = () => {
return (
<div>
<SettingsHeader
title="Notification Routing"
title={t('notifications.routingTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { getBypassPrefs, setGlobalDnd } from '../../../services/webviewAccountService';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { type NotificationCategory, setPreference } from '../../../store/notificationSlice';
@@ -41,6 +42,7 @@ const CATEGORIES: { id: NotificationCategory; title: string; description: string
];
const NotificationsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const preferences = useAppSelector(s => s.notifications.preferences);
const dispatch = useAppDispatch();
@@ -77,7 +79,7 @@ const NotificationsPanel = () => {
return (
<div>
<SettingsHeader
title="Notifications"
title={t('settings.notifications')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -88,14 +90,16 @@ const NotificationsPanel = () => {
{/* Do Not Disturb */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
Do Not Disturb
{t('settings.notifications.doNotDisturb')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">Suppress all notifications</p>
<p className="text-sm font-medium text-stone-900">
{t('settings.notifications.suppressAll')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
Block all OS notification toasts from embedded apps regardless of focus state.
{t('settings.notifications.suppressAllDesc')}
</p>
</div>
{dndLoading ? (
@@ -111,7 +115,7 @@ const NotificationsPanel = () => {
}`}
role="switch"
aria-checked={dnd}
aria-label="Toggle Do Not Disturb">
aria-label={t('settings.notifications.toggleDnd')}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
dnd ? 'translate-x-5' : 'translate-x-0'
@@ -126,7 +130,7 @@ const NotificationsPanel = () => {
{/* Categories */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2 px-1">
Categories
{t('settings.notifications.categories')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden divide-y divide-stone-100">
{CATEGORIES.map(cat => {
@@ -159,8 +163,7 @@ const NotificationsPanel = () => {
</div>
<p className="text-xs text-stone-500 leading-relaxed px-1 mt-2">
Disabling a category stops new notifications of that type from appearing in the
notification center. Existing notifications remain until cleared.
{t('settings.notifications.categoryFooter')}
</p>
</div>
</div>
@@ -1,6 +1,7 @@
import debug from 'debug';
import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import {
type Capability,
@@ -17,14 +18,6 @@ interface AnnotatedCapability extends Capability {
privacy: CapabilityPrivacy;
}
const KIND_LABEL: Record<PrivacyDataKind, string> = {
raw: 'Raw user content',
derived: 'Derived signals',
credentials: 'Credentials',
diagnostics: 'Diagnostics',
metadata: 'Metadata',
};
const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
raw: 'bg-sage-50 text-sage-700 border-sage-200',
derived: 'bg-amber-50 text-amber-700 border-amber-200',
@@ -33,11 +26,27 @@ const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
metadata: 'bg-stone-50 text-stone-600 border-stone-200',
};
function kindLabel(kind: PrivacyDataKind, t: (key: string) => string): string {
switch (kind) {
case 'raw':
return t('privacy.dataKind.raw');
case 'derived':
return t('privacy.dataKind.derived');
case 'credentials':
return t('privacy.dataKind.credentials');
case 'diagnostics':
return t('privacy.dataKind.diagnostics');
case 'metadata':
return t('privacy.dataKind.metadata');
}
}
const PrivacyPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot, setAnalyticsEnabled, setMeetAutoOrchestratorHandoff } = useCoreState();
const analyticsEnabled = snapshot.analyticsEnabled;
const meetAutoHandoff = snapshot.meetAutoOrchestratorHandoff;
const { t } = useT();
const [capabilities, setCapabilities] = useState<AnnotatedCapability[]>([]);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
@@ -86,7 +95,7 @@ const PrivacyPanel = () => {
return (
<div data-testid="settings-privacy-panel">
<SettingsHeader
title="Privacy & Security"
title={t('privacy.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -97,21 +106,19 @@ const PrivacyPanel = () => {
{/* What leaves my computer */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
What leaves your computer
{t('privacy.whatLeavesComputer')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
{loadState === 'loading' && (
<p className="p-4 text-xs text-stone-500">Loading privacy details&hellip;</p>
<p className="p-4 text-xs text-stone-500">{t('privacy.loading')}</p>
)}
{loadState === 'error' && (
<p className="p-4 text-xs text-stone-500" data-testid="privacy-load-error">
Couldn&rsquo;t load the live privacy list. Analytics controls below still work.
{t('privacy.loadError')}
</p>
)}
{loadState === 'ready' && capabilities.length === 0 && (
<p className="p-4 text-xs text-stone-500">
No capabilities currently disclose data movement.
</p>
<p className="p-4 text-xs text-stone-500">{t('privacy.noCapabilities')}</p>
)}
{loadState === 'ready' && capabilities.length > 0 && (
<ul className="divide-y divide-stone-100" data-testid="privacy-capability-list">
@@ -125,17 +132,19 @@ const PrivacyPanel = () => {
</p>
{cap.privacy.destinations.length > 0 && (
<p className="text-xs text-stone-400 mt-1">
Sent to: {cap.privacy.destinations.join(', ')}
{t('privacy.sentTo')}: {cap.privacy.destinations.join(', ')}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<span
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ${KIND_BADGE_CLASS[cap.privacy.data_kind]}`}>
{KIND_LABEL[cap.privacy.data_kind]}
{kindLabel(cap.privacy.data_kind, t)}
</span>
<span className="text-[10px] text-stone-500">
{cap.privacy.leaves_device ? 'Leaves device' : 'Stays local'}
{cap.privacy.leaves_device
? t('privacy.leavesDevice')
: t('privacy.staysLocal')}
</span>
</div>
</div>
@@ -149,16 +158,16 @@ const PrivacyPanel = () => {
{/* Analytics Section */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
Anonymized Analytics
{t('privacy.anonymizedAnalytics')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">Share Anonymized Usage Data</p>
<p className="text-sm font-medium text-stone-900">
{t('privacy.shareAnonymizedData')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
Help improve OpenHuman by sharing anonymous crash reports and usage analytics.
All data is fully anonymized &mdash; no personal data, messages, wallet keys, or
session information is ever collected.
{t('privacy.shareAnonymizedDataDesc')}
</p>
</div>
<button
@@ -182,18 +191,16 @@ const PrivacyPanel = () => {
{/* Meeting Follow-ups Section (#1299) */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
Meeting follow-ups
{t('privacy.meetingFollowUps')}
</h3>
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between p-4">
<div className="flex-1 mr-4">
<p className="text-sm font-medium text-stone-900">
Auto-handoff Google Meet transcripts to the orchestrator
{t('privacy.autoHandoffMeet')}
</p>
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
When a Google Meet call ends, OpenHuman&rsquo;s orchestrator can read the
transcript and may take actions like drafting messages, scheduling follow-ups,
or posting summaries to your connected Slack workspace. Off by default.
{t('privacy.autoHandoffMeetDesc')}
</p>
</div>
<button
@@ -230,6 +237,7 @@ const PrivacyPanel = () => {
</svg>
<div>
<p className="text-xs text-stone-500 leading-relaxed">
{t('privacy.analyticsDisclaimer')}
All analytics and bug reports are fully anonymized. When enabled, we collect crash
information and device type (via Sentry), plus anonymous usage analytics such as
page views and feature engagement (via Google Analytics). We never access your
@@ -1,6 +1,7 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { persistLocalWalletFromMnemonic } from '../../../features/wallet/setupLocalWalletFromMnemonic';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import {
generateMnemonicPhrase,
@@ -15,6 +16,7 @@ const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const RecoveryPhrasePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
@@ -63,7 +65,6 @@ const RecoveryPhrasePanel = () => {
setError(null);
}, []);
// Navigate back after success
useEffect(() => {
if (success) {
const timer = setTimeout(() => {
@@ -146,13 +147,13 @@ const RecoveryPhrasePanel = () => {
setImportValid(isValid);
if (!isValid) {
setError('Invalid recovery phrase. Please check your words and try again.');
setError(t('mnemonic.invalidPhrase'));
return false;
}
setError(null);
return true;
}, [importWords]);
}, [importWords, t]);
const handleSave = async () => {
setError(null);
@@ -176,7 +177,7 @@ const RecoveryPhrasePanel = () => {
}
if (!user?._id) {
setError('User not loaded. Please sign in again or refresh the page.');
setError(t('mnemonic.userNotLoaded'));
return;
}
await persistLocalWalletFromMnemonic({
@@ -186,7 +187,7 @@ const RecoveryPhrasePanel = () => {
});
setSuccess(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
setError(e instanceof Error ? e.message : t('mnemonic.somethingWentWrong'));
} finally {
setLoading(false);
}
@@ -201,7 +202,7 @@ const RecoveryPhrasePanel = () => {
return (
<div>
<SettingsHeader
title="Recovery Phrase"
title={t('mnemonic.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -221,10 +222,8 @@ const RecoveryPhrasePanel = () => {
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-sage-500">Recovery phrase saved</p>
<p className="text-xs text-stone-500">
Multi-chain wallet identities are ready. Returning to settings...
</p>
<p className="text-sm font-medium text-sage-500">{t('mnemonic.phraseSaved')}</p>
<p className="text-xs text-stone-500">{t('mnemonic.walletReady')}</p>
</div>
) : (
<>
@@ -232,9 +231,8 @@ const RecoveryPhrasePanel = () => {
<>
<div className="mb-4 space-y-3">
<p className="text-sm text-stone-600 leading-relaxed">
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
somewhere safe. This phrase secures your local encryption key and your EVM,
BTC, Solana, and Tron wallet identities.
{t('mnemonic.writeDownWords')} {MNEMONIC_GENERATE_WORD_COUNT}{' '}
{t('mnemonic.wordsInOrder')}
</p>
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50 border border-amber-200/70">
<svg
@@ -250,8 +248,7 @@ const RecoveryPhrasePanel = () => {
/>
</svg>
<p className="text-xs text-amber-800 leading-relaxed">
This phrase can never be recovered if lost and should stay fully local to
your device.
{t('mnemonic.cannotRecover')}
</p>
</div>
</div>
@@ -284,7 +281,7 @@ const RecoveryPhrasePanel = () => {
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-sage-400">Copied to Clipboard</span>
<span className="text-sage-400">{t('common.copied')}</span>
</>
) : (
<>
@@ -300,7 +297,7 @@ const RecoveryPhrasePanel = () => {
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span>Copy to Clipboard</span>
<span>{t('mnemonic.copyToClipboard')}</span>
</>
)}
</button>
@@ -308,7 +305,7 @@ const RecoveryPhrasePanel = () => {
<button
onClick={() => switchMode('import')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 transition-colors mb-3">
I already have a recovery phrase
{t('mnemonic.alreadyHavePhrase')}
</button>
<label className="flex items-start gap-3 cursor-pointer mb-4">
@@ -318,23 +315,19 @@ const RecoveryPhrasePanel = () => {
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-500 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-stone-700">
I saved this phrase and consent to using it for local wallet setup
</span>
<span className="text-sm text-stone-700">{t('mnemonic.consentSaved')}</span>
</label>
</>
) : (
<>
<div className="mb-4">
<p className="text-sm text-stone-600 leading-relaxed">
Enter your recovery phrase below to restore your local wallet identities, or
paste the full phrase into any field (12 words for new backups; 24-word
phrases from older versions still work).
{t('mnemonic.enterPhraseToRestore')}
</p>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-stone-500">Words:</span>
<span className="text-xs text-stone-500">{t('mnemonic.words')}:</span>
{BIP39_IMPORT_LENGTHS.map(len => (
<button
key={len}
@@ -391,14 +384,14 @@ const RecoveryPhrasePanel = () => {
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Valid recovery phrase</span>
<span>{t('mnemonic.validPhrase')}</span>
</div>
)}
<button
onClick={() => switchMode('generate')}
className="w-full text-center text-sm text-primary-400 hover:text-primary-600 transition-colors mb-3">
Generate a new recovery phrase instead
{t('mnemonic.generateNewPhrase')}
</button>
</>
)}
@@ -445,10 +438,10 @@ const RecoveryPhrasePanel = () => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Securing Your Data...</span>
<span>{t('mnemonic.securingData')}</span>
</>
) : (
'Save Recovery Phrase'
t('mnemonic.saveRecoveryPhrase')
)}
</button>
</>
@@ -2,6 +2,7 @@ import { type ComponentProps, useRef, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -28,6 +29,7 @@ const DebugSection = ({
};
const ScreenAwarenessDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const {
status,
@@ -99,7 +101,7 @@ const ScreenAwarenessDebugPanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Screen Awareness Debug"
title={t('screenAwareness.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -20,6 +21,7 @@ const formatRemaining = (remainingMs: number | null): string => {
};
const ScreenIntelligencePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const {
status,
@@ -109,7 +111,7 @@ const ScreenIntelligencePanel = () => {
return (
<div className="z-10 relative">
<SettingsHeader
title="Screen Awareness"
title={t('settings.features.screenAwareness')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -134,10 +136,12 @@ const ScreenIntelligencePanel = () => {
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Screen Awareness</h3>
<h3 className="text-sm font-semibold text-stone-900">
{t('settings.features.screenAwareness')}
</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Enabled</span>
<span className="text-sm text-stone-700">{t('common.enabled')}</span>
<input
type="checkbox"
checked={enabled}
@@ -146,7 +150,7 @@ const ScreenIntelligencePanel = () => {
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Mode</span>
<span className="text-sm text-stone-700">{t('settings.screenAwareness.mode')}</span>
<select
value={policyMode}
onChange={event =>
@@ -157,13 +161,17 @@ const ScreenIntelligencePanel = () => {
)
}
className="rounded border border-stone-200 bg-white px-2 py-1 text-xs text-stone-700">
<option value="all_except_blacklist">All Except Blacklist</option>
<option value="whitelist_only">Whitelist Only</option>
<option value="all_except_blacklist">
{t('settings.screenAwareness.allExceptBlacklist')}
</option>
<option value="whitelist_only">{t('settings.screenAwareness.whitelistOnly')}</option>
</select>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<span className="text-sm text-stone-700">Screen Monitoring</span>
<span className="text-sm text-stone-700">
{t('settings.screenAwareness.screenMonitoring')}
</span>
<input
type="checkbox"
checked={screenMonitoring}
@@ -181,16 +189,25 @@ const ScreenIntelligencePanel = () => {
onClick={() => void saveConfig()}
disabled={isSavingConfig}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isSavingConfig ? 'Saving…' : 'Save Settings'}
{isSavingConfig ? 'Saving…' : t('settings.screenAwareness.saveSettings')}
</button>
{configError && <div className="text-xs text-red-600">{configError}</div>}
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Session</h3>
<h3 className="text-sm font-semibold text-stone-900">
{t('settings.screenAwareness.session')}
</h3>
<div className="text-sm text-stone-600 space-y-1">
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
<div>Remaining: {remaining}</div>
<div>
{t('settings.screenAwareness.status')}:{' '}
{status?.session.active
? t('settings.screenAwareness.active')
: t('settings.screenAwareness.stopped')}
</div>
<div>
{t('settings.screenAwareness.remaining')}: {remaining}
</div>
</div>
<div className="flex gap-2">
@@ -205,29 +222,28 @@ const ScreenIntelligencePanel = () => {
}
disabled={startDisabled}
className="rounded-lg border border-green-400 bg-green-50 px-3 py-2 text-sm text-green-700 disabled:opacity-50">
{isStartingSession ? 'Starting…' : 'Start Session'}
{isStartingSession ? 'Starting…' : t('settings.screenAwareness.startSession')}
</button>
<button
type="button"
onClick={() => void stopSession('manual_stop')}
disabled={stopDisabled}
className="rounded-lg border border-red-400 bg-red-50 px-3 py-2 text-sm text-red-700 disabled:opacity-50">
{isStoppingSession ? 'Stopping…' : 'Stop Session'}
{isStoppingSession ? 'Stopping…' : t('settings.screenAwareness.stopSession')}
</button>
<button
type="button"
onClick={() => void flushVision()}
disabled={isFlushingVision || !status?.session.active}
className="rounded-lg border border-primary-400 bg-primary-50 px-3 py-2 text-sm text-primary-700 disabled:opacity-50">
{isFlushingVision ? 'Analyzing…' : 'Analyze Now'}
{isFlushingVision ? 'Analyzing…' : t('settings.screenAwareness.analyzeNow')}
</button>
</div>
</section>
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
Screen Awareness desktop capture and permission controls are currently supported on
macOS only.
{t('settings.screenAwareness.macosOnly')}
</div>
)}
@@ -236,17 +252,6 @@ const ScreenIntelligencePanel = () => {
{lastError}
</div>
)}
{/* Screen Awareness Debug entry hidden alongside Developer Options entries. */}
{/* <button
type="button"
onClick={() => navigateToSettings('screen-awareness-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button> */}
</div>
</div>
);
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamInvitesPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
@@ -103,7 +105,7 @@ const TeamInvitesPanel = () => {
return (
<div>
<SettingsHeader
title="Invites"
title={t('invites.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamManagementPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { teams, refreshTeams } = useCoreState();
@@ -90,13 +92,13 @@ const TeamManagementPanel = () => {
return (
<div className="">
<SettingsHeader
title="Team Management"
title={t('team.management')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500">Team not found</p>
<p className="text-sm text-stone-500">{t('team.notFound')}</p>
</div>
</div>
);
@@ -106,13 +108,13 @@ const TeamManagementPanel = () => {
return (
<div className="">
<SettingsHeader
title="Team Management"
title={t('team.management')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-stone-500">Access denied</p>
<p className="text-sm text-stone-500">{t('team.accessDenied')}</p>
</div>
</div>
);
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
@@ -10,6 +11,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER'];
const TeamMembersPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
@@ -115,7 +117,7 @@ const TeamMembersPanel = () => {
return (
<div>
<SettingsHeader
title="Members"
title={t('team.members')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamWithRole } from '../../../types/team';
@@ -7,6 +8,7 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamPanel = () => {
const { t } = useT();
const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation();
const { snapshot, teams, refresh, refreshTeams } = useCoreState();
const user = snapshot.currentUser;
@@ -20,7 +22,6 @@ const TeamPanel = () => {
const [isLeaving, setIsLeaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Confirmation modal state for leaving team
const [teamToLeave, setTeamToLeave] = useState<TeamWithRole | null>(null);
const activeTeamId = user?.activeTeamId;
@@ -51,7 +52,7 @@ const TeamPanel = () => {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: 'Failed to create team'
: t('team.failedToCreate')
);
} finally {
setIsCreating(false);
@@ -71,7 +72,7 @@ const TeamPanel = () => {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: 'Invalid or expired invite code'
: t('team.invalidInviteCode')
);
} finally {
setIsJoining(false);
@@ -89,7 +90,7 @@ const TeamPanel = () => {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: 'Failed to switch team'
: t('team.failedToSwitch')
);
} finally {
setIsSwitching(null);
@@ -97,7 +98,6 @@ const TeamPanel = () => {
};
const handleLeaveTeam = (teamEntry: TeamWithRole) => {
// Show confirmation modal for leaving teams
setTeamToLeave(teamEntry);
};
@@ -115,7 +115,7 @@ const TeamPanel = () => {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: 'Failed to leave team'
: t('team.failedToLeave')
);
} finally {
setIsLeaving(null);
@@ -123,19 +123,16 @@ const TeamPanel = () => {
};
const roleBadge = (role: string, teamCreatedBy?: string) => {
// Normalize role to uppercase for consistent comparison
const normalizedRole = role.toUpperCase();
// Show "Owner" if this is the team creator and admin
const isOwner = normalizedRole === 'ADMIN' && teamCreatedBy === user?._id;
const roleLabel = isOwner
? 'Owner'
? t('team.role.owner')
: normalizedRole === 'ADMIN'
? 'Admin'
? t('team.role.admin')
: normalizedRole === 'BILLING_MANAGER'
? 'Billing Manager'
: 'Member';
? t('team.role.billingManager')
: t('team.role.member');
const colors: Record<string, string> = {
ADMIN: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
@@ -180,7 +177,6 @@ const TeamPanel = () => {
: 'border-stone-200 bg-white hover:bg-stone-50'
}`}>
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Team avatar */}
<div className="w-9 h-9 rounded-lg bg-stone-100 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-semibold text-stone-600">
{team.name.charAt(0).toUpperCase()}
@@ -193,11 +189,13 @@ const TeamPanel = () => {
{planBadge(team.subscription.plan)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
Active
{t('team.active')}
</span>
)}
</div>
{team.isPersonal && <p className="text-xs text-stone-400 mt-0.5">Personal team</p>}
{team.isPersonal && (
<p className="text-xs text-stone-400 mt-0.5">{t('team.personalTeam')}</p>
)}
</div>
</div>
@@ -206,7 +204,7 @@ const TeamPanel = () => {
<button
onClick={() => navigateToTeamManagement(team._id)}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-primary-50 hover:bg-primary-100 text-primary-600 transition-colors">
Manage Team
{t('team.manageTeam')}
</button>
)}
{!isActive && (
@@ -214,7 +212,7 @@ const TeamPanel = () => {
onClick={() => handleSwitchTeam(team._id)}
disabled={isSwitching === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50">
{isSwitching === team._id ? 'Switching...' : 'Switch'}
{isSwitching === team._id ? t('team.switching') : t('team.switch')}
</button>
)}
{canLeave && (
@@ -222,7 +220,7 @@ const TeamPanel = () => {
onClick={() => handleLeaveTeam(entry)}
disabled={isLeaving === team._id}
className="px-2.5 py-1 text-xs font-medium rounded-lg text-amber-700 hover:bg-amber-50 transition-colors disabled:opacity-50">
{isLeaving === team._id ? 'Leaving...' : 'Leave'}
{isLeaving === team._id ? t('team.leaving') : t('team.leave')}
</button>
)}
</div>
@@ -233,7 +231,7 @@ const TeamPanel = () => {
return (
<div>
<SettingsHeader
title="Team"
title={t('settings.account.team')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -241,14 +239,12 @@ const TeamPanel = () => {
<div>
<div className="p-4 space-y-4">
{/* Error banner */}
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3">
<p className="text-xs text-coral-400">{error}</p>
</div>
)}
{/* Loading */}
{isLoading && teams.length === 0 && (
<div className="flex items-center justify-center py-8">
<svg className="w-5 h-5 text-stone-500 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -269,11 +265,10 @@ const TeamPanel = () => {
</div>
)}
{/* Teams List - Primary Content */}
{teams.length > 0 && (
<div className="space-y-3">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
Your Teams ({teams.length})
{t('team.yourTeams')} ({teams.length})
</h3>
<div className="space-y-2">
{teams.map(entry => (
@@ -283,12 +278,10 @@ const TeamPanel = () => {
</div>
)}
{/* Team Actions - Secondary Content */}
<div className="space-y-4 border-t border-stone-200 pt-4">
{/* Create team */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
Create New Team
{t('team.createNewTeam')}
</h3>
<div className="flex gap-2">
<input
@@ -296,22 +289,21 @@ const TeamPanel = () => {
value={newTeamName}
onChange={e => setNewTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateTeam()}
placeholder="Team name"
placeholder={t('team.teamName')}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-xl text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50"
/>
<button
onClick={handleCreateTeam}
disabled={isCreating || !newTeamName.trim()}
className="px-4 py-2 text-xs font-medium rounded-xl bg-primary-500 hover:bg-primary-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isCreating ? 'Creating...' : 'Create'}
{isCreating ? t('team.creating') : t('common.create')}
</button>
</div>
</div>
{/* Join team */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-stone-500 uppercase tracking-wider px-1">
Join Existing Team
{t('team.joinExistingTeam')}
</h3>
<div className="flex gap-2">
<input
@@ -319,24 +311,23 @@ const TeamPanel = () => {
value={joinCode}
onChange={e => setJoinCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleJoinTeam()}
placeholder="Invite code"
placeholder={t('team.inviteCode')}
className="flex-1 px-3 py-2 text-sm bg-white border border-stone-200 rounded-xl text-stone-900 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 font-mono"
/>
<button
onClick={handleJoinTeam}
disabled={isJoining || !joinCode.trim()}
className="px-4 py-2 text-xs font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{isJoining ? 'Joining...' : 'Join'}
{isJoining ? t('team.joining') : t('team.join')}
</button>
</div>
</div>
</div>
{/* Leave Team Confirmation Modal */}
{teamToLeave && (
<div className="fixed inset-0 bg-stone-900/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md border border-stone-200">
<h3 className="text-sm font-semibold text-stone-900 mb-4">Leave Team</h3>
<h3 className="text-sm font-semibold text-stone-900 mb-4">{t('team.leaveTeam')}</h3>
{error && (
<div className="rounded-xl bg-coral-500/10 border border-coral-500/20 p-3 mb-4">
@@ -347,13 +338,10 @@ const TeamPanel = () => {
<div className="space-y-4">
<div className="text-sm text-stone-500">
<p>
Are you sure you want to leave{' '}
{t('team.confirmLeave')}{' '}
<strong className="text-stone-900">{teamToLeave.team.name}</strong>?
</p>
<p className="mt-2 text-amber-400">
You will lose access to the team and all team resources. You'll need a new
invite to rejoin.
</p>
<p className="mt-2 text-amber-400">{t('team.leaveWarning')}</p>
</div>
<div className="flex gap-2 pt-2">
@@ -361,13 +349,13 @@ const TeamPanel = () => {
onClick={() => setTeamToLeave(null)}
disabled={isLeaving === teamToLeave.team._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-stone-100 hover:bg-stone-200 text-stone-600 transition-colors disabled:opacity-50">
Cancel
{t('common.cancel')}
</button>
<button
onClick={confirmLeaveTeam}
disabled={isLeaving === teamToLeave.team._id}
className="flex-1 px-4 py-2 text-sm font-medium rounded-xl bg-amber-500 hover:bg-amber-600 text-white transition-colors disabled:opacity-50">
{isLeaving === teamToLeave.team._id ? 'Leaving...' : 'Leave Team'}
{isLeaving === teamToLeave.team._id ? t('team.leaving') : t('team.leaveTeam')}
</button>
</div>
</div>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import {
CATEGORY_DESCRIPTIONS,
@@ -12,6 +13,7 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ToolsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot, setOnboardingTasks } = useCoreState();
const toolsByCategory = getToolsByCategory();
@@ -81,16 +83,14 @@ const ToolsPanel = () => {
return (
<div>
<SettingsHeader
title="Tools"
title={t('settings.features.tools')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<p className="text-stone-500 text-sm">
Choose which capabilities OpenHuman can use on your behalf.
</p>
<p className="text-stone-500 text-sm">{t('settings.tools.chooseCapabilities')}</p>
<div className="max-h-[420px] overflow-y-auto pr-1 space-y-4">
{TOOL_CATEGORIES.map(category => {
@@ -141,16 +141,16 @@ const ToolsPanel = () => {
onClick={handleSave}
disabled={saving}
className="mt-4 w-full py-2 rounded-xl bg-primary-600 text-white text-sm font-medium hover:bg-primary-500 transition-colors disabled:opacity-50">
{saving ? 'Saving...' : 'Save Changes'}
{saving ? 'Saving...' : t('settings.tools.saveChanges')}
</button>
)}
{saveStatus === 'saved' && (
<p className="text-xs text-center text-green-600 mt-1">Preferences saved</p>
<p className="text-xs text-center text-green-600 mt-1">
{t('settings.tools.preferencesSaved')}
</p>
)}
{saveStatus === 'error' && (
<p className="text-xs text-center text-red-500 mt-1">
Failed to save preferences. Try again.
</p>
<p className="text-xs text-center text-red-500 mt-1">{t('settings.tools.saveFailed')}</p>
)}
</div>
</div>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
@@ -13,6 +14,7 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const VoiceDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
@@ -113,7 +115,7 @@ const VoiceDebugPanel = () => {
return (
<div>
<SettingsHeader
title="Voice Debug"
title={t('voice.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { synthesizeSpeech } from '../../../features/human/voice/ttsClient';
import { useT } from '../../../lib/i18n/I18nContext';
import {
installPiper,
installWhisper,
@@ -47,6 +48,7 @@ const PIPER_VOICE_PRESETS: ReadonlyArray<{ id: string; label: string }> = [
];
const VoicePanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const dispatch = useDispatch();
// Issue #1762 — user-selected ElevenLabs voice id for the mascot's
@@ -228,9 +230,9 @@ const VoicePanel = () => {
activation_mode: settings.activation_mode,
skip_cleanup: settings.skip_cleanup,
});
setNotice('Voice server restarted with the new settings.');
setNotice(t('voice.serverRestarted'));
} else {
setNotice('Voice settings saved.');
setNotice(t('voice.settingsSaved'));
}
await loadData(true);
@@ -263,7 +265,7 @@ const VoicePanel = () => {
activation_mode: settings.activation_mode,
skip_cleanup: settings.skip_cleanup,
});
setNotice('Voice server started.');
setNotice(t('voice.serverStarted'));
await loadData(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start voice server';
@@ -279,7 +281,7 @@ const VoicePanel = () => {
setNotice(null);
try {
await openhumanVoiceServerStop();
setNotice('Voice server stopped.');
setNotice(t('voice.serverStopped'));
await loadData(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to stop voice server';
@@ -493,7 +495,7 @@ const VoicePanel = () => {
return (
<div>
<SettingsHeader
title="Voice"
title={t('voice.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -833,16 +835,14 @@ const VoicePanel = () => {
<section className={`space-y-3 ${disabled ? 'opacity-60' : ''}`}>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Voice Settings</h3>
<p className="text-xs text-stone-500 mt-1">
Hold the hotkey to dictate and insert text into the active field.
</p>
<h3 className="text-sm font-semibold text-stone-900">{t('voice.settings')}</h3>
<p className="text-xs text-stone-500 mt-1">{t('voice.settingsDesc')}</p>
</div>
{!disabled && settings && (
<>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Hotkey</span>
<span className="text-xs font-medium text-stone-600">{t('voice.hotkey')}</span>
<input
value={settings.hotkey}
onChange={e => updateSetting('hotkey', e.target.value)}
@@ -853,26 +853,30 @@ const VoicePanel = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Activation Mode</span>
<span className="text-xs font-medium text-stone-600">
{t('voice.activationMode')}
</span>
<select
value={settings.activation_mode}
onChange={e =>
updateSetting('activation_mode', e.target.value as 'tap' | 'push')
}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="push">Push to talk</option>
<option value="tap">Tap to toggle</option>
<option value="push">{t('voice.pushToTalk')}</option>
<option value="tap">{t('voice.tapToToggle')}</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Writing Style</span>
<span className="text-xs font-medium text-stone-600">
{t('voice.writingStyle')}
</span>
<select
value={settings.skip_cleanup ? 'verbatim' : 'natural'}
onChange={e => updateSetting('skip_cleanup', e.target.value === 'verbatim')}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
<option value="verbatim">Verbatim transcription</option>
<option value="natural">Natural cleanup</option>
<option value="verbatim">{t('voice.verbatimTranscription')}</option>
<option value="natural">{t('voice.naturalCleanup')}</option>
</select>
</label>
</div>
@@ -884,15 +888,15 @@ const VoicePanel = () => {
onChange={e => updateSetting('auto_start', e.target.checked)}
className="h-4 w-4 rounded border-stone-300 text-primary-600 focus:ring-primary-500"
/>
Start voice server automatically with the core
{t('voice.autoStart')}
</label>
<div className="space-y-2">
<div>
<span className="text-xs font-medium text-stone-600">Custom Dictionary</span>
<p className="text-[11px] text-stone-400">
Add names, technical terms, and domain words to improve recognition accuracy.
</p>
<span className="text-xs font-medium text-stone-600">
{t('voice.customDictionary')}
</span>
<p className="text-[11px] text-stone-400">{t('voice.customDictionaryDesc')}</p>
</div>
<div className="flex gap-2">
<input
@@ -911,7 +915,7 @@ const VoicePanel = () => {
setNewDictWord('');
}
}}
placeholder="Add a word..."
placeholder={t('voice.addWord')}
className="flex-1 rounded-md border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
@@ -925,7 +929,7 @@ const VoicePanel = () => {
}}
disabled={!newDictWord.trim()}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
Add
{t('common.add')}
</button>
</div>
{settings.custom_dictionary.length > 0 && (
@@ -978,21 +982,21 @@ const VoicePanel = () => {
onClick={() => void saveSettings(true)}
disabled={disabled || isSaving || !hasUnsavedChanges}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
{isSaving ? 'Saving' : 'Save Voice Settings'}
{isSaving ? t('common.loading') : t('voice.saveVoiceSettings')}
</button>
<button
type="button"
onClick={() => void startServer()}
disabled={disabled || isStarting}
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
{isStarting ? 'Starting' : 'Start Voice Server'}
{isStarting ? t('common.loading') : t('voice.startVoiceServer')}
</button>
<button
type="button"
onClick={() => void stopServer()}
disabled={!isRunning || isStopping}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 hover:border-stone-400 disabled:opacity-60 text-stone-700">
{isStopping ? 'Stopping' : 'Stop Voice Server'}
{isStopping ? t('common.loading') : t('voice.stopVoiceServer')}
</button>
</div>
</div>
@@ -1002,7 +1006,7 @@ const VoicePanel = () => {
type="button"
onClick={() => navigateToSettings('voice-debug')}
className="flex items-center gap-1.5 text-xs text-stone-400 hover:text-stone-600 transition-colors">
Advanced settings
{t('settings.advanced')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBackendUrl } from '../../../hooks/useBackendUrl';
import { useT } from '../../../lib/i18n/I18nContext';
import { tunnelsApi } from '../../../services/api/tunnelsApi';
import { getCoreHttpBaseUrl } from '../../../services/coreRpcClient';
import {
@@ -39,6 +40,7 @@ function prettyJson(value: unknown): string {
}
const WebhooksDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const backendUrl = useBackendUrl();
const [registrations, setRegistrations] = useState<WebhookDebugRegistration[]>([]);
@@ -140,7 +142,7 @@ const WebhooksDebugPanel = () => {
return (
<div>
<SettingsHeader
title="Webhooks Debug"
title={t('webhooks.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
@@ -129,7 +129,7 @@ describe('AutocompletePanel (simplified)', () => {
// Verify runtime status section shows
await waitFor(() => {
expect(screen.getByText('Running: no')).toBeInTheDocument();
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Change style preset and save
@@ -155,7 +155,7 @@ describe('AutocompletePanel (simplified)', () => {
// Wait for status to load
await waitFor(() => {
expect(screen.getByText('Running: no')).toBeInTheDocument();
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Start
@@ -188,7 +188,7 @@ describe('AutocompletePanel (simplified)', () => {
// Wait for config to load
await waitFor(() => {
expect(screen.getByText('Running: no')).toBeInTheDocument();
expect(screen.getByText('Running: No')).toBeInTheDocument();
});
// Toggle enabled off and save
@@ -74,7 +74,7 @@ describe('MascotPanel', () => {
it('invokes navigateBack from the header back button', () => {
renderPanel();
fireEvent.click(screen.getByLabelText('Go back'));
fireEvent.click(screen.getByLabelText('Back'));
expect(mockNavigateBack).toHaveBeenCalledTimes(1);
});
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
import { useAppSelector } from '../../store/hooks';
import { selectMascotColor } from '../../store/mascotSlice';
@@ -9,6 +10,7 @@ import { useHumanMascot } from './useHumanMascot';
const SPEAK_REPLIES_KEY = 'human.speakReplies';
const HumanPage = () => {
const { t } = useT();
const [speakReplies, setSpeakReplies] = useState<boolean>(() => {
const raw = window.localStorage.getItem(SPEAK_REPLIES_KEY);
return raw === null ? true : raw === '1';
@@ -47,7 +49,7 @@ const HumanPage = () => {
onChange={e => setSpeakReplies(e.target.checked)}
className="cursor-pointer"
/>
Speak replies
{t('voice.pushToTalk')}
</label>
{/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */}
+14 -12
View File
@@ -1,6 +1,7 @@
import debug from 'debug';
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { transcribeWithFactory } from './voice/sttClient';
import { encodeBlobToWav } from './voice/wavEncoder';
@@ -66,6 +67,7 @@ export function MicComposer({
language = 'en',
showDeviceSelector = false,
}: MicComposerProps) {
const { t } = useT();
const [state, setState] = useState<RecordingState>('idle');
const [devices, setDevices] = useState<AudioInputDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
@@ -223,7 +225,7 @@ export function MicComposer({
async function startRecording() {
if (state !== 'idle' || disabled || startInFlightRef.current) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
onError?.('Microphone access is not available in this runtime.');
onError?.(t('mic.unavailable'));
return;
}
startInFlightRef.current = true;
@@ -261,7 +263,7 @@ export function MicComposer({
composerLog('getUserMedia rejected: %s', msg);
if (err instanceof DOMException) {
if (err.name === 'NotAllowedError' || err.name === 'SecurityError') {
onError?.(`Microphone permission denied: ${msg}`);
onError?.(`${t('mic.permissionDenied')}: ${msg}`);
} else if (err.name === 'NotFoundError' || err.name === 'OverconstrainedError') {
onError?.('Selected microphone is unavailable — try a different device.');
} else if (err.name === 'NotReadableError') {
@@ -296,7 +298,7 @@ export function MicComposer({
stream.getTracks().forEach(t => t.stop());
startInFlightRef.current = false;
const msg = err instanceof Error ? err.message : String(err);
onError?.(`Failed to start recorder: ${msg}`);
onError?.(`${t('mic.failedToStartRecorder')}: ${msg}`);
return;
}
@@ -327,7 +329,7 @@ export function MicComposer({
// resets `state`, leaving the UI stuck on "Transcribing…". Recover here.
composerLog('recorder.stop threw: %s', err);
const msg = err instanceof Error ? err.message : String(err);
onError?.(`Failed to stop recording: ${msg}`);
onError?.(t('mic.failedToStopRecording').replace('{message}', msg));
stopStream();
recorderRef.current = null;
setState('idle');
@@ -356,14 +358,14 @@ export function MicComposer({
if (blob.size === 0) {
setState('idle');
onError?.('No audio captured. Try holding the mic a little longer.');
onError?.(t('mic.noAudioCaptured'));
return;
}
try {
const transcript = await transcribeWithFallback(blob);
if (!transcript) {
onError?.('No speech detected. Try again.');
onError?.(t('mic.noSpeechDetected'));
setState('idle');
return;
}
@@ -371,7 +373,7 @@ export function MicComposer({
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
composerLog('transcribe failed: %s', msg);
onError?.(`Voice transcription failed: ${msg}`);
onError?.(t('mic.transcriptionFailed').replace('{message}', msg));
} finally {
setState('idle');
}
@@ -421,12 +423,12 @@ export function MicComposer({
const buttonDisabled = disabled || isBusy;
const label = isBusy
? 'Transcribing'
? t('mic.transcribing')
: isRecording
? 'Tap to send'
? t('mic.tapToSend')
: disabled
? 'Waiting for the agent'
: 'Tap and speak';
? t('mic.waitingForAgent')
: t('mic.tapAndSpeak');
return (
<div className="flex flex-col items-center gap-2">
@@ -447,7 +449,7 @@ export function MicComposer({
<div className="flex items-center justify-center gap-3">
<button
type="button"
aria-label={isRecording ? 'Stop recording and send' : 'Start recording'}
aria-label={isRecording ? t('mic.stopRecording') : t('mic.startRecording')}
onClick={() => (isRecording ? stopRecording() : void startRecording())}
disabled={buttonDisabled}
className={`relative w-14 h-14 flex items-center justify-center rounded-full text-white shadow-soft transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import Button from '../../components/ui/Button';
import { useT } from '../../lib/i18n/I18nContext';
import { WHAT_LEAVES_HEADLINE, WHAT_LEAVES_ITEMS, WHAT_LEAVES_SUBHEAD } from './whatLeavesItems';
export interface WhatLeavesMyComputerSheetProps {
@@ -10,6 +11,7 @@ export interface WhatLeavesMyComputerSheetProps {
}
const WhatLeavesMyComputerSheet = ({ open, onClose }: WhatLeavesMyComputerSheetProps) => {
const { t } = useT();
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -30,7 +32,7 @@ const WhatLeavesMyComputerSheet = ({ open, onClose }: WhatLeavesMyComputerSheetP
role="presentation">
<button
type="button"
aria-label="Close"
aria-label={t('common.close')}
onClick={onClose}
className="absolute inset-0 bg-neutral-900/40 backdrop-blur-sm cursor-default"
/>
@@ -62,11 +64,9 @@ const WhatLeavesMyComputerSheet = ({ open, onClose }: WhatLeavesMyComputerSheetP
</ul>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-neutral-500">
Full controls live in Settings Privacy & Security.
</p>
<p className="text-xs text-neutral-500">{t('privacy.description')}</p>
<Button variant="primary" size="md" onClick={onClose}>
Got it
{t('common.ok')}
</Button>
</div>
</div>
+61
View File
@@ -0,0 +1,61 @@
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react';
import { useAppSelector } from '../../store/hooks';
import en from './en';
import type { Locale } from './types';
import zhCN from './zh-CN';
interface I18nContextValue {
t: (key: string) => string;
locale: Locale;
}
const translations: Record<Locale, Record<string, string>> = { en, 'zh-CN': zhCN };
// Resolve the effective English map at call time. `en` may be wrapped in
// `{ default: { ... } }` by CJS/ESM interop in test runners, or tree-shaken
// to an empty object. We check at each call to handle lazy module resolution.
function resolveEn(): Record<string, string> {
const raw: Record<string, unknown> = en as unknown as Record<string, unknown>;
// CJS interop: `import en from './en'` → `{ default: { key: value } }`
const unwrapped =
raw != null && typeof raw === 'object' && 'default' in raw && typeof raw.default === 'object'
? (raw.default as Record<string, string>)
: (raw as unknown as Record<string, string>);
// If `en` resolved to more keys than `translations.en` (which might be
// the same reference), prefer the richer one.
if (Object.keys(unwrapped).length > 0) return unwrapped;
if (Object.keys(translations.en).length > 0) return translations.en;
return {};
}
const I18nContext = createContext<I18nContextValue>({
t: (key: string) => {
const map = resolveEn();
return map[key] ?? key;
},
locale: 'en',
});
export function I18nProvider({ children }: { children: ReactNode }) {
const locale = useAppSelector(state => state.locale.current);
const t = useCallback(
(key: string): string => {
const map = translations[locale] ?? resolveEn();
return map[key] ?? resolveEn()[key] ?? key;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale]
);
const value = useMemo(() => ({ t, locale }), [t, locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useT(): I18nContextValue {
return useContext(I18nContext);
}
export { type Locale } from './types';
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export type Locale = 'en' | 'zh-CN';
export interface TranslationMap {
[key: string]: string;
}
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -4,11 +4,12 @@ import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
// import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
import WebviewHost from '../components/accounts/WebviewHost';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// import { isWelcomeLocked } from '../lib/coreState/store';
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// import { useCoreState } from '../providers/CoreStateProvider';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
import {
hideWebviewAccount,
@@ -99,6 +100,7 @@ interface ContextMenuState {
}
const Accounts = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const accountsById = useAppSelector(state => state.accounts.accounts);
const order = useAppSelector(state => state.accounts.order);
@@ -241,7 +243,7 @@ const Accounts = () => {
{/* Narrow icon rail — always rendered. */}
{/* [#1123] welcomeLocked guard removed — welcome-agent onboarding replaced by Joyride walkthrough */}
<aside className="z-30 flex w-16 flex-none flex-col items-center gap-2 bg-white/60 py-3 backdrop-blur-md my-3 ml-3 rounded-2xl border border-stone-200/70 shadow-soft">
<RailButton active={isAgentSelected} onClick={selectAgent} tooltip="Agent">
<RailButton active={isAgentSelected} onClick={selectAgent} tooltip={t('accounts.agent')}>
<AgentIcon className="h-9 w-9 rounded-lg" />
</RailButton>
@@ -260,14 +262,14 @@ const Accounts = () => {
<button
onClick={() => setAddOpen(true)}
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:z-50 hover:bg-stone-50 hover:text-stone-600"
aria-label="Add app">
aria-label={t('accounts.addAccount')}>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{/* Issue #1284 — see RailButton for why the tooltip sits below
the icon instead of to the right. */}
<span className="pointer-events-none absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
Add app
{t('accounts.addAccount')}
</span>
</button>
</aside>
@@ -297,7 +299,7 @@ const Accounts = () => {
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-stone-400">
Select or add an app to get started.
{t('accounts.noAccounts')}
</div>
)}
</main>
@@ -325,7 +327,7 @@ const Accounts = () => {
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
{t('accounts.disconnect')}
</button>
</div>
)}
+3 -1
View File
@@ -3,9 +3,11 @@ import { useState } from 'react';
import ChannelConfigPanel from '../components/channels/ChannelConfigPanel';
import ChannelSelector from '../components/channels/ChannelSelector';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useT } from '../lib/i18n/I18nContext';
import type { ChannelType } from '../types/channels';
const Channels = () => {
const { t } = useT();
const { definitions, loading, error } = useChannelDefinitions();
const [selectedChannel, setSelectedChannel] = useState<ChannelType>('telegram');
@@ -20,7 +22,7 @@ const Channels = () => {
{loading ? (
<div className="rounded-xl border border-stone-200 bg-white p-6 text-sm text-stone-400">
Loading channel definitions...
{t('common.loading')}
</div>
) : (
<>
+70 -56
View File
@@ -15,6 +15,7 @@ import MicComposer from '../features/human/MicComposer';
// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat';
import { useStickToBottom } from '../hooks/useStickToBottom';
import { useUsageState } from '../hooks/useUsageState';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
// [#1123] getCoreStateSnapshot and isWelcomeLocked commented out — welcome-agent onboarding replaced by Joyride walkthrough
// import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store';
@@ -175,6 +176,7 @@ export function formatThreadLoadError(err: unknown): string {
// }
const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsProps = {}) => {
const { t } = useT();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const {
@@ -420,12 +422,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
sendingThreadIdRef.current = threadId;
sendingTimeoutRef.current = setTimeout(() => {
console.warn('[chat] silence timeout: no inference signal for 120s');
setSendError(
chatSendError(
'safety_timeout',
'No response from the agent after 2 minutes. Try again or check your connection.'
)
);
setSendError(chatSendError('safety_timeout', t('chat.safetyTimeout')));
dispatch(clearRuntimeForThread({ threadId }));
dispatch(setActiveThread(null));
sendingTimeoutRef.current = null;
@@ -997,10 +994,10 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
// Fixed tab set so categories don't disappear when empty and the active
// filter state remains unambiguous regardless of what threads exist.
const labelTabs = [
{ label: 'All', value: 'all' },
{ label: 'Work', value: 'work' },
{ label: 'Briefing', value: 'briefing' },
{ label: 'Notification', value: 'notification' },
{ label: t('chat.filter.all'), value: 'all' },
{ label: t('chat.filter.work'), value: 'work' },
{ label: t('chat.filter.briefing'), value: 'briefing' },
{ label: t('chat.filter.notification'), value: 'notification' },
];
const isSidebar = variant === 'sidebar';
@@ -1014,8 +1011,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
// Stable title resolver used by both the sidebar thread list and the header.
// [#1123] welcome-lock title override removed — Joyride walkthrough replaced welcome-agent
const resolveThreadDisplayTitle = (threadId: string | null): string => {
if (!threadId) return 'Select a thread';
const t = threads.find(thr => thr.id === threadId);
if (!threadId) return t('chat.selectThread');
const thr = threads.find(th => th.id === threadId);
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// if (
// welcomeLocked &&
@@ -1024,7 +1021,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
// ) {
// return 'Onboarding';
// }
return t?.title ?? 'Select a thread';
return thr?.title ?? t('chat.selectThread');
};
return (
@@ -1041,12 +1038,12 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
{!isSidebar && effectiveShowSidebar && (
<div className="w-64 flex-shrink-0 flex flex-col bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-100">
<h2 className="text-sm font-semibold text-stone-700">Threads</h2>
<h2 className="text-sm font-semibold text-stone-700">{t('chat.threads')}</h2>
{/* [#1123] welcomeLocked guard removed — always show new thread button */}
<button
onClick={() => void handleCreateNewThread()}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title="New thread">
title={t('chat.newThread')}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -1069,7 +1066,9 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<div className="flex-1 overflow-y-auto">
{sortedThreads.length === 0 ? (
<p className="px-4 py-6 text-xs text-stone-400 text-center">
{selectedLabel === 'all' ? 'No threads yet' : `No "${selectedLabel}" threads`}
{selectedLabel === 'all'
? t('chat.noThreads')
: t('chat.noLabelThreads').replace('{label}', selectedLabel)}
</p>
) : (
sortedThreads.map(thread => (
@@ -1109,10 +1108,13 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
e.stopPropagation();
setDeleteModal({
isOpen: true,
title: 'Delete thread',
message: `Are you sure you want to delete "${thread.title || 'Untitled thread'}"? This cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
title: t('chat.deleteThread'),
message: t('chat.deleteThreadConfirm').replace(
'{title}',
thread.title || t('chat.untitledThread')
),
confirmText: t('common.delete'),
cancelText: t('common.cancel'),
destructive: true,
onConfirm: () => {
void dispatch(deleteThread(thread.id));
@@ -1121,7 +1123,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
});
}}
className="ml-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-stone-200 text-stone-400 hover:text-coral-500 transition-all flex-shrink-0"
title="Delete thread">
title={t('chat.deleteThread')}>
<svg
className="w-3 h-3"
fill="none"
@@ -1171,7 +1173,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<button
onClick={() => setShowSidebar(prev => !prev)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title={effectiveShowSidebar ? 'Hide sidebar' : 'Show sidebar'}>
title={effectiveShowSidebar ? t('chat.hideSidebar') : t('chat.showSidebar')}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -1190,8 +1192,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<button
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
title="New thread (/new)">
+ New
title={t('chat.newThreadShortcut')}>
{t('chat.new')}
</button>
</>
</div>
@@ -1223,12 +1225,12 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-sm text-stone-400 mb-1">Failed to load messages</p>
<p className="text-sm text-stone-400 mb-1">{t('chat.failedToLoadMessages')}</p>
<p className="text-xs text-stone-600 mb-3 text-center">{messagesError}</p>
<button
onClick={() => window.location.reload()}
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
Reload
{t('common.reload')}
</button>
</div>
) : hasVisibleMessages ? (
@@ -1298,7 +1300,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<button
onClick={() => handleCopyMessage(msg.id, msg.content)}
className={`absolute -top-1 ${msg.sender === 'user' ? '-left-8' : '-right-8'} p-1 rounded-md opacity-0 group-hover/msg:opacity-100 hover:bg-stone-100 text-stone-400 hover:text-stone-600 transition-all`}
title="Copy message">
title={t('chat.copyResponse')}>
{copiedMessageId === msg.id ? (
<svg
className="w-3.5 h-3.5 text-sage-500"
@@ -1430,7 +1432,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<details className="mb-1.5 bg-stone-100 rounded-lg px-3 py-1.5 text-xs text-stone-600 open:bg-stone-100">
<summary className="cursor-pointer select-none flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary-400 animate-pulse" />
<span>Thinking</span>
<span>{t('chat.thinking')}</span>
</summary>
<pre className="whitespace-pre-wrap break-words mt-1.5 font-sans text-[11px] text-stone-500">
{selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
@@ -1457,8 +1459,11 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<span>
{selectedInferenceStatus.phase === 'thinking' &&
(selectedInferenceStatus.iteration > 0
? `Thinking (iteration ${selectedInferenceStatus.iteration})...`
: 'Thinking...')}
? t('chat.thinkingIteration').replace(
'{n}',
String(selectedInferenceStatus.iteration)
)
: t('chat.thinkingDots'))}
{selectedInferenceStatus.phase === 'tool_use' &&
`${
formatTimelineEntry(
@@ -1496,7 +1501,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
if (selectedThreadId) void chatCancel(selectedThreadId);
}}
className="text-xs text-stone-500 hover:text-stone-700 transition-colors">
Cancel
{t('common.cancel')}
</button>
</div>
)}
@@ -1518,7 +1523,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
// <WelcomeThinkingTypewriter />
// </div>
<div className="flex-1 flex items-center justify-center h-full">
<p className="text-sm text-stone-600">No messages yet</p>
<p className="text-sm text-stone-600">{t('chat.noMessages')}</p>
</div>
)}
</div>
@@ -1533,9 +1538,12 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<div className="mb-3">
<UpsellBanner
variant="warning"
title="Approaching usage limit"
message={`You've used ${Math.round(Math.max(usagePct10h, usagePct7d) * 100)}% of your inference budget. Upgrade for higher limits.`}
ctaLabel="Upgrade"
title={t('chat.approachingLimit')}
message={t('chat.approachingLimitMsg').replace(
'{pct}',
String(Math.round(Math.max(usagePct10h, usagePct7d) * 100))
)}
ctaLabel={t('chat.upgrade')}
onCtaClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
@@ -1562,9 +1570,9 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<p className="text-xs text-coral-600 truncate">
{shouldShowBudgetCompletedMessage
? teamUsage.cycleBudgetUsd > 0
? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
: 'Your included budget is complete. Add credits or upgrade to continue.'
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
? `${t('chat.weeklyLimitHit')}${teamUsage.cycleEndsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} ${t('chat.topUpToContinue')}`
: t('chat.budgetComplete')
: `${t('chat.rateLimitReached')}${teamUsage.fiveHourResetsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
</p>
</div>
{shouldShowBudgetCompletedMessage && (
@@ -1573,7 +1581,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
void openUrl(BILLING_DASHBOARD_URL);
}}
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
Top Up
{t('chat.topUp')}
</button>
)}
</div>
@@ -1610,33 +1618,35 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
/>
</div>
) : (
<span className="text-[10px] text-stone-400 animate-pulse">loading</span>
<span className="text-[10px] text-stone-400 animate-pulse">
{t('common.loading')}
</span>
)}
{teamUsage && (
<div className="absolute bottom-full right-0 mb-2 hidden group-hover:block z-50">
<div className="bg-stone-900 text-white text-[10px] rounded-lg px-3 py-2 shadow-lg whitespace-nowrap space-y-1.5">
{!teamUsage.bypassCycleLimit && (
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">5-hour limit</span>
<span className="text-stone-400">{t('chat.fiveHourLimit')}</span>
<span>
${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
{teamUsage.fiveHourResetsAt && (
<span className="text-stone-400 ml-1">
resets {formatResetTime(teamUsage.fiveHourResetsAt)}
{t('chat.resets')} {formatResetTime(teamUsage.fiveHourResetsAt)}
</span>
)}
</span>
</div>
)}
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">Weekly limit</span>
<span className="text-stone-400">{t('chat.weeklyLimit')}</span>
<span>
${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} {t('chat.left')}
{teamUsage.cycleEndsAt && (
<span className="text-stone-400 ml-1">
resets {formatResetTime(teamUsage.cycleEndsAt)}
{t('chat.resets')} {formatResetTime(teamUsage.cycleEndsAt)}
</span>
)}
</span>
@@ -1657,7 +1667,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
<button
onClick={() => setSendAdvisory(null)}
className="text-xs text-stone-500 hover:text-stone-700 transition-colors ml-2">
Dismiss
{t('common.dismiss')}
</button>
</div>
)}
@@ -1681,13 +1691,13 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
navigate('/settings/voice');
}}
className="text-xs text-primary-500 hover:text-primary-600 font-medium transition-colors">
Set up
{t('chat.setup')}
</button>
)}
<button
onClick={() => setSendError(null)}
className="text-xs text-stone-500 hover:text-stone-700 transition-colors">
Dismiss
{t('common.dismiss')}
</button>
</div>
</div>
@@ -1723,7 +1733,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
isComposingTextRef.current = false;
}}
onKeyDown={handleInputKeyDown}
placeholder="Type a message..."
placeholder={t('chat.typeMessage')}
rows={1}
disabled={composerInteractionBlocked}
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 placeholder:text-stone-400 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
@@ -1731,8 +1741,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
</div>
<button
aria-label="Send message"
title="Send message"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void handleSendMessage();
}}
@@ -1773,7 +1783,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
onClick={() => setInputMode('text')}
disabled={isRecording || isTranscribing}
className="w-10 h-10 flex items-center justify-center rounded-full border border-stone-200 bg-white text-stone-500 hover:text-stone-700 hover:border-stone-300 transition-colors disabled:opacity-40"
title="Switch to text input">
title={t('chat.switchToText')}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -1794,15 +1804,19 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
? 'bg-coral-500 hover:bg-coral-400 text-white'
: 'bg-primary-600 hover:bg-primary-500 text-white'
} disabled:opacity-40 disabled:cursor-not-allowed`}>
{isTranscribing ? 'Transcribing…' : isRecording ? 'Stop & Send' : 'Start Talking'}
{isTranscribing
? t('chat.transcribing')
: isRecording
? t('chat.stopAndSend')
: t('chat.startTalking')}
</button>
<p className="text-xs text-stone-400 truncate">
{voiceStatus ??
(isPlayingReply && replyMode === 'voice'
? 'Playing voice reply'
? t('chat.playingVoiceReply')
: canUseMicrophoneApi
? 'Click "Start Talking" to speak to the agent.'
: 'Microphone input is not available in this runtime.')}
? t('chat.voiceHint')
: t('chat.micUnavailable'))}
</p>
</div>
)}
+8 -8
View File
@@ -11,6 +11,7 @@ import {
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
import { useUsageState } from '../hooks/useUsageState';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
import { restartCoreProcess } from '../services/coreProcessControl';
import { selectBlockingState } from '../store/connectivitySelectors';
import { useAppSelector } from '../store/hooks';
@@ -40,6 +41,7 @@ export function resolveHomeUserName(user: unknown): string {
}
const Home = () => {
const { t } = useT();
const { user } = useUser();
const navigate = useNavigate();
const { isRateLimited, shouldShowBudgetCompletedMessage } = useUsageState();
@@ -87,12 +89,10 @@ const Home = () => {
};
const statusCopy = {
ok: 'Your device is connected. Keep the app running to keep the connection alive. Message your agent with the button below.',
'backend-only': 'Reconnecting to backend… your agent will be available again shortly.',
'core-unreachable':
"Local core sidecar isn't responding. The OpenHuman background process may have crashed or failed to start.",
'internet-offline':
'Your device is offline right now. Check your network or restart the app to reconnect.',
ok: t('home.statusOk'),
'backend-only': t('home.statusBackendOnly'),
'core-unreachable': t('home.statusCoreUnreachable'),
'internet-offline': t('home.statusInternetOffline'),
}[blocking];
// Open in-app chat.
@@ -197,7 +197,7 @@ const Home = () => {
onClick={handleRestartCore}
disabled={isRestartingCore}
className="w-full py-3 bg-amber-500 hover:bg-amber-600 disabled:opacity-50 text-white font-medium rounded-xl transition-colors duration-200">
{isRestartingCore ? 'Restarting core' : 'Restart Core'}
{isRestartingCore ? t('home.restartingCore') : t('home.restartCore')}
</button>
{restartError && (
<p className="mt-2 text-xs text-coral-500 text-center">{restartError}</p>
@@ -211,7 +211,7 @@ const Home = () => {
onClick={handleStartCooking}
disabled={blocking === 'core-unreachable' || blocking === 'internet-offline'}
className="w-full py-3 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors duration-200">
Message OpenHuman
{t('home.askAssistant')}
</button>
</div>
+24 -17
View File
@@ -15,6 +15,7 @@ import {
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import { useMemoryIngestionStatus } from '../hooks/useMemoryIngestionStatus';
import { useSubconscious } from '../hooks/useSubconscious';
import { useT } from '../lib/i18n/I18nContext';
import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
@@ -23,6 +24,7 @@ import type {
type IntelligenceTab = 'memory' | 'subconscious' | 'calls' | 'dreams';
export default function Intelligence() {
const { t } = useT();
const { aiStatus } = useIntelligenceStats();
const { status: ingestionStatus } = useMemoryIngestionStatus();
@@ -103,18 +105,18 @@ export default function Intelligence() {
: aiStatus;
const systemStatusLabel = isRunning
? 'Analyzing'
? t('common.loading')
: systemStatus === 'ready'
? 'System Ready'
? t('common.success')
: systemStatus === 'loading'
? 'Loading'
? t('common.loading')
: systemStatus === 'disconnected'
? 'Connecting'
? t('welcome.connecting')
: systemStatus === 'initializing'
? 'Initializing'
? t('welcome.connecting')
: systemStatus === 'error'
? 'System Error'
: 'System Idle';
? t('common.error')
: t('misc.rehydrating');
const systemStatusDot =
isRunning || systemStatus === 'loading'
@@ -128,10 +130,10 @@ export default function Intelligence() {
: 'bg-stone-600';
const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [
{ id: 'memory', label: 'Memory' },
{ id: 'subconscious', label: 'Subconscious' },
{ id: 'calls', label: 'Calls' },
{ id: 'dreams', label: 'Dreams', comingSoon: true },
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{ id: 'calls', label: t('memory.tab.calls') },
{ id: 'dreams', label: t('memory.tab.dreams'), comingSoon: true },
];
return (
@@ -154,7 +156,7 @@ export default function Intelligence() {
? 'border-white/30 bg-white/15 text-white'
: 'border-stone-200 bg-stone-50 text-stone-500'
}`}>
Soon
{t('misc.beta')}
</span>
)}
</span>
@@ -170,7 +172,7 @@ export default function Intelligence() {
<h1
className="text-xl font-bold text-stone-900"
data-walkthrough="intelligence-header">
Intelligence
{t('memory.title')}
</h1>
{/* Header count badge was sourced from `stats.total` which
in turn came from the legacy actionable-items pipeline
@@ -195,13 +197,18 @@ export default function Intelligence() {
title={
ingestionStatus.running
? ingestionStatus.currentTitle
? `Ingesting: ${ingestionStatus.currentTitle}`
: 'Memory ingestion running'
: 'Memory ingestion queued'
? t('memory.ingestingTitle').replace(
'{title}',
ingestionStatus.currentTitle
)
: t('memory.ingesting')
: t('memory.ingestionQueued')
}>
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
<span className="text-[11px] font-medium">
{ingestionStatus.running ? 'Ingesting' : 'Queued'}
{ingestionStatus.running
? t('memory.ingesting')
: t('memory.ingestionQueued')}
{ingestionStatus.queueDepth > 0 && ` · ${ingestionStatus.queueDepth}`}
</span>
</div>
+19 -18
View File
@@ -2,6 +2,7 @@ import debugFactory from 'debug';
import { useEffect, useRef, useState } from 'react';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
import { inviteApi } from '../services/api/inviteApi';
import type { InviteCode } from '../types/invite';
@@ -10,6 +11,7 @@ const log = debugFactory('invites');
type RedeemStatus = 'idle' | 'loading' | 'success' | 'error';
const CodeRow = ({ invite }: { invite: InviteCode }) => {
const { t } = useT();
const [copied, setCopied] = useState(false);
const claimed = invite.currentUses >= invite.maxUses;
const claimedUser = invite.usageHistory[0]?.userId;
@@ -28,22 +30,26 @@ const CodeRow = ({ invite }: { invite: InviteCode }) => {
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors">
<div className="flex-1 min-w-0">
<span className="font-mono text-sm tracking-wider">{invite.code}</span>
{claimed && <p className="text-xs text-stone-500 mt-0.5">Claimed by {displayName}</p>}
{claimed && (
<p className="text-xs text-stone-500 mt-0.5">
{t('rewards.credits')} {displayName}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-3">
{claimed ? (
<span className="text-xs px-2 py-1 rounded-full bg-stone-700/50 text-stone-400">
Used
{t('common.disabled')}
</span>
) : (
<span className="text-xs px-2 py-1 rounded-full bg-sage-500/20 text-sage-500">
Available
{t('common.enabled')}
</span>
)}
<button
onClick={handleCopy}
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200"
title="Copy code">
title={t('common.copy')}>
{copied ? (
<svg
className="w-4 h-4 text-sage-500"
@@ -74,6 +80,7 @@ const CodeRow = ({ invite }: { invite: InviteCode }) => {
};
const Invites = () => {
const { t } = useT();
const { user, refetch: refetchUser } = useUser();
const [codes, setCodes] = useState<InviteCode[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -154,17 +161,15 @@ const Invites = () => {
{/* Redeem Section — shown only if user hasn't redeemed yet */}
{!hasBeenInvited && (
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
<h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2>
<p className="text-xs opacity-70 mb-4">
Got a code from a friend? Enter it below to unlock free credits.
</p>
<h2 className="text-lg font-bold mb-1">{t('rewards.referralCode')}</h2>
<p className="text-xs opacity-70 mb-4">{t('rewards.share')}</p>
<div className="flex gap-2">
<input
type="text"
value={redeemInput}
onChange={e => setRedeemInput(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
placeholder="Enter code"
placeholder={t('common.search')}
className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={redeemStatus === 'loading'}
/>
@@ -172,11 +177,11 @@ const Invites = () => {
onClick={handleRedeem}
disabled={redeemStatus === 'loading' || !redeemInput.trim()}
className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
{redeemStatus === 'loading' ? '...' : 'Redeem'}
{redeemStatus === 'loading' ? '...' : t('rewards.referrals')}
</button>
</div>
{redeemStatus === 'success' && (
<p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p>
<p className="text-sage-500 text-xs mt-2">{t('common.success')}</p>
)}
{redeemStatus === 'error' && redeemError && (
<p className="text-coral-500 text-xs mt-2">{redeemError}</p>
@@ -187,10 +192,8 @@ const Invites = () => {
{/* Your Invite Codes */}
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
<div className="mb-4">
<h2 className="text-lg font-bold mb-1">Your Invite Codes</h2>
<p className="text-xs opacity-70">
Share these codes with friends. Each code can be used once.
</p>
<h2 className="text-lg font-bold mb-1">{t('rewards.referralCode')}</h2>
<p className="text-xs opacity-70">{t('rewards.share')}</p>
</div>
{loadError && <p className="text-coral-500 text-xs text-center py-2">{loadError}</p>}
@@ -208,9 +211,7 @@ const Invites = () => {
))}
</div>
) : (
<p className="text-sm text-stone-500 text-center py-6">
No invite codes available yet.
</p>
<p className="text-sm text-stone-500 text-center py-6">{t('invites.noInvites')}</p>
)}
</div>
</div>
+5 -7
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import LottieAnimation from '../components/LottieAnimation';
import { persistLocalWalletFromMnemonic } from '../features/wallet/setupLocalWalletFromMnemonic';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import {
generateMnemonicPhrase,
@@ -16,6 +17,7 @@ const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const Mnemonic = () => {
const { t } = useT();
const navigate = useNavigate();
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
@@ -254,7 +256,7 @@ const Mnemonic = () => {
<button
onClick={() => switchMode('import')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
I already have a recovery phrase
{t('mnemonic.alreadyHavePhrase')}
</button>
{/* Confirmation Checkbox */}
@@ -274,11 +276,7 @@ const Mnemonic = () => {
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Enter your recovery phrase below to restore your local encryption key and wallet
identities, or paste the full phrase into any field (12 words for new backups;
24-word phrases from older versions still work).
</p>
<p className="opacity-70 text-sm">{t('mnemonic.copyWarning')}</p>
</div>
{/* Import Word Inputs Grid */}
@@ -331,7 +329,7 @@ const Mnemonic = () => {
<button
onClick={() => switchMode('generate')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
Generate a new recovery phrase instead
{t('mnemonic.generateNewPhrase')}
</button>
</>
)}
+34 -23
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import NotificationCenter from '../components/notifications/NotificationCenter';
import { useT } from '../lib/i18n/I18nContext';
import { resolveSystemRoute } from '../lib/notificationRouter';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
@@ -13,33 +14,43 @@ import {
selectUnreadCount,
} from '../store/notificationSlice';
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
messages: 'Messages',
agents: 'Agents',
skills: 'Skills',
system: 'System',
meetings: 'Meetings',
reminders: 'Reminders',
important: 'Important',
};
function formatTime(ts: number): string {
function formatTime(ts: number, t: (key: string) => string): string {
const delta = Date.now() - ts;
const min = Math.floor(delta / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min}m ago`;
if (min < 1) return t('notifications.justNow');
if (min < 60) return t('notifications.minAgo').replace('{n}', String(min));
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
if (hr < 24) return t('notifications.hrAgo').replace('{n}', String(hr));
const d = Math.floor(hr / 24);
return `${d}d ago`;
return t('notifications.dayAgo').replace('{n}', String(d));
}
const Notifications = () => {
const { t } = useT();
const items = useAppSelector(s => s.notifications.items);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const unread = useMemo(() => selectUnreadCount(items), [items]);
const categoryLabel = (category: NotificationCategory): string => {
switch (category) {
case 'messages':
return t('notifications.category.messages');
case 'agents':
return t('notifications.category.agents');
case 'skills':
return t('notifications.category.skills');
case 'system':
return t('notifications.category.system');
case 'meetings':
return t('notifications.category.meetings');
case 'reminders':
return t('notifications.category.reminders');
case 'important':
return t('notifications.category.important');
}
};
const handleClick = (item: NotificationItem) => {
if (!item.read) dispatch(markRead({ id: item.id }));
navigate(resolveSystemRoute(item));
@@ -60,9 +71,9 @@ const Notifications = () => {
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<div>
<h1 className="text-lg font-semibold text-stone-900">System Events</h1>
<h1 className="text-lg font-semibold text-stone-900">{t('alerts.title')}</h1>
<p className="text-xs text-stone-500">
{unread > 0 ? `${unread} unread` : 'All caught up'}
{unread > 0 ? `${unread} ${t('alerts.unread')}` : t('alerts.empty')}
</p>
</div>
<div className="flex items-center gap-2">
@@ -71,20 +82,20 @@ const Notifications = () => {
onClick={() => dispatch(markAllRead())}
disabled={unread === 0}
className="text-xs font-medium text-stone-600 hover:text-stone-900 disabled:opacity-40 disabled:cursor-not-allowed">
Mark all read
{t('alerts.markAllRead')}
</button>
<button
type="button"
onClick={() => dispatch(clearAll())}
disabled={items.length === 0}
className="text-xs font-medium text-stone-600 hover:text-stone-900 disabled:opacity-40 disabled:cursor-not-allowed">
Clear
{t('common.clear')}
</button>
</div>
</div>
{items.length === 0 ? (
<div className="px-6 py-16 text-center text-sm text-stone-500">No notifications yet.</div>
<div className="px-6 py-16 text-center text-sm text-stone-500">{t('alerts.empty')}</div>
) : (
<ul className="divide-y divide-stone-100">
{items.map(item => (
@@ -101,11 +112,11 @@ const Notifications = () => {
{!item.read && (
<span
className="w-2 h-2 rounded-full bg-primary-500"
aria-label="unread"
aria-label={t('alerts.unread')}
/>
)}
<span className="text-xs uppercase tracking-wide text-stone-400">
{CATEGORY_LABEL[item.category]}
{categoryLabel(item.category)}
</span>
</div>
<p className="mt-1 text-sm font-semibold text-stone-900 truncate">
@@ -114,7 +125,7 @@ const Notifications = () => {
<p className="mt-0.5 text-sm text-stone-600 line-clamp-2">{item.body}</p>
</div>
<span className="text-[11px] text-stone-400 whitespace-nowrap">
{formatTime(item.timestamp)}
{formatTime(item.timestamp, t)}
</span>
</div>
</button>
+6 -4
View File
@@ -4,6 +4,7 @@ import PillTabBar from '../components/PillTabBar';
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab';
import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab';
import { useT } from '../lib/i18n/I18nContext';
import { rewardsApi } from '../services/api/rewardsApi';
import type { RewardsSnapshot } from '../types/rewards';
@@ -16,10 +17,11 @@ function errorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return 'Unable to load rewards';
return 'Unable to load rewards'; // fallback — translated at call site
}
const Rewards = () => {
const { t } = useT();
const [selectedTab, setSelectedTab] = useState<RewardsTab>('rewards');
const [snapshot, setSnapshot] = useState<RewardsSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -73,9 +75,9 @@ const Rewards = () => {
<div className="mx-auto max-w-2xl space-y-4">
<PillTabBar
items={[
{ label: 'Referrals', value: 'referrals' },
{ label: 'Rewards', value: 'rewards' },
{ label: 'Redeem', value: 'redeem' },
{ label: t('rewards.referrals'), value: 'referrals' },
{ label: t('rewards.title'), value: 'rewards' },
{ label: t('rewards.coupons'), value: 'redeem' },
]}
selected={selectedTab}
onChange={handleTabChange}
+40 -34
View File
@@ -33,6 +33,7 @@ import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useComposioIntegrations } from '../lib/composio/hooks';
import { canonicalizeComposioToolkitSlug } from '../lib/composio/toolkitSlug';
import { type ComposioConnection, deriveComposioState } from '../lib/composio/types';
import { useT } from '../lib/i18n/I18nContext';
import { skillsApi, type SkillSummary } from '../services/api/skillsApi';
import { useAppSelector } from '../store/hooks';
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
@@ -40,16 +41,16 @@ import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { subconsciousEscalationsDismiss } from '../utils/tauriCommands';
function channelStatusLabel(status: ChannelConnectionStatus): string {
function channelStatusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
switch (status) {
case 'connected':
return 'Connected';
return t('skills.connected');
case 'connecting':
return 'Connecting';
return t('channels.status.connecting');
case 'error':
return 'Error';
return t('common.error');
default:
return 'Not configured';
return t('channels.status.notConfigured');
}
}
@@ -70,14 +71,17 @@ function channelStatusColor(status: ChannelConnectionStatus): string {
// Reuse the same dot/label/color vocabulary as the channel cards so the
// "Integrations" section sits visually flush with the rest of the grid.
function composioStatusLabel(connection: ComposioConnection | undefined): string {
function composioStatusLabel(
connection: ComposioConnection | undefined,
t: (key: string) => string
): string {
switch (deriveComposioState(connection)) {
case 'connected':
return 'Connected';
return t('skills.connected');
case 'pending':
return 'Connecting';
return t('channels.status.connecting');
case 'error':
return 'Error';
return t('common.error');
default:
return '';
}
@@ -125,17 +129,20 @@ function ComposioConnectorTile({
onOpen,
onRetryGlobal,
}: ComposioConnectorTileProps) {
const { t } = useT();
const state = hasComposioError ? 'error' : deriveComposioState(connection);
const statusLabel = hasComposioError ? 'Status unavailable' : composioStatusLabel(connection);
const statusLabel = hasComposioError
? t('composio.statusUnavailable')
: composioStatusLabel(connection, t);
const ctaLabel = hasComposioError
? 'Retry'
? t('common.retry')
: state === 'connected'
? 'Manage'
? t('skills.configure')
: state === 'pending'
? 'Waiting'
? t('skills.connect')
: state === 'error'
? 'Retry'
: 'Connect';
? t('common.retry')
: t('skills.connect');
const isConnected = state === 'connected';
const isPending = state === 'pending';
@@ -190,11 +197,12 @@ interface ChannelTileProps {
}
function ChannelTile({ def, status, icon, onOpen }: ChannelTileProps) {
const { t } = useT();
const isConnected = status === 'connected';
const isPending = status === 'connecting';
const isError = status === 'error';
const statusLabel = channelStatusLabel(status);
const ctaLabel = isConnected ? 'Manage' : 'Setup';
const statusLabel = channelStatusLabel(status, t);
const ctaLabel = isConnected ? t('skills.configure') : t('channels.setup');
return (
<button
@@ -268,6 +276,7 @@ interface SkillItem {
// ─── Main Skills Page ──────────────────────────────────────────────────────────
export default function Skills() {
const { t } = useT();
const location = useLocation();
const navigate = useNavigate();
const { definitions: channelDefs } = useChannelDefinitions();
@@ -657,7 +666,7 @@ export default function Skills() {
icon={item.icon}
title={item.name}
description={item.description}
ctaLabel="Settings"
ctaLabel={t('nav.settings')}
onCtaClick={() => navigate(item.route!)}
/>
);
@@ -666,12 +675,12 @@ export default function Skills() {
if (item.kind === 'discovered') {
const skill = item.discoveredSkill!;
const scopeLabel = skill.legacy
? 'Legacy'
? t('scope.legacy')
: skill.scope === 'user'
? 'User'
? t('scope.user')
: skill.scope === 'project'
? 'Project'
: 'Legacy';
? t('scope.project')
: t('scope.legacy');
const scopeColor = skill.legacy
? 'text-stone-600'
: skill.scope === 'user'
@@ -688,7 +697,7 @@ export default function Skills() {
description={item.description}
statusLabel={scopeLabel}
statusColor={scopeColor}
ctaLabel="View"
ctaLabel={t('common.seeAll')}
onCtaClick={() => {
console.debug('[skills][discovered] open drawer', { skillId: skill.id });
setSelectedSkill(skill);
@@ -697,7 +706,7 @@ export default function Skills() {
canUninstall
? [
{
label: 'Uninstall',
label: t('skills.disconnect'),
testId: `uninstall-skill-${skill.id}`,
icon: (
<svg
@@ -793,12 +802,10 @@ export default function Skills() {
className={skillCategoryHeadingClassName('Channels')}
/>
</span>
Channels
{t('skills.channels')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
Connect messaging platforms so your agent can chat where you already are.
Note that you will need to have OpenHuman running either on your desktop or
in your own cloud.
{t('channels.defaultMessaging')}
</p>
</div>
<div
@@ -822,11 +829,10 @@ export default function Skills() {
<h2
className="text-sm font-semibold text-stone-900"
data-walkthrough="skills-grid">
Integrations
{t('skills.integrations')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
Connect external apps. Connected services give your agent access to the tools
it needs to perform tasks.
{t('skills.available')}
</p>
</div>
<div className="space-y-3 px-1 pb-3">
@@ -854,7 +860,7 @@ export default function Skills() {
</div>
) : (
<p className="px-1 py-4 text-center text-xs text-stone-400">
No integrations match your search.
{t('skills.noResults')}
</p>
)}
</div>
@@ -954,8 +960,8 @@ export default function Skills() {
});
addToast({
type: 'success',
title: 'Skill uninstalled',
message: `"${result.name}" was removed successfully.`,
title: t('skills.disconnect'),
message: `"${result.name}" ${t('common.success')}`,
});
// If the detail drawer was showing the skill we just removed,
// close it — the resource tree is now stale and any `read_resource`
+12 -13
View File
@@ -1,7 +1,9 @@
import ComposeioTriggerHistory from '../components/webhooks/ComposeioTriggerHistory';
import { useComposeioTriggerHistory } from '../hooks/useComposeioTriggerHistory';
import { useT } from '../lib/i18n/I18nContext';
export default function Webhooks() {
const { t } = useT();
const { archiveDir, currentDayFile, entries, loading, error, coreConnected, refresh } =
useComposeioTriggerHistory(100);
@@ -10,7 +12,7 @@ export default function Webhooks() {
<div className="h-full flex items-center justify-center p-4 pt-6">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
<span className="text-sm text-stone-500">Loading ComposeIO trigger history</span>
<span className="text-sm text-stone-500">{t('common.loading')}</span>
</div>
</div>
);
@@ -21,7 +23,7 @@ export default function Webhooks() {
<div className="max-w-2xl mx-auto space-y-4">
{/* Connection status */}
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold text-stone-900">ComposeIO Triggers</h2>
<h2 className="text-xl font-semibold text-stone-900">{t('skills.integrations')}</h2>
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
coreConnected ? 'bg-sage-100 text-sage-700' : 'bg-stone-100 text-stone-500'
@@ -31,13 +33,13 @@ export default function Webhooks() {
coreConnected ? 'bg-sage-500' : 'bg-stone-400'
}`}
/>
{coreConnected ? 'Connected' : 'Disconnected'}
{coreConnected ? t('skills.connected') : t('skills.disconnect')}
</span>
<button
type="button"
onClick={() => void refresh()}
className="rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 transition hover:border-stone-300 hover:bg-stone-50">
Refresh
{t('common.refresh')}
</button>
</div>
@@ -45,26 +47,23 @@ export default function Webhooks() {
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">Archive</h3>
<p className="text-sm text-stone-600">
Every ComposeIO trigger is appended to a daily JSONL file. Files are labeled by UTC
day.
</p>
<h3 className="text-lg font-semibold text-stone-900">{t('skills.search')}</h3>
<p className="text-sm text-stone-600">{t('misc.rehydrating')}</p>
<div className="space-y-2 rounded-2xl border border-stone-200 bg-stone-50 p-4">
<div>
<div className="text-xs uppercase tracking-wide text-stone-400">
Archive Directory
{t('webhooks.archiveDirectory')}
</div>
<div className="font-mono text-xs break-all text-stone-700">
{archiveDir ?? 'Not available yet'}
{archiveDir ?? t('common.loading')}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wide text-stone-400">
Today&apos;s File
{t('webhooks.todayFile')}
</div>
<div className="font-mono text-xs break-all text-stone-700">
{currentDayFile ?? 'Not available yet'}
{currentDayFile ?? t('common.loading')}
</div>
</div>
</div>
+19 -14
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import OAuthProviderButton from '../components/oauth/OAuthProviderButton';
import { oauthProviderConfigs } from '../components/oauth/providerConfigs';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { useT } from '../lib/i18n/I18nContext';
import { clearBackendUrlCache } from '../services/backendUrl';
import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../services/coreRpcClient';
import { useDeepLinkAuthState } from '../store/deepLinkAuthState';
@@ -20,6 +21,7 @@ import {
const log = createDebug('app:welcome');
const Welcome = () => {
const { t } = useT();
const { isProcessing, errorMessage, requiresAppDataReset } = useDeepLinkAuthState();
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -55,7 +57,7 @@ const Welcome = () => {
const normalized = normalizeRpcUrl(rpcUrl);
if (!isValidRpcUrl(normalized)) {
setRpcUrlError('Please enter a valid HTTP or HTTPS URL');
setRpcUrlError(t('welcome.invalidUrl'));
return;
}
@@ -81,7 +83,7 @@ const Welcome = () => {
const normalized = normalizeRpcUrl(rpcUrl);
if (!isValidRpcUrl(normalized)) {
setRpcUrlError('Please enter a valid HTTP or HTTPS URL');
setRpcUrlError(t('welcome.invalidUrl'));
return;
}
@@ -97,11 +99,15 @@ const Welcome = () => {
clearCoreRpcUrlCache();
clearBackendUrlCache();
} else {
setRpcUrlError(`Connection failed: ${response.status} ${response.statusText}`);
setRpcUrlError(
t('welcome.connectionFailed')
.replace('{status}', String(response.status))
.replace('{statusText}', response.statusText)
);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to reach the RPC endpoint';
setRpcUrlError(`Connection failed: ${message}`);
const message = err instanceof Error ? err.message : t('misc.serviceUnavailable');
setRpcUrlError(t('welcome.connectionFailedMsg').replace('{message}', message));
} finally {
setIsTestingConnection(false);
}
@@ -118,25 +124,24 @@ const Welcome = () => {
</div>
<h1 className="text-2xl font-bold text-stone-900 text-center mb-2">
Sign in! Let's Cook
{t('welcome.title')}
</h1>
<p className="text-sm text-stone-500 text-center mb-6 leading-relaxed">
Welcome to <span className="font-medium text-stone-900">OpenHuman</span>! Your Personal
AI Super Intelligence. Private, Simple and extremely powerful.
{t('welcome.subtitle')}
</p>
{showAdvanced ? (
<div className="mb-5 p-4 bg-stone-50 rounded-xl border border-stone-200">
<div className="flex items-center justify-between mb-3">
<label htmlFor="rpc-url-input" className="text-xs font-medium text-stone-700">
Core RPC URL
{t('welcome.urlPlaceholder')}
</label>
<button
type="button"
onClick={() => setShowAdvanced(false)}
className="text-xs text-stone-500 hover:text-stone-700">
Close
{t('common.close')}
</button>
</div>
<div className="flex gap-2">
@@ -156,10 +161,10 @@ const Welcome = () => {
{isTestingConnection ? (
<span className="flex items-center gap-1">
<span className="h-3 w-3 animate-spin rounded-full border border-stone-400 border-t-transparent" />
Testing
{t('welcome.connecting')}
</span>
) : (
'Test'
t('welcome.connect')
)}
</button>
</div>
@@ -173,7 +178,7 @@ const Welcome = () => {
type="button"
onClick={handleSaveRpcUrl}
className="px-3 py-1.5 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded-lg transition-colors">
Save
{t('common.save')}
</button>
<button
type="button"
@@ -188,7 +193,7 @@ const Welcome = () => {
type="button"
onClick={() => setShowAdvanced(true)}
className="mb-5 text-xs text-stone-500 hover:text-stone-700 underline">
Configure RPC URL (Advanced)
{t('welcome.connectPrompt')}
</button>
)}
@@ -1,5 +1,6 @@
import { useDispatch } from 'react-redux';
import { useT } from '../../../lib/i18n/I18nContext';
import { setActiveThread } from '../../../store/threadSlice';
import type { WorkerThreadRef } from '../utils/workerThreadRef';
@@ -10,11 +11,12 @@ import type { WorkerThreadRef } from '../utils/workerThreadRef';
* sub-agent's full transcript without losing the parent conversation.
*/
export function WorkerThreadRefCard({ ref }: { ref: WorkerThreadRef }) {
const { t } = useT();
const dispatch = useDispatch();
const meta: string[] = [];
if (ref.agentId) meta.push(ref.agentId);
if (typeof ref.iterations === 'number') {
meta.push(`${ref.iterations} ${ref.iterations === 1 ? 'turn' : 'turns'}`);
meta.push(`${ref.iterations} ${ref.iterations === 1 ? t('chat.turn') : t('chat.turns')}`);
}
if (typeof ref.elapsedMs === 'number') {
meta.push(`${Math.round(ref.elapsedMs)}ms`);
@@ -30,7 +32,9 @@ export function WorkerThreadRefCard({ ref }: { ref: WorkerThreadRef }) {
<span className="rounded-full bg-primary-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-800">
{ref.label}
</span>
<span className="truncate text-xs font-medium text-primary-900">Open worker thread</span>
<span className="truncate text-xs font-medium text-primary-900">
{t('chat.openWorkerThread')}
</span>
</div>
{meta.length > 0 ? (
<div className="mt-0.5 text-[10px] text-primary-700/80">{meta.join(' · ')}</div>
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { DISCORD_INVITE_URL } from '../../../utils/links';
const DISMISSED_KEY = 'openhuman_beta_banner_dismissed';
const BetaBanner = () => {
const { t } = useT();
const [visible, setVisible] = useState(() => {
try {
return localStorage.getItem(DISMISSED_KEY) !== 'true';
@@ -28,22 +30,22 @@ const BetaBanner = () => {
<div className="mb-4 flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
{/* Message */}
<p className="flex-1 text-xs leading-relaxed text-stone-700">
🐣 OpenHuman is in early beta. Report bugs, give feedback, and get free credits.{' '}
{t('misc.beta')}{' '}
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="Join OpenHuman Discord"
aria-label={t('common.learnMore')}
className="font-medium text-amber-800 underline underline-offset-2 hover:text-amber-900">
Join our Discord
{t('common.learnMore')}
</a>{' '}
to be a part of the community!
{t('onboarding.welcomeDesc')}
</p>
{/* Dismiss */}
<button
type="button"
aria-label="Dismiss beta notice"
aria-label={t('common.dismiss')}
onClick={handleDismiss}
className="mt-0.5 flex-shrink-0 text-stone-400 hover:text-stone-600 transition-colors">
<svg
@@ -1,3 +1,5 @@
import { useT } from '../../../lib/i18n/I18nContext';
interface OnboardingNextButtonProps {
label?: string;
onClick: () => void;
@@ -7,20 +9,25 @@ interface OnboardingNextButtonProps {
}
const OnboardingNextButton = ({
label = 'Continue',
label,
onClick,
disabled = false,
loading = false,
loadingLabel,
}: OnboardingNextButtonProps) => (
<button
type="button"
data-testid="onboarding-next-button"
onClick={onClick}
disabled={disabled || loading}
className="w-full py-2.5 bg-primary-500 hover:bg-primary-600 active:bg-primary-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? (loadingLabel ?? label) : label}
</button>
);
}: OnboardingNextButtonProps) => {
const { t } = useT();
const effectiveLabel = label ?? t('common.continue');
const effectiveLoadingLabel = loadingLabel ?? effectiveLabel;
return (
<button
type="button"
data-testid="onboarding-next-button"
onClick={onClick}
disabled={disabled || loading}
className="w-full py-2.5 bg-primary-500 hover:bg-primary-600 active:bg-primary-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? effectiveLoadingLabel : effectiveLabel}
</button>
);
};
export default OnboardingNextButton;
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import OnboardingNextButton from '../components/OnboardingNextButton';
import { useOnboardingContext } from '../OnboardingContext';
@@ -12,6 +13,7 @@ import { useOnboardingContext } from '../OnboardingContext';
* scaffolding can ship on its own.
*/
const ChatProviderPage = () => {
const { t } = useT();
const { completeAndExit } = useOnboardingContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -32,14 +34,14 @@ const ChatProviderPage = () => {
data-testid="onboarding-chat-provider-step"
className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-5">
<h1 className="text-xl font-bold mb-2 text-stone-900">Pick your chat provider</h1>
<h1 className="text-xl font-bold mb-2 text-stone-900">{t('onboarding.chatProvider')}</h1>
<p className="text-stone-500 text-sm leading-relaxed max-w-sm mx-auto">
Choose one chat provider to start with. You can connect more later from Skills.
{t('onboarding.chatProviderDesc')}
</p>
</div>
<div className="rounded-xl border border-dashed border-stone-200 bg-stone-50 p-6 mb-5 text-center text-sm text-stone-500">
Provider picker coming soon.
{t('misc.beta')}
</div>
{error && <p className="text-coral-400 text-sm mb-3 text-center">{error}</p>}
@@ -47,8 +49,8 @@ const ChatProviderPage = () => {
<OnboardingNextButton
onClick={handleFinish}
loading={loading}
loadingLabel="Finishing…"
label="Finish"
loadingLabel={t('common.finish')}
label={t('common.finish')}
/>
</div>
);
@@ -286,10 +286,10 @@ const ContextGatheringStep = ({
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="flex flex-col items-center justify-center gap-5">
<h1 className="text-xl font-bold text-stone-900">Almost there!</h1>
<h1 className="text-xl font-bold text-stone-900">Context Gathering</h1>
<p className="text-sm text-stone-600 text-center max-w-xs leading-relaxed">
We couldn&apos;t build your full profile right now, but that&apos;s okay you can
always update it later.
continue and your profile will build over time.
</p>
<OnboardingNextButton label="Continue to chat" onClick={continueToChat} />
</div>
@@ -305,7 +305,9 @@ const ContextGatheringStep = ({
{/* Title */}
<h1 className="text-xl font-bold text-stone-900 animate-pulse">Building your profile...</h1>
<p className="text-sm text-stone-500 leading-relaxed">This will only take a moment.</p>
<p className="text-sm text-stone-500 leading-relaxed">
Connecting your integrations and building your personal context.
</p>
{/* Skeleton bars */}
<div className="w-64 flex flex-col gap-3 mt-2">
+18 -36
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { bootstrapLocalAiWithRecommendedPreset } from '../../../utils/localAiBootstrap';
import { openhumanLocalAiPresets } from '../../../utils/tauriCommands';
import OnboardingNextButton from '../components/OnboardingNextButton';
@@ -13,6 +14,7 @@ interface LocalAIStepProps {
}
const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepProps) => {
const { t } = useT();
const downloadStartedRef = useRef(false);
const [recommendDisabled, setRecommendDisabled] = useState<boolean | null>(null);
@@ -78,37 +80,27 @@ const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepPr
/>
</svg>
</div>
<h1 className="text-xl font-bold mb-2 text-stone-900">AI Cloud Mode</h1>
<p className="text-stone-600 text-sm text-center">
Your device has limited RAM, so we&apos;ll use a fast, lightweight cloud model for AI
features. You can switch to local AI later in Settings.
</p>
<h1 className="text-xl font-bold mb-2 text-stone-900">{t('onboarding.localAI')}</h1>
<p className="text-stone-600 text-sm text-center">{t('onboarding.localAIDesc')}</p>
</div>
<div className="space-y-2 mb-5">
<div className="rounded-xl border border-primary-200 bg-primary-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Fast &amp; lightweight</span>
<span className="text-stone-600">
&nbsp; uses a cheap cloud summarizer model with minimal latency.
</span>
<span className="font-semibold">{t('onboarding.localAI')}</span>
<span className="text-stone-600">&nbsp; {t('onboarding.localAIDesc')}</span>
</p>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">No downloads needed</span>
<span className="text-stone-600">
&nbsp; no large model files or Ollama install required.
</span>
<span className="font-semibold">{t('common.download')}</span>
<span className="text-stone-600">&nbsp; {t('misc.downloading')}</span>
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Requires internet</span>
<span className="text-stone-600">
&nbsp; AI features need an active connection. You can opt into local AI in Settings
if preferred.
</span>
<span className="font-semibold">{t('welcome.connect')}</span>
<span className="text-stone-600">&nbsp; {t('onboarding.localAIDesc')}</span>
</p>
</div>
</div>
@@ -144,37 +136,27 @@ const LocalAIStep = ({ onNext, onBack: _onBack, onDownloadError }: LocalAIStepPr
/>
</svg>
</div>
<h1 className="text-xl font-bold mb-2 text-stone-900">Choose how AI runs</h1>
<p className="text-stone-600 text-sm text-center">
We&apos;ll start with a fast cloud model so you&apos;re productive right away. You can
switch to fully local AI any time either now or later from Settings.
</p>
<h1 className="text-xl font-bold mb-2 text-stone-900">{t('onboarding.localAI')}</h1>
<p className="text-stone-600 text-sm text-center">{t('onboarding.localAIDesc')}</p>
</div>
<div className="space-y-2 mb-5">
<div className="rounded-xl border border-primary-200 bg-primary-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Cloud AI (default)</span>
<span className="text-stone-600">
&nbsp; fast, lightweight, no downloads. Requires an internet connection.
</span>
<span className="font-semibold">{t('onboarding.localAI')}</span>
<span className="text-stone-600">&nbsp; {t('onboarding.localAIDesc')}</span>
</p>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Local AI (opt-in)</span>
<span className="text-stone-600">
&nbsp; runs fully on-device with Ollama for complete privacy. Uses disk space and
RAM.
</span>
<span className="font-semibold">{t('onboarding.localAI')}</span>
<span className="text-stone-600">&nbsp; {t('onboarding.localAIDesc')}</span>
</p>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 px-3 py-2">
<p className="text-xs text-stone-700">
<span className="font-semibold">Switch any time</span>
<span className="text-stone-600">
&nbsp; change your AI mode later from Settings Local AI Model.
</span>
<span className="font-semibold">{t('common.refresh')}</span>
<span className="text-stone-600">&nbsp; {t('onboarding.localAIDesc')}</span>
</p>
</div>
</div>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { referralApi } from '../../../services/api/referralApi';
@@ -15,6 +16,7 @@ interface ReferralApplyStepProps {
* Only eligible if the user has not yet subscribed.
*/
const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
const { t } = useT();
const { refresh } = useCoreState();
const [code, setCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -71,11 +73,8 @@ const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-6">
<h1 className="text-xl font-bold mb-2 text-stone-900">Referral code</h1>
<p className="text-stone-600 text-sm">
If a friend shared OpenHuman with you, enter their referral code here. You can skipthis
stays available on the Rewards page while you&apos;re eligible.
</p>
<h1 className="text-xl font-bold mb-2 text-stone-900">{t('onboarding.referral')}</h1>
<p className="text-stone-600 text-sm">{t('onboarding.referralDesc')}</p>
</div>
{success ? (
@@ -94,7 +93,7 @@ const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
/>
</svg>
</div>
<p className="text-sage-600 font-medium text-sm">Referral code applied.</p>
<p className="text-sage-600 font-medium text-sm">{t('common.success')}</p>
</div>
) : (
<>
@@ -104,7 +103,7 @@ const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && void handleApply()}
placeholder="Enter referral code"
placeholder={t('rewards.referralCode')}
className="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-center font-mono text-lg tracking-widest text-stone-900 placeholder:text-stone-400 placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={isLoading}
/>
@@ -117,14 +116,14 @@ const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
onClick={onNext}
disabled={isLoading}
className="flex-1 py-2.5 text-sm font-medium rounded-xl border border-stone-200 text-stone-500 hover:text-stone-700 hover:border-stone-300 transition-colors">
Skip for now
{t('onboarding.skip')}
</button>
<button
type="button"
onClick={() => void handleApply()}
disabled={isLoading || !code.trim()}
className="btn-primary flex-1 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed">
{isLoading ? 'Applying' : 'Apply code'}
{isLoading ? t('common.loading') : t('common.submit')}
</button>
</div>
</>
+19 -19
View File
@@ -7,6 +7,7 @@ import {
} from '../../../components/composio/toolkitMeta';
import { useComposioIntegrations } from '../../../lib/composio/hooks';
import { type ComposioConnection, deriveComposioState } from '../../../lib/composio/types';
import { useT } from '../../../lib/i18n/I18nContext';
import OnboardingNextButton from '../components/OnboardingNextButton';
export interface SkillsConnections {
@@ -32,14 +33,17 @@ function statusDotClass(connection: ComposioConnection | undefined): string {
}
}
function statusLabel(state: ReturnType<typeof deriveComposioState>): string {
function statusLabel(
state: ReturnType<typeof deriveComposioState>,
t: (key: string) => string
): string {
switch (state) {
case 'connected':
return 'Connected';
return t('skills.connected');
case 'pending':
return 'Connecting';
return t('channels.status.connecting');
case 'error':
return 'Error';
return t('common.error');
default:
return '';
}
@@ -59,6 +63,7 @@ function statusColor(state: ReturnType<typeof deriveComposioState>): string {
}
const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
const { t } = useT();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeToolkit, setActiveToolkit] = useState<ComposioToolkitMeta | null>(null);
@@ -90,22 +95,19 @@ const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
return (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2 text-stone-900">Connect your Gmail</h1>
<p className="text-stone-600 text-sm">
Sign in to Gmail so OpenHuman can build a short profile about you. Your data stays on your
device.
</p>
<h1 className="text-xl font-bold mb-2 text-stone-900">{t('skills.connect')}</h1>
<p className="text-stone-600 text-sm">{t('skills.available')}</p>
</div>
<div className="mb-4 space-y-2">
{composioError ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-center">
<p className="text-sm text-amber-700 mb-2">Could not load integrations</p>
<p className="text-sm text-amber-700 mb-2">{t('common.error')}</p>
<button
type="button"
onClick={() => void refreshComposio()}
className="text-xs font-medium text-amber-800 border border-amber-300 rounded-lg px-3 py-1 hover:bg-amber-100 transition-colors">
Retry
{t('common.retry')}
</button>
</div>
) : (
@@ -123,13 +125,13 @@ const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
<span className="truncate text-sm font-semibold text-stone-900">
{gmailMeta.name}
</span>
{statusLabel(gmailState) && (
{statusLabel(gmailState, t) && (
<>
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${statusDotClass(gmailConnection)}`}
/>
<span className={`flex-shrink-0 text-xs ${statusColor(gmailState)}`}>
{statusLabel(gmailState)}
{statusLabel(gmailState, t)}
</span>
</>
)}
@@ -147,15 +149,13 @@ const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-primary-200 bg-primary-50 text-primary-700'
}`}>
{gmailConnected ? 'Manage' : gmailState === 'pending' ? 'Waiting' : 'Connect'}
{gmailConnected ? t('skills.configure') : t('skills.connect')}
</span>
</button>
)}
<div className="rounded-xl border border-stone-100 bg-stone-50 px-3 py-2.5 text-center">
<p className="text-xs text-stone-400">
More providers (Slack, Notion, Drive, ) available after setup
</p>
<p className="text-xs text-stone-400">{t('skills.available')}</p>
</div>
</div>
@@ -164,8 +164,8 @@ const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
<OnboardingNextButton
onClick={handleContinue}
loading={submitting}
loadingLabel="Loading..."
label={gmailConnected ? 'Continue' : 'Skip for Now'}
loadingLabel={t('common.loading')}
label={gmailConnected ? t('common.continue') : 'Skip for Now'}
/>
{activeToolkit && (
@@ -1,4 +1,5 @@
import WhatLeavesLink from '../../../features/privacy/WhatLeavesLink';
import { useT } from '../../../lib/i18n/I18nContext';
import OnboardingNextButton from '../components/OnboardingNextButton';
interface WelcomeStepProps {
@@ -6,6 +7,7 @@ interface WelcomeStepProps {
}
const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
const { t } = useT();
return (
<div
data-testid="onboarding-welcome-step"
@@ -13,15 +15,14 @@ const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
<div className="flex flex-col items-center text-center">
<img src="/logo.png" alt="OpenHuman" className="w-20 h-20 rounded-2xl mb-5" />
<h1 className="text-3xl font-display text-stone-900 mb-3 leading-tight">
Hi. I'm OpenHuman.
{t('onboarding.welcome')}
</h1>
<p className="text-stone-500 text-sm leading-relaxed max-w-sm">
Your super-intelligent AI assistant that runs on your computer. Private, simple, and
extremely powerful.
{t('onboarding.welcomeDesc')}
</p>
</div>
<div className="mt-8">
<OnboardingNextButton label="Let's Start" onClick={onNext} />
<OnboardingNextButton label={t('onboarding.getStarted')} onClick={onNext} />
</div>
<div className="mt-4 flex justify-center">
<WhatLeavesLink />
@@ -23,12 +23,12 @@ describe('WelcomeStep', () => {
it('fires onNext when the CTA is clicked', () => {
const onNext = vi.fn();
renderWithProviders(<WelcomeStep onNext={onNext} />);
fireEvent.click(screen.getByRole('button', { name: "Let's Start" }));
fireEvent.click(screen.getByRole('button', { name: 'Get Started' }));
expect(onNext).toHaveBeenCalledTimes(1);
});
it('CTA is always enabled (WelcomeStep has no disabled/loading props)', () => {
renderWithProviders(<WelcomeStep onNext={() => {}} />);
expect(screen.getByRole('button', { name: "Let's Start" })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Get Started' })).not.toBeDisabled();
});
});
+5
View File
@@ -17,6 +17,7 @@ import channelConnectionsReducer from './channelConnectionsSlice';
import chatRuntimeReducer from './chatRuntimeSlice';
import connectivityReducer from './connectivitySlice';
import coreModeReducer from './coreModeSlice';
import localeReducer from './localeSlice';
import mascotReducer from './mascotSlice';
import notificationReducer from './notificationSlice';
import providerSurfacesReducer from './providerSurfaceSlice';
@@ -73,6 +74,9 @@ const coreModePersistConfig = {
};
const persistedCoreModeReducer = persistReducer(coreModePersistConfig, coreModeReducer);
const localePersistConfig = { key: 'locale', storage: localStorageAdapter, whitelist: ['current'] };
const persistedLocaleReducer = persistReducer(localePersistConfig, localeReducer);
const channelConnectionsPersistConfig = {
key: 'channelConnections',
storage,
@@ -125,6 +129,7 @@ export const store = configureStore({
notifications: persistedNotificationReducer,
providerSurfaces: providerSurfacesReducer,
coreMode: persistedCoreModeReducer,
locale: persistedLocaleReducer,
mascot: persistedMascotReducer,
},
middleware: getDefaultMiddleware => {
+32
View File
@@ -0,0 +1,32 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Locale } from '../lib/i18n/types';
function detectLocale(): Locale {
try {
const nav = navigator.language;
if (nav && nav.toLowerCase().startsWith('zh')) return 'zh-CN';
} catch {
// browser API unavailable
}
return 'en';
}
interface LocaleState {
current: Locale;
}
const initialState: LocaleState = { current: detectLocale() };
const localeSlice = createSlice({
name: 'locale',
initialState,
reducers: {
setLocale(state, action: PayloadAction<Locale>) {
state.current = action.payload;
},
},
});
export const { setLocale } = localeSlice.actions;
export default localeSlice.reducer;