feat: redesign onboarding, boot, and LLM settings end-to-end (#1885)

This commit is contained in:
Steven Enamakel
2026-05-15 19:28:51 -07:00
committed by GitHub
parent 02cf2cee3e
commit 05de1c4e8a
48 changed files with 2625 additions and 1873 deletions
+3 -3
View File
@@ -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
+20
View File
@@ -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)', () => {
+3 -15
View File
@@ -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'),
+47
View File
@@ -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;
+39 -152
View File
@@ -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&apos;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&apos;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&apos;t be visible</span> they live in
TinyHumans&apos; Composio tenant.
<span className="font-semibold">won&apos;t be visible</span> they live in the
OpenHuman-managed Composio tenant.
</p>
<p>You&apos;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
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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',
+2 -1
View File
@@ -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': '连接中...',
+1 -1
View File
@@ -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
View File
@@ -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>
);
-1
View File
@@ -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(),
}));
+38 -395
View File
@@ -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();
});
+27 -13
View File
@@ -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;
+156
View File
@@ -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') {
+2
View File
@@ -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,
});
+4 -12
View File
@@ -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[] = [