mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat: add Chinese (简体中文) i18n support (#1518)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
+21
-18
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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'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…</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’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 — 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’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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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'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
@@ -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't build your full profile right now, but that'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">
|
||||
|
||||
@@ -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'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 & lightweight</span>
|
||||
<span className="text-stone-600">
|
||||
— uses a cheap cloud summarizer model with minimal latency.
|
||||
</span>
|
||||
<span className="font-semibold">{t('onboarding.localAI')}</span>
|
||||
<span className="text-stone-600"> — {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">
|
||||
— no large model files or Ollama install required.
|
||||
</span>
|
||||
<span className="font-semibold">{t('common.download')}</span>
|
||||
<span className="text-stone-600"> — {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">
|
||||
— 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"> — {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'll start with a fast cloud model so you'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">
|
||||
— fast, lightweight, no downloads. Requires an internet connection.
|
||||
</span>
|
||||
<span className="font-semibold">{t('onboarding.localAI')}</span>
|
||||
<span className="text-stone-600"> — {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">
|
||||
— 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"> — {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">
|
||||
— change your AI mode later from Settings → Local AI Model.
|
||||
</span>
|
||||
<span className="font-semibold">{t('common.refresh')}</span>
|
||||
<span className="text-stone-600"> — {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 skip—this
|
||||
stays available on the Rewards page while you'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>
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user