From dd2e33e1c656b136579e1dc60110e6cd04b8f177 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:29:19 -0700 Subject: [PATCH] fix: onboarding flow - registry skills, config-based flag, dead code cleanup (#177) * feat(onboarding): enhance onboarding steps with back navigation and UI improvements - Added back navigation functionality to all onboarding steps (LocalAIStep, ScreenPermissionsStep, SkillsStep, ToolsStep, MnemonicStep) for improved user experience. - Updated Onboarding component to adjust total steps from 7 to 6. - Modified ProgressIndicator to reflect the change in total steps. - Enhanced LocalAIStep UI by removing unnecessary state and improving layout. - Improved error handling and user feedback in MnemonicStep and ScreenPermissionsStep. - General UI refinements across onboarding steps for better consistency and usability. * refactor(onboarding): update skip button and UI elements for improved user experience - Replaced "Set up later" button with a "Skip" button in the Onboarding component for better clarity. - Removed unnecessary back navigation buttons from LocalAIStep to streamline the UI. - Enhanced messaging in ScreenPermissionsStep to clarify data processing and permissions. - Adjusted button layouts for improved accessibility and consistency across onboarding steps. * refactor(onboarding): streamline button layout and styling in onboarding steps - Updated button styles in ScreenPermissionsStep and ToolsStep for consistency and improved user experience. - Replaced the back navigation button in ToolsStep with a full-width continue button, enhancing layout and accessibility. * refactor(onboarding): update ProgressIndicator and button styles for improved consistency - Adjusted ProgressIndicator component to use 'bg-sage-500' for active steps and increased height for better visibility. - Streamlined button styles in LocalAIStep and SkillsStep for a more cohesive user experience, including removing unnecessary margins and ensuring full-width buttons where applicable. - Enhanced ToolsStep to focus on skill installation, updating UI elements and descriptions for clarity and improved user engagement. * refactor(onboarding): update SkillsStep title and description for clarity - Changed the title from "Install Skills" to "Connect Skills" to better reflect the action. - Revised the description to clarify that skills interact with the user's workflow and that all data is processed locally, enhancing user understanding of the feature. * refactor(onboarding): enhance onboarding steps with UI improvements and functionality updates - Integrated useUser hook in Onboarding component for better user state management. - Updated MnemonicStep title and messaging for clarity, emphasizing the importance of the recovery phrase. - Streamlined button layout in MnemonicStep and ScreenPermissionsStep for improved accessibility and consistency. - Refactored SkillsStep to utilize available skills more effectively, enhancing the user experience with clearer skill descriptions and connection statuses. - Transitioned ToolsStep to focus on enabling tools, updating UI elements and descriptions for better user guidance. * refactor(onboarding): replace workspace flag checks with onboarding_completed config - Removed Redux state checks for onboarding status and workspace flags. - Integrated new core config methods to read and write onboarding completion status. - Updated OnboardingOverlay and Onboarding components to utilize onboarding_completed for flow control. - Enhanced error handling and user feedback during onboarding state persistence. * refactor(onboarding): remove deferred flow and dead workspace flag code The onboarding_completed config field is now the sole source of truth. Skip and complete both write the same flag. Remove SetupBanner, onboardingDeferredByUser, selectOnboardingDeferred, and the old workspace flag file functions (openhumanWorkspaceOnboardingFlagExists/Set). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve lint errors for unused onBack params in onboarding steps Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove unused handleSkip in LocalAIStep Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/src/App.tsx | 2 - app/src/components/OnboardingOverlay.tsx | 62 ++--- app/src/components/ProgressIndicator.tsx | 4 +- app/src/components/SetupBanner.tsx | 70 ------ .../components/__tests__/SetupBanner.test.tsx | 82 ------- app/src/components/settings/SettingsHome.tsx | 13 +- app/src/pages/onboarding/Onboarding.tsx | 65 ++--- .../pages/onboarding/steps/LocalAIStep.tsx | 113 +++------ .../pages/onboarding/steps/MnemonicStep.tsx | 12 +- .../steps/ScreenPermissionsStep.tsx | 51 ++-- app/src/pages/onboarding/steps/SkillsStep.tsx | 227 +++++++++--------- app/src/pages/onboarding/steps/ToolsStep.tsx | 5 +- app/src/providers/UserProvider.tsx | 6 +- app/src/store/__tests__/authSlice.test.ts | 3 - app/src/store/authSelectors.ts | 6 - app/src/store/authSlice.ts | 16 -- app/src/store/index.ts | 1 - app/src/utils/tauriCommands.ts | 49 ++-- src/openhuman/config/ops.rs | 18 ++ src/openhuman/config/schema/types.rs | 5 + src/openhuman/config/schemas.rs | 55 +++++ 21 files changed, 351 insertions(+), 514 deletions(-) delete mode 100644 app/src/components/SetupBanner.tsx delete mode 100644 app/src/components/__tests__/SetupBanner.test.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index b88b8d11c..ea6cf4278 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -9,7 +9,6 @@ import ErrorFallbackScreen from './components/ErrorFallbackScreen'; import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import MiniSidebar from './components/MiniSidebar'; import OnboardingOverlay from './components/OnboardingOverlay'; -import SetupBanner from './components/SetupBanner'; import SocketProvider from './providers/SocketProvider'; import UserProvider from './providers/UserProvider'; import { tagErrorSource } from './services/errorReportQueue'; @@ -45,7 +44,6 @@ function App() {
-
diff --git a/app/src/components/OnboardingOverlay.tsx b/app/src/components/OnboardingOverlay.tsx index a4b9f7cb6..57abe3b96 100644 --- a/app/src/components/OnboardingOverlay.tsx +++ b/app/src/components/OnboardingOverlay.tsx @@ -2,31 +2,26 @@ import { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import Onboarding from '../pages/onboarding/Onboarding'; -import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors'; -import { setOnboardingDeferredForUser } from '../store/authSlice'; -import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { useAppSelector } from '../store/hooks'; import { DEV_FORCE_ONBOARDING } from '../utils/config'; import { - DEFAULT_WORKSPACE_ONBOARDING_FLAG, - openhumanWorkspaceOnboardingFlagExists, + getOnboardingCompleted, + setOnboardingCompleted as persistOnboardingCompleted, } from '../utils/tauriCommands'; /** * Full-screen overlay that renders the onboarding flow on top of any page * when the user has not completed onboarding. * - * Checks both Redux `isOnboarded` and the workspace flag file. - * Waits for the user profile to load before making a decision. + * Reads `onboarding_completed` from the core config (persisted in config.toml). */ const OnboardingOverlay = () => { - const dispatch = useAppDispatch(); const token = useAppSelector(state => state.auth.token); const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); const user = useAppSelector(state => state.user.user); - const isOnboarded = useAppSelector(selectIsOnboarded); - const isDeferred = useAppSelector(selectOnboardingDeferred); - const [hasWorkspaceFlag, setHasWorkspaceFlag] = useState(null); - const [dismissed, setDismissed] = useState(false); + + /** null = still loading, true/false = resolved from core config */ + const [onboardingCompleted, setOnboardingCompleted] = useState(null); const [userLoadTimedOut, setUserLoadTimedOut] = useState(false); // Timeout: if user profile hasn't loaded after 3s but we have token + bootstrap, @@ -38,58 +33,49 @@ const OnboardingOverlay = () => { return () => clearTimeout(timer); }, [token, isAuthBootstrapComplete, user?._id]); - // User is ready when profile loaded or timeout elapsed. - // Note: userLoadTimedOut is sticky across sessions but harmless — when token - // is null (logged out) the early-return guard prevents any visible effect, - // and the workspace flag check doesn't require userId. const userReady = !!user?._id || userLoadTimedOut; + + // Read onboarding_completed from core config. useEffect(() => { if (!token || !isAuthBootstrapComplete || !userReady) return; let mounted = true; const check = async () => { try { - const exists = await openhumanWorkspaceOnboardingFlagExists( - DEFAULT_WORKSPACE_ONBOARDING_FLAG - ); - if (mounted) setHasWorkspaceFlag(exists); + const completed = await getOnboardingCompleted(); + if (mounted) setOnboardingCompleted(completed); } catch { - if (mounted) setHasWorkspaceFlag(false); + if (mounted) setOnboardingCompleted(false); } }; void check(); return () => { mounted = false; }; - }, [token, isAuthBootstrapComplete, userReady, isOnboarded]); + }, [token, isAuthBootstrapComplete, userReady]); - const handleComplete = useCallback(() => { - setDismissed(true); - }, []); - - const handleDefer = useCallback(() => { - if (user?._id) { - dispatch(setOnboardingDeferredForUser({ userId: user._id, deferred: true })); + const handleDone = useCallback(async () => { + setOnboardingCompleted(true); + try { + await persistOnboardingCompleted(true); + } catch { + console.warn('[onboarding] Failed to persist onboarding_completed'); } - setDismissed(true); - }, [dispatch, user]); + }, []); // Don't show if not logged in, bootstrap not complete, or user not ready if (!token || !isAuthBootstrapComplete || !userReady) return null; - // Still loading workspace flag - if (hasWorkspaceFlag === null) return null; + // Still loading the flag from core + if (onboardingCompleted === null) return null; - // Determine if onboarding should show - const shouldShow = DEV_FORCE_ONBOARDING - ? !dismissed - : !isOnboarded && !hasWorkspaceFlag && !isDeferred && !dismissed; + const shouldShow = DEV_FORCE_ONBOARDING ? !onboardingCompleted : !onboardingCompleted; if (!shouldShow) return null; return createPortal(
- +
, document.body ); diff --git a/app/src/components/ProgressIndicator.tsx b/app/src/components/ProgressIndicator.tsx index e8bf5e6f6..b6dc6dd4a 100644 --- a/app/src/components/ProgressIndicator.tsx +++ b/app/src/components/ProgressIndicator.tsx @@ -9,8 +9,8 @@ const ProgressIndicator = ({ currentStep, totalSteps }: ProgressIndicatorProps) {Array.from({ length: totalSteps }).map((_, index) => (
))} diff --git a/app/src/components/SetupBanner.tsx b/app/src/components/SetupBanner.tsx deleted file mode 100644 index 59e2afbb3..000000000 --- a/app/src/components/SetupBanner.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors'; -import { setOnboardingDeferredForUser } from '../store/authSlice'; -import { useAppDispatch, useAppSelector } from '../store/hooks'; - -const SESSION_KEY = 'setupBannerDismissed'; - -/** - * Non-intrusive banner shown when a user has deferred onboarding but hasn't completed it. - * Provides a clear path to resume setup without blocking the app. - */ -const SetupBanner = () => { - const dispatch = useAppDispatch(); - const isOnboarded = useAppSelector(selectIsOnboarded); - const isDeferred = useAppSelector(selectOnboardingDeferred); - const userId = useAppSelector(state => state.user.user?._id); - - const [sessionDismissed, setSessionDismissed] = useState( - () => sessionStorage.getItem(SESSION_KEY) === 'true' - ); - - const handleResume = useCallback(() => { - if (userId) { - dispatch(setOnboardingDeferredForUser({ userId, deferred: false })); - } - }, [dispatch, userId]); - - const handleDismiss = useCallback(() => { - sessionStorage.setItem(SESSION_KEY, 'true'); - setSessionDismissed(true); - }, []); - - if (!isDeferred || isOnboarded || sessionDismissed || !userId) return null; - - return ( -
-
- - - - Finish setting up OpenHuman -
-
- - -
-
- ); -}; - -export default SetupBanner; diff --git a/app/src/components/__tests__/SetupBanner.test.tsx b/app/src/components/__tests__/SetupBanner.test.tsx deleted file mode 100644 index c40832b8d..000000000 --- a/app/src/components/__tests__/SetupBanner.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { renderWithProviders } from '../../test/test-utils'; -import SetupBanner from '../SetupBanner'; - -const baseAuth = { - token: 'test-jwt', - isAuthBootstrapComplete: true, - isOnboardedByUser: {} as Record, - onboardingDeferredByUser: {} as Record, - isAnalyticsEnabledByUser: {}, - onboardingTasksByUser: {}, - hasIncompleteOnboardingByUser: {}, - encryptionKeyByUser: {}, - primaryWalletAddressByUser: {}, -}; - -const baseUser = { user: { _id: 'user-1', username: 'tester', firstName: 'Test' } }; - -describe('SetupBanner', () => { - beforeEach(() => { - sessionStorage.clear(); - }); - - it('does not render when user is onboarded', () => { - renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } }, - user: baseUser, - }, - }); - - expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument(); - }); - - it('does not render when not deferred', () => { - renderWithProviders(, { preloadedState: { auth: baseAuth, user: baseUser } }); - - expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument(); - }); - - it('renders when deferred and not onboarded', () => { - renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } }, - user: baseUser, - }, - }); - - expect(screen.getByText('Finish setting up OpenHuman')).toBeInTheDocument(); - expect(screen.getByText('Continue Setup')).toBeInTheDocument(); - }); - - it('clears deferred state when Continue Setup is clicked', () => { - const { store } = renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } }, - user: baseUser, - }, - }); - - fireEvent.click(screen.getByText('Continue Setup')); - - const state = store.getState(); - expect(state.auth.onboardingDeferredByUser['user-1']).toBeUndefined(); - }); - - it('hides banner for session when dismissed', () => { - renderWithProviders(, { - preloadedState: { - auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } }, - user: baseUser, - }, - }); - - fireEvent.click(screen.getByLabelText('Dismiss setup banner')); - - expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument(); - expect(sessionStorage.getItem('setupBannerDismissed')).toBe('true'); - }); -}); diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 12a02cf77..90c401310 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -4,10 +4,7 @@ import { skillManager } from '../../lib/skills/manager'; import { persistor } from '../../store'; import { clearToken } from '../../store/authSlice'; import { useAppDispatch } from '../../store/hooks'; -import { - openhumanWorkspaceOnboardingFlagSet, - logout as tauriLogout, -} from '../../utils/tauriCommands'; +import { setOnboardingCompleted, logout as tauriLogout } from '../../utils/tauriCommands'; import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; import { useSettingsNavigation } from './hooks/useSettingsNavigation'; @@ -22,9 +19,9 @@ const SettingsHome = () => { const handleLogout = async () => { await dispatch(clearToken()); try { - await openhumanWorkspaceOnboardingFlagSet(false); + await setOnboardingCompleted(false); } catch (err) { - console.warn('[Settings] Failed to clear workspace onboarding flag:', err); + console.warn('[Settings] Failed to clear onboarding_completed in config:', err); } try { await tauriLogout(); @@ -37,9 +34,9 @@ const SettingsHome = () => { const clearAllAppData = async () => { await dispatch(clearToken()); try { - await openhumanWorkspaceOnboardingFlagSet(false); + await setOnboardingCompleted(false); } catch (err) { - console.warn('[Settings] Failed to clear workspace onboarding flag:', err); + console.warn('[Settings] Failed to clear onboarding_completed in config:', err); } try { await tauriLogout(); diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx index a93f52690..c6efceeeb 100644 --- a/app/src/pages/onboarding/Onboarding.tsx +++ b/app/src/pages/onboarding/Onboarding.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; import ProgressIndicator from '../../components/ProgressIndicator'; +import { useUser } from '../../hooks/useUser'; import { userApi } from '../../services/api/userApi'; import { setOnboardedForUser, setOnboardingTasksForUser } from '../../store/authSlice'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { openhumanWorkspaceOnboardingFlagSet } from '../../utils/tauriCommands'; +import { useAppDispatch } from '../../store/hooks'; +import { setOnboardingCompleted } from '../../utils/tauriCommands'; import LocalAIStep from './steps/LocalAIStep'; import MnemonicStep from './steps/MnemonicStep'; import ScreenPermissionsStep from './steps/ScreenPermissionsStep'; @@ -27,7 +28,7 @@ interface OnboardingDraft { const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { const dispatch = useAppDispatch(); - const user = useAppSelector(state => state.user.user); + const { user } = useUser(); const [currentStep, setCurrentStep] = useState(0); const [draft, setDraft] = useState({ localModelConsentGiven: false, @@ -36,7 +37,7 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { enabledTools: [], connectedSources: [], }); - const totalSteps = 7; + const totalSteps = 6; const handleNext = () => { if (currentStep < totalSteps - 1) { @@ -44,6 +45,12 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { } }; + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + const handleLocalAINext = (result: { consentGiven: boolean; downloadStarted: boolean }) => { setDraft(prev => ({ ...prev, @@ -82,18 +89,11 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { ); } - // Notify backend + // Notify backend (best-effort — don't block onboarding completion) try { await userApi.onboardingComplete(); - } catch (e) { - const msg = - e && - typeof e === 'object' && - 'error' in e && - typeof (e as { error: unknown }).error === 'string' - ? (e as { error: string }).error - : 'Failed to complete onboarding. Please try again.'; - throw new Error(msg); + } catch { + console.warn('[onboarding] Failed to notify backend of onboarding completion'); } // Advance to mnemonic step @@ -101,16 +101,16 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { }; const handleMnemonicComplete = async () => { - // Mark onboarded in Redux + // Mark onboarded in Redux (belt-and-suspenders alongside config) if (user?._id) { dispatch(setOnboardedForUser({ userId: user._id, value: true })); } - // Write workspace flag so the overlay won't show again + // Write onboarding_completed to core config (source of truth) try { - await openhumanWorkspaceOnboardingFlagSet(true); + await setOnboardingCompleted(true); } catch { - // Non-critical — Redux state is the primary gate + console.warn('[onboarding] Failed to persist onboarding_completed to core config'); } onComplete?.(); @@ -121,15 +121,15 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { case 0: return ; case 1: - return ; + return ; case 2: - return ; + return ; case 3: - return ; + return ; case 4: - return ; + return ; case 5: - return ; + return ; default: return null; } @@ -137,16 +137,17 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { return (
+ {onDefer && ( +
+ +
+ )}
- {onDefer && ( -
- -
- )} {renderStep()}
diff --git a/app/src/pages/onboarding/steps/LocalAIStep.tsx b/app/src/pages/onboarding/steps/LocalAIStep.tsx index 54f9c289b..18eebaa61 100644 --- a/app/src/pages/onboarding/steps/LocalAIStep.tsx +++ b/app/src/pages/onboarding/steps/LocalAIStep.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef } from 'react'; import { openhumanLocalAiDownload, @@ -9,16 +9,15 @@ import { interface LocalAIStepProps { onNext: (result: { consentGiven: boolean; downloadStarted: boolean }) => void; + onBack?: () => void; } -const LocalAIStep = ({ onNext }: LocalAIStepProps) => { - const [consent, setConsent] = useState(null); +const LocalAIStep = ({ onNext, onBack: _onBack }: LocalAIStepProps) => { const downloadStartedRef = useRef(false); const handleConsent = useCallback(() => { if (downloadStartedRef.current) return; downloadStartedRef.current = true; - setConsent(true); // Fire-and-forget: start downloads in the background — the global snackbar tracks progress void openhumanLocalAiDownload(false).catch(() => {}); @@ -28,83 +27,51 @@ const LocalAIStep = ({ onNext }: LocalAIStepProps) => { onNext({ consentGiven: true, downloadStarted: true }); }, [onNext]); - /* ---------- Phase 1: consent ---------- */ - if (consent === null) { - return ( -
- + return ( +
+
+ Ollama +

Run AI Models Locally with Ollama

+

+ OpenHuman will auto-install Ollama for you so that you can download and run AI models + locally on your device. +

+
-
- Ollama -

Run AI Models Locally with Ollama

-

- OpenHuman will auto-install Ollama for you so that you can download and run AI models - locally on your device. +

+
+

+ Complete Privacy + +  - all data stays on your device. Nothing is sent to any third party or cloud. +

- -
-
-

- Complete Privacy - -  - all data stays on your device. Nothing is sent to any third party or cloud. - -

-
-
-

- Absolutely Free - -  - Ollama and the AI models are open-source. No subscription needed. - -

-
-
-

- Resource impact - -  - uses some disk space and RAM. We will optimize this for your device. - -

-
+
+

+ Absolutely Free + +  - Ollama and the AI models are open-source. No subscription needed. + +

- - -
- ); - } - - /* ---------- Phase 2: consent=false, skip ---------- */ - if (consent === false) { - return ( -
-
- Ollama -

Ollama Skipped

-

- No worries — you can download Ollama and set up local models anytime in Settings. +

+

+ Resource impact + +  - uses some disk space and RAM. We will optimize this for your device. +

-
- ); - } - /* consent=true triggers download + advance via handleConsent — render nothing */ - return null; + +
+ ); }; export default LocalAIStep; diff --git a/app/src/pages/onboarding/steps/MnemonicStep.tsx b/app/src/pages/onboarding/steps/MnemonicStep.tsx index 016e39a46..2bbb8b2d4 100644 --- a/app/src/pages/onboarding/steps/MnemonicStep.tsx +++ b/app/src/pages/onboarding/steps/MnemonicStep.tsx @@ -17,9 +17,10 @@ const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT; interface MnemonicStepProps { onComplete: () => void | Promise; + onBack?: () => void; } -const MnemonicStep = ({ onComplete }: MnemonicStepProps) => { +const MnemonicStep = ({ onComplete, onBack: _onBack }: MnemonicStepProps) => { const dispatch = useAppDispatch(); const user = useAppSelector(state => state.user.user); const [mode, setMode] = useState<'generate' | 'import'>('generate'); @@ -183,10 +184,11 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => { {mode === 'generate' ? ( <>
-

Your Recovery Phrase

+

Lastly, your Recovery Phrase

Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them - somewhere safe. This phrase encrypts your data and can never be recovered if lost. + somewhere safe. This phrase encrypts your data and can never be recovered if lost. You + can always back up later.

@@ -324,12 +326,12 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
); diff --git a/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx b/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx index ee0805098..7bc2b61cf 100644 --- a/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx +++ b/app/src/pages/onboarding/steps/ScreenPermissionsStep.tsx @@ -9,9 +9,10 @@ import { useAppDispatch, useAppSelector } from '../../../store/hooks'; interface ScreenPermissionsStepProps { onNext: (accessibilityPermissionGranted: boolean) => void; + onBack?: () => void; } -const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => { +const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => { const navigate = useNavigate(); const dispatch = useAppDispatch(); const { status, isLoading, isRequestingPermissions } = useAppSelector( @@ -40,13 +41,7 @@ const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => {

Complete Privacy

All screenshots and accessibility information gets processed locally by your local AI - model. No data is sent to any third party. -

-
-
-

Absolutely Free

-

- Processing uses your local AI model and hence remains free. + model. No data is sent to any third party or cloud.

@@ -67,27 +62,29 @@ const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => {
-
+ {!isGranted ? ( +
+ + +
+ ) : ( - -
- - + )}
); }; diff --git a/app/src/pages/onboarding/steps/SkillsStep.tsx b/app/src/pages/onboarding/steps/SkillsStep.tsx index 5aefc7c51..9fa5942a7 100644 --- a/app/src/pages/onboarding/steps/SkillsStep.tsx +++ b/app/src/pages/onboarding/steps/SkillsStep.tsx @@ -1,105 +1,111 @@ import { useMemo, useState } from 'react'; -import GoogleIcon from '../../../assets/icons/GoogleIcon'; -import MetamaskIcon from '../../../assets/icons/metamask.svg'; -import NotionIcon from '../../../assets/icons/notion.svg'; -import TelegramIcon from '../../../assets/icons/telegram.svg'; +import { + DefaultIcon, + SKILL_ICONS, + SkillActionButton, + type SkillListEntry, + STATUS_DISPLAY, +} from '../../../components/skills/shared'; import SkillSetupModal from '../../../components/skills/SkillSetupModal'; -import { useAllSkillSnapshots } from '../../../lib/skills/hooks'; +import { useAvailableSkills, useSkillConnectionStatus } from '../../../lib/skills/hooks'; +import { installSkill } from '../../../lib/skills/skillsApi'; import type { SkillConnectionStatus } from '../../../lib/skills/types'; +import { IS_DEV } from '../../../utils/config'; interface SkillsStepProps { onComplete: (connectedSources: string[]) => void | Promise; + onBack?: () => void; } -interface SourceOption { - id: string; - skillId: string; - name: string; - description: string; - icon: React.ReactElement; +/** Status dot color for skill connection status */ +function statusDotClass(status: SkillConnectionStatus): string { + switch (status) { + case 'connected': + return 'bg-sage-400'; + case 'connecting': + return 'bg-amber-400 animate-pulse'; + case 'error': + return 'bg-coral-400'; + default: + return 'bg-stone-600'; + } } -const STATUS_BADGE: Record = { - connected: { label: 'Connected', classes: 'bg-sage-500/20 text-sage-400 border-sage-500/30' }, - connecting: { - label: 'Connecting', - classes: 'bg-amber-500/20 text-amber-400 border-amber-500/30', - }, - not_authenticated: { - label: 'Not Authenticated', - classes: 'bg-amber-500/20 text-amber-400 border-amber-500/30', - }, - disconnected: { - label: 'Disconnected', - classes: 'bg-stone-500/20 text-stone-400 border-stone-500/30', - }, - error: { label: 'Error', classes: 'bg-coral-500/20 text-coral-400 border-coral-500/30' }, - offline: { label: 'Unavailable', classes: 'bg-stone-500/20 text-stone-400 border-stone-500/30' }, - setup_required: { - label: 'Setup Required', - classes: 'bg-primary-500/20 text-primary-400 border-primary-500/30', - }, -}; +function SkillRow({ skill, onSetup }: { skill: SkillListEntry; onSetup: () => void }) { + const connectionStatus = useSkillConnectionStatus(skill.id); + const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline; -const SOURCE_OPTIONS: SourceOption[] = [ - { - id: 'telegram', - skillId: 'telegram', - name: 'Telegram', - description: 'Sync chats and message context for faster assistant responses.', - icon: Telegram, - }, - { - id: 'email', - skillId: 'email', - name: 'Google Email', - description: 'Connect inbox workflows, summaries, and follow-up reminders.', - icon: , - }, - { - id: 'notion', - skillId: 'notion', - name: 'Notion', - description: 'Bring docs and tasks into assistant context.', - icon: Notion, - }, - { - id: 'wallet', - skillId: 'wallet', - name: 'Wallet', - description: 'Enable secure on-chain workflows and portfolio actions.', - icon: Wallet, - }, -]; + return ( +
+
+ {skill.icon || } +
+
+
+ {skill.name} +
+ + {statusDisplay.text} + +
+ {skill.description && ( +

{skill.description}

+ )} +
+ +
+ ); +} -const SkillsStep = ({ onComplete }: SkillsStepProps) => { +const SkillsStep = ({ onComplete, onBack: _onBack }: SkillsStepProps) => { + const { skills: availableSkills, loading: skillsLoading } = useAvailableSkills(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [installing, setInstalling] = useState(null); const [setupModalOpen, setSetupModalOpen] = useState(false); const [activeSkillId, setActiveSkillId] = useState(null); const [activeSkillName, setActiveSkillName] = useState(''); const [activeSkillDescription, setActiveSkillDescription] = useState(''); - const snapshots = useAllSkillSnapshots(); + const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false); - const sources = useMemo(() => { - return SOURCE_OPTIONS.map(option => { - const snap = snapshots.find(s => s.skill_id === option.skillId); - const connectionStatus: SkillConnectionStatus = snap - ? (snap.connection_status as SkillConnectionStatus) || 'offline' - : 'offline'; - return { ...option, snap, connectionStatus }; - }); - }, [snapshots]); + const skillsList: SkillListEntry[] = useMemo(() => { + return availableSkills + .filter(e => { + if (e.id.includes('_')) return false; + if (!IS_DEV && e.ignore_in_production) return false; + return true; + }) + .map(e => ({ + id: e.id, + name: e.name || e.id.charAt(0).toUpperCase() + e.id.slice(1), + description: e.description || '', + icon: SKILL_ICONS[e.id], + ignoreInProduction: e.ignore_in_production, + hasSetup: !!(e.setup && e.setup.required), + })); + }, [availableSkills]); - const connectedSources = sources - .filter(source => source.connectionStatus === 'connected') - .map(source => source.id); + const sortedSkills = useMemo(() => { + return [...skillsList].sort((a, b) => a.name.localeCompare(b.name)); + }, [skillsList]); - const handleConnect = (source: (typeof sources)[number]) => { - setActiveSkillId(source.skillId); - setActiveSkillName(source.snap?.name || source.name); - setActiveSkillDescription(source.description); + const openSkillSetup = async (skill: SkillListEntry) => { + try { + setInstalling(skill.id); + await installSkill(skill.id); + } catch (err) { + console.warn(`[SkillsStep] install failed for ${skill.id}, continuing:`, err); + } finally { + setInstalling(null); + } + + setActiveSkillId(skill.id); + setActiveSkillName(skill.name); + setActiveSkillDescription(skill.description); + setActiveSkillHasSetup(skill.hasSetup); setSetupModalOpen(true); }; @@ -107,7 +113,8 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => { setError(null); setLoading(true); try { - await onComplete(connectedSources); + const connectedIds = sortedSkills.map(s => s.id); + await onComplete(connectedIds); } catch (e) { setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.'); } finally { @@ -120,53 +127,36 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => {

Install Skills

- Connect integrations to give OpenHuman richer context. You can skip this and set them up - later. + Skills give OpenHuman richer context & access to your workflow. All data consumed by + skills is saved and processed locally. You can connect as many skills as you want.

-
- {sources.map(source => { - const badge = STATUS_BADGE[source.connectionStatus]; - return ( - - ); - })} +
+ {skillsLoading || installing ? ( +
+

+ {installing ? `Installing ${installing}...` : 'Loading skills...'} +

+
+ ) : sortedSkills.length === 0 ? ( +
+

No skills discovered

+
+ ) : ( + sortedSkills.map(skill => ( + openSkillSetup(skill)} /> + )) + )}
- {connectedSources.length === 0 && ( -

- No skills connected yet. You can finish and configure later. -

- )} - {error &&

{error}

} {setupModalOpen && activeSkillId && ( @@ -174,6 +164,7 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => { skillId={activeSkillId} skillName={activeSkillName} skillDescription={activeSkillDescription} + hasSetup={activeSkillHasSetup} onClose={() => { setSetupModalOpen(false); setActiveSkillId(null); diff --git a/app/src/pages/onboarding/steps/ToolsStep.tsx b/app/src/pages/onboarding/steps/ToolsStep.tsx index c752a7ec2..04f51b1dd 100644 --- a/app/src/pages/onboarding/steps/ToolsStep.tsx +++ b/app/src/pages/onboarding/steps/ToolsStep.tsx @@ -9,6 +9,7 @@ import { interface ToolsStepProps { onNext: (enabledTools: string[]) => void; + onBack?: () => void; } const CATEGORY_DESCRIPTIONS: Record = { @@ -20,7 +21,7 @@ const CATEGORY_DESCRIPTIONS: Record = { Automation: 'Cron jobs and scheduled tasks', }; -const ToolsStep = ({ onNext }: ToolsStepProps) => { +const ToolsStep = ({ onNext, onBack: _onBack }: ToolsStepProps) => { const toolsByCategory = getToolsByCategory(); const [enabled, setEnabled] = useState>(() => { const defaults: Record = {}; @@ -93,7 +94,7 @@ const ToolsStep = ({ onNext }: ToolsStepProps) => {
diff --git a/app/src/providers/UserProvider.tsx b/app/src/providers/UserProvider.tsx index 9dd317b15..f6577fc43 100644 --- a/app/src/providers/UserProvider.tsx +++ b/app/src/providers/UserProvider.tsx @@ -6,7 +6,7 @@ import { getAuthState, getSessionToken, isTauri, - openhumanWorkspaceOnboardingFlagSet, + setOnboardingCompleted, } from '../utils/tauriCommands'; const AUTH_BOOTSTRAP_TIMEOUT_MS = 5000; @@ -56,9 +56,9 @@ const UserProvider = ({ children }: { children: React.ReactNode }) => { } else if (!authState.is_authenticated && token) { await dispatch(clearToken()); try { - await openhumanWorkspaceOnboardingFlagSet(false); + await setOnboardingCompleted(false); } catch (err) { - console.warn('[auth] Failed to clear workspace onboarding flag:', err); + console.warn('[auth] Failed to clear onboarding_completed in config:', err); } } } catch (err) { diff --git a/app/src/store/__tests__/authSlice.test.ts b/app/src/store/__tests__/authSlice.test.ts index 31d5341b9..d1c5efb26 100644 --- a/app/src/store/__tests__/authSlice.test.ts +++ b/app/src/store/__tests__/authSlice.test.ts @@ -6,7 +6,6 @@ import authReducer, { setAnalyticsForUser, setEncryptionKeyForUser, setOnboardedForUser, - setOnboardingDeferredForUser, setOnboardingTasksForUser, setPrimaryWalletAddressForUser, setToken, @@ -101,7 +100,6 @@ describe('authSlice', () => { store.dispatch(setAnalyticsForUser({ userId: 'u1', enabled: true })); store.dispatch(setEncryptionKeyForUser({ userId: 'u1', key: 'aes-hex' })); store.dispatch(setPrimaryWalletAddressForUser({ userId: 'u1', address: '0xabc' })); - store.dispatch(setOnboardingDeferredForUser({ userId: 'u1', deferred: true })); store.dispatch( setOnboardingTasksForUser({ userId: 'u1', @@ -134,6 +132,5 @@ describe('authSlice', () => { expect(after.isAnalyticsEnabledByUser).toEqual({}); expect(after.encryptionKeyByUser).toEqual({}); expect(after.primaryWalletAddressByUser).toEqual({}); - expect(after.onboardingDeferredByUser).toEqual({}); }); }); diff --git a/app/src/store/authSelectors.ts b/app/src/store/authSelectors.ts index 782f3c7b3..2a983104a 100644 --- a/app/src/store/authSelectors.ts +++ b/app/src/store/authSelectors.ts @@ -17,9 +17,3 @@ export const selectHasIncompleteOnboarding = (state: RootState): boolean => { if (!userId) return false; return state.auth.hasIncompleteOnboardingByUser[userId] ?? false; }; - -export const selectOnboardingDeferred = (state: RootState): boolean => { - const userId = state.user.user?._id; - if (!userId) return false; - return !!state.auth.onboardingDeferredByUser?.[userId]; -}; diff --git a/app/src/store/authSlice.ts b/app/src/store/authSlice.ts index 5825ce76b..5b1fc885d 100644 --- a/app/src/store/authSlice.ts +++ b/app/src/store/authSlice.ts @@ -19,8 +19,6 @@ export interface AuthState { encryptionKeyByUser: Record; /** Primary EVM wallet address (0x...) derived from mnemonic, per user id */ primaryWalletAddressByUser: Record; - /** Timestamp when user deferred onboarding (value = epoch ms), per user id */ - onboardingDeferredByUser: Record; } export interface UserOnboardingTasks { @@ -41,7 +39,6 @@ const initialState: AuthState = { isAnalyticsEnabledByUser: {}, encryptionKeyByUser: {}, primaryWalletAddressByUser: {}, - onboardingDeferredByUser: {}, }; const authSlice = createSlice({ @@ -62,7 +59,6 @@ const authSlice = createSlice({ state.isAnalyticsEnabledByUser = {}; state.encryptionKeyByUser = {}; state.primaryWalletAddressByUser = {}; - state.onboardingDeferredByUser = {}; }, setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => { const { userId, value } = action.payload; @@ -94,17 +90,6 @@ const authSlice = createSlice({ const { userId, address } = action.payload; state.primaryWalletAddressByUser[userId] = address; }, - setOnboardingDeferredForUser: ( - state, - action: PayloadAction<{ userId: string; deferred: boolean }> - ) => { - const { userId, deferred } = action.payload; - if (deferred) { - state.onboardingDeferredByUser[userId] = Date.now(); - } else { - delete state.onboardingDeferredByUser[userId]; - } - }, }, }); @@ -123,6 +108,5 @@ export const { setOnboardingTasksForUser, setEncryptionKeyForUser, setPrimaryWalletAddressForUser, - setOnboardingDeferredForUser, } = authSlice.actions; export default authSlice.reducer; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index d07c6213b..c19dd410b 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -43,7 +43,6 @@ const authPersistConfig = { 'isAnalyticsEnabledByUser', 'encryptionKeyByUser', 'primaryWalletAddressByUser', - 'onboardingDeferredByUser', ], }; diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index 6e10f39e1..b1cee307d 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -836,7 +836,29 @@ export interface RuntimeFlags { log_prompts: boolean; } -export const DEFAULT_WORKSPACE_ONBOARDING_FLAG = '.skip_onboarding'; +/** Read onboarding_completed from core config. */ +export async function getOnboardingCompleted(): Promise { + if (!isTauri()) return false; + const res = await callCoreRpc({ + method: 'openhuman.config_get_onboarding_completed', + }); + // RpcOutcome may wrap value in { result, logs } when logs are present + if (typeof res === 'boolean') return res; + if (res && typeof res === 'object' && 'result' in res) return res.result; + return false; +} + +/** Write onboarding_completed to core config. */ +export async function setOnboardingCompleted(value: boolean): Promise { + if (!isTauri()) return false; + const res = await callCoreRpc({ + method: 'openhuman.config_set_onboarding_completed', + params: { value }, + }); + if (typeof res === 'boolean') return res; + if (res && typeof res === 'object' && 'result' in res) return res.result; + return false; +} export interface LocalAiStatus { state: string; @@ -1108,31 +1130,6 @@ export async function openhumanGetRuntimeFlags(): Promise { - if (!isTauri()) { - return false; - } - return await callCoreRpc({ - method: 'openhuman.workspace_onboarding_flag_exists', - params: { flag_name: flagName }, - }); -} - -export async function openhumanWorkspaceOnboardingFlagSet( - value: boolean, - flagName = DEFAULT_WORKSPACE_ONBOARDING_FLAG -): Promise { - if (!isTauri()) { - return false; - } - return await callCoreRpc({ - method: 'openhuman.workspace_onboarding_flag_set', - params: { flag_name: flagName, value }, - }); -} - export async function openhumanSetBrowserAllowAll( enabled: boolean ): Promise> { diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 83d17c3a7..261927ccc 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -432,6 +432,24 @@ pub async fn workspace_onboarding_flag_set( )) } +pub async fn get_onboarding_completed() -> Result, String> { + let config = load_config_with_timeout().await?; + Ok(RpcOutcome::single_log( + config.onboarding_completed, + "onboarding_completed read from config", + )) +} + +pub async fn set_onboarding_completed(value: bool) -> Result, String> { + let mut config = load_config_with_timeout().await?; + config.onboarding_completed = value; + config.save().await.map_err(|e| e.to_string())?; + Ok(RpcOutcome::single_log( + config.onboarding_completed, + "onboarding_completed saved to config", + )) +} + pub fn agent_server_status() -> RpcOutcome { let running = crate::openhuman::service::mock::mock_agent_running().unwrap_or(true); log::info!("[config] agent_server_status requested: running={running}"); diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 5384f1d15..b7fa6cc8f 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -117,6 +117,10 @@ pub struct Config { #[serde(default)] pub orchestrator: OrchestratorConfig, + + /// Whether the user has completed the onboarding flow. + #[serde(default)] + pub onboarding_completed: bool, } impl Default for Config { @@ -163,6 +167,7 @@ impl Default for Config { query_classification: QueryClassificationConfig::default(), learning: LearningConfig::default(), orchestrator: OrchestratorConfig::default(), + onboarding_completed: false, } } } diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index e5d6f72b8..b466358b2 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -70,6 +70,11 @@ struct WorkspaceOnboardingFlagSetParams { value: bool, } +#[derive(Debug, Deserialize)] +struct OnboardingCompletedSetParams { + value: bool, +} + pub fn all_controller_schemas() -> Vec { vec![ schemas("get_config"), @@ -86,6 +91,8 @@ pub fn all_controller_schemas() -> Vec { schemas("update_analytics_settings"), schemas("get_analytics_settings"), schemas("agent_server_status"), + schemas("get_onboarding_completed"), + schemas("set_onboarding_completed"), ] } @@ -147,6 +154,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("agent_server_status"), handler: handle_agent_server_status, }, + RegisteredController { + schema: schemas("get_onboarding_completed"), + handler: handle_get_onboarding_completed, + }, + RegisteredController { + schema: schemas("set_onboarding_completed"), + handler: handle_set_onboarding_completed, + }, ] } @@ -368,6 +383,35 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Agent server status payload.")], }, + "get_onboarding_completed" => ControllerSchema { + namespace: "config", + function: "get_onboarding_completed", + description: "Read whether the user has completed the onboarding flow.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "completed", + ty: TypeSchema::Bool, + comment: "True when onboarding has been completed.", + required: true, + }], + }, + "set_onboarding_completed" => ControllerSchema { + namespace: "config", + function: "set_onboarding_completed", + description: "Mark the onboarding flow as completed or reset it.", + inputs: vec![FieldSchema { + name: "value", + ty: TypeSchema::Bool, + comment: "True to mark completed, false to reset.", + required: true, + }], + outputs: vec![FieldSchema { + name: "completed", + ty: TypeSchema::Bool, + comment: "Updated onboarding completed state.", + required: true, + }], + }, _ => ControllerSchema { namespace: "config", function: "unknown", @@ -521,6 +565,17 @@ fn handle_agent_server_status(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } +fn handle_get_onboarding_completed(_params: Map) -> ControllerFuture { + Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) }) +} + +fn handle_set_onboarding_completed(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = deserialize_params::(params)?; + to_json(config_rpc::set_onboarding_completed(payload.value).await?) + }) +} + fn deserialize_params(params: Map) -> Result { serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) }