diff --git a/.claude/memory.md b/.claude/memory.md
index da6e9f790..5a8fd2947 100644
--- a/.claude/memory.md
+++ b/.claude/memory.md
@@ -54,6 +54,14 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **`DEV_FORCE_ONBOARDING` was a no-op** — The old ternary had identical branches; fixed to actually force-show when the flag is set.
- **`isOnboardedRedux` must be in useEffect deps** — When reading a selector value inside a useEffect, add it to the dependency array or the effect won't re-run when Redux state changes.
+## CoreStateProvider & Auth Bootstrap
+
+- **Auth session tokens are NOT in Redux persist** — They live entirely in the Rust sidecar, fetched via `fetchCoreAppSnapshot()` RPC. `PersistGate` only gates non-auth state (AI config, threads, channel connections). `CoreStateProvider` bootstrap is the critical auth path.
+- **`CoreStateProvider` premature `isBootstrapping: false` causes blank Settings** — If the initial RPC call fails (sidecar still starting), the old error handler set `isBootstrapping: false` immediately, causing `ProtectedRoute` to redirect to `/` before the 3s poll could recover. Fix (issue #413): keep `isBootstrapping: true` on initial failure, let the poll retry, give up after 5 attempts (~15s).
+- **`CoreStateProvider` is consumed by ~25 components** — Changes to its state shape or bootstrap behavior affect routes, socket, onboarding, nav, settings, and hooks. Treat it as a high-blast-radius file.
+- **Settings is a full route, not a modal** — `/settings/*` uses nested `` in `Settings.tsx`. The `.claude/rules/15-settings-modal-system.md` doc describing a portal/modal approach is outdated. A catch-all `` redirects unmatched sub-paths to `/settings`.
+- **`PersistGate loading={null}` causes flash** — Changed to `loading={}` (issue #413). `RouteLoadingScreen` accepts an optional `label` prop (defaults to "Initializing OpenHuman...") and can be rendered with no props.
+
## Build Blockers: macOS Tahoe + whisper-rs
- **`whisper-rs` breaks `cargo build` on macOS Tahoe (Apple Silicon)** — Added in main via `whisper-rs = "0.16"` (voice feature #178). Apple clang 21+ refuses `-mcpu=native` when `--target=arm64-apple-macosx` is also set. This is NOT fixable by updating CLT.
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 5aa7682ec..9c62a1b30 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -11,6 +11,7 @@ import ErrorFallbackScreen from './components/ErrorFallbackScreen';
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
import MeshGradient from './components/MeshGradient';
import OnboardingOverlay from './components/OnboardingOverlay';
+import RouteLoadingScreen from './components/RouteLoadingScreen';
import CoreStateProvider from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
import { tagErrorSource } from './services/errorReportQueue';
@@ -26,7 +27,7 @@ function App() {
tagErrorSource(eventId, 'react', componentStack);
}}>
-
+ } persistor={persistor}>
diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx
index ee4e8e0f6..651c73441 100644
--- a/app/src/pages/Settings.tsx
+++ b/app/src/pages/Settings.tsx
@@ -1,4 +1,4 @@
-import { Route, Routes } from 'react-router-dom';
+import { Navigate, Route, Routes } from 'react-router-dom';
import AccessibilityPanel from '../components/settings/panels/AccessibilityPanel';
import AdvancedPanel from '../components/settings/panels/AdvancedPanel';
@@ -331,6 +331,7 @@ const Settings = () => {
} />
} />
} />
+ } />
Beta build - v{APP_VERSION}
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index 72c5262de..c1e016a39 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -33,6 +33,7 @@ import {
} from '../utils/tauriCommands';
const POLL_MS = 3000;
+const MAX_BOOTSTRAP_RETRIES = 5;
interface CoreStateContextValue extends CoreState {
refresh: () => Promise;
@@ -76,6 +77,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const snapshotRequestIdRef = useRef(0);
const teamsRequestIdRef = useRef(0);
const memoryTokenRef = useRef(state.snapshot.sessionToken);
+ const bootstrapFailCountRef = useRef(0);
const commitState = useCallback((updater: (previous: CoreState) => CoreState) => {
setState(previous => {
@@ -170,23 +172,49 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const load = async () => {
try {
await refresh();
+ bootstrapFailCountRef.current = 0;
const next = getCoreStateSnapshot();
if (next.snapshot.auth.isAuthenticated) {
await refreshTeams().catch(() => {});
}
} catch (error) {
if (!cancelled) {
- console.warn('[core-state] initial refresh failed:', error);
- commitState(previous => ({ ...previous, isBootstrapping: false }));
+ bootstrapFailCountRef.current += 1;
+ console.warn(
+ `[core-state] initial refresh failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`,
+ error
+ );
+ if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) {
+ commitState(previous => ({ ...previous, isBootstrapping: false }));
+ }
}
}
};
void load();
const interval = window.setInterval(() => {
- void refresh().catch(error => {
- console.warn('[core-state] poll failed:', error);
- });
+ void (async () => {
+ try {
+ await refresh();
+ bootstrapFailCountRef.current = 0;
+ } catch (error) {
+ if (!cancelled) {
+ bootstrapFailCountRef.current += 1;
+ console.warn(
+ `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`,
+ error
+ );
+ if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) {
+ commitState(previous => {
+ if (previous.isBootstrapping) {
+ return { ...previous, isBootstrapping: false };
+ }
+ return previous;
+ });
+ }
+ }
+ }
+ })();
}, POLL_MS);
return () => {