mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
+23
-3
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user