Threads
@@ -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 && (
-
+ {!welcomeLocked && (
+
+ )}
{threads.find(t => t.id === selectedThreadId)?.title ?? 'Select a thread'}
-
+ {!welcomeLocked && (
+
+ )}
)}
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index 1220d1da9..fd7d00432 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -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,
};
}
diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx
index 9eb04aa59..ac473730d 100644
--- a/app/src/providers/__tests__/CoreStateProvider.test.tsx
+++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx
@@ -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 },
diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts
index b6aeba2a0..a76be69aa 100644
--- a/app/src/services/coreStateApi.ts
+++ b/app/src/services/coreStateApi.ts
@@ -31,6 +31,7 @@ interface AppStateSnapshotResult {
sessionToken: string | null;
currentUser: User | null;
onboardingCompleted: boolean;
+ chatOnboardingCompleted: boolean;
analyticsEnabled: boolean;
localState: {
encryptionKey?: string | null;
diff --git a/app/src/store/__tests__/socketSelectors.test.ts b/app/src/store/__tests__/socketSelectors.test.ts
index 59fe65c6b..d2c8cb119 100644
--- a/app/src/store/__tests__/socketSelectors.test.ts
+++ b/app/src/store/__tests__/socketSelectors.test.ts
@@ -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 },
diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs
index 06adc4d03..29a63544c 100644
--- a/src/openhuman/app_state/ops.rs
+++ b/src/openhuman/app_state/ops.rs
@@ -65,6 +65,13 @@ pub struct AppStateSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub current_user: Option,
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, 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, 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,
diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs
index 127312457..eb15b8794 100644
--- a/tests/json_rpc_e2e.rs
+++ b/tests/json_rpc_e2e.rs
@@ -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();