| description | The React + Vite frontend (`app/src/`) - architecture, state, services, providers, routing, components, hooks. |
|---|---|
| icon | browsers |
The OpenHuman desktop UI: a Vite + React 19 tree under app/src/ (Yarn workspace openhuman-app). It uses Redux Toolkit with persistence for session state, talks to the backend over REST + Socket.io, and calls the Rust core sidecar via JSON-RPC (coreRpcClient / Tauri core_rpc_relay). Heavy logic lives in the core, not here.
This is one consolidated reference. Use the table of contents above (or your reader's outline) to jump between sections.
| Section | Covers |
|---|---|
| Architecture | Provider chain, build, layout, conventions |
| State Management | Redux Toolkit slices, selectors, persistence |
| Services Layer | apiClient, socketService, coreRpcClient |
| Providers | User, Socket, AI, Skill providers |
| Pages & Routing | HashRouter, route guards, main routes |
| Components | UI / settings component patterns |
| Hooks & Utilities | Shared hooks, helpers, config |
| Metric | Value |
|---|---|
TypeScript / TSX files under app/src/ |
~285 (find app/src -name '*.ts' -o -name '*.tsx' | wc -l to refresh) |
| Test runner | Vitest (app/test/vitest.config.ts) |
app/src/
├── App.tsx # Provider chain + HashRouter shell
├── AppRoutes.tsx # Route table + guards
├── main.tsx # Entry (Sentry, store, styles)
├── store/ # Redux slices and selectors
├── providers/ # UserProvider, SocketProvider, AIProvider, SkillProvider
├── services/ # apiClient, socketService, coreRpcClient, api/*
├── lib/ # AI loaders, MCP helpers, skills sync, etc.
├── pages/ # Route-level screens
├── components/ # Shared UI
├── hooks/ # App hooks
├── utils/ # Config, Tauri helpers, routing utilities
└── assets/ # Icons and static assets
OpenHuman’s desktop UI is a React 19 app (app/src/) that:
- Uses Redux Toolkit with persistence for session-related state
- Connects to the backend with REST (
apiClient) and Socket.io (socketService) - Calls the Rust core process over HTTP via
coreRpcClient/ Tauricore_rpc_relay(JSON-RPC methods implemented in repo rootsrc/openhuman/, exposed throughcore_server) - Loads AI prompts from bundled
src/openhuman/agent/prompts(repo root) and from Tauriai_get_configwhen packaged - Uses a minimal MCP-style helper layer under
lib/mcp/(transport, validation), not a large in-repo Telegram MCP tool bundle
| File | Purpose |
|---|---|
app/src/main.tsx |
React root, Sentry boundary, store, global styles |
app/src/App.tsx |
Provider chain: Redux → PersistGate → User → Socket → AI → Skill → Router |
app/src/AppRoutes.tsx |
HashRouter routes, ProtectedRoute / PublicRoute, onboarding and mnemonic gates |
Redux Provider
└─ PersistGate
└─ UserProvider
└─ SocketProvider
└─ AIProvider
└─ SkillProvider
└─ HashRouter
└─ AppRoutes (pages + settings)
Why this order
- Redux is outermost for
useAppSelector/ dispatch everywhere. PersistGaterehydrates persisted slices before children assume stable auth.SocketProvideruses the auth token for Socket.io.AIProvider/SkillProviderwrap features that depend on socket and store state.HashRoutersupplies navigation to all routes.
App.tsx
├─ Redux store + persistor
├─ UserProvider - user profile / workspace context
├─ SocketProvider - connects socketService when token present
├─ AIProvider - AI session / memory client coordination
├─ SkillProvider - skills catalog and sync
└─ AppRoutes
├─ PublicRoute - e.g. Welcome on `/`
├─ ProtectedRoute - onboarding, home, skills, settings, …
└─ DefaultRedirect - unauthenticated users
services/
├─ apiClient → REST to a URL resolved at runtime via `services/backendUrl#getBackendUrl`
├─ backendUrl → Calls `openhuman.config_resolve_api_url`; falls back to VITE_BACKEND_URL only outside Tauri
├─ socketService → Socket.io; realtime + MCP-style envelopes
└─ coreRpcClient → HTTP to local openhuman core (JSON-RPC), used with Tauri relay
The desktop app does not bake the core RPC URL or the API host into the bundle as a hard requirement. At runtime the app resolves them in this order (highest first):
- Login-screen RPC URL field, saved via
utils/configPersistenceand restored on next launch. End users configure the sidecar address here, not by hand-editingconfig.tomlor.envfiles. - Tauri
core_rpc_urlcommand, the port the bundled sidecar is listening on for this process. VITE_OPENHUMAN_CORE_RPC_URL, build-time fallback for development.- The hardcoded
http://127.0.0.1:7788/rpcdefault.
Once the RPC handshake succeeds, services/backendUrl calls openhuman.config_resolve_api_url to pull api_url (and other safe client fields) from the loaded core Config. VITE_BACKEND_URL is only used as a web fallback when the app runs outside Tauri.
Components that need the backend URL should call useBackendUrl() (or getBackendUrl() from non-React code), they must not import the static BACKEND_URL constant from utils/config, which represents the build-time value only.
- Rust architecture: Architecture
- Tauri shell: Tauri Shell
The application uses Redux Toolkit with Redux-Persist for robust state management.
File: store/index.ts
// Combines all slices with persistence
const persistConfig = {
key: 'root',
storage,
whitelist: ['auth', 'telegram'], // Persisted slices
};RootState = {
auth: {
token: string | null, // JWT (persisted)
isOnboardedByUser: Record<string, boolean>, // Per-user flag (persisted)
},
socket: {
byUser: Record<
string,
{
// Per user ID
status: 'connecting' | 'connected' | 'disconnected';
socketId: string | null;
}
>,
},
user: { profile: User | null, loading: boolean, error: string | null },
telegram: {
byUser: Record<string, TelegramState>, // Per Telegram user (persisted)
},
};Manages JWT token and per-user onboarding status.
State:
interface AuthState {
token: string | null;
isOnboardedByUser: Record<string, boolean>;
}Actions:
setToken(token: string)- Store JWT after loginclearToken()- Remove token on logoutsetOnboarded({ userId, isOnboarded })- Mark user as onboarded
Selectors (store/authSelectors.ts):
selectToken- Get current JWTselectIsOnboarded(userId)- Check if user completed onboarding
Tracks Socket.io connection status per user.
State:
interface SocketState {
byUser: Record<
string,
{ status: 'connecting' | 'connected' | 'disconnected'; socketId: string | null }
>;
}Actions:
setSocketStatus({ userId, status })- Update connection statussetSocketId({ userId, socketId })- Store socket IDclearSocketState(userId)- Clear user's socket state
Selectors (store/socketSelectors.ts):
selectSocketStatus(userId)- Get connection statusselectIsSocketConnected(userId)- Boolean connected check
Stores user profile data.
State:
interface UserState {
profile: User | null;
loading: boolean;
error: string | null;
}Actions:
setUser(user)- Store user profilesetUserLoading(loading)- Set loading statesetUserError(error)- Set error stateclearUser()- Clear profile on logout
Complex nested state management for Telegram integration.
Files:
index.ts- Slice exports (actions, thunks)types.ts- Entity and state interfacesreducers.ts- Synchronous reducersextraReducers.ts- Async thunk handlersthunks.ts- Async operations
State Structure:
telegram.byUser[telegramUserId] = {
connectionStatus: "disconnected" | "connecting" | "connected" | "error",
authStatus: "not_authenticated" | "authenticating" | "authenticated" | "error",
currentUser: TelegramUser | null,
sessionString: string | null, // Stored here, NOT localStorage
chats: Record<string, TelegramChat>,
chatsOrder: string[],
messages: Record<chatId, Record<msgId, TelegramMessage>>,
threads: Record<chatId, TelegramThread[]>
}Reducers:
setCurrentUser- Store authenticated Telegram usersetSessionString- Store MTProto session (for persistence)setConnectionStatus- Update connection statesetAuthStatus- Update authentication stateaddChat/updateChat- Manage chat listaddMessage/updateMessage- Manage message historysetThreads- Store thread data
Thunks (store/telegram/thunks.ts):
initializeTelegram(userId)- Initialize MTProto clientconnectTelegram(userId)- Establish Telegram connectionfetchChats(userId)- Load chat listfetchMessages({ userId, chatId })- Load message historydisconnectTelegram(userId)- Clean disconnect
Selectors (store/telegramSelectors.ts):
selectTelegramState(userId)- Get full Telegram stateselectTelegramConnectionStatus(userId)- Get connection statusselectTelegramAuthStatus(userId)- Get auth statusselectTelegramChats(userId)- Get chat listselectTelegramMessages(userId, chatId)- Get messages for chat
File: store/hooks.ts
// Use these instead of plain useDispatch/useSelector
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;auth.token- JWT for authenticationauth.isOnboardedByUser- Per-user onboarding statustelegram.byUser- Telegram state (sessions, chats, etc.)
socket- Connection state (reconnects on app start)user.loading/user.error- Transient UI states- Telegram loading/error states
Redux-Persist uses localStorage adapter by default. This is the ONLY acceptable use of localStorage in the application.
import { useAppSelector } from '../store/hooks';
function MyComponent() {
const token = useAppSelector(state => state.auth.token);
const isConnected = useAppSelector(state => state.socket.byUser[userId]?.status === 'connected');
const chats = useAppSelector(state => state.telegram.byUser[userId]?.chats);
}import { clearToken, setToken } from '../store/authSlice';
import { useAppDispatch } from '../store/hooks';
import { initializeTelegram } from '../store/telegram/thunks';
function MyComponent() {
const dispatch = useAppDispatch();
// Sync action
const handleLogin = (token: string) => {
dispatch(setToken(token));
};
// Async thunk
const handleConnect = async () => {
await dispatch(initializeTelegram(userId)).unwrap();
};
}import { selectIsOnboarded } from '../store/authSelectors';
import { useAppSelector } from '../store/hooks';
import { selectTelegramConnectionStatus } from '../store/telegramSelectors';
function MyComponent({ userId }) {
const isOnboarded = useAppSelector(state => selectIsOnboarded(state, userId));
const connectionStatus = useAppSelector(state => selectTelegramConnectionStatus(state, userId));
}- Always use typed hooks -
useAppDispatchanduseAppSelector - Use selectors for derived state - Memoized and testable
- Keep thunks in separate files - Better organization
- Per-user state scoping - Key state by user ID
- Avoid localStorage - Use Redux-Persist instead
The application uses singleton services for external communication. This prevents connection leaks and provides consistent API access.
app/src/services/
├─ apiClient (HTTP REST)
│ ├─ reads auth.token from Redux
│ └─ calls VITE_BACKEND_URL (see utils/config.ts)
├─ socketService (Socket.io)
│ ├─ web: JS client
│ └─ Tauri: coordinates with Rust-side socket via utils/tauriSocket.ts
├─ coreRpcClient.ts
│ └─ invoke('core_rpc_relay', …) → local openhuman core (JSON-RPC)
└─ services/api/* - domain REST modules (auth, user, teams, …)
HTTP REST client for backend communication.
- Fetch-based implementation
- Auto-injects JWT from Redux store
- Typed request/response handling
- Error handling with typed errors
import apiClient from "../services/apiClient";
// GET request
const user = await apiClient.get<User>("/users/me");
// POST request
const result = await apiClient.post<LoginResponse>("/auth/login", {
email,
password,
});
// With custom headers
const data = await apiClient.get<Data>("/endpoint", {
headers: { "X-Custom": "value" },
});Reads VITE_BACKEND_URL from environment or uses default:
const BACKEND_URL =
import.meta.env.VITE_BACKEND_URL || "https://api.example.com";Authentication-related endpoints.
import { authApi } from "../services/api/authApi";
// Login
const { token, user } = await authApi.login(credentials);
// Token exchange (for deep link flow)
const { sessionToken, user } = await authApi.exchangeToken(loginToken);
// Logout
await authApi.logout();User profile endpoints.
import { userApi } from "../services/api/userApi";
// Get current user
const user = await userApi.getCurrentUser();
// Update profile
const updated = await userApi.updateProfile({ firstName, lastName });
// Get settings
const settings = await userApi.getSettings();Socket.io client singleton for real-time communication.
- Singleton pattern - single connection per app
- Auth token passed in socket
authobject - Transports: polling first, then WebSocket upgrade
- Auto-reconnection handling
import socketService from "../services/socketService";
// Connect with auth token
socketService.connect(token);
// Disconnect
socketService.disconnect();
// Emit event
socketService.emit("event-name", data);
// Listen for events
socketService.on("event-name", (data) => {
// Handle event
});
// Remove listener
socketService.off("event-name", handler);
// One-time listener
socketService.once("event-name", (data) => {
// Handle once
});
// Get socket instance
const socket = socketService.getSocket();
// Check connection status
const isConnected = socketService.isConnected();// In SocketProvider.tsx
useEffect(() => {
if (token) {
socketService.connect(token);
socketService.on("connect", () => {
dispatch(setSocketStatus({ userId, status: "connected" }));
dispatch(setSocketId({ userId, socketId: socket.id }));
// Initialize MCP server
initMCPServer(socketService.getSocket());
});
socketService.on("disconnect", () => {
dispatch(setSocketStatus({ userId, status: "disconnected" }));
});
}
return () => {
socketService.disconnect();
};
}, [token]);const socket = io(BACKEND_URL, {
auth: { token },
transports: ["polling", "websocket"],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});In Tauri mode, connection and events are bridged through utils/tauriSocket.ts (setupTauriSocketListeners, connectRustSocket, etc.). See providers/SocketProvider.tsx for the full flow (including daemon lifecycle hooks).
The desktop app runs a separate openhuman Rust binary (staged under app/src-tauri/binaries/). The UI calls JSON-RPC methods on that process through Tauri:
import { callCoreRpc } from "../services/coreRpcClient";
const result = await callCoreRpc<MyType>({
method: "some.openhuman.method",
params: {
/* … */
},
serviceManaged: false, // true if the relay should ensure the systemd/launchd-style service
});Implementation: invoke('core_rpc_relay', { request: { method, params, serviceManaged } }) → app/src-tauri/src/commands/core_relay.rs → HTTP client in app/src-tauri/src/core_rpc.rs.
app/src/providers/SocketProvider.tsx connects when auth.token is present. In Tauri, it prefers the Rust-backed socket path; in web, it uses the JS Socket.io client. See the source for logging and useDaemonLifecycle integration.
These wrap user profile loading, AI/memory client coordination, and skills catalog/sync. They sit inside PersistGate and outside or alongside the router as shown in App.tsx.
- Use singletons - Never create multiple service instances
- Store sessions in Redux - Not localStorage
- Clean up on unmount - Disconnect in useEffect cleanup
- Handle errors gracefully - Retry for transient failures
- Pass auth via proper channels - Socket auth object, not query string
React context providers manage service lifecycle and provide shared state.
The providers wrap the application in a specific order (app/src/App.tsx):
<Sentry.ErrorBoundary>
<Provider store={store}>
<PersistGate persistor={persistor} onBeforeLift={...}>
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<AppRoutes />
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>(Router is HashRouter from react-router-dom.)
Order matters because:
- Redux is outermost for store access.
PersistGaterehydrates persisted slices before children rely on auth.SocketProvideruses the JWT from the store.AIProvider/SkillProviderdepend on socket and store-backed features.- The router supplies navigation to all routes.
Manages realtime connectivity: web uses the JS Socket.io client; Tauri bridges to the Rust socket via utils/tauriSocket.ts and reports status back to Redux.
- Connect when
auth.tokenis available; disconnect when cleared - In Tauri: install listeners once, connect Rust socket, coordinate daemon lifecycle (
useDaemonLifecycle) - Update Redux socket slice / connection status
See app/src/providers/SocketProvider.tsx. The file branches on isTauri(): web mode uses socketService directly; Tauri sets up tauriSocket listeners and connectRustSocket / disconnectRustSocket. Do not treat the pseudocode below as the live implementation.
import { useSocket } from '../providers/SocketProvider';
function MyComponent() {
const { socket, isConnected, emit, on, off } = useSocket();
useEffect(() => {
const handler = (data) => console.log('Received:', data);
on('event-name', handler);
return () => off('event-name', handler);
}, [on, off]);
const sendMessage = () => {
emit('send-message', { text: 'Hello!' });
};
return (
<div>
<span>Status: {isConnected ? 'Connected' : 'Disconnected'}</span>
<button onClick={sendMessage}>Send</button>
</div>
);
}Initializes memory, sessions, tool registry (including memory + web-search tools), entity manager, LLM / embedding providers, and constitution loading. Exposes useAI() for children. Heavy logic lives under app/src/lib/ai/.
On mount (when authenticated), discovers skills from the QuickJS skills engine via Tauri helpers (runtimeDiscoverSkills), syncs manifests into Redux, listens for skill-related Tauri events, and can auto-start configured skills in development.
Minimal user context provider (most user state is in Redux).
- Legacy user context for compatibility
- May be deprecated in favor of Redux
interface UserContextValue {
user: User | null;
loading: boolean;
}
export function UserProvider({ children }) {
const user = useAppSelector((state) => state.user.profile);
const loading = useAppSelector((state) => state.user.loading);
return (
<UserContext.Provider value={{ user, loading }}>
{children}
</UserContext.Provider>
);
}import { useUserContext } from '../providers/UserProvider';
function Header() {
const { user, loading } = useUserContext();
if (loading) return <Skeleton />;
if (!user) return null;
return <span>Welcome, {user.firstName}</span>;
}Providers use useEffect to manage service lifecycle:
useEffect(() => {
// Setup on mount or dependency change
service.connect();
// Cleanup on unmount or dependency change
return () => {
service.disconnect();
};
}, [dependencies]);Providers read from and dispatch to Redux:
// Read state
const token = useAppSelector((state) => state.auth.token);
// Dispatch actions
const dispatch = useAppDispatch();
dispatch(setStatus({ userId, status: "connected" }));SkillProvider and AIProvider may kick off several async tasks on mount (skill discovery, memory init, constitution load). Prefer reading the source for ordering guarantees rather than assuming parallel Promise.all everywhere.
Providers restore persisted state on mount:
useEffect(() => {
if (persistedSession) {
service.restoreSession(persistedSession);
}
}, [persistedSession]);| Use Context For | Use Redux For |
|---|---|
| Service instances (socket, client) | Serializable state (status, data) |
| Methods (emit, on, off) | Persisted state (sessions, tokens) |
| Derived values | Complex state logic |
Example:
SocketContextprovidessocketinstance andemitmethod- Redux stores
socketStatusandsocketId
// test-utils.tsx
const mockSocketContext: SocketContextValue = {
socket: null,
isConnected: true,
emit: jest.fn(),
on: jest.fn(),
off: jest.fn()
};
export function TestProviders({ children }) {
return (
<Provider store={testStore}>
<SocketContext.Provider value={mockSocketContext}>
{children}
</SocketContext.Provider>
</Provider>
);
}test('SocketProvider connects when token is available', () => {
const store = createTestStore({ auth: { token: 'test-token' } });
render(
<Provider store={store}>
<SocketProvider>
<TestComponent />
</SocketProvider>
</Provider>
);
expect(socketService.connect).toHaveBeenCalledWith('test-token');
});The Human page (app/src/features/human/HumanPage.tsx) renders the main
YellowMascot beside the conversation sidebar. The mascot face still comes
from useHumanMascot, which subscribes to chat lifecycle events for thinking,
speaking, acknowledgement, and error states.
Sub-agent delegation is visualized by SubMascotLayer. It does not introduce a
new socket protocol. Instead, it reads the selected or active thread's
chatRuntime.toolTimelineByThread entries that ChatRuntimeProvider already
builds from subagent_spawned, subagent_completed, subagent_failed,
subagent_iteration_start, subagent_tool_call, and subagent_tool_result.
Lifecycle mapping:
| Runtime timeline state | Sub-mascot state |
|---|---|
running |
Small colored mascot in a thinking face with a short activity bubble |
success |
Same mascot resolves to a happy face and completion bubble |
error |
Same mascot resolves to a concerned face and failure bubble |
Activity bubble text is intentionally compact: current child tool call, child iteration, the delegation prompt excerpt, or final status. The thread timeline remains the authoritative detailed view; sub-mascots are only the glanceable orchestration layer around the main mascot.
The application uses HashRouter with protected and public route guards.
Defined in app/src/AppRoutes.tsx (HashRouter). Approximate map:
/ → Welcome (public wrapper)
/onboarding → Onboarding (auth, onboarding not complete)
/mnemonic → Mnemonic / encryption setup (auth)
/home → Home (auth + onboarding + encryption key)
/intelligence → Intelligence (auth)
/skills → Skills (auth)
/conversations → Conversations (auth)
/invites → Invites (auth)
/agents → Agents (auth)
/settings/* → Settings (auth)
* → DefaultRedirect
There is no top-level /login route in AppRoutes; authentication flows are handled via welcome/onboarding and backend redirects.
export function AppRoutes() {
return (
<>
<Routes>
{/* Public routes - redirect if authenticated */}
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>
{/* Protected routes - require authentication */}
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
{/* Protected + onboarded routes */}
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>
{/* Fallback redirect */}
<Route path="*" element={<DefaultRedirect />} />
</Routes>
{/* Settings modal overlay - renders on top of routes */}
<SettingsModal />
</>
);
}Redirects authenticated users away from public pages.
export function PublicRoute() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (token) {
// Authenticated - redirect to appropriate page
return <Navigate to={isOnboarded ? "/home" : "/onboarding"} replace />;
}
return <Outlet />;
}Enforces authentication and optionally onboarding status.
interface ProtectedRouteProps {
requireOnboarded?: boolean;
}
export function ProtectedRoute({ requireOnboarded = false }) {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (!token) {
return <Navigate to="/login" replace />;
}
if (requireOnboarded && !isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Outlet />;
}Fallback route that redirects based on auth state.
export function DefaultRedirect() {
const token = useAppSelector((state) => state.auth.token);
const isOnboarded = useAppSelector((state) =>
selectIsOnboarded(state, userId),
);
if (!token) {
return <Navigate to="/" replace />;
}
if (!isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Navigate to="/home" replace />;
}Landing page for unauthenticated users.
Features:
- App introduction and branding
- CTA to login/signup
- Public route (redirects if authenticated)
Authentication page.
Features:
- Telegram OAuth button
- Opens
/auth/telegram?platform=desktopin browser - Handles deep link callback
export function Login() {
const handleTelegramLogin = () => {
// Opens Telegram OAuth in system browser
openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`);
};
return (
<div className="login-page">
<TelegramLoginButton onClick={handleTelegramLogin} />
</div>
);
}Main dashboard after authentication.
Features:
- Protected route (requires auth + onboarded)
- Connection status indicators
- Navigation to settings modal
- Future: Chat list, messages, etc.
export function Home() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.user.profile);
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, user?.id),
);
return (
<div className="home-page">
<header>
<h1>Welcome, {user?.firstName}</h1>
<button onClick={() => navigate("/settings")}>Settings</button>
</header>
<TelegramConnectionIndicator status={telegramStatus} />
<ConnectionIndicator />
{/* Main content */}
</div>
);
}Multi-step onboarding process.
pages/onboarding/
├── Onboarding.tsx # Flow controller
└── steps/
├── GetStartedStep.tsx # Welcome
├── PrivacyStep.tsx # Privacy policy
├── AnalyticsStep.tsx # Analytics opt-in
├── ConnectStep.tsx # Telegram connection
└── FeaturesStep.tsx # Features overview
const STEPS = [
{ id: "get-started", component: GetStartedStep },
{ id: "privacy", component: PrivacyStep },
{ id: "analytics", component: AnalyticsStep },
{ id: "connect", component: ConnectStep },
{ id: "features", component: FeaturesStep },
];
export function Onboarding() {
const [currentStep, setCurrentStep] = useState(0);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
// Complete onboarding
dispatch(setOnboarded({ userId, isOnboarded: true }));
navigate("/home");
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const StepComponent = STEPS[currentStep].component;
return (
<div className="onboarding">
<ProgressIndicator current={currentStep} total={STEPS.length} />
<StepComponent onNext={handleNext} onBack={handleBack} />
</div>
);
}Each step receives onNext and onBack callbacks:
interface StepProps {
onNext: () => void;
onBack: () => void;
}
export function ConnectStep({ onNext, onBack }: StepProps) {
const [showModal, setShowModal] = useState(false);
const telegramStatus = useAppSelector(/* ... */);
return (
<div className="step">
<h2>Connect Your Accounts</h2>
{connectOptions.map((option) => (
<ConnectionOption
key={option.id}
{...option}
onClick={() => option.id === "telegram" && setShowModal(true)}
/>
))}
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
<div className="actions">
<button onClick={onBack}>Back</button>
<button onClick={onNext}>Continue</button>
</div>
</div>
);
}The settings modal overlays existing content using URL-based routing.
// In SettingsModal.tsx
const location = useLocation();
const isOpen = location.pathname.startsWith("/settings");/settings → SettingsHome (main menu)
/settings/connections → ConnectionsPanel
/settings/messaging → MessagingPanel (future)
/settings/privacy → PrivacyPanel (future)
/settings/profile → ProfilePanel (future)
/settings/advanced → AdvancedPanel (future)
/settings/billing → BillingPanel (future)
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsHome() {
const { navigateTo, closeModal } = useSettingsNavigation();
return (
<div>
<SettingsMenuItem
label="Connections"
onClick={() => navigateTo("connections")}
/>
<button onClick={closeModal}>Close</button>
</div>
);
}The app uses HashRouter for desktop compatibility:
// App.tsx
import { HashRouter } from "react-router-dom";
// URLs look like: app://localhost/#/home
// Instead of: app://localhost/homeWhy HashRouter:
- Tauri deep links work with hash-based URLs
- No server configuration needed
- Works with file:// protocol
- Prevents 404 on direct URL access
Deep links are handled before routing:
// main.tsx
import("./utils/desktopDeepLinkListener").then((m) => {
m.setupDesktopDeepLinkListener().catch(console.error);
});The listener intercepts openhuman://auth?token=... and:
- Exchanges token via Rust command
- Stores session in Redux
- Navigates to
/onboardingor/home
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
// Navigate to route
navigate("/home");
// Replace history entry
navigate("/login", { replace: true });
// Go back
navigate(-1);import { Link } from "react-router-dom";
<Link to="/settings">Settings</Link>;// Pass state to route
navigate("/details", { state: { itemId: 123 } });
// Receive state
const location = useLocation();
const { itemId } = location.state;Reusable React components organized by feature.
components/
├── Route Guards
│ ├── ProtectedRoute.tsx
│ ├── PublicRoute.tsx
│ └── DefaultRedirect.tsx
│
├── Authentication
│ └── TelegramLoginButton.tsx
│
├── Connection Status
│ ├── ConnectionIndicator.tsx
│ ├── TelegramConnectionIndicator.tsx
│ ├── TelegramConnectionModal.tsx
│ └── GmailConnectionIndicator.tsx
│
├── Onboarding
│ ├── ProgressIndicator.tsx
│ └── LottieAnimation.tsx
│
├── Settings Modal (16 files)
│ ├── SettingsModal.tsx
│ ├── SettingsLayout.tsx
│ ├── SettingsHome.tsx
│ ├── panels/
│ ├── components/
│ └── hooks/
│
└── Development
└── DesignSystemShowcase.tsx
Requires authentication and optionally onboarding.
interface ProtectedRouteProps {
requireOnboarded?: boolean;
}
// Usage in AppRoutes.tsx
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>Redirects authenticated users away.
// Usage in AppRoutes.tsx
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>Fallback that routes based on auth state.
// Redirects to:
// - "/" if not authenticated
// - "/onboarding" if authenticated but not onboarded
// - "/home" if authenticated and onboardedOAuth login button for Telegram.
interface TelegramLoginButtonProps {
onClick: () => void;
disabled?: boolean;
}
// Usage
<TelegramLoginButton
onClick={() => openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`)}
/>Generic connection status badge.
interface ConnectionIndicatorProps {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
label?: string;
}
<ConnectionIndicator status="connected" label="Socket" />Telegram-specific status display.
interface TelegramConnectionIndicatorProps {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
}
// Usage with Redux state
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, userId)
);
<TelegramConnectionIndicator status={telegramStatus} />Modal for setting up Telegram connection.
interface TelegramConnectionModalProps {
isOpen: boolean;
onClose: () => void;
}
// Usage in onboarding/settings
const [showModal, setShowModal] = useState(false);
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>Features:
- QR code login flow
- Phone number login flow
- Connection status display
- Error handling
Gmail status badge (future integration).
<GmailConnectionIndicator status="coming-soon" />Visual progress through onboarding steps.
interface ProgressIndicatorProps {
current: number;
total: number;
}
<ProgressIndicator current={2} total={5} />Lottie animation player for onboarding.
interface LottieAnimationProps {
animationData: object;
loop?: boolean;
autoplay?: boolean;
className?: string;
}
import welcomeAnimation from '../assets/animations/welcome.json';
<LottieAnimation
animationData={welcomeAnimation}
loop={true}
autoplay={true}
/>Complete modal system with URL-based routing.
components/settings/
├── SettingsModal.tsx # Route-based container
├── SettingsLayout.tsx # Portal + backdrop wrapper
├── SettingsHome.tsx # Main menu with profile
├── panels/
│ ├── ConnectionsPanel.tsx # Connection management
│ ├── MessagingPanel.tsx # (Future)
│ ├── PrivacyPanel.tsx # (Future)
│ ├── ProfilePanel.tsx # (Future)
│ ├── AdvancedPanel.tsx # (Future)
│ └── BillingPanel.tsx # (Future)
├── components/
│ ├── SettingsHeader.tsx # User profile section
│ ├── SettingsMenuItem.tsx # Menu item component
│ ├── SettingsBackButton.tsx # Back navigation
│ └── SettingsPanelLayout.tsx# Panel wrapper
└── hooks/
├── useSettingsNavigation.ts # URL routing
└── useSettingsAnimation.ts # Animation state
Main container that renders based on URL.
export function SettingsModal() {
const location = useLocation();
const isOpen = location.pathname.startsWith('/settings');
if (!isOpen) return null;
return (
<SettingsLayout>
{/* Route to appropriate panel */}
{location.pathname === '/settings' && <SettingsHome />}
{location.pathname === '/settings/connections' && <ConnectionsPanel />}
{/* ... more panels */}
</SettingsLayout>
);
}Portal-based modal wrapper.
export function SettingsLayout({ children }) {
const { closeModal } = useSettingsNavigation();
return createPortal(
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={closeModal}
/>
{/* Modal */}
<div className="absolute inset-4 flex items-center justify-center">
<div className="bg-white rounded-2xl w-full max-w-[520px] shadow-xl">
{children}
</div>
</div>
</div>,
document.body
);
}Main menu with user profile.
export function SettingsHome() {
const { navigateTo, closeModal } = useSettingsNavigation();
const user = useAppSelector((state) => state.user.profile);
const menuItems = [
{ id: 'connections', label: 'Connections', icon: LinkIcon },
{ id: 'messaging', label: 'Messaging', icon: MessageIcon },
{ id: 'privacy', label: 'Privacy', icon: ShieldIcon },
// ... more items
];
return (
<div>
<SettingsHeader user={user} onClose={closeModal} />
{menuItems.map((item) => (
<SettingsMenuItem
key={item.id}
{...item}
onClick={() => navigateTo(item.id)}
/>
))}
</div>
);
}Connection management interface.
export function ConnectionsPanel() {
const { navigateBack } = useSettingsNavigation();
const [telegramModalOpen, setTelegramModalOpen] = useState(false);
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, userId)
);
// Reuses connectOptions from onboarding
const connections = connectOptions.map((opt) => ({
...opt,
status: opt.id === 'telegram' ? telegramStatus : 'coming-soon'
}));
return (
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{connections.map((conn) => (
<ConnectionItem
key={conn.id}
{...conn}
onConnect={() => conn.id === 'telegram' && setTelegramModalOpen(true)}
/>
))}
<TelegramConnectionModal
isOpen={telegramModalOpen}
onClose={() => setTelegramModalOpen(false)}
/>
</SettingsPanelLayout>
);
}useSettingsNavigation
URL-based navigation for settings modal.
interface UseSettingsNavigationReturn {
currentRoute: string;
navigateTo: (panel: string) => void;
navigateBack: () => void;
closeModal: () => void;
}
const { navigateTo, navigateBack, closeModal } = useSettingsNavigation();
// Navigate to panel
navigateTo('connections'); // → /settings/connections
// Go back
navigateBack(); // → /settings
// Close modal
closeModal(); // → previous non-settings routeuseSettingsAnimation
Animation state management.
interface UseSettingsAnimationReturn {
isEntering: boolean;
isExiting: boolean;
animationClass: string;
}
const { animationClass } = useSettingsAnimation();
<div className={`modal ${animationClass}`}>
{/* Content */}
</div>SettingsHeader
User profile section at top of settings.
interface SettingsHeaderProps {
user: User | null;
onClose: () => void;
}
<SettingsHeader user={user} onClose={handleClose} />SettingsMenuItem
Individual menu item with icon and chevron.
interface SettingsMenuItemProps {
label: string;
icon: React.ComponentType;
onClick: () => void;
badge?: string;
disabled?: boolean;
}
<SettingsMenuItem
label="Connections"
icon={LinkIcon}
onClick={() => navigateTo('connections')}
badge="2"
/>SettingsBackButton
Back navigation button.
interface SettingsBackButtonProps {
onClick: () => void;
}
<SettingsBackButton onClick={navigateBack} />SettingsPanelLayout
Wrapper for settings panels.
interface SettingsPanelLayoutProps {
title: string;
onBack: () => void;
children: React.ReactNode;
}
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{/* Panel content */}
</SettingsPanelLayout>The connectOptions array is shared between onboarding and settings:
// Defined in ConnectStep.tsx, imported elsewhere
export const connectOptions = [
{
id: 'telegram',
label: 'Telegram',
icon: TelegramIcon,
description: 'Connect your Telegram account',
},
{
id: 'gmail',
label: 'Gmail',
icon: GmailIcon,
description: 'Connect your Gmail account',
comingSoon: true,
},
];Settings modal uses createPortal to render outside the component tree:
return createPortal(
<div className="modal-container">
{/* Modal content */}
</div>,
document.body
);Connection modals are controlled components:
// Parent controls open state
const [isOpen, setIsOpen] = useState(false);
<TelegramConnectionModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
/>Custom React hooks and utility functions.
Access Socket.io functionality from any component.
interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
emit: (event: string, data: unknown) => void;
on: (event: string, handler: Function) => void;
off: (event: string, handler: Function) => void;
once: (event: string, handler: Function) => void;
}
function useSocket(): UseSocketReturn;Usage:
import { useSocket } from "../hooks/useSocket";
function ChatInput() {
const { emit, isConnected } = useSocket();
const sendMessage = (text: string) => {
if (isConnected) {
emit("chat:message", { text });
}
};
return (
<input
disabled={!isConnected}
onKeyDown={(e) => e.key === "Enter" && sendMessage(e.target.value)}
/>
);
}With event listeners:
function Notifications() {
const { on, off } = useSocket();
const [notifications, setNotifications] = useState([]);
useEffect(() => {
const handler = (notification) => {
setNotifications((prev) => [...prev, notification]);
};
on("notification", handler);
return () => off("notification", handler);
}, [on, off]);
return <NotificationList items={notifications} />;
}Access user profile data and loading state.
interface UseUserReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
function useUser(): UseUserReturn;Usage:
import { useUser } from "../hooks/useUser";
function ProfileHeader() {
const { user, loading, error, refetch } = useUser();
if (loading) return <Skeleton />;
if (error) return <Error message={error} onRetry={refetch} />;
if (!user) return null;
return (
<div className="profile">
<Avatar src={user.avatar} />
<span>
{user.firstName} {user.lastName}
</span>
</div>
);
}useSettingsNavigation (components/settings/hooks/useSettingsNavigation.ts)
URL-based navigation for settings modal.
interface UseSettingsNavigationReturn {
currentRoute: string; // Current settings path
navigateTo: (panel: string) => void; // Navigate to panel
navigateBack: () => void; // Go back one level
closeModal: () => void; // Close settings entirely
}
function useSettingsNavigation(): UseSettingsNavigationReturn;Usage:
import { useSettingsNavigation } from "./hooks/useSettingsNavigation";
function SettingsMenu() {
const { navigateTo, closeModal } = useSettingsNavigation();
return (
<nav>
<button onClick={() => navigateTo("connections")}>Connections</button>
<button onClick={() => navigateTo("privacy")}>Privacy</button>
<button onClick={closeModal}>Close</button>
</nav>
);
}useSettingsAnimation (components/settings/hooks/useSettingsAnimation.ts)
Animation state management for settings modal.
interface UseSettingsAnimationReturn {
isEntering: boolean; // Modal is animating in
isExiting: boolean; // Modal is animating out
animationClass: string; // CSS class for current state
}
function useSettingsAnimation(): UseSettingsAnimationReturn;Usage:
import { useSettingsAnimation } from "./hooks/useSettingsAnimation";
function SettingsModal() {
const { animationClass, isExiting } = useSettingsAnimation();
return <div className={`modal ${animationClass}`}>{/* Content */}</div>;
}Build-time environment variable access. These constants only carry the value that was baked into the bundle, for the runtime URL the app actually talks to, see services/backendUrl and hooks/useBackendUrl below.
// Build-time fallback only (used outside Tauri).
export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.example.com';
// Debug mode
export const DEBUG = import.meta.env.VITE_DEBUG === 'true';Usage (build-time only, feature flags, debug toggles, …):
import { DEBUG } from '../utils/config';
if (DEBUG) {
console.log('debug enabled');
}Do not import
BACKEND_URLdirectly to make API calls. Resolve the URL at runtime so the core sidecar'sapi_url(set on the login screen viaopenhuman.config_resolve_api_url) takes effect:// React components import { useBackendUrl } from '../hooks/useBackendUrl'; const backendUrl = useBackendUrl(); // Non-React code import { getBackendUrl } from '../services/backendUrl'; const backendUrl = await getBackendUrl();
Build deep link URLs for authentication handoff.
// Build auth deep link
function buildAuthDeepLink(token: string): string;
// Parse deep link URL
function parseDeepLink(url: string): { path: string; params: URLSearchParams };Usage:
import { buildAuthDeepLink } from '../utils/deeplink';
// Build URL for browser redirect
const deepLink = buildAuthDeepLink(loginToken);
// → "openhuman://auth?token=abc123"
// In web frontend after auth:
window.location.href = deepLink;Handle incoming deep links in desktop app.
// Setup listener for deep link events
async function setupDesktopDeepLinkListener(): Promise<void>;Called in main.tsx:
// Lazy import to ensure Tauri IPC is ready
import('./utils/desktopDeepLinkListener').then(m => {
m.setupDesktopDeepLinkListener().catch(console.error);
});What it does:
- Listens for
onOpenUrlevents from Tauri deep-link plugin - Parses
openhuman://auth?token=...URLs - Calls Rust
exchange_tokencommand (bypasses CORS) - Stores session in Redux
- Navigates to
/onboardingor/home
Loop prevention:
// Set flag before navigation to prevent reprocessing
localStorage.setItem('deepLinkHandled', 'true');
window.location.replace('/');
// On next load, clear flag
if (localStorage.getItem('deepLinkHandled') === 'true') {
localStorage.removeItem('deepLinkHandled');
return; // Don't process again
}Cross-platform URL opening.
// Open URL in system browser
async function openUrl(url: string): Promise<void>;Usage:
import { openUrl } from '../utils/openUrl';
// Opens in system browser (not in-app WebView)
await openUrl('https://telegram.org/auth');Implementation:
export async function openUrl(url: string): Promise<void> {
try {
// Try Tauri opener plugin first
const { open } = await import('@tauri-apps/plugin-opener');
await open(url);
} catch {
// Fallback to browser API
window.open(url, '_blank');
}
}Node.js polyfills for browser environment.
The telegram npm package requires Node.js APIs. These are polyfilled:
// polyfills.ts
import { Buffer } from 'buffer';
import process from 'process';
import util from 'util';
window.Buffer = Buffer;
window.process = process;
window.util = util;Imported at app entry:
// main.tsx
import './polyfills';
// ... rest of appVite configuration:
// vite.config.ts
export default defineConfig({
resolve: { alias: { buffer: 'buffer', process: 'process/browser', util: 'util' } },
define: { 'process.env': {}, global: 'globalThis' },
});// API response wrapper
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// API error
interface ApiError {
code: string;
message: string;
details?: unknown;
}
// User interface
interface User {
id: string;
firstName: string;
lastName?: string;
username?: string;
email?: string;
avatar?: string;
telegramId?: string;
subscription?: SubscriptionInfo;
usage?: UsageInfo;
createdAt: string;
updatedAt: string;
}// Onboarding step definition
interface OnboardingStep {
id: string;
title: string;
component: React.ComponentType<StepProps>;
}
// Step component props
interface StepProps {
onNext: () => void;
onBack: () => void;
}
// Connection option
interface ConnectionOption {
id: string;
label: string;
icon: React.ComponentType;
description: string;
comingSoon?: boolean;
}Country list for phone number input.
interface Country {
code: string; // "US"
name: string; // "United States"
dialCode: string; // "+1"
flag: string; // "🇺🇸"
}
export const countries: Country[];Usage:
import { countries } from "../data/countries";
function PhoneInput() {
const [country, setCountry] = useState(countries[0]);
return (
<div>
<select
value={country.code}
onChange={(e) =>
setCountry(countries.find((c) => c.code === e.target.value))
}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.flag} {c.name} ({c.dialCode})
</option>
))}
</select>
<input placeholder="Phone number" />
</div>
);
}Always include dependencies in useEffect:
// Good
useEffect(() => {
on('event', handler);
return () => off('event', handler);
}, [on, off, handler]);
// Bad - missing dependencies
useEffect(() => {
on('event', handler);
return () => off('event', handler);
}, []);Always clean up subscriptions:
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);Wrap utility calls in try-catch:
try {
await openUrl(url);
} catch (error) {
console.error('Failed to open URL:', error);
// Fallback behavior
}Use TypeScript generics for API calls:
const user = await apiClient.get<User>('/users/me');
// user is typed as User