feat(ux): lock UI to welcome agent until chat onboarding completes (#883) (#892)

This commit is contained in:
oxoxDev
2026-04-24 15:46:31 -07:00
committed by GitHub
parent fc4b97abc2
commit 215a776eea
12 changed files with 327 additions and 61 deletions
+23 -3
View File
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import { Provider } from 'react-redux';
import { HashRouter as Router, useLocation } from 'react-router-dom';
import { HashRouter as Router, useLocation, useNavigate } from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
@@ -14,10 +15,11 @@ import MeshGradient from './components/MeshGradient';
import OnboardingOverlay from './components/OnboardingOverlay';
import RouteLoadingScreen from './components/RouteLoadingScreen';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
import { isWelcomeLocked } from './lib/coreState/store';
import { startNativeNotificationsService } from './lib/nativeNotifications';
import { startWebviewNotificationsService } from './lib/webviewNotifications';
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
import CoreStateProvider from './providers/CoreStateProvider';
import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
import { tagErrorSource } from './services/errorReportQueue';
import { startWebviewAccountService } from './services/webviewAccountService';
@@ -69,17 +71,35 @@ function App() {
/** Inner shell — lives inside the Router so it can use useLocation. */
function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const { snapshot, isBootstrapping } = useCoreState();
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
// On /accounts, only the agent view keeps the tab bar + its reserved
// bottom padding. Any other selected "app" (e.g. WhatsApp) takes the
// full viewport so the embedded webview goes edge-to-edge.
const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId);
const welcomeLocked = isWelcomeLocked(snapshot);
// Welcome lockdown (#883) — force any route other than `/chat` back to
// `/chat` while the welcome-agent conversation is still in progress.
// Wait for bootstrap so we don't fight the router during initial paint,
// and skip while the onboarding overlay is still covering the screen
// (`!onboardingCompleted` — overlay handles its own navigation on
// dismiss).
useEffect(() => {
if (!welcomeLocked || isBootstrapping) return;
if (location.pathname === '/chat') return;
console.debug(
`[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)`
);
navigate('/chat', { replace: true });
}, [welcomeLocked, isBootstrapping, location.pathname, navigate]);
return (
<div className="relative h-screen flex flex-col overflow-hidden">
<MeshGradient />
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
<div className={`flex-1 overflow-y-auto ${fullscreen ? '' : 'pb-16'}`}>
<div className={`flex-1 overflow-y-auto ${fullscreen || welcomeLocked ? '' : 'pb-16'}`}>
<GlobalUpsellBanner />
<AppRoutes />
</div>
+8
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { isWelcomeLocked } from '../lib/coreState/store';
import { useCoreState } from '../providers/CoreStateProvider';
import { useAppSelector } from '../store/hooks';
import { selectUnreadCount } from '../store/notificationSlice';
@@ -138,6 +139,13 @@ const BottomTabBar = () => {
return null;
}
// Welcome lockdown (#883) — hide the bottom nav entirely while the
// chat-based welcome-agent flow is still in progress so the user
// cannot navigate away from the welcome conversation.
if (isWelcomeLocked(snapshot)) {
return null;
}
// On /accounts we want as much real estate as possible for the embedded
// webview — but *only* when a real account (WhatsApp, …) is selected.
// The Agent entry keeps the tab bar visible so chatting with the agent
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { type CoreAppSnapshot, isWelcomeLocked } from '../store';
function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot {
return {
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
sessionToken: 'tok',
currentUser: null,
onboardingCompleted: true,
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
...overrides,
};
}
describe('isWelcomeLocked', () => {
it('locks when authenticated user finished the wizard but chat onboarding is still false', () => {
expect(isWelcomeLocked(makeSnapshot())).toBe(true);
});
it('unlocks once chat onboarding completes', () => {
expect(isWelcomeLocked(makeSnapshot({ chatOnboardingCompleted: true }))).toBe(false);
});
it('stays unlocked while the wizard is still up — OnboardingOverlay owns that gate', () => {
expect(isWelcomeLocked(makeSnapshot({ onboardingCompleted: false }))).toBe(false);
});
it('stays unlocked when signed out so the signed-out first paint does not flicker', () => {
expect(
isWelcomeLocked(
makeSnapshot({
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
})
)
).toBe(false);
});
});
+32
View File
@@ -37,6 +37,14 @@ export interface CoreAppSnapshot {
sessionToken: string | null;
currentUser: User | null;
onboardingCompleted: boolean;
/**
* Whether the chat-based welcome-agent flow has finished. Mirrors
* `Config::chat_onboarding_completed` in the Rust core (see
* `src/openhuman/config/schema/types.rs`). Flipped to `true` by the
* welcome agent calling `complete_onboarding(action: "complete")`.
* Drives the UI "welcome lockdown" — see {@link isWelcomeLocked}.
*/
chatOnboardingCompleted: boolean;
analyticsEnabled: boolean;
localState: CoreLocalState;
runtime: CoreRuntimeSnapshot;
@@ -56,6 +64,7 @@ const emptySnapshot: CoreAppSnapshot = {
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
@@ -78,6 +87,29 @@ export function setCoreStateSnapshot(next: CoreState): void {
currentState = next;
}
/**
* Is the UI currently locked to the welcome-agent conversation? (#883)
*
* Returns `true` when the authenticated user has completed the React
* wizard (`onboardingCompleted`) but the chat-based welcome flow has
* not yet finalized (`chatOnboardingCompleted === false`). Consumers
* (BottomTabBar, Accounts left rail, Conversations thread sidebar,
* AppShell redirect) hide their navigation affordances while this is
* `true` so the user cannot escape the welcome conversation until the
* welcome agent calls `complete_onboarding(action: "complete")`.
*
* The auth guard prevents a lock flicker during signed-out first paint
* (snapshot briefly reports `onboardingCompleted=false` before the
* async refresh completes; the overlay handles that path).
*/
export function isWelcomeLocked(snapshot: CoreAppSnapshot): boolean {
return (
snapshot.auth.isAuthenticated &&
snapshot.onboardingCompleted &&
!snapshot.chatOnboardingCompleted
);
}
export function patchCoreStateSnapshot(patch: {
snapshot?: Record<string, unknown> & { localState?: Partial<CoreLocalState> };
[key: string]: unknown;
+61 -34
View File
@@ -4,6 +4,8 @@ import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
import WebviewHost from '../components/accounts/WebviewHost';
import { isWelcomeLocked } from '../lib/coreState/store';
import { useCoreState } from '../providers/CoreStateProvider';
import {
hideWebviewAccount,
purgeWebviewAccount,
@@ -77,6 +79,8 @@ const Accounts = () => {
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
const { snapshot } = useCoreState();
const welcomeLocked = isWelcomeLocked(snapshot);
const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
@@ -89,6 +93,16 @@ const Accounts = () => {
startWebviewAccountService();
}, []);
// Welcome lockdown (#883) — force the Agent pane while the welcome
// conversation is in progress so the user cannot jump to a connected
// account webview. The rail is hidden below, so this is belt-and-
// suspenders in case an external caller toggles `activeAccountId`.
useEffect(() => {
if (welcomeLocked && activeAccountId !== AGENT_ID) {
dispatch(setActiveAccount(AGENT_ID));
}
}, [welcomeLocked, activeAccountId, dispatch]);
useEffect(() => {
void dispatch(fetchRespondQueue());
const id = window.setInterval(() => {
@@ -107,7 +121,11 @@ const Accounts = () => {
[accounts]
);
const selectedId = activeAccountId ?? AGENT_ID;
// While welcome-locked, derive the effective selection directly from
// `welcomeLocked` so the first paint after a lock flip never renders the
// stale `activeAccountId`. The post-paint `useEffect` above still
// syncs Redux so other consumers observe the forced selection.
const selectedId = welcomeLocked ? AGENT_ID : (activeAccountId ?? AGENT_ID);
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
const isAgentSelected = selectedId === AGENT_ID;
@@ -178,41 +196,50 @@ const Accounts = () => {
return (
<div className="relative flex h-full overflow-hidden">
{/* Narrow icon rail — floats when Agent is selected, flush to the
edge when an app webview is taking the full pane. */}
<aside
className={`z-30 flex w-16 flex-none flex-col items-center gap-2 bg-white/60 py-3 backdrop-blur-md transition-all duration-300 ${
isAgentSelected
? 'my-3 ml-3 rounded-2xl border border-stone-200/70 shadow-soft'
: 'border-r border-stone-200/60'
}`}>
<RailButton active={isAgentSelected} onClick={selectAgent} tooltip="Agent">
<AgentIcon className="h-9 w-9 rounded-lg" />
</RailButton>
{accounts.map(acct => (
<RailButton
key={acct.id}
active={acct.id === selectedId}
onClick={() => selectAccount(acct.id)}
onContextMenu={e => openContextMenu(acct.id, e)}
tooltip={acct.label}
badge={unreadByAccount[acct.id]}>
<ProviderIcon provider={acct.provider} className="h-8 w-8 rounded-md" />
edge when an app webview is taking the full pane. Hidden during
welcome lockdown (#883) so the user cannot navigate to a
connected account or add a new one. */}
{!welcomeLocked && (
<aside
className={`z-30 flex w-16 flex-none flex-col items-center gap-2 bg-white/60 py-3 backdrop-blur-md transition-all duration-300 ${
isAgentSelected
? 'my-3 ml-3 rounded-2xl border border-stone-200/70 shadow-soft'
: 'border-r border-stone-200/60'
}`}>
<RailButton active={isAgentSelected} onClick={selectAgent} tooltip="Agent">
<AgentIcon className="h-9 w-9 rounded-lg" />
</RailButton>
))}
<button
onClick={() => setAddOpen(true)}
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600"
aria-label="Add app">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
Add app
</span>
</button>
</aside>
{accounts.map(acct => (
<RailButton
key={acct.id}
active={acct.id === selectedId}
onClick={() => selectAccount(acct.id)}
onContextMenu={e => openContextMenu(acct.id, e)}
tooltip={acct.label}
badge={unreadByAccount[acct.id]}>
<ProviderIcon provider={acct.provider} className="h-8 w-8 rounded-md" />
</RailButton>
))}
<button
onClick={() => setAddOpen(true)}
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600"
aria-label="Add app">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
<span className="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
Add app
</span>
</button>
</aside>
)}
{/* Main pane */}
<main className="flex min-w-0 flex-1 flex-col">
+72 -23
View File
@@ -10,6 +10,8 @@ import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDism
import UsageLimitModal from '../components/upsell/UsageLimitModal';
import { useStickToBottom } from '../hooks/useStickToBottom';
import { useUsageState } from '../hooks/useUsageState';
import { isWelcomeLocked } from '../lib/coreState/store';
import { useCoreState } from '../providers/CoreStateProvider';
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
import {
beginInferenceTurn,
@@ -91,6 +93,16 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
activeThreadId,
} = useAppSelector(state => state.thread);
const { snapshot } = useCoreState();
const welcomeLocked = isWelcomeLocked(snapshot);
const chatOnboardingCompleted = snapshot.chatOnboardingCompleted;
const previousChatOnboardingCompletedRef = useRef<boolean | null>(null);
// Guard against the mount-time `loadThreads()` promise resolving AFTER
// the welcome-lock unlock transition creates a fresh thread. Without
// this, the stale `.then(...)` would re-select the old welcome thread
// and clobber the auto-created one (#883 CodeRabbit feedback).
const skipInitialThreadSelectionRef = useRef(false);
const [showSidebar, setShowSidebar] = useState(true);
const [inputValue, setInputValue] = useState('');
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
@@ -177,7 +189,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
void dispatch(loadThreads())
.unwrap()
.then(data => {
if (cancelled) return;
if (cancelled || skipInitialThreadSelectionRef.current) return;
if (data.threads.length > 0) {
const mostRecent = data.threads[0];
dispatch(setSelectedThread(mostRecent.id));
@@ -199,6 +211,28 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
}
}, [selectedThreadId, dispatch]);
// Welcome lockdown unlock (#883) — when `chatOnboardingCompleted`
// transitions from `false` → `true` (the welcome agent just called
// `complete_onboarding(action: "complete")`), open a fresh thread so
// the user starts their first "real" conversation with the orchestrator
// instead of continuing the welcome thread. Ref-tracked one-shot so
// the 2s snapshot poll cannot re-fire this.
useEffect(() => {
const prev = previousChatOnboardingCompletedRef.current;
previousChatOnboardingCompletedRef.current = chatOnboardingCompleted;
if (prev === false && chatOnboardingCompleted === true) {
// Signal the mount-time `loadThreads()` promise to bail if it is
// still pending — otherwise its stale resolution would overwrite
// our freshly created thread selection.
skipInitialThreadSelectionRef.current = true;
console.debug('[welcome-lock] chat onboarding completed — opening new thread');
void handleCreateNewThread();
}
// handleCreateNewThread is stable for the component lifetime (only
// uses `dispatch`); the ref guards against duplicate fires.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatOnboardingCompleted]);
useEffect(() => {
if (selectedThreadId && messages.length === 0) {
dispatch(fetchSuggestedQuestions(selectedThreadId));
@@ -372,6 +406,13 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
const handleSlashCommand = (command: string): boolean => {
const cmd = command.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
// Welcome lockdown (#883) — consume the command so it is not sent
// to the agent, but skip thread creation/reset so the user cannot
// escape the welcome conversation via `/new` or `/clear`.
if (welcomeLocked) {
setInputValue('');
return true;
}
setInputValue('');
void handleCreateNewThread();
return true;
@@ -726,8 +767,10 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
: 'h-full relative z-10 flex justify-center overflow-hidden p-4 pt-6 gap-3'
}>
{/* Thread sidebar — only shown in page mode (when Conversations itself
is a top-level route, not embedded as a sidebar in another page). */}
{!isSidebar && showSidebar && (
is a top-level route, not embedded as a sidebar in another page).
Suppressed during welcome lockdown (#883) — the user must stay in
the welcome conversation. */}
{!isSidebar && showSidebar && !welcomeLocked && (
<div className="w-64 flex-shrink-0 flex flex-col bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-100">
<h2 className="text-sm font-semibold text-stone-700">Threads</h2>
@@ -837,32 +880,38 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
: 'flex-1 flex flex-col min-w-0 max-w-2xl bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden'
}>
{/* Chat header — only shown in page mode; the sidebar embed uses the
parent page's chrome instead. */}
parent page's chrome instead. During welcome lockdown (#883)
the sidebar toggle and "+ New" are hidden because the user has
no sidebar to toggle and cannot spawn new threads. */}
{!isSidebar && (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-stone-100">
<button
onClick={() => setShowSidebar(prev => !prev)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title={showSidebar ? 'Hide sidebar' : 'Show sidebar'}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
{!welcomeLocked && (
<button
onClick={() => setShowSidebar(prev => !prev)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title={showSidebar ? 'Hide sidebar' : 'Show sidebar'}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
)}
<h3 className="text-sm font-medium text-stone-700 truncate flex-1">
{threads.find(t => t.id === selectedThreadId)?.title ?? 'Select a thread'}
</h3>
<TokenUsagePill />
<button
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
title="New thread (/new)">
+ New
</button>
{!welcomeLocked && (
<button
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
title="New thread (/new)">
+ New
</button>
)}
</div>
)}
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6]">
+2
View File
@@ -82,6 +82,7 @@ function normalizeSnapshot(
sessionToken: result.sessionToken,
currentUser: result.currentUser,
onboardingCompleted: result.onboardingCompleted,
chatOnboardingCompleted: result.chatOnboardingCompleted,
analyticsEnabled: result.analyticsEnabled,
localState: {
encryptionKey: result.localState.encryptionKey ?? null,
@@ -104,6 +105,7 @@ function toSignedOutSnapshot(snapshot: CoreAppSnapshot): CoreAppSnapshot {
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
};
}
@@ -26,6 +26,7 @@ function makeSnapshot(overrides: {
sessionToken: overrides.sessionToken ?? null,
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: {},
runtime: {
@@ -73,6 +74,7 @@ function resetCoreStateStore() {
sessionToken: null,
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
+1
View File
@@ -31,6 +31,7 @@ interface AppStateSnapshotResult {
sessionToken: string | null;
currentUser: User | null;
onboardingCompleted: boolean;
chatOnboardingCompleted: boolean;
analyticsEnabled: boolean;
localState: {
encryptionKey?: string | null;
@@ -19,6 +19,7 @@ function makeCoreState(token: string | null): CoreState {
sessionToken: token,
currentUser: null,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
+10 -1
View File
@@ -65,6 +65,13 @@ pub struct AppStateSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub current_user: Option<Value>,
pub onboarding_completed: bool,
/// Whether the chat-based welcome-agent flow has completed. Sourced
/// from [`Config::chat_onboarding_completed`]. The React app hides
/// the bottom tab bar, thread sidebar, and account rail while this is
/// `false` (and `onboarding_completed` is `true`) so the user stays
/// with the welcome agent until it calls
/// `complete_onboarding(action="complete")`.
pub chat_onboarding_completed: bool,
pub analytics_enabled: bool,
pub local_state: StoredAppState,
pub runtime: RuntimeSnapshot,
@@ -347,9 +354,10 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
let runtime = build_runtime_snapshot(&config).await;
debug!(
"{LOG_PREFIX} snapshot auth={} onboarding={} analytics={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
"{LOG_PREFIX} snapshot auth={} onboarding={} chat_onboarding={} analytics={} wallet_present={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}",
auth.is_authenticated,
config.onboarding_completed,
config.chat_onboarding_completed,
config.observability.analytics_enabled,
local_state.primary_wallet_address.is_some(),
runtime.screen_intelligence.session.active,
@@ -364,6 +372,7 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
session_token,
current_user,
onboarding_completed: config.onboarding_completed,
chat_onboarding_completed: config.chat_onboarding_completed,
analytics_enabled: config.observability.analytics_enabled,
local_state,
runtime,
+74
View File
@@ -1314,6 +1314,20 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() {
body.get("localState").and_then(Value::as_object).is_some(),
"expected localState object: {body}"
);
assert_eq!(
body.get("onboardingCompleted").and_then(Value::as_bool),
Some(false),
"expected onboardingCompleted=false default: {body}"
);
// Welcome-lockdown frontend gate (#883). `write_min_config` sets
// `chat_onboarding_completed = true` so the test harness bypasses the
// welcome agent; the snapshot must surface the same camelCase key the
// React app reads.
assert_eq!(
body.get("chatOnboardingCompleted").and_then(Value::as_bool),
Some(true),
"expected chatOnboardingCompleted=true from test config: {body}"
);
let runtime = body.get("runtime").expect("expected runtime object");
assert!(
@@ -1343,6 +1357,66 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() {
rpc_join.abort();
}
/// #883 — when `chat_onboarding_completed` is unset in config.toml (fresh
/// user), the `openhuman.app_state_snapshot` RPC must surface the flag as
/// `false` so the React welcome-lockdown kicks in.
#[tokio::test]
async fn json_rpc_app_state_snapshot_chat_onboarding_defaults_false() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
// Fresh-user config: no `chat_onboarding_completed` key → serde default
// of `false`. Cannot reuse `write_min_config` because it hard-codes the
// flag to `true` so the e2e mock can bypass the welcome agent.
let cfg = format!(
r#"api_url = "{mock_origin}"
default_model = "e2e-mock-model"
default_temperature = 0.7
[secrets]
encrypt = false
"#
);
std::fs::create_dir_all(&openhuman_home).expect("mkdir openhuman");
std::fs::write(openhuman_home.join("config.toml"), &cfg).expect("write config");
std::fs::create_dir_all(openhuman_home.join("users").join("local")).expect("mkdir users/local");
std::fs::write(
openhuman_home
.join("users")
.join("local")
.join("config.toml"),
&cfg,
)
.expect("write user config");
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let snapshot = post_json_rpc(&rpc_base, 1005, "openhuman.app_state_snapshot", json!({})).await;
let result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot");
let body = result.get("result").unwrap_or(result);
assert_eq!(
body.get("chatOnboardingCompleted").and_then(Value::as_bool),
Some(false),
"fresh-user config without chat_onboarding_completed must surface chatOnboardingCompleted=false: {body}"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_screen_intelligence_vision_recent_returns_empty_without_session() {
let _env_lock = json_rpc_e2e_env_lock();