mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: redesign onboarding, boot, and LLM settings end-to-end (#1885)
This commit is contained in:
+3
-3
@@ -5,6 +5,7 @@ import { HashRouter as Router, useLocation, useNavigate } from 'react-router-dom
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import AppRoutes from './AppRoutes';
|
||||
import AppBackground from './components/AppBackground';
|
||||
import AppUpdatePrompt from './components/AppUpdatePrompt';
|
||||
import BootCheckGate from './components/BootCheckGate/BootCheckGate';
|
||||
import BottomTabBar from './components/BottomTabBar';
|
||||
@@ -13,7 +14,6 @@ import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
|
||||
import DictationHotkeyManager from './components/DictationHotkeyManager';
|
||||
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
|
||||
import MeshGradient from './components/MeshGradient';
|
||||
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
|
||||
import PersistRehydrationScreen from './components/PersistRehydrationScreen';
|
||||
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
|
||||
@@ -181,8 +181,8 @@ function AppShell() {
|
||||
|
||||
return (
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
<MeshGradient />
|
||||
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
|
||||
<AppBackground />
|
||||
<div className="relative z-10 flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${
|
||||
// [#1123] welcomeLocked removed — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import MeshGradient from './MeshGradient';
|
||||
|
||||
interface AppBackgroundProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's shared background layer: animated mesh gradient + dotted canvas
|
||||
* overlay. Renders as an absolutely-positioned layer that fills its parent,
|
||||
* so callers stay in control of layout. Place your foreground content in a
|
||||
* sibling `relative z-10` container.
|
||||
*/
|
||||
export default function AppBackground({ className = '' }: AppBackgroundProps) {
|
||||
return (
|
||||
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
|
||||
<MeshGradient />
|
||||
<div className="app-dotted-canvas absolute inset-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
* 2. Subsequent launches: run version / reachability check and block until
|
||||
* the result is `match`.
|
||||
*
|
||||
* Visual language follows ServiceBlockingGate.tsx (bg-stone-950/80 overlay,
|
||||
* bg-stone-900 panel, ocean-500 / coral-500 semantics).
|
||||
* Visual language matches the rest of the app shell: light stone palette,
|
||||
* primary-500 accent, soft-shadow card on a stone-100 backdrop.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
storeRpcUrl,
|
||||
} from '../../utils/configPersistence';
|
||||
import { isTauri } from '../../utils/tauriCommands/common';
|
||||
import AppBackground from '../AppBackground';
|
||||
import LanguageSelect from '../LanguageSelect';
|
||||
|
||||
const log = debug('boot-check');
|
||||
const logError = debug('boot-check:error');
|
||||
@@ -54,14 +56,24 @@ interface PanelProps {
|
||||
|
||||
function Panel({ children }: PanelProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-stone-700/50 bg-stone-900 p-6 shadow-2xl">
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[#f5f5f5] p-4">
|
||||
<AppBackground />
|
||||
<div className="relative z-10 w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-10 shadow-soft animate-fade-up">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BootCheckLanguageSelect() {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="absolute right-5 top-5">
|
||||
<LanguageSelect id="boot-check-language" ariaLabel={t('settings.language')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Picker (first-ever launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,23 +211,24 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
<BootCheckLanguageSelect />
|
||||
<h2 className="text-xl font-semibold text-stone-900">
|
||||
{isDesktop ? t('bootCheck.chooseCoreMode') : t('bootCheck.connectToCore')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-stone-300">
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
{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"
|
||||
className="mt-4 rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600"
|
||||
data-testid="web-download-cta">
|
||||
{t('bootCheck.preferDesktop')}{' '}
|
||||
<a
|
||||
href={DESKTOP_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ocean-400 underline hover:text-ocean-300">
|
||||
className="text-primary-500 underline hover:text-primary-600">
|
||||
{t('bootCheck.downloadDesktop')}
|
||||
</a>
|
||||
.
|
||||
@@ -228,13 +241,14 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected('local')}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
aria-pressed={selected === 'local'}
|
||||
className={`rounded-xl border-2 p-5 text-left transition-colors focus:outline-none ${
|
||||
selected === 'local'
|
||||
? 'border-ocean-500 bg-ocean-500/10 text-white'
|
||||
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
|
||||
? '!border-primary-500 bg-primary-50 text-stone-900 shadow-sm'
|
||||
: '!border-stone-200 bg-white text-stone-700 hover:!border-stone-300 hover:bg-stone-50'
|
||||
}`}>
|
||||
<div className="font-medium">{t('bootCheck.localRecommended')}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.localDescription')}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">{t('bootCheck.localDescription')}</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -243,20 +257,21 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected('cloud')}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
aria-pressed={selected === 'cloud'}
|
||||
className={`rounded-xl border-2 p-5 text-left transition-colors focus:outline-none ${
|
||||
selected === 'cloud'
|
||||
? 'border-ocean-500 bg-ocean-500/10 text-white'
|
||||
: 'border-stone-700 text-stone-300 hover:border-stone-500 hover:bg-stone-800'
|
||||
? '!border-primary-500 bg-primary-50 text-stone-900 shadow-sm'
|
||||
: '!border-stone-200 bg-white text-stone-700 hover:!border-stone-300 hover:bg-stone-50'
|
||||
}`}>
|
||||
<div className="font-medium">{t('bootCheck.cloudMode')}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-400">{t('bootCheck.cloudDescription')}</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">{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">
|
||||
<label className="text-xs font-medium text-stone-700">
|
||||
{t('bootCheck.coreRpcUrl')}
|
||||
</label>
|
||||
<input
|
||||
@@ -268,12 +283,12 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
setUrlError(null);
|
||||
setTestStatus({ kind: 'idle' });
|
||||
}}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
{urlError && <p className="text-xs text-coral-400">{urlError}</p>}
|
||||
{urlError && <p className="text-xs text-red-600">{urlError}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-stone-300">
|
||||
<label className="text-xs font-medium text-stone-700">
|
||||
{t('bootCheck.authToken')} (
|
||||
<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
|
||||
</label>
|
||||
@@ -288,10 +303,10 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
setTokenError(null);
|
||||
setTestStatus({ kind: 'idle' });
|
||||
}}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
{tokenError && <p className="text-xs text-coral-400">{tokenError}</p>}
|
||||
<p className="text-[11px] text-stone-500">
|
||||
{tokenError && <p className="text-xs text-red-600">{tokenError}</p>}
|
||||
<p className="text-[11px] text-stone-500 leading-snug">
|
||||
{t('bootCheck.storedLocally')} <code>Authorization: Bearer …</code> on every RPC.
|
||||
</p>
|
||||
</div>
|
||||
@@ -301,23 +316,23 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-1.5 text-xs text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{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">
|
||||
<span className="text-xs text-emerald-600" data-testid="test-status-ok">
|
||||
{t('bootCheck.connectedOk')}
|
||||
</span>
|
||||
)}
|
||||
{testStatus.kind === 'auth' && (
|
||||
<span className="text-xs text-coral-400" data-testid="test-status-auth">
|
||||
<span className="text-xs text-red-600" data-testid="test-status-auth">
|
||||
{t('bootCheck.authFailed')}
|
||||
</span>
|
||||
)}
|
||||
{testStatus.kind === 'unreachable' && (
|
||||
<span className="text-xs text-coral-400" data-testid="test-status-unreachable">
|
||||
<span className="text-xs text-red-600" data-testid="test-status-unreachable">
|
||||
{t('bootCheck.unreachablePrefix')} {testStatus.reason}
|
||||
</span>
|
||||
)}
|
||||
@@ -330,7 +345,7 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="rounded-lg bg-ocean-500 px-5 py-2 text-sm font-medium text-white hover:bg-ocean-600">
|
||||
className="rounded-lg bg-primary-500 px-5 py-2 text-sm font-medium text-white hover:bg-primary-600">
|
||||
{t('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -347,8 +362,8 @@ function CheckingScreen() {
|
||||
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">{t('bootCheck.checkingCore')}</p>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-primary-500" />
|
||||
<p className="text-sm text-stone-600">{t('bootCheck.checkingCore')}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
@@ -383,29 +398,29 @@ function ResultScreen({
|
||||
if (result.kind === 'unreachable') {
|
||||
return (
|
||||
<Panel>
|
||||
<h2 className="text-xl font-semibold text-white">{t('bootCheck.cannotReach')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-300">
|
||||
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.cannotReach')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
{result.reason || t('bootCheck.cannotReachDesc')}
|
||||
</p>
|
||||
{actionError && <p className="mt-3 text-xs text-coral-400">{actionError}</p>}
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50">
|
||||
{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">
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
{t('bootCheck.quit')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -416,22 +431,22 @@ function ResultScreen({
|
||||
if (result.kind === 'daemonDetected') {
|
||||
return (
|
||||
<Panel>
|
||||
<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>}
|
||||
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.legacyDetected')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.legacyDescription')}</p>
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
|
||||
{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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{t('bootCheck.switchMode')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -442,22 +457,22 @@ function ResultScreen({
|
||||
if (result.kind === 'outdatedLocal') {
|
||||
return (
|
||||
<Panel>
|
||||
<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>}
|
||||
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.localNeedsRestart')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.localNeedsRestartDesc')}</p>
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
|
||||
{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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{t('bootCheck.switchMode')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -468,22 +483,22 @@ function ResultScreen({
|
||||
if (result.kind === 'outdatedCloud') {
|
||||
return (
|
||||
<Panel>
|
||||
<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>}
|
||||
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.cloudNeedsUpdate')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.cloudNeedsUpdateDesc')}</p>
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
|
||||
{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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{t('bootCheck.switchMode')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -494,22 +509,22 @@ function ResultScreen({
|
||||
// noVersionMethod — treat like outdated, user picks which flavor of action
|
||||
return (
|
||||
<Panel>
|
||||
<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>}
|
||||
<h2 className="text-xl font-semibold text-stone-900">{t('bootCheck.versionCheckFailed')}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{t('bootCheck.versionCheckFailedDesc')}</p>
|
||||
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
|
||||
{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">
|
||||
className="rounded-lg border border-stone-300 bg-white px-4 py-2 text-sm text-stone-700 hover:bg-stone-50 disabled:opacity-60">
|
||||
{t('bootCheck.switchMode')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Provider } from 'react-redux';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import coreModeReducer, { type CoreModeState } from '../../../store/coreModeSlice';
|
||||
import localeReducer from '../../../store/localeSlice';
|
||||
import BootCheckGate from '../BootCheckGate';
|
||||
|
||||
// The global test setup mocks isTauri()=>false (web). The existing picker
|
||||
@@ -57,7 +58,7 @@ vi.mock('../../../utils/configPersistence', async importOriginal => {
|
||||
|
||||
function makeStore(initialMode?: CoreModeState['mode']) {
|
||||
return configureStore({
|
||||
reducer: { coreMode: coreModeReducer },
|
||||
reducer: { coreMode: coreModeReducer, locale: localeReducer },
|
||||
preloadedState: {
|
||||
coreMode: { mode: initialMode ?? { kind: 'unset' } } satisfies CoreModeState,
|
||||
},
|
||||
@@ -86,9 +87,9 @@ beforeEach(() => {
|
||||
describe('BootCheckGate — picker (unset mode)', () => {
|
||||
it('shows the mode picker when coreMode is unset', () => {
|
||||
renderGate();
|
||||
expect(screen.getByText('Choose core mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Local (recommended)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cloud')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a Runtime')).toBeInTheDocument();
|
||||
expect(screen.getByText('Run Locally (Recommended)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Run on the Cloud (Complex)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render children while in picker', () => {
|
||||
@@ -112,7 +113,7 @@ describe('BootCheckGate — picker (unset mode)', () => {
|
||||
it('shows URL input when user selects Cloud', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
|
||||
expect(screen.getByPlaceholderText(/https:\/\/core\.example\.com/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -120,51 +121,51 @@ describe('BootCheckGate — picker (unset mode)', () => {
|
||||
it('shows URL validation error when cloud URL is empty', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText('Please enter a core URL.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter a runtime URL.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows URL validation error for non-http URL', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
const input = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(input, { target: { value: 'ftp://invalid' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText(/must start with http/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/start with http/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows URL validation error for malformed URL string', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
const input = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(input, { target: { value: 'not a url at all' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText(/Please enter a valid URL/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/That doesn't look like a valid URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows token validation error when cloud URL is valid but token is missing', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(urlInput, { target: { value: 'https://core.example.com/rpc' } });
|
||||
// Token left blank.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We'll need an auth token to connect/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts a Tailscale HTTP core URL in cloud mode', async () => {
|
||||
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
|
||||
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'http://100.116.244.64:7788/rpc' },
|
||||
});
|
||||
@@ -189,7 +190,7 @@ describe('BootCheckGate — picker (unset mode)', () => {
|
||||
it('rejects public HTTP cloud URLs', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(urlInput, { target: { value: 'http://core.example.com/rpc' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
@@ -200,24 +201,24 @@ describe('BootCheckGate — picker (unset mode)', () => {
|
||||
it('clears the token error as soon as the user types into the token field', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'https://core.example.com/rpc' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We'll need an auth token to connect/i)).toBeInTheDocument();
|
||||
|
||||
const tokenInput = screen.getByPlaceholderText(/Bearer token/i);
|
||||
fireEvent.change(tokenInput, { target: { value: 'tok' } });
|
||||
|
||||
expect(screen.queryByText(/Please enter the core auth token/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/We'll need an auth token to connect/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('advances past picker and triggers boot check when cloud URL + token are both set', async () => {
|
||||
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
|
||||
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'https://core.example.com/rpc' },
|
||||
});
|
||||
@@ -246,7 +247,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
});
|
||||
|
||||
function fillCloudInputs(url = 'https://core.example.com/rpc', token = 'tok-abc') {
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: url },
|
||||
});
|
||||
@@ -262,7 +263,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-ok')).toBeInTheDocument();
|
||||
@@ -282,7 +283,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-auth')).toBeInTheDocument();
|
||||
@@ -298,7 +299,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-auth')).toBeInTheDocument();
|
||||
@@ -310,7 +311,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument();
|
||||
@@ -327,7 +328,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument();
|
||||
@@ -337,23 +338,23 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
it('does not call the test endpoint when URL is missing', () => {
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
expect(mockTestCoreRpcConnection).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Please enter a core URL.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter a runtime URL.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call the test endpoint when token is missing', () => {
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.click(screen.getByText('Run on the Cloud (Complex)'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'https://core.example.com/rpc' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
expect(mockTestCoreRpcConnection).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/We'll need an auth token to connect/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears a stale ok status when the user edits inputs again', async () => {
|
||||
@@ -365,7 +366,7 @@ describe('BootCheckGate — picker test connection', () => {
|
||||
|
||||
renderGate();
|
||||
fillCloudInputs();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-status-ok')).toBeInTheDocument();
|
||||
});
|
||||
@@ -387,7 +388,7 @@ describe('BootCheckGate — checking state', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Checking core…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Waking up your runtime…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -413,8 +414,8 @@ describe('BootCheckGate — daemonDetected', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Legacy background core detected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove and continue' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy Background Runtime Detected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove and Continue' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -427,8 +428,8 @@ describe('BootCheckGate — outdatedLocal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Local core needs a restart')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Restart core' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Local Runtime Needs a Restart')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Restart Runtime' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -449,8 +450,8 @@ describe('BootCheckGate — outdatedCloud', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cloud core needs an update')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Update cloud core' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Cloud Runtime Needs an Update')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Update Cloud Runtime' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -463,7 +464,7 @@ describe('BootCheckGate — noVersionMethod', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Core version check failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runtime Version Check Failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -476,26 +477,26 @@ describe('BootCheckGate — unreachable', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Could not reach core')).toBeInTheDocument();
|
||||
expect(screen.getByText("Can't Reach the Runtime")).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Quit' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Switch mode' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pick a Different Runtime' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to picker when Switch mode is clicked', async () => {
|
||||
it("returns to picker when 'Pick a Different Runtime' is clicked", async () => {
|
||||
mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'Connection refused' });
|
||||
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Switch mode' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pick a Different Runtime' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch mode' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick a Different Runtime' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Choose core mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a Runtime')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -518,10 +519,10 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Checking core…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Waking up your runtime…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a Runtime')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -533,12 +534,14 @@ describe('BootCheckGate — picker (web build, !isTauri)', () => {
|
||||
it('uses the web-friendly title and hides the Local option', () => {
|
||||
renderGate();
|
||||
|
||||
expect(screen.getByText('Connect to your core')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Choose core mode')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Local (recommended)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Connect to Your Runtime')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a Runtime')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Run Locally (Recommended)')).not.toBeInTheDocument();
|
||||
// The selectable Cloud tile is also gone — cloud is implicit and the
|
||||
// URL/token form is rendered directly.
|
||||
expect(screen.queryByRole('button', { name: 'Cloud' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Run on the Cloud (Complex)' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the cloud form fields immediately (cloud is the only option)', () => {
|
||||
|
||||
@@ -85,21 +85,9 @@ const makeTabs = (t: (key: string) => string) => [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: t('nav.alerts'),
|
||||
path: '/notifications',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// Alerts/Notifications used to be its own bottom tab; moved into
|
||||
// Settings › Notifications since it's a low-traffic destination.
|
||||
// The /notifications route still exists for deep links.
|
||||
{
|
||||
id: 'rewards',
|
||||
label: t('nav.rewards'),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Locale } from '../lib/i18n/types';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { setLocale } from '../store/localeSlice';
|
||||
|
||||
const LOCALE_OPTIONS: Array<{ value: Locale; flag: string; label: string }> = [
|
||||
{ value: 'en', flag: '🇬🇧', label: 'English' },
|
||||
{ value: 'zh-CN', flag: '🇨🇳', label: '中文' },
|
||||
];
|
||||
|
||||
interface LanguageSelectProps {
|
||||
/** Accessible label for the underlying <select>. */
|
||||
ariaLabel?: string;
|
||||
/** Optional id for label association. */
|
||||
id?: string;
|
||||
/** Override the default classNames if a host needs different padding/size. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLASS =
|
||||
"appearance-none rounded-lg border border-stone-200 bg-white bg-[url('data:image/svg+xml;utf8,<svg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2020%2020%22%20fill=%22%2378716c%22><path%20d=%22M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%22/></svg>')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem_1rem] py-2 pl-3 pr-8 text-xs font-medium text-stone-700 hover:border-stone-300 hover:bg-stone-50 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 cursor-pointer";
|
||||
|
||||
/**
|
||||
* Shared language picker used by the boot-check gate and the Settings home
|
||||
* screen. Renders the locale options as flag + label pairs and dispatches
|
||||
* `setLocale` to the redux store on change.
|
||||
*/
|
||||
const LanguageSelect = ({ ariaLabel = 'Language', id, className }: LanguageSelectProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const current = useAppSelector(state => state.locale.current);
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={current}
|
||||
onChange={e => dispatch(setLocale(e.target.value as Locale))}
|
||||
aria-label={ariaLabel}
|
||||
className={className ?? DEFAULT_CLASS}>
|
||||
{LOCALE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.flag} {opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelect;
|
||||
@@ -2,14 +2,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';
|
||||
import { resetWalkthrough } from '../walkthrough/AppWalkthrough';
|
||||
import LanguageSelect from '../LanguageSelect';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
import SettingsMenuItem from './components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
|
||||
@@ -29,22 +26,11 @@ interface SettingsItem {
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
// Subtle uppercase section header label separating settings groups
|
||||
const SectionHeader = ({ label }: { label: string }) => (
|
||||
<div className="px-4 pt-5 pb-1">
|
||||
<span className="text-[10px] font-semibold tracking-widest uppercase text-stone-400">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -91,6 +77,22 @@ const SettingsHome = () => {
|
||||
),
|
||||
onClick: () => navigateToSettings('account'),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
title: t('nav.alerts'),
|
||||
description: t('settings.alertsDesc'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigate('/notifications'),
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: t('settings.notifications'),
|
||||
@@ -121,16 +123,7 @@ const SettingsHome = () => {
|
||||
/>
|
||||
</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>
|
||||
),
|
||||
rightElement: <LanguageSelect ariaLabel={t('settings.language')} />,
|
||||
},
|
||||
{
|
||||
id: 'mascot',
|
||||
@@ -150,43 +143,9 @@ const SettingsHome = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('settings.featuresAndAI'),
|
||||
items: [
|
||||
{
|
||||
id: 'features',
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('features'),
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('ai'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Features tile (Screen Awareness / Messaging Channels / Notifications /
|
||||
// Tools) used to live here. Everything under it moved into Advanced
|
||||
// (DeveloperOptionsPanel), so the section is gone from the home menu.
|
||||
{
|
||||
label: t('settings.billingAndRewards'),
|
||||
items: [
|
||||
@@ -208,62 +167,6 @@ const SettingsHome = () => {
|
||||
openUrl(BILLING_DASHBOARD_URL).catch(() => {});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rewards',
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigate('/rewards'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('settings.support'),
|
||||
items: [
|
||||
{
|
||||
id: 'restart-tour',
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
resetWalkthrough();
|
||||
navigate('/home');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
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
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('about'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -334,40 +237,24 @@ const SettingsHome = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Grouped sections with section headers */}
|
||||
{settingsSections.map(section => (
|
||||
<div key={section.label}>
|
||||
<SectionHeader label={section.label} />
|
||||
{section.items.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onClick={item.onClick}
|
||||
dangerous={item.dangerous}
|
||||
isFirst={index === 0}
|
||||
isLast={index === section.items.length - 1}
|
||||
rightElement={item.rightElement}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Danger Zone */}
|
||||
<SectionHeader label={t('settings.dangerZone')} />
|
||||
{destructiveItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onClick={item.onClick}
|
||||
dangerous={item.dangerous}
|
||||
isFirst={index === 0}
|
||||
isLast={index === destructiveItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
{/* Flat list — group titles removed for clarity. Regular items first,
|
||||
destructive items appended at the end. */}
|
||||
{(() => {
|
||||
const flatItems = settingsSections.flatMap(s => s.items).concat(destructiveItems);
|
||||
return flatItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onClick={item.onClick}
|
||||
dangerous={item.dangerous}
|
||||
isFirst={index === 0}
|
||||
isLast={index === flatItems.length - 1}
|
||||
rightElement={item.rightElement}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Log Out & Clear Data Confirmation Modal */}
|
||||
|
||||
@@ -75,126 +75,36 @@ describe('SettingsHome', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('section headers', () => {
|
||||
it('renders the General section header', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
describe('flat menu', () => {
|
||||
// Section headers ("General", "Features & AI", "Billing & Rewards",
|
||||
// "Support", "Danger Zone") were intentionally removed — the menu is
|
||||
// now a single flat list to reduce visual noise.
|
||||
it.each(['General', 'Features & AI', 'Billing & Rewards', 'Support', 'Danger Zone'])(
|
||||
'does not render section header: %s',
|
||||
label => {
|
||||
renderSettingsHome();
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('renders the Features & AI section header', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Features & AI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Billing & Rewards section header', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Billing & Rewards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Support section header', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Advanced section header', () => {
|
||||
it('renders the core menu items in a single list', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Account')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alerts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Billing & Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Advanced')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear App Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Danger Zone section header', () => {
|
||||
it('no longer renders Features / AI / Rewards / Restart Tour / About on the home screen', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Danger Zone')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('item grouping order', () => {
|
||||
it('places Account and Notifications under General', () => {
|
||||
renderSettingsHome();
|
||||
const generalHeader = screen.getByText('General');
|
||||
const accountItem = screen.getByText('Account');
|
||||
const notificationsItem = screen.getByText('Notifications');
|
||||
|
||||
// All should appear after the General header in DOM order
|
||||
expect(generalHeader.compareDocumentPosition(accountItem)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(generalHeader.compareDocumentPosition(notificationsItem)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it('places Features and AI under Features & AI', () => {
|
||||
// After #1710: the catch-all "AI & Models" row collapsed into a
|
||||
// nested "AI" section page (with LLM + Voice as children), so
|
||||
// the home tile label changed from "AI & Models" to just "AI".
|
||||
renderSettingsHome();
|
||||
const header = screen.getByText('Features & AI');
|
||||
expect(header.compareDocumentPosition(screen.getByText('Features'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(header.compareDocumentPosition(screen.getByText('AI'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it('places Billing & Usage and Rewards under Billing & Rewards', () => {
|
||||
renderSettingsHome();
|
||||
const header = screen.getByText('Billing & Rewards');
|
||||
expect(header.compareDocumentPosition(screen.getByText('Billing & Usage'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(header.compareDocumentPosition(screen.getByText('Rewards'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it('places Restart Tour and About under Support', () => {
|
||||
renderSettingsHome();
|
||||
const header = screen.getByText('Support');
|
||||
expect(header.compareDocumentPosition(screen.getByText('Restart Tour'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(header.compareDocumentPosition(screen.getByText('About'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it('places Developer Options under Advanced', () => {
|
||||
renderSettingsHome();
|
||||
const header = screen.getByText('Advanced');
|
||||
expect(header.compareDocumentPosition(screen.getByText('Developer Options'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
|
||||
it('places Clear App Data and Log out under Danger Zone', () => {
|
||||
renderSettingsHome();
|
||||
const header = screen.getByText('Danger Zone');
|
||||
expect(header.compareDocumentPosition(screen.getByText('Clear App Data'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
expect(header.compareDocumentPosition(screen.getByText('Log out'))).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rewards menu item', () => {
|
||||
it('renders the Rewards item', () => {
|
||||
renderSettingsHome();
|
||||
expect(screen.getByText('Rewards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to /rewards when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
// The Rewards item description is used to find the right button
|
||||
const rewardsButton = screen.getByText('Rewards').closest('button');
|
||||
expect(rewardsButton).toBeTruthy();
|
||||
await user.click(rewardsButton!);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/rewards');
|
||||
expect(screen.queryByText('Features')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('AI Configuration')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Rewards')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart Tour')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('About')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,28 +125,12 @@ describe('SettingsHome', () => {
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications');
|
||||
});
|
||||
|
||||
it('navigates to /home when Restart Tour is clicked', async () => {
|
||||
it('navigates to /notifications inbox when Alerts is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('Restart Tour').closest('button')!);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
|
||||
it('navigates to features settings when Features is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('Features').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('features');
|
||||
});
|
||||
|
||||
it('navigates to ai settings when AI is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('AI').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('ai');
|
||||
await user.click(screen.getByText('Alerts').closest('button')!);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/notifications');
|
||||
});
|
||||
|
||||
it('opens billing URL when Billing & Usage is clicked', async () => {
|
||||
@@ -248,19 +142,11 @@ describe('SettingsHome', () => {
|
||||
expect(openUrl).toHaveBeenCalledWith('https://billing.example.com');
|
||||
});
|
||||
|
||||
it('navigates to about settings when About is clicked', async () => {
|
||||
it('navigates to developer-options when Advanced is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('About').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('about');
|
||||
});
|
||||
|
||||
it('navigates to developer-options settings when Developer Options is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsHome();
|
||||
|
||||
await user.click(screen.getByText('Developer Options').closest('button')!);
|
||||
await user.click(screen.getByText('Advanced').closest('button')!);
|
||||
expect(mockNavigateToSettings).toHaveBeenCalledWith('developer-options');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,13 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
type Mode = 'backend' | 'direct';
|
||||
|
||||
const ComposioPanel = () => {
|
||||
interface ComposioPanelProps {
|
||||
/** When true, render without the SettingsHeader chrome (used when embedded
|
||||
* inside the onboarding custom wizard). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const ComposioPanel = ({ embedded = false }: ComposioPanelProps = {}) => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('backend');
|
||||
@@ -187,13 +193,15 @@ const ComposioPanel = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Composio"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4">
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title="Composio"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
<div className={embedded ? '' : 'p-4'}>
|
||||
<p className="text-sm text-stone-500">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,19 +210,21 @@ const ComposioPanel = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Composio"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title="Composio"
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
<div className={embedded ? 'space-y-5' : 'p-4 space-y-5'}>
|
||||
<p className="text-sm text-stone-500">
|
||||
Composio powers integrations with Gmail, Notion, Slack, GitHub, and 1000+ other apps. By
|
||||
default OpenHuman proxies these calls through the TinyHumans backend so you don't
|
||||
need to manage an API key. If you prefer to use your own Composio account directly, switch
|
||||
to <span className="font-medium">Direct</span> mode and paste your key below.
|
||||
default OpenHuman manages it for you, so you don't need to bring an API key. If you
|
||||
prefer to use your own Composio account directly, switch to{' '}
|
||||
<span className="font-medium">Direct</span> mode and paste your key below.
|
||||
</p>
|
||||
|
||||
{/* Mode toggle */}
|
||||
@@ -229,16 +239,16 @@ const ComposioPanel = () => {
|
||||
value="backend"
|
||||
checked={mode === 'backend'}
|
||||
onChange={() => setMode('backend')}
|
||||
aria-label="Backend (proxied through TinyHumans)"
|
||||
aria-label="Managed (OpenHuman handles it for you)"
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-stone-900">
|
||||
Backend (proxied through TinyHumans)
|
||||
Managed (OpenHuman handles it for you)
|
||||
</span>
|
||||
<p className="text-xs text-stone-500 mt-0.5">
|
||||
Default. Calls go through <span className="font-mono">api.tinyhumans.ai</span>.
|
||||
Trigger webhooks (real-time Gmail / Slack events) work out of the box.
|
||||
Default. OpenHuman manages the Composio connection for you. Trigger webhooks
|
||||
(real-time Gmail / Slack events) work out of the box.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -276,7 +286,7 @@ const ComposioPanel = () => {
|
||||
</label>
|
||||
<p className="text-xs text-stone-500">
|
||||
Find your key at <span className="font-mono">app.composio.dev/api-keys</span>. The key
|
||||
is stored encrypted on this device and is never sent to TinyHumans servers.
|
||||
is stored encrypted on this device and is never sent to OpenHuman.
|
||||
</p>
|
||||
<input
|
||||
id="composio-api-key"
|
||||
@@ -316,8 +326,8 @@ const ComposioPanel = () => {
|
||||
<div className="text-xs text-amber-900 space-y-2">
|
||||
<p>
|
||||
Your existing integrations (Gmail, Slack, GitHub, etc. linked through OpenHuman){' '}
|
||||
<span className="font-semibold">won't be visible</span> — they live in
|
||||
TinyHumans' Composio tenant.
|
||||
<span className="font-semibold">won't be visible</span> — they live in the
|
||||
OpenHuman-managed Composio tenant.
|
||||
</p>
|
||||
<p>You'll need:</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5 ml-2">
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { triggerSentryTestEvent } from '../../../services/analytics';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { APP_ENVIRONMENT } from '../../../utils/config';
|
||||
import { isTauri } from '../../../utils/tauriCommands/common';
|
||||
import { resetWalkthrough } from '../../walkthrough/AppWalkthrough';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import SettingsMenuItem from '../components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const developerItems = [
|
||||
// Items pulled in from the (now-removed) Features tile and the AI tile
|
||||
// in SettingsHome: AI Configuration, Screen Awareness, Messaging
|
||||
// Channels, and Tools. They're power-user destinations and don't merit
|
||||
// top-level menu space.
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Configuration',
|
||||
description: 'Configure SOUL persona and AI behavior',
|
||||
description: 'Cloud providers, local Ollama models, and per-workload routing',
|
||||
route: 'ai',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -22,7 +28,61 @@ const developerItems = [
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3l1.9 3.85 4.25.62-3.08 3 .73 4.23L12 12.77 8.2 14.7l.73-4.23-3.08-3 4.25-.62L12 3z"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'screen-intelligence',
|
||||
title: 'Screen Awareness',
|
||||
description: 'Screen capture permissions, monitoring policy, and session controls',
|
||||
route: 'screen-intelligence',
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'messaging',
|
||||
title: 'Messaging Channels',
|
||||
description: 'Configure Telegram/Discord auth modes and default channel routing',
|
||||
route: 'messaging',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: 'Tools',
|
||||
description: 'Enable or disable capabilities OpenHuman can use on your behalf',
|
||||
route: 'tools',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
@@ -185,7 +245,7 @@ const CoreModeBadge = () => {
|
||||
|
||||
if (mode.kind === 'unset') {
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-coral-300 bg-coral-50">
|
||||
<div className="px-4 py-3 rounded-lg border border-coral-300 bg-coral-50">
|
||||
<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>
|
||||
@@ -194,22 +254,22 @@ const CoreModeBadge = () => {
|
||||
|
||||
if (mode.kind === 'local') {
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-ocean-300 bg-ocean-50">
|
||||
<div className="px-4 py-3 rounded-lg border border-primary-300 bg-primary-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">
|
||||
<span className="px-2 py-0.5 rounded-full bg-primary-600 text-white text-[11px] font-medium">
|
||||
{t('devOptions.local')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-ocean-900">
|
||||
<span className="text-sm font-semibold text-primary-900">
|
||||
{t('devOptions.embeddedCoreSidecar')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-ocean-800 mt-1">{t('devOptions.sidecarSpawned')}</div>
|
||||
<div className="text-xs text-primary-800 mt-1">{t('devOptions.sidecarSpawned')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-sage-300 bg-sage-50">
|
||||
<div className="px-4 py-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">
|
||||
{t('devOptions.cloud')}
|
||||
@@ -255,7 +315,7 @@ const SentryTestRow = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-amber-300 bg-amber-50">
|
||||
<div className="px-4 py-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">
|
||||
@@ -319,7 +379,7 @@ const LogsFolderRow = () => {
|
||||
if (!isTauri()) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-slate-200 bg-slate-50">
|
||||
<div className="px-4 py-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">{t('devOptions.appLogs')}</div>
|
||||
@@ -343,9 +403,51 @@ const LogsFolderRow = () => {
|
||||
|
||||
const DeveloperOptionsPanel = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const showSentryTest = APP_ENVIRONMENT === 'staging';
|
||||
|
||||
// Trailing items moved here from SettingsHome: About + Restart Tour. They
|
||||
// don't fit cleanly in the trimmed-down top-level menu but still need a
|
||||
// home, so they live here under Advanced.
|
||||
const trailingItems = [
|
||||
{
|
||||
id: 'restart-tour',
|
||||
title: t('settings.restartTour'),
|
||||
description: t('settings.restartTourDesc'),
|
||||
onClick: () => {
|
||||
resetWalkthrough();
|
||||
navigate('/home');
|
||||
},
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: t('settings.about'),
|
||||
description: t('settings.aboutDesc'),
|
||||
onClick: () => navigateToSettings('about'),
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
@@ -356,9 +458,6 @@ const DeveloperOptionsPanel = () => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<CoreModeBadge />
|
||||
<LogsFolderRow />
|
||||
{showSentryTest && <SentryTestRow />}
|
||||
{developerItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
@@ -367,9 +466,27 @@ const DeveloperOptionsPanel = () => {
|
||||
description={item.description}
|
||||
onClick={() => navigateToSettings(item.route)}
|
||||
isFirst={index === 0}
|
||||
isLast={index === developerItems.length - 1}
|
||||
isLast={false}
|
||||
/>
|
||||
))}
|
||||
{trailingItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onClick={item.onClick}
|
||||
isFirst={false}
|
||||
isLast={index === trailingItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Diagnostics callouts live outside the menu card so the spacing
|
||||
and alignment don't clash with the SettingsMenuItem rows. */}
|
||||
<div className="px-4 pt-6 flex flex-col gap-3">
|
||||
<CoreModeBadge />
|
||||
<LogsFolderRow />
|
||||
{showSentryTest && <SentryTestRow />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,13 @@ import MemoryWindowControl from '../components/MemoryWindowControl';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const MemoryDataPanel = () => {
|
||||
interface MemoryDataPanelProps {
|
||||
/** When true, render without the SettingsHeader chrome (used when embedded
|
||||
* inside the onboarding custom wizard). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
@@ -38,13 +44,15 @@ const MemoryDataPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader
|
||||
title={t('memory.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
<div className="p-4 space-y-4">
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title={t('memory.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
|
||||
<MemoryWindowControl onError={handleWindowError} onSaved={handleWindowSaved} />
|
||||
<MemoryWorkspace onToast={addToast} />
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const ToolsPanel = () => {
|
||||
interface ToolsPanelProps {
|
||||
/** When true, render without the SettingsHeader chrome (used when embedded
|
||||
* inside the onboarding custom wizard). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot, setOnboardingTasks } = useCoreState();
|
||||
@@ -82,14 +88,16 @@ const ToolsPanel = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t('settings.features.tools')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title={t('settings.features.tools')}
|
||||
showBackButton
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
|
||||
<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">
|
||||
|
||||
@@ -47,7 +47,13 @@ const PIPER_VOICE_PRESETS: ReadonlyArray<{ id: string; label: string }> = [
|
||||
{ id: 'en_GB-northern_english_male-medium', label: 'GB · Northern English (male)' },
|
||||
];
|
||||
|
||||
const VoicePanel = () => {
|
||||
interface VoicePanelProps {
|
||||
/** When true, render without the SettingsHeader chrome (used when embedded
|
||||
* inside the onboarding custom wizard). */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
|
||||
const { t } = useT();
|
||||
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -494,14 +500,16 @@ const VoicePanel = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t('voice.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
{!embedded && (
|
||||
<SettingsHeader
|
||||
title={t('voice.title')}
|
||||
showBackButton={true}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
|
||||
<section className="space-y-3">
|
||||
<div
|
||||
className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4"
|
||||
|
||||
@@ -70,25 +70,24 @@ describe('AIPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the three section labels', async () => {
|
||||
it('renders the LLM Providers + Routing top-level section headers', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
// Section labels are SectionLabel components — pick the one that
|
||||
// matches each header exactly. Loose regex matches body copy too
|
||||
// ("only use cloud providers" appears in the local-provider
|
||||
// explanation, "Primary" appears on the provider card badge AND in
|
||||
// workload routing rows).
|
||||
await waitFor(() => expect(screen.getAllByText(/Cloud providers/i).length).toBeGreaterThan(0));
|
||||
expect(screen.getAllByText(/Local provider/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Workload routing/i).length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(screen.getAllByText(/^LLM Providers$/).length).toBeGreaterThan(0));
|
||||
// The Local provider sub-section was removed entirely.
|
||||
expect(screen.queryByText(/Local provider/i)).not.toBeInTheDocument();
|
||||
// The old "Auth" header was renamed to "LLM Providers"; "Cloud providers"
|
||||
// sub-label is gone in favour of the chip toggles.
|
||||
expect(screen.queryByText(/^Auth$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cloud providers$/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^Routing$/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the OpenHuman primary card after load', async () => {
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() => expect(screen.getByText(/OpenHuman/i)).toBeInTheDocument());
|
||||
// "Primary" shows up on the provider card badge AND in workload
|
||||
// routing rows that read "Primary resolves to …", so multiple
|
||||
// matches are expected.
|
||||
expect(screen.getAllByText(/Primary/).length).toBeGreaterThan(0);
|
||||
// The OpenHuman label now appears in multiple places (provider card,
|
||||
// each workload routing row's "↳ OpenHuman" resolution hint), so we
|
||||
// assert at-least-one match rather than getByText.
|
||||
await waitFor(() => expect(screen.getAllByText(/OpenHuman/i).length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('renders all eight workload labels', async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('ComposioPanel', () => {
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull());
|
||||
|
||||
const backendRadio = screen.getByLabelText(
|
||||
'Backend (proxied through TinyHumans)'
|
||||
'Managed (OpenHuman handles it for you)'
|
||||
) as HTMLInputElement;
|
||||
const directRadio = screen.getByLabelText(
|
||||
'Direct (bring your own API key)'
|
||||
@@ -182,7 +182,7 @@ describe('ComposioPanel', () => {
|
||||
renderWithProviders(<Panel />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Backend (proxied through TinyHumans)'));
|
||||
fireEvent.click(screen.getByLabelText('Managed (OpenHuman handles it for you)'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
// No dialog appeared — clearApiKey was invoked straight through.
|
||||
@@ -243,7 +243,7 @@ describe('ComposioPanel', () => {
|
||||
renderWithProviders(<Panel />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Backend (proxied through TinyHumans)'));
|
||||
fireEvent.click(screen.getByLabelText('Managed (OpenHuman handles it for you)'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -279,7 +279,7 @@ describe('ComposioPanel', () => {
|
||||
renderWithProviders(<Panel />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull());
|
||||
|
||||
expect(screen.getByLabelText('Backend (proxied through TinyHumans)')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Managed (OpenHuman handles it for you)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('trigger-webhook gap is surfaced in the Direct mode description', async () => {
|
||||
|
||||
@@ -24,6 +24,14 @@ vi.mock('../../../../utils/config', () => ({
|
||||
get APP_ENVIRONMENT() {
|
||||
return hoisted.appEnvironment;
|
||||
},
|
||||
// Pulled transitively via `resetWalkthrough` → configPersistence.
|
||||
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
|
||||
BACKEND_URL: 'http://localhost:5005',
|
||||
}));
|
||||
|
||||
vi.mock('../../../walkthrough/AppWalkthrough', () => ({
|
||||
resetWalkthrough: vi.fn(),
|
||||
setWalkthroughPending: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useSettingsNavigation', () => ({
|
||||
|
||||
@@ -4,8 +4,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import RecoveryPhrasePanel from '../RecoveryPhrasePanel';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
vi.mock('../../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({
|
||||
snapshot: { currentUser: null },
|
||||
|
||||
@@ -562,7 +562,7 @@ describe('VoicePanel', () => {
|
||||
// appears. The dropdown is the gate-keeper of the section.
|
||||
const section = await screen.findByTestId('mascot-voice-section');
|
||||
// DEBUG: full DOM if section appears empty
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.log('SECTION HTML:', section.outerHTML.slice(0, 2000));
|
||||
const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement;
|
||||
// With no override stored, the picker reflects the build-time default.
|
||||
|
||||
+4
-8
@@ -93,7 +93,9 @@
|
||||
@apply text-lg lg:text-xl;
|
||||
}
|
||||
|
||||
/* Complete focus outline suppression - no borders, no rings, no outlines */
|
||||
/* Suppress the default browser focus ring (outline + tap highlight) but
|
||||
leave borders and box-shadows alone — components rely on those for
|
||||
selected / focus-visible styling (e.g. the runtime picker cards). */
|
||||
* {
|
||||
outline: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
@@ -101,12 +103,8 @@
|
||||
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border: inherit !important;
|
||||
border-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Specific overrides for common elements that might show blue outlines */
|
||||
button:focus,
|
||||
a:focus,
|
||||
input:focus,
|
||||
@@ -115,8 +113,6 @@
|
||||
[role='button']:focus,
|
||||
[tabindex]:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
@@ -177,7 +173,7 @@
|
||||
|
||||
.app-dotted-canvas {
|
||||
background-color: transparent;
|
||||
background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.5) 1px, transparent 1px);
|
||||
background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.3) 1px, transparent 1px);
|
||||
background-size: 18px 18px;
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const map = translations[locale] ?? resolveEn();
|
||||
return map[key] ?? resolveEn()[key] ?? key;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
[locale]
|
||||
);
|
||||
|
||||
|
||||
+155
-50
@@ -6,7 +6,7 @@ const en: TranslationMap = {
|
||||
'nav.human': 'Human',
|
||||
'nav.chat': 'Chat',
|
||||
'nav.connections': 'Connections',
|
||||
'nav.memory': 'Memory',
|
||||
'nav.memory': 'Intelligence',
|
||||
'nav.alerts': 'Alerts',
|
||||
'nav.rewards': 'Rewards',
|
||||
'nav.settings': 'Settings',
|
||||
@@ -68,7 +68,7 @@ const en: TranslationMap = {
|
||||
'settings.featuresDesc': 'Screen awareness, messaging, and tools',
|
||||
'settings.aiModels': 'AI & Models',
|
||||
'settings.aiModelsDesc': 'Local AI model setup, downloads, and LLM provider',
|
||||
'settings.ai': 'AI',
|
||||
'settings.ai': 'AI Configuration',
|
||||
'settings.aiDesc': 'Cloud providers, local Ollama models, and per-workload routing',
|
||||
'settings.billingUsage': 'Billing & Usage',
|
||||
'settings.billingUsageDesc': 'Subscription plan, credits, and payment methods',
|
||||
@@ -78,14 +78,17 @@ const en: TranslationMap = {
|
||||
'settings.restartTourDesc': 'Replay the product walkthrough from the beginning',
|
||||
'settings.about': 'About',
|
||||
'settings.aboutDesc': 'App version and software updates',
|
||||
'settings.developerOptions': 'Developer Options',
|
||||
'settings.developerOptionsDesc': 'Diagnostics, debug panels, webhooks, and memory inspection',
|
||||
'settings.developerOptions': 'Advanced',
|
||||
'settings.developerOptionsDesc':
|
||||
'AI configuration, messaging channels, tools, diagnostics, and debug panels',
|
||||
'settings.clearAppData': 'Clear App Data',
|
||||
'settings.clearAppDataDesc': 'Sign out and permanently clear all local app data',
|
||||
'settings.logOut': 'Log out',
|
||||
'settings.logOutDesc': 'Sign out of your account',
|
||||
'settings.language': 'Language',
|
||||
'settings.languageDesc': 'Display language for the app interface',
|
||||
'settings.alerts': 'Alerts',
|
||||
'settings.alertsDesc': 'View recent alerts and activity in your inbox',
|
||||
|
||||
// Settings: Account
|
||||
'settings.account.recoveryPhrase': 'Recovery Phrase',
|
||||
@@ -133,8 +136,10 @@ const en: TranslationMap = {
|
||||
|
||||
// Welcome page
|
||||
'welcome.title': 'Welcome to OpenHuman',
|
||||
'welcome.subtitle': 'Your AI assistant for communities',
|
||||
'welcome.subtitle':
|
||||
'Your personal AI super intelligence. Private, simple and extremely powerful.',
|
||||
'welcome.connectPrompt': 'Configure RPC URL (Advanced)',
|
||||
'welcome.selectRuntime': 'Select a Runtime',
|
||||
'welcome.urlPlaceholder': 'http://localhost:8089',
|
||||
'welcome.invalidUrl': 'Please enter a valid HTTP or HTTPS URL',
|
||||
'welcome.connecting': 'Testing',
|
||||
@@ -227,6 +232,105 @@ const en: TranslationMap = {
|
||||
'onboarding.skip': 'Skip',
|
||||
'onboarding.getStarted': 'Get Started',
|
||||
|
||||
// Onboarding: runtime-choice step (Cloud vs Custom)
|
||||
'onboarding.runtimeChoice.title': 'How would you like to run OpenHuman?',
|
||||
'onboarding.runtimeChoice.subtitle':
|
||||
'Pick the setup that fits you best. You can change this later in Settings.',
|
||||
'onboarding.runtimeChoice.cloud.title': 'Simple',
|
||||
'onboarding.runtimeChoice.cloud.tagline': 'Let OpenHuman manage everything for you.',
|
||||
'onboarding.runtimeChoice.cloud.f1': 'Built-in security',
|
||||
'onboarding.runtimeChoice.cloud.f2': 'Token compression to stretch your usage further',
|
||||
'onboarding.runtimeChoice.cloud.f3': 'One subscription, every model included',
|
||||
'onboarding.runtimeChoice.cloud.f4': 'No API keys to manage',
|
||||
'onboarding.runtimeChoice.cloud.f5': 'Simple to set up',
|
||||
'onboarding.runtimeChoice.custom.title': 'Run Custom',
|
||||
'onboarding.runtimeChoice.custom.tagline':
|
||||
"Bring your own keys. Full control of what you're using.",
|
||||
'onboarding.runtimeChoice.custom.f1': "You'll need API keys for almost everything",
|
||||
'onboarding.runtimeChoice.custom.f2': 'Reuses services you already pay for',
|
||||
'onboarding.runtimeChoice.custom.f3': 'Can be free if you run everything locally',
|
||||
'onboarding.runtimeChoice.custom.f4': 'More setup, more knobs',
|
||||
'onboarding.runtimeChoice.custom.f5': 'Best for power users and developers',
|
||||
'onboarding.runtimeChoice.cloud.creditHighlight': '$1 free credit to try it out',
|
||||
'onboarding.runtimeChoice.continueCloud': 'Continue with Simple',
|
||||
'onboarding.runtimeChoice.continueCustom': 'Continue with Custom',
|
||||
'onboarding.runtimeChoice.recommended': 'Recommended',
|
||||
|
||||
// Onboarding: API keys step (only when Custom is picked)
|
||||
'onboarding.apiKeys.title': "Let's Add Your API Keys",
|
||||
'onboarding.apiKeys.subtitle':
|
||||
'You can paste them now or skip and add them later in Settings › AI. Keys are stored on this device, encrypted at rest.',
|
||||
'onboarding.apiKeys.openaiLabel': 'OpenAI API key',
|
||||
'onboarding.apiKeys.openaiPlaceholder': 'sk-...',
|
||||
'onboarding.apiKeys.anthropicLabel': 'Anthropic API key',
|
||||
'onboarding.apiKeys.anthropicPlaceholder': 'sk-ant-...',
|
||||
'onboarding.apiKeys.saveError': "Couldn't save that key. Please double-check it and try again.",
|
||||
'onboarding.apiKeys.skipForNow': 'Skip for now',
|
||||
'onboarding.apiKeys.continue': 'Save and continue',
|
||||
'onboarding.apiKeys.saving': 'Saving…',
|
||||
|
||||
// Onboarding: Custom wizard (Inference / Voice / OAuth / Search / Memory)
|
||||
'onboarding.custom.stepperInference': 'Inference',
|
||||
'onboarding.custom.stepperVoice': 'Voice',
|
||||
'onboarding.custom.stepperOAuth': 'OAuth',
|
||||
'onboarding.custom.stepperSearch': 'Search',
|
||||
'onboarding.custom.stepperMemory': 'Memory',
|
||||
'onboarding.custom.stepCounter': 'Step {n} of {total}',
|
||||
'onboarding.custom.defaultTitle': 'Default',
|
||||
'onboarding.custom.defaultSubtitle': 'Let OpenHuman manage it for you.',
|
||||
'onboarding.custom.configureTitle': 'Configure',
|
||||
'onboarding.custom.configureSubtitle': "I'll pick what to use.",
|
||||
'onboarding.custom.progressAriaLabel': 'Onboarding progress',
|
||||
'onboarding.custom.continue': 'Continue',
|
||||
'onboarding.custom.back': 'Back',
|
||||
'onboarding.custom.finish': 'Finish Setup',
|
||||
'onboarding.custom.configureLater':
|
||||
"You can finish wiring this up after onboarding. We'll drop you on the matching Settings page once you're done.",
|
||||
'onboarding.custom.openSettings': 'Open in Settings',
|
||||
|
||||
// Onboarding: Custom > Inference (text)
|
||||
'onboarding.custom.inference.title': 'Inference (Text)',
|
||||
'onboarding.custom.inference.subtitle':
|
||||
'Which language model should answer your questions and run your agents?',
|
||||
'onboarding.custom.inference.defaultDesc':
|
||||
'OpenHuman routes every workload to a sensible default model. No keys, no setup.',
|
||||
'onboarding.custom.inference.configureDesc':
|
||||
'Bring your own OpenAI or Anthropic key. We use it for every text-based workload.',
|
||||
|
||||
// Onboarding: Custom > Voice
|
||||
'onboarding.custom.voice.title': 'Voice',
|
||||
'onboarding.custom.voice.subtitle': 'Speech-to-text and text-to-speech for voice mode.',
|
||||
'onboarding.custom.voice.defaultDesc':
|
||||
'OpenHuman ships with managed STT/TTS that just works. Nothing to wire up.',
|
||||
'onboarding.custom.voice.configureDesc':
|
||||
'Use your own ElevenLabs / OpenAI Whisper / etc. Configure in Settings › Voice.',
|
||||
|
||||
// Onboarding: Custom > OAuth (Composio)
|
||||
'onboarding.custom.oauth.title': 'Connections (OAuth)',
|
||||
'onboarding.custom.oauth.subtitle':
|
||||
'Gmail, Slack, Notion, and other connected services that need OAuth.',
|
||||
'onboarding.custom.oauth.defaultDesc':
|
||||
'OpenHuman runs a managed Composio workspace. One click to connect each service later.',
|
||||
'onboarding.custom.oauth.configureDesc':
|
||||
'Bring your own Composio account / API key. Configure in Settings › Connections.',
|
||||
|
||||
// Onboarding: Custom > Search
|
||||
'onboarding.custom.search.title': 'Web Search',
|
||||
'onboarding.custom.search.subtitle': 'How OpenHuman searches the web on your behalf.',
|
||||
'onboarding.custom.search.defaultDesc':
|
||||
'OpenHuman uses a managed search backend. No keys needed.',
|
||||
'onboarding.custom.search.configureDesc':
|
||||
'Bring your own search provider key (Tavily, Brave, etc.). Configure in Settings › Tools.',
|
||||
|
||||
// Onboarding: Custom > Memory
|
||||
'onboarding.custom.memory.title': 'Memory',
|
||||
'onboarding.custom.memory.subtitle':
|
||||
'How OpenHuman remembers your context, preferences, and prior conversations.',
|
||||
'onboarding.custom.memory.defaultDesc':
|
||||
'OpenHuman manages memory storage and retrieval automatically. Nothing to set up.',
|
||||
'onboarding.custom.memory.configureDesc':
|
||||
'Inspect, export, or wipe memory yourself. Configure in Settings › Memory.',
|
||||
|
||||
// Accounts
|
||||
'accounts.addAccount': 'Add Account',
|
||||
'accounts.manageAccounts': 'Manage Accounts',
|
||||
@@ -268,7 +372,7 @@ const en: TranslationMap = {
|
||||
'invites.copyLink': 'Copy Link',
|
||||
|
||||
// Developer Options
|
||||
'devOptions.title': 'Developer Options',
|
||||
'devOptions.title': 'Advanced',
|
||||
'devOptions.diagnostics': 'Diagnostics',
|
||||
'devOptions.diagnosticsDesc': 'System health, logs, and performance metrics',
|
||||
'devOptions.debugPanels': 'Debug Panels',
|
||||
@@ -296,7 +400,9 @@ const en: TranslationMap = {
|
||||
'misc.updateLater': 'Later',
|
||||
'misc.downloading': 'Downloading...',
|
||||
'misc.installing': 'Installing...',
|
||||
'misc.beta': 'Beta',
|
||||
'misc.beta':
|
||||
'OpenHuman is in early beta. Feel free to share feedback or report any bugs you run into — every report helps us ship faster.',
|
||||
'misc.betaFeedback': 'Send feedback',
|
||||
|
||||
// Mnemonic / Recovery
|
||||
'mnemonic.title': 'Recovery Phrase',
|
||||
@@ -917,62 +1023,61 @@ const en: TranslationMap = {
|
||||
'stats.tokens': 'tokens',
|
||||
|
||||
// Boot Check Gate
|
||||
'bootCheck.invalidUrl': 'Please enter a core URL.',
|
||||
'bootCheck.urlMustStartWith': 'URL must start with http:// or https://',
|
||||
'bootCheck.validUrlRequired': 'Please enter a valid URL (e.g. https://core.example.com/rpc)',
|
||||
'bootCheck.tokenRequired': 'Please enter the core auth token.',
|
||||
'bootCheck.chooseCoreMode': 'Choose core mode',
|
||||
'bootCheck.connectToCore': 'Connect to your core',
|
||||
'bootCheck.desktopDescription':
|
||||
'OpenHuman needs a running core to operate. Choose how you want to connect.',
|
||||
'bootCheck.invalidUrl': 'Please enter a runtime URL.',
|
||||
'bootCheck.urlMustStartWith': 'The URL needs to start with http:// or https://',
|
||||
'bootCheck.validUrlRequired':
|
||||
"That doesn't look like a valid URL (try https://core.example.com/rpc)",
|
||||
'bootCheck.tokenRequired': "We'll need an auth token to connect.",
|
||||
'bootCheck.chooseCoreMode': 'Select a Runtime',
|
||||
'bootCheck.connectToCore': 'Connect to Your Runtime',
|
||||
'bootCheck.desktopDescription': 'OpenHuman needs a runtime to think. Pick where it should live.',
|
||||
'bootCheck.webDescription':
|
||||
'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.',
|
||||
'bootCheck.preferDesktop': 'Prefer to run everything on your own device?',
|
||||
'bootCheck.downloadDesktop': 'Download the desktop app',
|
||||
'bootCheck.localRecommended': 'Local (recommended)',
|
||||
'On the web, OpenHuman connects to a runtime you control. Drop in its URL and auth token below, or grab the desktop app to run one right on your machine.',
|
||||
'bootCheck.preferDesktop': 'Rather keep everything on your own device?',
|
||||
'bootCheck.downloadDesktop': 'Get the Desktop App',
|
||||
'bootCheck.localRecommended': 'Run Locally (Recommended)',
|
||||
'bootCheck.localDescription':
|
||||
'Embedded core runs on this device — fastest, no configuration required.',
|
||||
'bootCheck.cloudMode': 'Cloud',
|
||||
'bootCheck.cloudDescription': 'Connect to a remote core at a custom URL.',
|
||||
'bootCheck.coreRpcUrl': 'Core RPC URL',
|
||||
'Runs right here on your computer. Fastest, fully private, nothing to set up.',
|
||||
'bootCheck.cloudMode': 'Run on the Cloud (Complex)',
|
||||
'bootCheck.cloudDescription':
|
||||
"Connect to a runtime you're hosting elsewhere. Stays online 24×7 so you don't need to keep this device running.",
|
||||
'bootCheck.coreRpcUrl': 'Runtime URL',
|
||||
'bootCheck.rpcUrlPlaceholder': 'https://core.example.com/rpc',
|
||||
'bootCheck.authToken': 'Auth token',
|
||||
'bootCheck.bearerTokenPlaceholder': 'Bearer token configured on the remote core',
|
||||
'bootCheck.storedLocally':
|
||||
'Stored on this device only. Required for remote cores — the desktop sends it as ',
|
||||
'bootCheck.authToken': 'Auth Token',
|
||||
'bootCheck.bearerTokenPlaceholder': 'The bearer token from your remote runtime',
|
||||
'bootCheck.storedLocally': 'Kept on this device only. Sent as ',
|
||||
'bootCheck.testing': 'Testing…',
|
||||
'bootCheck.testConnection': 'Test connection',
|
||||
'bootCheck.connectedOk': 'Connected ✓',
|
||||
'bootCheck.authFailed': 'Auth failed — check the token (got 401/403).',
|
||||
'bootCheck.unreachablePrefix': 'Unreachable:',
|
||||
'bootCheck.checkingCore': 'Checking core…',
|
||||
'bootCheck.cannotReach': 'Could not reach core',
|
||||
'bootCheck.cannotReachDesc':
|
||||
'The core process is unreachable. Try switching to a different mode.',
|
||||
'bootCheck.switchMode': 'Switch mode',
|
||||
'bootCheck.testConnection': 'Test Connection',
|
||||
'bootCheck.connectedOk': "Connected. You're good to go.",
|
||||
'bootCheck.authFailed': "That token didn't work. Double-check it and try again.",
|
||||
'bootCheck.unreachablePrefix': "Couldn't reach it:",
|
||||
'bootCheck.checkingCore': 'Waking up your runtime…',
|
||||
'bootCheck.cannotReach': "Can't Reach the Runtime",
|
||||
'bootCheck.cannotReachDesc': "We couldn't connect to your runtime. Want to try a different one?",
|
||||
'bootCheck.switchMode': 'Pick a Different Runtime',
|
||||
'bootCheck.quit': 'Quit',
|
||||
'bootCheck.legacyDetected': 'Legacy background core detected',
|
||||
'bootCheck.legacyDetected': 'Legacy Background Runtime Detected',
|
||||
'bootCheck.legacyDescription':
|
||||
'A separately-installed OpenHuman daemon is running on this device. It must be removed before the embedded core can take over.',
|
||||
'A separately-installed OpenHuman daemon is already running on this device. We need to clear it out before the built-in runtime can take over.',
|
||||
'bootCheck.removing': 'Removing…',
|
||||
'bootCheck.removeContinue': 'Remove and continue',
|
||||
'bootCheck.localNeedsRestart': 'Local core needs a restart',
|
||||
'bootCheck.removeContinue': 'Remove and Continue',
|
||||
'bootCheck.localNeedsRestart': 'Local Runtime Needs a Restart',
|
||||
'bootCheck.localNeedsRestartDesc':
|
||||
'The local core version does not match this app build. Restarting it will load the correct version.',
|
||||
'Your local runtime is on a different version than this app. A quick restart will get them back in sync.',
|
||||
'bootCheck.restarting': 'Restarting…',
|
||||
'bootCheck.restartCore': 'Restart core',
|
||||
'bootCheck.cloudNeedsUpdate': 'Cloud core needs an update',
|
||||
'bootCheck.restartCore': 'Restart Runtime',
|
||||
'bootCheck.cloudNeedsUpdate': 'Cloud Runtime Needs an Update',
|
||||
'bootCheck.cloudNeedsUpdateDesc':
|
||||
'The cloud core version does not match this app build. Run the core updater to resolve the mismatch.',
|
||||
'Your cloud runtime is on a different version than this app. Run the updater to bring them back in sync.',
|
||||
'bootCheck.updating': 'Updating…',
|
||||
'bootCheck.updateCloudCore': 'Update cloud core',
|
||||
'bootCheck.versionCheckFailed': 'Core version check failed',
|
||||
'bootCheck.updateCloudCore': 'Update Cloud Runtime',
|
||||
'bootCheck.versionCheckFailed': 'Runtime Version Check Failed',
|
||||
'bootCheck.versionCheckFailedDesc':
|
||||
'The core is running but does not expose a version endpoint. It may be outdated. Restart or update the core to continue.',
|
||||
"Your runtime is up but isn't reporting its version. It may be outdated. Restart or update it to continue.",
|
||||
'bootCheck.working': 'Working…',
|
||||
'bootCheck.restartUpdateCore': 'Restart / update core',
|
||||
'bootCheck.unexpectedError': 'Unexpected boot-check error',
|
||||
'bootCheck.actionFailed': 'Action failed — please try again.',
|
||||
'bootCheck.restartUpdateCore': 'Restart / Update Runtime',
|
||||
'bootCheck.unexpectedError': 'Unexpected Boot-Check Error',
|
||||
'bootCheck.actionFailed': 'Something went wrong. Please try again.',
|
||||
|
||||
// Notifications: category labels & timestamps
|
||||
'notifications.justNow': 'just now',
|
||||
|
||||
@@ -132,8 +132,9 @@ const zhCN: TranslationMap = {
|
||||
|
||||
// Welcome page
|
||||
'welcome.title': '欢迎使用 OpenHuman',
|
||||
'welcome.subtitle': '你的社区 AI 助手',
|
||||
'welcome.subtitle': '你的私人 AI 超级智能。私密、简单、强大。',
|
||||
'welcome.connectPrompt': '输入 Core RPC 地址以开始使用',
|
||||
'welcome.selectRuntime': '选择运行时',
|
||||
'welcome.urlPlaceholder': 'http://localhost:8089',
|
||||
'welcome.invalidUrl': '请输入有效的 HTTP 或 HTTPS URL',
|
||||
'welcome.connecting': '连接中...',
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function Intelligence() {
|
||||
{ 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 },
|
||||
{ id: 'dreams', label: t('memory.tab.dreams') },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
+23
-142
@@ -4,31 +4,23 @@ import { useState } from 'react';
|
||||
import OAuthProviderButton from '../components/oauth/OAuthProviderButton';
|
||||
import { oauthProviderConfigs } from '../components/oauth/providerConfigs';
|
||||
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
|
||||
import Button from '../components/ui/Button';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { clearBackendUrlCache } from '../services/backendUrl';
|
||||
import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../services/coreRpcClient';
|
||||
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../services/coreRpcClient';
|
||||
import { resetCoreMode } from '../store/coreModeSlice';
|
||||
import { useDeepLinkAuthState } from '../store/deepLinkAuthState';
|
||||
import { useAppDispatch } from '../store/hooks';
|
||||
import { clearAllAppData } from '../utils/clearAllAppData';
|
||||
import {
|
||||
clearStoredRpcUrl,
|
||||
getDefaultRpcUrl,
|
||||
getStoredRpcUrl,
|
||||
isValidRpcUrl,
|
||||
normalizeRpcUrl,
|
||||
storeRpcUrl,
|
||||
} from '../utils/configPersistence';
|
||||
import { clearStoredCoreMode, clearStoredCoreToken, storeRpcUrl } from '../utils/configPersistence';
|
||||
|
||||
const log = createDebug('app:welcome');
|
||||
|
||||
const Welcome = () => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isProcessing, errorMessage, requiresAppDataReset } = useDeepLinkAuthState();
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [rpcUrl, setRpcUrl] = useState(getStoredRpcUrl());
|
||||
const [rpcUrlError, setRpcUrlError] = useState<string | null>(null);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [isClearingAppData, setIsClearingAppData] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
|
||||
@@ -47,70 +39,15 @@ const Welcome = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRpcUrlChange = (value: string) => {
|
||||
setRpcUrl(value);
|
||||
setRpcUrlError(null);
|
||||
setSaveSuccess(false);
|
||||
};
|
||||
|
||||
const handleSaveRpcUrl = () => {
|
||||
const normalized = normalizeRpcUrl(rpcUrl);
|
||||
|
||||
if (!isValidRpcUrl(normalized)) {
|
||||
setRpcUrlError(t('welcome.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
storeRpcUrl(normalized);
|
||||
const handleSelectRuntime = () => {
|
||||
log('[welcome] select-runtime — resetting core mode to return to picker');
|
||||
storeRpcUrl('');
|
||||
clearStoredCoreToken();
|
||||
clearStoredCoreMode();
|
||||
clearCoreRpcUrlCache();
|
||||
clearCoreRpcTokenCache();
|
||||
clearBackendUrlCache();
|
||||
setRpcUrlError(null);
|
||||
setSaveSuccess(true);
|
||||
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
};
|
||||
|
||||
const handleResetRpcUrl = () => {
|
||||
clearStoredRpcUrl();
|
||||
clearCoreRpcUrlCache();
|
||||
clearBackendUrlCache();
|
||||
setRpcUrl(getDefaultRpcUrl());
|
||||
setRpcUrlError(null);
|
||||
setSaveSuccess(false);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
const normalized = normalizeRpcUrl(rpcUrl);
|
||||
|
||||
if (!isValidRpcUrl(normalized)) {
|
||||
setRpcUrlError(t('welcome.invalidUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTestingConnection(true);
|
||||
setRpcUrlError(null);
|
||||
|
||||
try {
|
||||
const response = await testCoreRpcConnection(normalized);
|
||||
|
||||
if (response.ok || response.status === 405) {
|
||||
setSaveSuccess(true);
|
||||
storeRpcUrl(normalized);
|
||||
clearCoreRpcUrlCache();
|
||||
clearBackendUrlCache();
|
||||
} else {
|
||||
setRpcUrlError(
|
||||
t('welcome.connectionFailed')
|
||||
.replace('{status}', String(response.status))
|
||||
.replace('{statusText}', response.statusText)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t('misc.serviceUnavailable');
|
||||
setRpcUrlError(t('welcome.connectionFailedMsg').replace('{message}', message));
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
}
|
||||
dispatch(resetCoreMode());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -131,72 +68,6 @@ const Welcome = () => {
|
||||
{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">
|
||||
{t('welcome.urlPlaceholder')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(false)}
|
||||
className="text-xs text-stone-500 hover:text-stone-700">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="rpc-url-input"
|
||||
type="url"
|
||||
value={rpcUrl}
|
||||
onChange={e => handleRpcUrlChange(e.target.value)}
|
||||
placeholder="http://127.0.0.1:7788/rpc"
|
||||
className="flex-1 rounded-lg border border-stone-300 bg-white px-3 py-2 text-xs text-stone-900 placeholder:text-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
className="px-3 py-2 bg-stone-200 hover:bg-stone-300 text-stone-700 text-xs font-medium rounded-lg transition-colors disabled:opacity-60">
|
||||
{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" />
|
||||
{t('welcome.connecting')}
|
||||
</span>
|
||||
) : (
|
||||
t('welcome.connect')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{rpcUrlError ? (
|
||||
<p className="mt-2 text-xs text-red-600">{rpcUrlError}</p>
|
||||
) : saveSuccess ? (
|
||||
<p className="mt-2 text-xs text-green-600">URL saved successfully.</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
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">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetRpcUrl}
|
||||
className="px-3 py-1.5 bg-stone-200 hover:bg-stone-300 text-stone-700 text-xs font-medium rounded-lg transition-colors">
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="mb-5 text-xs text-stone-500 hover:text-stone-700 underline">
|
||||
{t('welcome.connectPrompt')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -256,6 +127,16 @@ const Welcome = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 px-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={handleSelectRuntime}
|
||||
className="w-full py-3">
|
||||
{t('welcome.selectRuntime')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock('../../hooks/useUsageState', () => ({
|
||||
// Default: return 'ok' so most tests see the normal state.
|
||||
const useAppSelectorMock = vi.fn(() => 'ok' as string);
|
||||
vi.mock('../../store/hooks', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
useAppSelector: (_selector: unknown) => useAppSelectorMock(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearBackendUrlCache } from '../../services/backendUrl';
|
||||
import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../../services/coreRpcClient';
|
||||
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient';
|
||||
import { useDeepLinkAuthState } from '../../store/deepLinkAuthState';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import {
|
||||
clearStoredRpcUrl,
|
||||
getDefaultRpcUrl,
|
||||
getStoredRpcUrl,
|
||||
clearStoredCoreMode,
|
||||
clearStoredCoreToken,
|
||||
storeRpcUrl,
|
||||
} from '../../utils/configPersistence';
|
||||
import Welcome from '../Welcome';
|
||||
@@ -61,6 +61,7 @@ vi.mock('../../utils/clearAllAppData', () => ({
|
||||
|
||||
vi.mock('../../services/coreRpcClient', () => ({
|
||||
clearCoreRpcUrlCache: vi.fn(),
|
||||
clearCoreRpcTokenCache: vi.fn(),
|
||||
testCoreRpcConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -102,17 +103,10 @@ describe('Welcome auth entrypoint', () => {
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(clearStoredRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(testCoreRpcConnection).mockReset();
|
||||
});
|
||||
|
||||
it('renders only the OAuth buttons when auth is idle', () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.queryByLabelText('Email address')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Continue with email' })).not.toBeInTheDocument();
|
||||
@@ -123,7 +117,7 @@ describe('Welcome auth entrypoint', () => {
|
||||
});
|
||||
|
||||
it('delegates OAuth clicks to OAuthProviderButton without an override', () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'google' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'github' }));
|
||||
@@ -142,7 +136,7 @@ describe('Welcome auth entrypoint', () => {
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Signing you in...');
|
||||
});
|
||||
@@ -154,7 +148,7 @@ describe('Welcome auth entrypoint', () => {
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('OAuth failed');
|
||||
expect(
|
||||
@@ -174,14 +168,14 @@ describe('Welcome — decryption-failure recovery action', () => {
|
||||
});
|
||||
|
||||
it('renders the "Clear app data & restart" button when reset is required', () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Clear app data & restart/ })).toBeInTheDocument();
|
||||
expect(screen.getByText(/cloud account is unaffected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes clearAllAppData on click', async () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ }));
|
||||
|
||||
@@ -193,7 +187,7 @@ describe('Welcome — decryption-failure recovery action', () => {
|
||||
it('surfaces an inline error if clearAllAppData rejects', async () => {
|
||||
mockClearAllAppData.mockRejectedValueOnce(new Error('reset blew up'));
|
||||
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -204,7 +198,7 @@ describe('Welcome — decryption-failure recovery action', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — RPC URL advanced panel', () => {
|
||||
describe('Welcome — Select runtime button', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
@@ -212,395 +206,44 @@ describe('Welcome — RPC URL advanced panel', () => {
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearCoreRpcTokenCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(clearStoredRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(testCoreRpcConnection).mockReset();
|
||||
vi.mocked(clearStoredCoreToken).mockReset();
|
||||
vi.mocked(clearStoredCoreMode).mockReset();
|
||||
});
|
||||
|
||||
it('renders with advanced panel collapsed by default', () => {
|
||||
render(<Welcome />);
|
||||
it('renders the "Select a Runtime" button below the card', () => {
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.queryByLabelText('Core RPC URL')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://127.0.0.1:7788/rpc')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Select a Runtime' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the "Configure RPC URL (Advanced)" toggle when panel is collapsed', () => {
|
||||
render(<Welcome />);
|
||||
it('does not render the legacy "Configure RPC URL (Advanced)" toggle', () => {
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole('button', { name: 'Configure RPC URL (Advanced)' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the toggle opens the advanced panel', () => {
|
||||
render(<Welcome />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset to Default' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('panel shows the stored RPC URL as the initial input value', () => {
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://custom-host:9999/rpc');
|
||||
|
||||
render(<Welcome />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue(
|
||||
'http://custom-host:9999/rpc'
|
||||
);
|
||||
});
|
||||
|
||||
it('panel shows the default URL when nothing custom is stored', () => {
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
|
||||
render(<Welcome />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue(
|
||||
'http://127.0.0.1:7788/rpc'
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking Close hides the advanced panel', () => {
|
||||
render(<Welcome />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByPlaceholderText('http://127.0.0.1:7788/rpc')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — Save button', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
it('clicking "Select a Runtime" clears persisted core-mode state and resets caches', () => {
|
||||
const { store } = renderWithProviders(<Welcome />, {
|
||||
preloadedState: { coreMode: { mode: { kind: 'cloud', url: 'http://x', token: 't' } } },
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(clearStoredRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
|
||||
function openPanel() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
}
|
||||
|
||||
it('clicking Save with a valid URL calls storeRpcUrl with the normalised URL', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'http://192.168.1.1:8000/rpc' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(storeRpcUrl).toHaveBeenCalledWith('http://192.168.1.1:8000/rpc');
|
||||
});
|
||||
|
||||
it('clicking Save calls clearCoreRpcUrlCache()', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select a Runtime' }));
|
||||
|
||||
expect(storeRpcUrl).toHaveBeenCalledWith('');
|
||||
expect(clearStoredCoreToken).toHaveBeenCalledTimes(1);
|
||||
expect(clearStoredCoreMode).toHaveBeenCalledTimes(1);
|
||||
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Save calls clearBackendUrlCache()', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(clearCoreRpcTokenCache).toHaveBeenCalledTimes(1);
|
||||
expect(clearBackendUrlCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Save with an invalid URL shows a validation error and does NOT call storeRpcUrl', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'not-a-valid-url' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument();
|
||||
expect(storeRpcUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking Save with empty string shows a validation error', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument();
|
||||
expect(storeRpcUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a success message after a successful save', async () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('URL saved successfully.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — Test Connection button', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(testCoreRpcConnection).mockReset();
|
||||
});
|
||||
|
||||
function openPanel() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
}
|
||||
|
||||
it('clicking Test Connection fires testCoreRpcConnection with the entered URL', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testCoreRpcConnection).toHaveBeenCalledWith('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
});
|
||||
|
||||
it('successful probe (200 ok) shows success message', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await screen.findByText('URL saved successfully.');
|
||||
});
|
||||
|
||||
it('successful probe with 405 status (expected for JSON-RPC ping) shows success message', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 405,
|
||||
statusText: 'Method Not Allowed',
|
||||
} as Response);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await screen.findByText('URL saved successfully.');
|
||||
});
|
||||
|
||||
it('failed probe (4xx/5xx status) shows an error message', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await screen.findByText('Connection failed: 503 Service Unavailable');
|
||||
});
|
||||
|
||||
it('failed probe (network error) shows an error message with the error text', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await screen.findByText('Connection failed: ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('Test Connection with invalid URL shows validation error without calling testCoreRpcConnection', async () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'bad-url' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument();
|
||||
expect(testCoreRpcConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading state while the probe is in flight', async () => {
|
||||
let resolveProbe!: (r: Response) => void;
|
||||
vi.mocked(testCoreRpcConnection).mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveProbe = resolve;
|
||||
})
|
||||
// Redux coreMode slice is reset to `unset` so BootCheckGate returns to picker.
|
||||
expect((store.getState() as { coreMode: { mode: { kind: string } } }).coreMode.mode.kind).toBe(
|
||||
'unset'
|
||||
);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
// During flight the button label changes to "Testing" and the button is disabled
|
||||
const testBtn = screen.getByRole('button', { name: /testing/i });
|
||||
expect(testBtn).toBeDisabled();
|
||||
|
||||
resolveProbe({ ok: true, status: 200 } as Response);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /testing/i })).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('successful probe stores the URL and clears both the RPC and backend URL caches', async () => {
|
||||
vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response);
|
||||
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storeRpcUrl).toHaveBeenCalledWith('http://127.0.0.1:7788/rpc');
|
||||
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
|
||||
expect(clearBackendUrlCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — Reset to Default button', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(clearStoredRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://custom:9999/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
|
||||
function openPanel() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
}
|
||||
|
||||
it('clicking Reset calls clearStoredRpcUrl()', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
|
||||
|
||||
expect(clearStoredRpcUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Reset calls clearCoreRpcUrlCache()', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
|
||||
|
||||
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clicking Reset calls clearBackendUrlCache()', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
|
||||
|
||||
expect(clearBackendUrlCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('after Reset, input value reverts to the default URL', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
// Input starts with the custom stored value
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue(
|
||||
'http://custom:9999/rpc'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue(
|
||||
'http://127.0.0.1:7788/rpc'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome — URL input behaviour', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useDeepLinkAuthState).mockReturnValue({
|
||||
isProcessing: false,
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
vi.mocked(clearCoreRpcUrlCache).mockReset();
|
||||
vi.mocked(clearBackendUrlCache).mockReset();
|
||||
vi.mocked(storeRpcUrl).mockReset();
|
||||
vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
|
||||
function openPanel() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }));
|
||||
}
|
||||
|
||||
it('typing in the input updates the displayed value', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'http://new-host:5555/rpc' } });
|
||||
|
||||
expect(input).toHaveValue('http://new-host:5555/rpc');
|
||||
});
|
||||
|
||||
it('typing a valid URL clears any existing error', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
// First trigger an error
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'bad' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument();
|
||||
|
||||
// Then type a valid URL — error should clear
|
||||
fireEvent.change(input, { target: { value: 'http://valid-host:9000/rpc' } });
|
||||
expect(screen.queryByText('Please enter a valid HTTP or HTTPS URL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invalid URL (missing protocol) shows inline error on Save', () => {
|
||||
render(<Welcome />);
|
||||
openPanel();
|
||||
|
||||
const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc');
|
||||
fireEvent.change(input, { target: { value: 'localhost:9999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -614,7 +257,7 @@ describe('Welcome — OAuth buttons presence', () => {
|
||||
});
|
||||
|
||||
it('renders all providers with showOnWelcome=true', () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'google' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'github' })).toBeInTheDocument();
|
||||
@@ -622,7 +265,7 @@ describe('Welcome — OAuth buttons presence', () => {
|
||||
});
|
||||
|
||||
it('does not render providers with showOnWelcome=false', () => {
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'discord' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -633,7 +276,7 @@ describe('Welcome — OAuth buttons presence', () => {
|
||||
errorMessage: null,
|
||||
requiresAppDataReset: false,
|
||||
});
|
||||
render(<Welcome />);
|
||||
renderWithProviders(<Welcome />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import OnboardingLayout from './OnboardingLayout';
|
||||
// import ChatProviderPage from './pages/ChatProviderPage';
|
||||
import ContextPage from './pages/ContextPage';
|
||||
import SkillsPage from './pages/SkillsPage';
|
||||
import CustomInferencePage from './pages/CustomInferencePage';
|
||||
// Search + Memory steps are hidden from the flow for now (files kept on
|
||||
// disk; uncomment alongside customWizardSteps.ts to re-enable):
|
||||
// import CustomMemoryPage from './pages/CustomMemoryPage';
|
||||
import CustomOAuthPage from './pages/CustomOAuthPage';
|
||||
// import CustomSearchPage from './pages/CustomSearchPage';
|
||||
import CustomVoicePage from './pages/CustomVoicePage';
|
||||
import RuntimeChoicePage from './pages/RuntimeChoicePage';
|
||||
import WelcomePage from './pages/WelcomePage';
|
||||
|
||||
/**
|
||||
* Routed onboarding flow. Each step is a real page under `/onboarding/*`
|
||||
* sharing chrome + draft state through {@link OnboardingLayout}. The flow
|
||||
* runs while `onboarding_completed` is false and ends by calling
|
||||
* `completeAndExit()` (persists the flag, navigates to /home).
|
||||
* Routed onboarding flow.
|
||||
*
|
||||
* welcome → runtime-choice
|
||||
* ├── cloud → /home
|
||||
* └── custom → /custom/inference → voice → oauth → search → memory → /home
|
||||
*
|
||||
* Each custom step asks Default (let OpenHuman manage it) vs Configure
|
||||
* (let me pick). Default is a one-click pick; Configure renders inline
|
||||
* controls (or a deep-link callout to Settings, for domains not yet
|
||||
* embedded). Gmail/Composio (`/onboarding/skills`) and Composio-driven
|
||||
* context gathering (`/onboarding/context`) are gone from the default
|
||||
* flow; their step + page files remain on disk in case we want to revive
|
||||
* them later.
|
||||
*/
|
||||
const Onboarding = () => {
|
||||
return (
|
||||
@@ -18,12 +32,12 @@ const Onboarding = () => {
|
||||
<Route element={<OnboardingLayout />}>
|
||||
<Route index element={<Navigate to="welcome" replace />} />
|
||||
<Route path="welcome" element={<WelcomePage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="context" element={<ContextPage />} />
|
||||
{/* Chat-provider step disabled for now — finalisation happens at
|
||||
the end of SkillsPage / ContextPage instead. Uncomment when
|
||||
the provider picker is ready to ship. */}
|
||||
{/* <Route path="chat-provider" element={<ChatProviderPage />} /> */}
|
||||
<Route path="runtime-choice" element={<RuntimeChoicePage />} />
|
||||
<Route path="custom/inference" element={<CustomInferencePage />} />
|
||||
<Route path="custom/voice" element={<CustomVoicePage />} />
|
||||
<Route path="custom/oauth" element={<CustomOAuthPage />} />
|
||||
{/* <Route path="custom/search" element={<CustomSearchPage />} /> */}
|
||||
{/* <Route path="custom/memory" element={<CustomMemoryPage />} /> */}
|
||||
<Route path="*" element={<Navigate to="welcome" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AiMode = 'cloud' | 'custom';
|
||||
|
||||
export type CustomStepKey = 'inference' | 'voice' | 'oauth' | 'search' | 'memory';
|
||||
export type CustomStepChoice = 'default' | 'configure';
|
||||
|
||||
export interface OnboardingDraft {
|
||||
connectedSources: string[];
|
||||
/** Which AI provisioning path the user chose on the runtime-choice step. */
|
||||
aiMode?: AiMode;
|
||||
/** Per-domain choices made while walking the Custom wizard. */
|
||||
customChoices?: Partial<Record<CustomStepKey, CustomStepChoice>>;
|
||||
}
|
||||
|
||||
export interface OnboardingContextValue {
|
||||
|
||||
@@ -159,7 +159,7 @@ const OnboardingLayout = () => {
|
||||
<div
|
||||
data-testid="onboarding-layout"
|
||||
className="min-h-full relative flex items-center justify-center py-10">
|
||||
<div className="relative z-10 w-full max-w-lg mx-4">
|
||||
<div className="relative z-10 w-full max-w-2xl mx-4">
|
||||
<BetaBanner />
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -35,11 +35,9 @@ const BetaBanner = () => {
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('common.learnMore')}
|
||||
className="font-medium text-amber-800 underline underline-offset-2 hover:text-amber-900">
|
||||
{t('common.learnMore')}
|
||||
</a>{' '}
|
||||
{t('onboarding.welcomeDesc')}
|
||||
{t('misc.betaFeedback')}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Dismiss */}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
|
||||
interface ConfigureLaterCalloutProps {
|
||||
/** Settings-route the user will be deep-linked to after onboarding ends. */
|
||||
settingsHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder rendered inside a CustomWizardStep when the user picks
|
||||
* "Configure" but we don't have inline controls for that domain yet.
|
||||
* Records the user's intent; final wiring lives in the matching Settings
|
||||
* panel, surfaced via `settingsHref` once onboarding completes.
|
||||
*/
|
||||
const ConfigureLaterCallout = ({ settingsHref }: ConfigureLaterCalloutProps) => {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-stone-700 leading-relaxed">
|
||||
{t('onboarding.custom.configureLater')}
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-500" data-testid="configure-later-hint">
|
||||
{t('onboarding.custom.openSettings')}:{' '}
|
||||
<code className="text-stone-700">{settingsHref}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureLaterCallout;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
|
||||
interface WizardStepperProps {
|
||||
/** Ordered labels for each step in the wizard. */
|
||||
labels: string[];
|
||||
/** Zero-based index of the step that is currently active. */
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal step indicator rendered above the body of each custom-wizard
|
||||
* step. Renders a dot per step, connected by lines, with three visual
|
||||
* states: completed (filled sage with a check), active (filled primary),
|
||||
* and upcoming (outlined stone).
|
||||
*/
|
||||
const WizardStepper = ({ labels, activeIndex }: WizardStepperProps) => {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<ol
|
||||
role="list"
|
||||
aria-label={t('onboarding.custom.progressAriaLabel')}
|
||||
className="flex w-full items-start justify-between"
|
||||
data-testid="onboarding-wizard-stepper">
|
||||
{labels.map((label, idx) => {
|
||||
const completed = idx < activeIndex;
|
||||
const active = idx === activeIndex;
|
||||
const isLast = idx === labels.length - 1;
|
||||
|
||||
const dotClasses = completed
|
||||
? 'bg-sage-500 border-sage-500 text-white'
|
||||
: active
|
||||
? 'bg-primary-500 border-primary-500 text-white'
|
||||
: 'bg-white border-stone-300 text-stone-400';
|
||||
|
||||
const labelClasses = completed
|
||||
? 'text-sage-700'
|
||||
: active
|
||||
? 'text-stone-900 font-semibold'
|
||||
: 'text-stone-400';
|
||||
|
||||
const connectorClasses = completed ? 'bg-sage-500' : 'bg-stone-200';
|
||||
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className="relative flex flex-1 flex-col items-center">
|
||||
<div className="flex w-full items-center">
|
||||
{/* Left spacer / connector */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={`h-0.5 flex-1 ${idx === 0 ? 'opacity-0' : connectorClasses}`}
|
||||
/>
|
||||
<div
|
||||
className={`flex h-6 w-6 flex-none items-center justify-center rounded-full border-2 text-[10px] font-semibold ${dotClasses}`}>
|
||||
{completed ? (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 6.5L5 9.5L10 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
{/* Right connector */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={`h-0.5 flex-1 ${isLast ? 'opacity-0' : connectorClasses}`}
|
||||
/>
|
||||
</div>
|
||||
<span className={`mt-2 text-[11px] leading-tight text-center ${labelClasses}`}>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
export default WizardStepper;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CustomStepKey } from './OnboardingContext';
|
||||
|
||||
/** Ordered list of custom-wizard steps. Index drives the step counter UI and
|
||||
* the back/continue navigation. `search` and `memory` are commented out for
|
||||
* now — their pages still exist and route in case we want to re-enable. */
|
||||
export const CUSTOM_WIZARD_STEPS: CustomStepKey[] = [
|
||||
'inference',
|
||||
'voice',
|
||||
'oauth',
|
||||
// 'search',
|
||||
// 'memory',
|
||||
];
|
||||
|
||||
export const CUSTOM_WIZARD_ROUTES: Record<CustomStepKey, string> = {
|
||||
inference: '/onboarding/custom/inference',
|
||||
voice: '/onboarding/custom/voice',
|
||||
oauth: '/onboarding/custom/oauth',
|
||||
search: '/onboarding/custom/search',
|
||||
memory: '/onboarding/custom/memory',
|
||||
};
|
||||
|
||||
/** Deep-link target inside Settings for users who pick "Configure" and want
|
||||
* to finish wiring this domain up after onboarding. */
|
||||
export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record<CustomStepKey, string> = {
|
||||
inference: '/settings/llm',
|
||||
voice: '/settings/voice',
|
||||
oauth: '/settings/connections',
|
||||
search: '/settings/tools',
|
||||
memory: '/settings/memory-data',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import ApiKeysStep from '../steps/ApiKeysStep';
|
||||
|
||||
const ApiKeysPage = () => {
|
||||
const { completeAndExit } = useOnboardingContext();
|
||||
|
||||
const finish = async (skipped: boolean) => {
|
||||
trackEvent('onboarding_step_complete', { step_name: 'api_keys', skipped });
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:api-keys-page] completeAndExit failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return <ApiKeysStep onNext={() => void finish(false)} onSkip={() => void finish(true)} />;
|
||||
};
|
||||
|
||||
export default ApiKeysPage;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AIPanel from '../../../components/settings/panels/AIPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'inference' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomInferencePage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-inference-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.inference.title')}
|
||||
subtitle={t('onboarding.custom.inference.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.inference.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.inference.configureDesc')}
|
||||
configureContent={<AIPanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate('/onboarding/runtime-choice')}
|
||||
onContinue={async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_inference',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
if (isLast) {
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-inference] completeAndExit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
|
||||
}}
|
||||
continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInferencePage;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import MemoryDataPanel from '../../../components/settings/panels/MemoryDataPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'memory' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomMemoryPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_memory',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-memory] completeAndExit failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-memory-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.memory.title')}
|
||||
subtitle={t('onboarding.custom.memory.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.memory.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.memory.configureDesc')}
|
||||
configureContent={<MemoryDataPanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={() => void handleFinish()}
|
||||
continueLabel={t('onboarding.custom.finish')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomMemoryPage;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ComposioPanel from '../../../components/settings/panels/ComposioPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'oauth' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomOAuthPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-oauth-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.oauth.title')}
|
||||
subtitle={t('onboarding.custom.oauth.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.oauth.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.oauth.configureDesc')}
|
||||
configureContent={<ComposioPanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_oauth',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
if (isLast) {
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-oauth] completeAndExit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
|
||||
}}
|
||||
continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomOAuthPage;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ToolsPanel from '../../../components/settings/panels/ToolsPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'search' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomSearchPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-search-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.search.title')}
|
||||
subtitle={t('onboarding.custom.search.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.search.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.search.configureDesc')}
|
||||
configureContent={<ToolsPanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={() => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_search',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSearchPage;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import VoicePanel from '../../../components/settings/panels/VoicePanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'voice' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomVoicePage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const isLast = STEP_INDEX === CUSTOM_WIZARD_STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-voice-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.voice.title')}
|
||||
subtitle={t('onboarding.custom.voice.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.voice.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.voice.configureDesc')}
|
||||
configureContent={<VoicePanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_voice',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
if (isLast) {
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-voice] completeAndExit failed', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX + 1]]);
|
||||
}}
|
||||
continueLabel={isLast ? t('onboarding.custom.finish') : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomVoicePage;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import RuntimeChoiceStep from '../steps/RuntimeChoiceStep';
|
||||
|
||||
const RuntimeChoicePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
return (
|
||||
<RuntimeChoiceStep
|
||||
onNext={async mode => {
|
||||
setDraft(prev => ({ ...prev, aiMode: mode }));
|
||||
trackEvent('onboarding_step_complete', { step_name: 'runtime_choice', ai_mode: mode });
|
||||
|
||||
if (mode === 'custom') {
|
||||
navigate('/onboarding/custom/inference');
|
||||
return;
|
||||
}
|
||||
// Cloud path: nothing else to configure, finish onboarding.
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:runtime-choice-page] completeAndExit failed', err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuntimeChoicePage;
|
||||
@@ -15,7 +15,7 @@ const WelcomePage = () => {
|
||||
<WelcomeStep
|
||||
onNext={() => {
|
||||
trackEvent('onboarding_step_complete', { step_name: 'welcome' });
|
||||
navigate('/onboarding/skills');
|
||||
navigate('/onboarding/runtime-choice');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { setCloudProviderKey } from '../../../services/api/aiSettingsApi';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
interface ApiKeysStepProps {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
|
||||
const { t } = useT();
|
||||
const [openai, setOpenai] = useState('');
|
||||
const [anthropic, setAnthropic] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedOpenai = openai.trim();
|
||||
const trimmedAnthropic = anthropic.trim();
|
||||
if (!trimmedOpenai && !trimmedAnthropic) {
|
||||
onSkip();
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (trimmedOpenai) {
|
||||
await setCloudProviderKey('openai', trimmedOpenai);
|
||||
}
|
||||
if (trimmedAnthropic) {
|
||||
await setCloudProviderKey('anthropic', trimmedAnthropic);
|
||||
}
|
||||
onNext();
|
||||
} catch (err) {
|
||||
console.warn('[onboarding:api-keys] save failed', err);
|
||||
setError(t('onboarding.apiKeys.saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="onboarding-api-keys-step"
|
||||
className="rounded-2xl bg-white p-10 shadow-soft animate-fade-up">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-display text-stone-900 mb-2 leading-tight">
|
||||
{t('onboarding.apiKeys.title')}
|
||||
</h1>
|
||||
<p className="text-stone-500 text-sm leading-relaxed">{t('onboarding.apiKeys.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="onboarding-openai-key" className="text-xs font-medium text-stone-700">
|
||||
{t('onboarding.apiKeys.openaiLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="onboarding-openai-key"
|
||||
data-testid="onboarding-api-keys-openai-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('onboarding.apiKeys.openaiPlaceholder')}
|
||||
value={openai}
|
||||
onChange={e => {
|
||||
setOpenai(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="onboarding-anthropic-key" className="text-xs font-medium text-stone-700">
|
||||
{t('onboarding.apiKeys.anthropicLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="onboarding-anthropic-key"
|
||||
data-testid="onboarding-api-keys-anthropic-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t('onboarding.apiKeys.anthropicPlaceholder')}
|
||||
value={anthropic}
|
||||
onChange={e => {
|
||||
setAnthropic(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 placeholder-stone-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-xs font-medium text-red-600">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<OnboardingNextButton
|
||||
label={t('onboarding.apiKeys.continue')}
|
||||
loading={saving}
|
||||
loadingLabel={t('onboarding.apiKeys.saving')}
|
||||
onClick={() => void handleSave()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={saving}
|
||||
className="text-xs text-stone-500 hover:text-stone-700 underline disabled:opacity-50">
|
||||
{t('onboarding.apiKeys.skipForNow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeysStep;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
import WizardStepper from '../components/WizardStepper';
|
||||
import type { CustomStepChoice } from '../OnboardingContext';
|
||||
|
||||
interface ChoiceCardProps {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
accent: 'sage' | 'primary';
|
||||
title: string;
|
||||
description: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const ChoiceCard = ({ selected, onClick, accent, title, description, testId }: ChoiceCardProps) => {
|
||||
const selectedClasses =
|
||||
accent === 'sage'
|
||||
? '!border-sage-500 bg-sage-50 shadow-sm'
|
||||
: '!border-primary-500 bg-primary-50 shadow-sm';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
data-testid={testId}
|
||||
className={`flex h-full w-full flex-col rounded-2xl border-2 p-5 text-left transition-colors focus:outline-none ${
|
||||
selected
|
||||
? selectedClasses
|
||||
: '!border-stone-200 bg-white hover:!border-stone-300 hover:bg-stone-50'
|
||||
}`}>
|
||||
<h3 className="text-base font-semibold text-stone-900">{title}</h3>
|
||||
<p className="mt-1 text-xs text-stone-600 leading-relaxed">{description}</p>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomWizardStepProps {
|
||||
stepIndex: number;
|
||||
stepCount: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
defaultDescription: string;
|
||||
configureDescription: string;
|
||||
/** Inline content rendered below the choice cards when 'configure' is picked. */
|
||||
configureContent?: ReactNode;
|
||||
choice: CustomStepChoice | null;
|
||||
onChoiceChange: (choice: CustomStepChoice) => void;
|
||||
onBack: () => void;
|
||||
onContinue: () => void | Promise<void>;
|
||||
/** Continue label override (used for the final "Finish setup" step). */
|
||||
continueLabel?: string;
|
||||
/** Disable the continue button (e.g. while an inline save is in flight). */
|
||||
continueDisabled?: boolean;
|
||||
/** Replace the continue button text with a busy label while loading. */
|
||||
continueLoading?: boolean;
|
||||
continueLoadingLabel?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
const CustomWizardStep = ({
|
||||
stepIndex,
|
||||
stepCount,
|
||||
title,
|
||||
subtitle,
|
||||
defaultDescription,
|
||||
configureDescription,
|
||||
configureContent,
|
||||
choice,
|
||||
onChoiceChange,
|
||||
onBack,
|
||||
onContinue,
|
||||
continueLabel,
|
||||
continueDisabled,
|
||||
continueLoading,
|
||||
continueLoadingLabel,
|
||||
testId,
|
||||
}: CustomWizardStepProps) => {
|
||||
const { t } = useT();
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isContinuing || choice === null || continueDisabled) return;
|
||||
try {
|
||||
setIsContinuing(true);
|
||||
await onContinue();
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepperLabels = [
|
||||
t('onboarding.custom.stepperInference'),
|
||||
t('onboarding.custom.stepperVoice'),
|
||||
t('onboarding.custom.stepperOAuth'),
|
||||
t('onboarding.custom.stepperSearch'),
|
||||
t('onboarding.custom.stepperMemory'),
|
||||
].slice(0, stepCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testId ?? 'onboarding-custom-wizard-step'}
|
||||
className="rounded-2xl bg-white p-10 shadow-soft animate-fade-up">
|
||||
<WizardStepper labels={stepperLabels} activeIndex={stepIndex} />
|
||||
|
||||
<h1 className="mt-8 text-2xl font-display text-stone-900 leading-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm text-stone-500 leading-relaxed">{subtitle}</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-stretch">
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-default`}
|
||||
accent="sage"
|
||||
selected={choice === 'default'}
|
||||
onClick={() => onChoiceChange('default')}
|
||||
title={t('onboarding.custom.defaultTitle')}
|
||||
description={defaultDescription || t('onboarding.custom.defaultSubtitle')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
testId={`${testId ?? 'onboarding-custom-wizard-step'}-configure`}
|
||||
accent="primary"
|
||||
selected={choice === 'configure'}
|
||||
onClick={() => onChoiceChange('configure')}
|
||||
title={t('onboarding.custom.configureTitle')}
|
||||
description={configureDescription || t('onboarding.custom.configureSubtitle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{choice === 'configure' && configureContent ? (
|
||||
<div className="mt-6 rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||
{configureContent}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-700 hover:bg-stone-50 focus:outline-none">
|
||||
{t('onboarding.custom.back')}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<OnboardingNextButton
|
||||
label={continueLabel ?? t('onboarding.custom.continue')}
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={choice === null || continueDisabled || isContinuing}
|
||||
loading={continueLoading || isContinuing}
|
||||
loadingLabel={continueLoadingLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomWizardStep;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
import type { AiMode } from '../OnboardingContext';
|
||||
|
||||
interface RuntimeChoiceStepProps {
|
||||
onNext: (mode: AiMode) => void;
|
||||
}
|
||||
|
||||
type Accent = 'sage' | 'primary';
|
||||
|
||||
interface ChoiceCardProps {
|
||||
selected: boolean;
|
||||
accent: Accent;
|
||||
onClick: () => void;
|
||||
badge?: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
features: string[];
|
||||
highlight?: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const ACCENT_CLASSES: Record<
|
||||
Accent,
|
||||
{ selected: string; dot: string; badge: string; highlight: string }
|
||||
> = {
|
||||
sage: {
|
||||
selected: '!border-sage-500 bg-sage-50 shadow-sm',
|
||||
dot: 'bg-sage-500',
|
||||
badge: 'bg-sage-500/10 text-sage-700',
|
||||
highlight: 'border-sage-300 bg-sage-100 text-sage-800',
|
||||
},
|
||||
primary: {
|
||||
selected: '!border-primary-500 bg-primary-50 shadow-sm',
|
||||
dot: 'bg-primary-500',
|
||||
badge: 'bg-primary-500/10 text-primary-600',
|
||||
highlight: 'border-primary-200 bg-primary-50 text-primary-700',
|
||||
},
|
||||
};
|
||||
|
||||
const ChoiceCard = ({
|
||||
selected,
|
||||
accent,
|
||||
onClick,
|
||||
badge,
|
||||
title,
|
||||
tagline,
|
||||
features,
|
||||
highlight,
|
||||
testId,
|
||||
}: ChoiceCardProps) => {
|
||||
const accentClasses = ACCENT_CLASSES[accent];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
data-testid={testId}
|
||||
className={`flex h-full w-full flex-col rounded-2xl border-2 p-5 text-left transition-colors focus:outline-none ${
|
||||
selected
|
||||
? accentClasses.selected
|
||||
: '!border-stone-200 bg-white hover:!border-stone-300 hover:bg-stone-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-stone-900">{title}</h3>
|
||||
{badge ? (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${accentClasses.badge}`}>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stone-500">{tagline}</p>
|
||||
<ul className="mt-3 flex flex-1 flex-col gap-1.5">
|
||||
{features.map(f => (
|
||||
<li key={f} className="flex items-start gap-2 text-xs text-stone-700">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`mt-1 inline-block h-1.5 w-1.5 flex-none rounded-full ${accentClasses.dot}`}
|
||||
/>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{highlight ? (
|
||||
<div
|
||||
className={`mt-4 rounded-lg border px-3 py-2 text-xs font-semibold ${accentClasses.highlight}`}>
|
||||
{highlight}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const RuntimeChoiceStep = ({ onNext }: RuntimeChoiceStepProps) => {
|
||||
const { t } = useT();
|
||||
const [selected, setSelected] = useState<AiMode | null>('cloud');
|
||||
|
||||
const cloudFeatures = [
|
||||
t('onboarding.runtimeChoice.cloud.f1'),
|
||||
t('onboarding.runtimeChoice.cloud.f2'),
|
||||
t('onboarding.runtimeChoice.cloud.f3'),
|
||||
t('onboarding.runtimeChoice.cloud.f4'),
|
||||
t('onboarding.runtimeChoice.cloud.f5'),
|
||||
];
|
||||
const customFeatures = [
|
||||
t('onboarding.runtimeChoice.custom.f1'),
|
||||
t('onboarding.runtimeChoice.custom.f2'),
|
||||
t('onboarding.runtimeChoice.custom.f3'),
|
||||
t('onboarding.runtimeChoice.custom.f4'),
|
||||
t('onboarding.runtimeChoice.custom.f5'),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="onboarding-runtime-choice-step"
|
||||
className="rounded-2xl bg-white p-10 shadow-soft animate-fade-up">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-display text-stone-900 mb-2 leading-tight">
|
||||
{t('onboarding.runtimeChoice.title')}
|
||||
</h1>
|
||||
<p className="text-stone-500 text-sm leading-relaxed">
|
||||
{t('onboarding.runtimeChoice.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-stretch">
|
||||
<ChoiceCard
|
||||
testId="onboarding-runtime-choice-cloud"
|
||||
accent="sage"
|
||||
selected={selected === 'cloud'}
|
||||
onClick={() => setSelected('cloud')}
|
||||
badge={t('onboarding.runtimeChoice.recommended')}
|
||||
title={t('onboarding.runtimeChoice.cloud.title')}
|
||||
tagline={t('onboarding.runtimeChoice.cloud.tagline')}
|
||||
features={cloudFeatures}
|
||||
highlight={t('onboarding.runtimeChoice.cloud.creditHighlight')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
testId="onboarding-runtime-choice-custom"
|
||||
accent="primary"
|
||||
selected={selected === 'custom'}
|
||||
onClick={() => setSelected('custom')}
|
||||
title={t('onboarding.runtimeChoice.custom.title')}
|
||||
tagline={t('onboarding.runtimeChoice.custom.tagline')}
|
||||
features={customFeatures}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<OnboardingNextButton
|
||||
label={
|
||||
selected === 'custom'
|
||||
? t('onboarding.runtimeChoice.continueCustom')
|
||||
: t('onboarding.runtimeChoice.continueCloud')
|
||||
}
|
||||
disabled={selected === null}
|
||||
onClick={() => selected && onNext(selected)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuntimeChoiceStep;
|
||||
@@ -256,6 +256,162 @@ export async function setCloudProviderKey(
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Provider key validation ──────────────────────────────────────────────
|
||||
//
|
||||
// Sanity-checks an API key by hitting the provider's "list models" endpoint.
|
||||
// Returns `ok: true` plus the model count on success, or `ok: false` with a
|
||||
// short error string on failure. Best-effort: providers we don't know how
|
||||
// to validate against (custom / local runtimes) resolve to `{ ok: true }`
|
||||
// without making a request, since "no failure" is the most useful default.
|
||||
|
||||
export interface ProviderValidationResult {
|
||||
ok: boolean;
|
||||
/** Number of models the endpoint returned on success, when available. */
|
||||
modelCount?: number;
|
||||
/** Sorted model IDs returned by the endpoint, when parseable. Used by
|
||||
* the custom-routing dialog to populate a model dropdown without
|
||||
* having to round-trip back through the core for the decrypted key. */
|
||||
modelIds?: string[];
|
||||
/** Short human-readable failure reason on `ok: false`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ValidationEndpoint {
|
||||
url: string;
|
||||
authHeader: (apiKey: string) => Record<string, string>;
|
||||
/** Extract the models list from the parsed JSON response, if shaped. */
|
||||
extractList?: (json: unknown) => unknown[] | null;
|
||||
}
|
||||
|
||||
const PROVIDER_VALIDATION: Partial<Record<CloudProviderType, ValidationEndpoint>> = {
|
||||
openai: {
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
authHeader: key => ({ Authorization: `Bearer ${key}` }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
anthropic: {
|
||||
url: 'https://api.anthropic.com/v1/models',
|
||||
authHeader: key => ({ 'x-api-key': key, 'anthropic-version': '2023-06-01' }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
openrouter: {
|
||||
url: 'https://openrouter.ai/api/v1/models',
|
||||
authHeader: key => ({ Authorization: `Bearer ${key}` }),
|
||||
extractList: json =>
|
||||
typeof json === 'object' &&
|
||||
json &&
|
||||
'data' in json &&
|
||||
Array.isArray((json as Record<string, unknown>).data)
|
||||
? ((json as Record<string, unknown>).data as unknown[])
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
export async function validateCloudProviderKey(
|
||||
providerType: CloudProviderType,
|
||||
apiKey: string
|
||||
): Promise<ProviderValidationResult> {
|
||||
const cfg = PROVIDER_VALIDATION[providerType];
|
||||
if (!cfg) {
|
||||
// Custom / local-runtime entries don't have a known models endpoint;
|
||||
// accept the key as-is.
|
||||
return { ok: true };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(cfg.url, { method: 'GET', headers: cfg.authHeader(apiKey) });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { ok: false, error: 'Key rejected — check it and try again.' };
|
||||
}
|
||||
return { ok: false, error: `Models endpoint returned ${res.status} ${res.statusText}.` };
|
||||
}
|
||||
let modelCount: number | undefined;
|
||||
let modelIds: string[] | undefined;
|
||||
try {
|
||||
const json = (await res.json()) as unknown;
|
||||
const list = cfg.extractList?.(json);
|
||||
if (Array.isArray(list)) {
|
||||
modelCount = list.length;
|
||||
const ids = list
|
||||
.map(entry => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (entry && typeof entry === 'object' && 'id' in (entry as Record<string, unknown>)) {
|
||||
const id = (entry as Record<string, unknown>).id;
|
||||
if (typeof id === 'string') return id;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
if (ids.length > 0) modelIds = ids.sort();
|
||||
}
|
||||
} catch {
|
||||
// Body parse failed — the 2xx alone is good enough.
|
||||
}
|
||||
return { ok: true, modelCount, modelIds };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Provider model-id cache ─────────────────────────────────────────────
|
||||
//
|
||||
// `validateCloudProviderKey` is the only path where the renderer has the
|
||||
// plaintext key in hand, so it doubles as the source of truth for the
|
||||
// per-provider model dropdown shown in the custom-routing dialog. Cache
|
||||
// the IDs here (localStorage) so the dropdown is populated immediately on
|
||||
// subsequent loads without re-prompting the user for their key.
|
||||
|
||||
const MODEL_CACHE_PREFIX = 'openhuman.provider_model_ids.v1.';
|
||||
|
||||
function modelCacheKey(providerType: CloudProviderType): string {
|
||||
return `${MODEL_CACHE_PREFIX}${providerType}`;
|
||||
}
|
||||
|
||||
export function cacheProviderModelIds(providerType: CloudProviderType, modelIds: string[]): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(modelCacheKey(providerType), JSON.stringify(modelIds));
|
||||
} catch {
|
||||
// Storage full / blocked — caching is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProviderModelIds(providerType: CloudProviderType): string[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(modelCacheKey(providerType));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((s): s is string => typeof s === 'string' && s.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// Bad JSON / parse failure — fall through.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function clearProviderModelIds(providerType: CloudProviderType): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(modelCacheKey(providerType));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a stored API key. */
|
||||
export async function clearCloudProviderKey(providerType: CloudProviderType): Promise<void> {
|
||||
if (providerType === 'openhuman') {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import channelConnectionsReducer from '../store/channelConnectionsSlice';
|
||||
import connectivityReducer from '../store/connectivitySlice';
|
||||
import coreModeReducer from '../store/coreModeSlice';
|
||||
import localeReducer from '../store/localeSlice';
|
||||
import mascotReducer from '../store/mascotSlice';
|
||||
import socketReducer from '../store/socketSlice';
|
||||
|
||||
@@ -26,6 +27,7 @@ const testRootReducer = combineReducers({
|
||||
channelConnections: channelConnectionsReducer,
|
||||
connectivity: connectivityReducer,
|
||||
coreMode: coreModeReducer,
|
||||
locale: localeReducer,
|
||||
mascot: mascotReducer,
|
||||
socket: socketReducer,
|
||||
});
|
||||
|
||||
@@ -98,18 +98,10 @@ const BASE_PROVIDERS: ProviderDescriptor[] = [
|
||||
description: 'Discord servers and DMs — channel list and unread counts.',
|
||||
serviceUrl: 'https://discord.com/channels/@me',
|
||||
},
|
||||
{
|
||||
id: 'google-meet',
|
||||
label: 'Google Meet',
|
||||
description: 'Join Google Meet calls and capture live captions.',
|
||||
serviceUrl: 'https://meet.google.com/',
|
||||
},
|
||||
{
|
||||
id: 'zoom',
|
||||
label: 'Zoom',
|
||||
description: 'Zoom web client — log in and join meetings from inside the app.',
|
||||
serviceUrl: 'https://zoom.us/',
|
||||
},
|
||||
// Google Meet + Zoom are hidden from the picker for now — usage is low
|
||||
// and the integrations need more polish before re-surfacing them. Their
|
||||
// AccountProvider ids stay in the type union so existing accounts keep
|
||||
// rendering correctly.
|
||||
];
|
||||
|
||||
const DEV_PROVIDERS: ProviderDescriptor[] = [
|
||||
|
||||
Reference in New Issue
Block a user