mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(onboarding): replace welcome-agent bot with react-joyride walkthrough (#1180)
This commit is contained in:
@@ -78,6 +78,7 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-joyride": "^3.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
|
||||
+56
-42
@@ -16,7 +16,9 @@ import MeshGradient from './components/MeshGradient';
|
||||
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
|
||||
import PersistRehydrationScreen from './components/PersistRehydrationScreen';
|
||||
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
|
||||
import { isWelcomeLocked } from './lib/coreState/store';
|
||||
import AppWalkthrough from './components/walkthrough/AppWalkthrough';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { isWelcomeLocked } from './lib/coreState/store';
|
||||
import { startNativeNotificationsService } from './lib/nativeNotifications';
|
||||
import { startWebviewNotificationsService } from './lib/webviewNotifications';
|
||||
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
|
||||
@@ -24,8 +26,10 @@ import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider';
|
||||
import SocketProvider from './providers/SocketProvider';
|
||||
import { startWebviewAccountService } from './services/webviewAccountService';
|
||||
import { persistor, store } from './store';
|
||||
import { useAppDispatch, useAppSelector } from './store/hooks';
|
||||
import { clearSelectedThread, deleteThread, setWelcomeThreadId } from './store/threadSlice';
|
||||
// [#1123] useAppDispatch commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
import { useAppSelector } from './store/hooks';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { clearSelectedThread, deleteThread, setWelcomeThreadId } from './store/threadSlice';
|
||||
import { isAccountsFullscreen } from './utils/accountsFullscreen';
|
||||
import { DEV_FORCE_ONBOARDING } from './utils/config';
|
||||
|
||||
@@ -77,7 +81,8 @@ function AppShell() {
|
||||
// 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);
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// const welcomeLocked = isWelcomeLocked(snapshot);
|
||||
const onOnboardingRoute = location.pathname.startsWith('/onboarding');
|
||||
const onboardingPending =
|
||||
!!snapshot.sessionToken && (DEV_FORCE_ONBOARDING || !snapshot.onboardingCompleted);
|
||||
@@ -107,51 +112,53 @@ function AppShell() {
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// After the welcome agent calls `complete_onboarding` and
|
||||
// `chat_onboarding_completed` flips false→true, discard the transient
|
||||
// welcome thread we created in `OnboardingLayout`. The next user
|
||||
// message will route to the orchestrator and create its own thread.
|
||||
const dispatch = useAppDispatch();
|
||||
const welcomeThreadId = useAppSelector(state => state.thread.welcomeThreadId);
|
||||
const chatOnboardingCompleted = snapshot.chatOnboardingCompleted;
|
||||
useEffect(() => {
|
||||
if (!chatOnboardingCompleted || !welcomeThreadId) return;
|
||||
let cancelled = false;
|
||||
console.debug(
|
||||
`[welcome-cleanup] chat_onboarding_completed=true — deleting welcome thread ${welcomeThreadId}`
|
||||
);
|
||||
// Await the delete before dropping the local id so a backend failure
|
||||
// leaves `welcomeThreadId` set for retry on the next render. Without
|
||||
// the await, a 500 from `threads.delete` would leave a stale row in
|
||||
// the user's thread list while the renderer thinks it's gone.
|
||||
(async () => {
|
||||
try {
|
||||
await dispatch(deleteThread(welcomeThreadId)).unwrap();
|
||||
if (cancelled) return;
|
||||
dispatch(clearSelectedThread());
|
||||
dispatch(setWelcomeThreadId(null));
|
||||
} catch (err) {
|
||||
console.warn('[welcome-cleanup] deleteThread failed; will retry on next render', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chatOnboardingCompleted, welcomeThreadId, dispatch]);
|
||||
|
||||
// const dispatch = useAppDispatch();
|
||||
// const welcomeThreadId = useAppSelector(state => state.thread.welcomeThreadId);
|
||||
// const chatOnboardingCompleted = snapshot.chatOnboardingCompleted;
|
||||
// useEffect(() => {
|
||||
// if (!chatOnboardingCompleted || !welcomeThreadId) return;
|
||||
// let cancelled = false;
|
||||
// console.debug(
|
||||
// `[welcome-cleanup] chat_onboarding_completed=true — deleting welcome thread ${welcomeThreadId}`
|
||||
// );
|
||||
// // Await the delete before dropping the local id so a backend failure
|
||||
// // leaves `welcomeThreadId` set for retry on the next render. Without
|
||||
// // the await, a 500 from `threads.delete` would leave a stale row in
|
||||
// // the user's thread list while the renderer thinks it's gone.
|
||||
// (async () => {
|
||||
// try {
|
||||
// await dispatch(deleteThread(welcomeThreadId)).unwrap();
|
||||
// if (cancelled) return;
|
||||
// dispatch(clearSelectedThread());
|
||||
// dispatch(setWelcomeThreadId(null));
|
||||
// } catch (err) {
|
||||
// console.warn('[welcome-cleanup] deleteThread failed; will retry on next render', err);
|
||||
// }
|
||||
// })();
|
||||
// return () => {
|
||||
// cancelled = true;
|
||||
// };
|
||||
// }, [chatOnboardingCompleted, welcomeThreadId, dispatch]);
|
||||
//
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// Welcome lockdown (#883) — force any route other than `/chat` back to
|
||||
// `/chat` while the welcome-agent conversation is still in progress.
|
||||
// Skipped while onboarding is still pending (the onboarding gate above
|
||||
// owns the route during that phase).
|
||||
useEffect(() => {
|
||||
if (!welcomeLocked || isBootstrapping) return;
|
||||
if (onboardingPending) return;
|
||||
if (location.pathname === '/chat') return;
|
||||
console.debug(
|
||||
`[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)`
|
||||
);
|
||||
navigate('/chat', { replace: true });
|
||||
}, [welcomeLocked, isBootstrapping, onboardingPending, location.pathname, navigate]);
|
||||
// useEffect(() => {
|
||||
// if (!welcomeLocked || isBootstrapping) return;
|
||||
// if (onboardingPending) return;
|
||||
// if (location.pathname === '/chat') return;
|
||||
// console.debug(
|
||||
// `[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)`
|
||||
// );
|
||||
// navigate('/chat', { replace: true });
|
||||
// }, [welcomeLocked, isBootstrapping, onboardingPending, location.pathname, navigate]);
|
||||
|
||||
return (
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
@@ -159,7 +166,8 @@ function AppShell() {
|
||||
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${
|
||||
fullscreen || welcomeLocked || onOnboardingRoute ? '' : 'pb-16'
|
||||
// [#1123] welcomeLocked removed — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
fullscreen || onOnboardingRoute ? '' : 'pb-16'
|
||||
}`}>
|
||||
<GlobalUpsellBanner />
|
||||
<AppRoutes />
|
||||
@@ -167,6 +175,12 @@ function AppShell() {
|
||||
{!onOnboardingRoute && <BottomTabBar />}
|
||||
</div>
|
||||
<OpenhumanLinkModal />
|
||||
{/* Post-onboarding Joyride walkthrough — mounted here (outside routes) so
|
||||
it persists across tab navigations. Joyride targets span Home + BottomTabBar
|
||||
tabs so it must stay mounted while the user moves between routes. */}
|
||||
{!isBootstrapping && !onOnboardingRoute && (
|
||||
<AppWalkthrough onboarded={!!snapshot.onboardingCompleted} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { isWelcomeLocked } from '../lib/coreState/store';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { isWelcomeLocked } from '../lib/coreState/store';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { selectUnreadCount } from '../store/notificationSlice';
|
||||
@@ -159,12 +160,13 @@ const BottomTabBar = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// 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;
|
||||
}
|
||||
// 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.
|
||||
@@ -215,9 +217,18 @@ const BottomTabBar = () => {
|
||||
.map(tab => {
|
||||
const active = isActive(tab.path);
|
||||
const showBadge = tab.id === 'notifications' && unreadCount > 0;
|
||||
// data-walkthrough attributes for the Joyride walkthrough steps.
|
||||
// Maps tab ids to their walkthrough target names (steps 3–6).
|
||||
const walkthroughAttr: Record<string, string> = {
|
||||
chat: 'tab-chat',
|
||||
skills: 'tab-skills',
|
||||
notifications: 'tab-automation',
|
||||
settings: 'tab-settings',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
data-walkthrough={walkthroughAttr[tab.id]}
|
||||
onClick={() => navigate(tab.path)}
|
||||
className={`group relative flex items-center px-2 py-2 rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
|
||||
active
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Tests for BottomTabBar — verifies that:
|
||||
* - the tab bar renders when the user has a session token and is on a non-hidden path
|
||||
* - the walkthroughAttr mapping (line 222) is exercised by rendering the tabs
|
||||
* - the tab bar is hidden on '/' and '/login' paths
|
||||
*
|
||||
* [#1123] Covers the walkthroughAttr object added for the Joyride walkthrough.
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import accountsReducer from '../../store/accountsSlice';
|
||||
import notificationReducer from '../../store/notificationSlice';
|
||||
import BottomTabBar from '../BottomTabBar';
|
||||
|
||||
// ── Module-level mocks ─────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
|
||||
vi.mock('../../utils/config', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../utils/config')>();
|
||||
return { ...actual, APP_ENVIRONMENT: 'development' };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/accountsFullscreen', () => ({ isAccountsFullscreen: vi.fn(() => false) }));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildStore() {
|
||||
return configureStore({
|
||||
reducer: { accounts: accountsReducer, notifications: notificationReducer },
|
||||
});
|
||||
}
|
||||
|
||||
async function renderBottomTabBar(pathname = '/home', hasToken = true) {
|
||||
const { useCoreState } = await import('../../providers/CoreStateProvider');
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: {
|
||||
sessionToken: hasToken ? 'tok-test' : null,
|
||||
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
|
||||
currentUser: null,
|
||||
onboardingCompleted: true,
|
||||
chatOnboardingCompleted: true,
|
||||
analyticsEnabled: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
setOnboardingCompletedFlag: vi.fn(),
|
||||
setOnboardingTasks: vi.fn(),
|
||||
refreshSnapshot: vi.fn(),
|
||||
} as never);
|
||||
|
||||
const store = buildStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
<BottomTabBar />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BottomTabBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// [#1123] Covers line 222 — walkthroughAttr object created per-tab inside .map()
|
||||
it('renders navigation tabs with data-walkthrough attributes when session is active', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
// The Home tab is always visible and has no walkthrough attr (not in the map)
|
||||
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
|
||||
|
||||
// Chat tab has data-walkthrough="tab-chat" (from walkthroughAttr map)
|
||||
const chatBtn = screen.getByRole('button', { name: 'Chat' });
|
||||
expect(chatBtn).toBeInTheDocument();
|
||||
expect(chatBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
|
||||
});
|
||||
|
||||
it('renders Settings tab with data-walkthrough="tab-settings"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const settingsBtn = screen.getByRole('button', { name: 'Settings' });
|
||||
expect(settingsBtn).toHaveAttribute('data-walkthrough', 'tab-settings');
|
||||
});
|
||||
|
||||
it('returns null when there is no session token', async () => {
|
||||
const { container } = await renderBottomTabBar('/home', false);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on the "/" path even with a session token', async () => {
|
||||
const { container } = await renderBottomTabBar('/');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
|
||||
|
||||
import { WALKTHROUGH_STEPS } from './walkthroughSteps';
|
||||
import WalkthroughTooltip from './WalkthroughTooltip';
|
||||
|
||||
// ── localStorage keys ──────────────────────────────────────────────────────
|
||||
|
||||
const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed';
|
||||
const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending';
|
||||
|
||||
/**
|
||||
* Returns `true` when the walkthrough should be shown. This is true when:
|
||||
* - The walkthrough has not yet been completed or skipped, AND
|
||||
* - Either the pending flag was explicitly set (fresh onboarding), OR
|
||||
* the caller indicates the user is already onboarded (migration path
|
||||
* for existing users who upgrade to the Joyride version).
|
||||
*
|
||||
* Wrapped in try/catch to gracefully handle SecurityError or quota exceptions
|
||||
* (e.g., in private-browsing mode or when storage is full/blocked).
|
||||
*/
|
||||
export function isWalkthroughPending(userIsOnboarded = false): boolean {
|
||||
try {
|
||||
if (localStorage.getItem(WALKTHROUGH_KEY) === 'true') return false;
|
||||
return localStorage.getItem(WALKTHROUGH_PENDING_KEY) === 'true' || userIsOnboarded;
|
||||
} catch (e) {
|
||||
console.warn('[walkthrough] localStorage unavailable — treating as not pending', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the walkthrough as pending. Called by OnboardingLayout when the user
|
||||
* completes the wizard and is about to navigate to /home.
|
||||
*
|
||||
* Best-effort: if localStorage is unavailable (SecurityError / quota) the
|
||||
* error is logged and the call is silently swallowed so navigation always
|
||||
* proceeds.
|
||||
*/
|
||||
export function setWalkthroughPending(): void {
|
||||
try {
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
console.debug('[walkthrough] pending flag set');
|
||||
} catch (e) {
|
||||
console.warn('[walkthrough] could not set pending flag in localStorage', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the walkthrough as completed (or skipped). Once set, the walkthrough
|
||||
* will not show again.
|
||||
*
|
||||
* Wrapped in try/catch to prevent SecurityError/quota exceptions from
|
||||
* interrupting the tour-end flow.
|
||||
*/
|
||||
export function markWalkthroughComplete(): void {
|
||||
try {
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
localStorage.removeItem(WALKTHROUGH_PENDING_KEY);
|
||||
console.debug('[walkthrough] marked as complete');
|
||||
} catch (e) {
|
||||
console.warn('[walkthrough] could not mark walkthrough complete in localStorage', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the post-onboarding Joyride walkthrough overlay (react-joyride v3).
|
||||
*
|
||||
* Only mounts the Joyride instance when `isWalkthroughPending()` is true.
|
||||
* On finish or skip (EVENTS.TOUR_END), calls `markWalkthroughComplete()` so
|
||||
* it never shows again.
|
||||
*
|
||||
* Mount this inside the Home page so it runs after the tab bar and home card
|
||||
* are in the DOM (all `data-walkthrough="*"` targets must exist).
|
||||
*/
|
||||
const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
|
||||
// Only start running if the walkthrough is pending on first render.
|
||||
// Using a lazy initializer keeps this stable across re-renders.
|
||||
const [run, setRun] = useState<boolean>(() => isWalkthroughPending(onboarded));
|
||||
|
||||
const handleEvent = (data: EventData) => {
|
||||
const { type, status } = data;
|
||||
console.debug('[walkthrough] event', { type, status, index: data.index });
|
||||
|
||||
// TOUR_END fires when the tour finishes or is skipped.
|
||||
if (type === EVENTS.TOUR_END) {
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
markWalkthroughComplete();
|
||||
setRun(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing to render when the walkthrough is not pending.
|
||||
if (!run) return null;
|
||||
|
||||
return (
|
||||
<Joyride
|
||||
steps={WALKTHROUGH_STEPS}
|
||||
run={run}
|
||||
continuous={true}
|
||||
tooltipComponent={WalkthroughTooltip}
|
||||
onEvent={handleEvent}
|
||||
options={{
|
||||
zIndex: 1200,
|
||||
overlayColor: 'rgba(0, 0, 0, 0.4)',
|
||||
buttons: ['back', 'primary', 'skip'],
|
||||
spotlightRadius: 16,
|
||||
spotlightPadding: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppWalkthrough;
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { TooltipRenderProps } from 'react-joyride';
|
||||
|
||||
/** Emoji accents per step — adds visual personality to each tooltip. */
|
||||
const STEP_ICONS = ['🏠', '💬', '🧠', '⚡', '🤖', '🎉'];
|
||||
|
||||
/**
|
||||
* Premium tooltip for the post-onboarding Joyride walkthrough.
|
||||
*
|
||||
* Design: frosted-glass card with smooth entrance animation, step-specific
|
||||
* emoji accent, pill progress bar, and polished button styling that matches
|
||||
* the OpenHuman design system (ocean primary #2F6EF4, warm neutrals).
|
||||
*/
|
||||
const WalkthroughTooltip = ({
|
||||
continuous,
|
||||
index,
|
||||
step,
|
||||
backProps,
|
||||
primaryProps,
|
||||
skipProps,
|
||||
tooltipProps,
|
||||
size,
|
||||
isLastStep,
|
||||
}: TooltipRenderProps) => {
|
||||
const progress = ((index + 1) / size) * 100;
|
||||
const icon = STEP_ICONS[index] ?? '✨';
|
||||
|
||||
return (
|
||||
<div
|
||||
{...tooltipProps}
|
||||
className="w-80 font-sans animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
style={{ animation: 'tooltipEnter 0.3s ease-out' }}>
|
||||
{/* Frosted card */}
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-xl border border-stone-200/60 overflow-hidden">
|
||||
{/* Progress bar — thin, smooth fill */}
|
||||
<div className="h-1 bg-stone-100">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#2F6EF4] to-[#5B9BF3] transition-all duration-500 ease-out rounded-r-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{/* Header: emoji + title + step counter */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl shrink-0 mt-0.5" role="img" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{step.title && (
|
||||
<h3 className="text-[15px] font-semibold text-stone-900 leading-snug">
|
||||
{step.title}
|
||||
</h3>
|
||||
)}
|
||||
<span className="text-[11px] text-stone-400 tabular-nums">
|
||||
{index + 1} of {size}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="text-[13px] text-stone-600 leading-relaxed mb-5">{step.content}</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Skip tour */}
|
||||
{!isLastStep && (
|
||||
<button
|
||||
{...skipProps}
|
||||
className="text-[11px] text-stone-400 hover:text-stone-600 transition-colors px-2 py-1.5 rounded-lg hover:bg-stone-100">
|
||||
Skip tour
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Back */}
|
||||
{index > 0 && (
|
||||
<button
|
||||
{...backProps}
|
||||
className="text-[12px] text-stone-500 hover:text-stone-800 border border-stone-200 hover:border-stone-300 transition-all px-4 py-2 rounded-xl hover:shadow-sm">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next / Let's go! */}
|
||||
{continuous && (
|
||||
<button
|
||||
{...primaryProps}
|
||||
className="text-[12px] text-white bg-[#2F6EF4] hover:bg-[#2563d4] active:scale-[0.97] transition-all px-4 py-2 rounded-xl font-medium shadow-sm hover:shadow-md">
|
||||
{isLastStep ? "Let's go!" : 'Next →'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalkthroughTooltip;
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Tests for the Joyride walkthrough components introduced in #1123.
|
||||
*
|
||||
* Verifies:
|
||||
* - isWalkthroughPending / setWalkthroughPending / markWalkthroughComplete helpers
|
||||
* - AppWalkthrough renders only when pending
|
||||
* - AppWalkthrough does not render when already completed
|
||||
* - Completing/skipping the tour calls markWalkthroughComplete (localStorage set)
|
||||
* - Step count matches WALKTHROUGH_STEPS
|
||||
* - WalkthroughTooltip renders step title, content, and navigation buttons
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isWalkthroughPending,
|
||||
markWalkthroughComplete,
|
||||
setWalkthroughPending,
|
||||
} from '../AppWalkthrough';
|
||||
import { WALKTHROUGH_STEPS } from '../walkthroughSteps';
|
||||
// ── WalkthroughTooltip rendering tests ───────────────────────────────────
|
||||
|
||||
import WalkthroughTooltip from '../WalkthroughTooltip';
|
||||
|
||||
// ── Mock react-joyride so tests don't need a real DOM with
|
||||
// positioned elements for each step target. ─────────────────────────────
|
||||
// The mock captures the `onEvent` callback so individual tests can
|
||||
// simulate tour events (TOUR_END with FINISHED / SKIPPED status).
|
||||
|
||||
type JoyrideMockProps = {
|
||||
run: boolean;
|
||||
onEvent?: (data: { type: string; status: string; index: number }) => void;
|
||||
};
|
||||
|
||||
let capturedOnEvent: JoyrideMockProps['onEvent'] | undefined;
|
||||
|
||||
vi.mock('react-joyride', () => ({
|
||||
Joyride: ({ run, onEvent }: JoyrideMockProps) => {
|
||||
capturedOnEvent = onEvent;
|
||||
return <div data-testid="joyride-mock" data-run={String(run)} />;
|
||||
},
|
||||
EVENTS: { TOUR_END: 'tour:end' },
|
||||
STATUS: { FINISHED: 'finished', SKIPPED: 'skipped' },
|
||||
}));
|
||||
|
||||
// ── localStorage helpers ───────────────────────────────────────────────────
|
||||
|
||||
const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed';
|
||||
const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
capturedOnEvent = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ── Helper state tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('isWalkthroughPending', () => {
|
||||
it('returns false when nothing is set', () => {
|
||||
expect(isWalkthroughPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when pending flag is set and completed flag is not', () => {
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
expect(isWalkthroughPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when both pending and completed are set', () => {
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
expect(isWalkthroughPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when only completed flag is set', () => {
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
expect(isWalkthroughPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWalkthroughPending', () => {
|
||||
it('sets the pending flag in localStorage', () => {
|
||||
setWalkthroughPending();
|
||||
expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('swallows error when localStorage.setItem throws (SecurityError / quota)', () => {
|
||||
// Temporarily replace localStorage with a broken implementation to trigger
|
||||
// the catch block at line 44 in setWalkthroughPending.
|
||||
const realStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
...realStorage,
|
||||
setItem() {
|
||||
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Should not throw — the error is swallowed inside setWalkthroughPending
|
||||
expect(() => setWalkthroughPending()).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: realStorage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('markWalkthroughComplete', () => {
|
||||
it('sets the completed flag and removes the pending flag', () => {
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
markWalkthroughComplete();
|
||||
expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true');
|
||||
expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('swallows error when localStorage.setItem throws (SecurityError / quota)', () => {
|
||||
// Temporarily replace localStorage with a broken implementation to trigger
|
||||
// the catch block at line 61 in markWalkthroughComplete.
|
||||
const realStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
...realStorage,
|
||||
setItem() {
|
||||
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Should not throw — the error is swallowed inside markWalkthroughComplete
|
||||
expect(() => markWalkthroughComplete()).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: realStorage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWalkthroughPending — localStorage unavailable', () => {
|
||||
it('returns false and swallows error when localStorage.getItem throws', () => {
|
||||
// Temporarily replace localStorage with a broken implementation to trigger
|
||||
// the catch block at lines 26-27 in isWalkthroughPending.
|
||||
const realStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
...realStorage,
|
||||
getItem() {
|
||||
throw new DOMException('SecurityError', 'SecurityError');
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Should return false (the catch branch) and not throw
|
||||
expect(isWalkthroughPending()).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: realStorage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── AppWalkthrough component tests ────────────────────────────────────────
|
||||
|
||||
describe('AppWalkthrough component', () => {
|
||||
it('renders Joyride when walkthrough is pending', async () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
|
||||
expect(screen.getByTestId('joyride-mock')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders nothing when walkthrough is not pending', async () => {
|
||||
// No pending flag set
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
const { container } = render(<AppWalkthrough />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when walkthrough is already completed', async () => {
|
||||
// Set pending but also completed — should not render
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
const { container } = render(<AppWalkthrough />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('calls markWalkthroughComplete and stops running when tour finishes (FINISHED)', async () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
|
||||
// Joyride should be running initially
|
||||
expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true');
|
||||
|
||||
// Simulate TOUR_END with FINISHED status
|
||||
await act(async () => {
|
||||
capturedOnEvent?.({ type: 'tour:end', status: 'finished', index: 5 });
|
||||
});
|
||||
|
||||
// Walkthrough should be marked complete in localStorage
|
||||
expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true');
|
||||
expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('calls markWalkthroughComplete and stops running when tour is skipped (SKIPPED)', async () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
|
||||
expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true');
|
||||
|
||||
// Simulate TOUR_END with SKIPPED status
|
||||
await act(async () => {
|
||||
capturedOnEvent?.({ type: 'tour:end', status: 'skipped', index: 1 });
|
||||
});
|
||||
|
||||
expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true');
|
||||
expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not call markWalkthroughComplete for non-TOUR_END events', async () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
|
||||
// Simulate a step:after event (not tour:end)
|
||||
await act(async () => {
|
||||
capturedOnEvent?.({ type: 'step:after', status: 'running', index: 0 });
|
||||
});
|
||||
|
||||
// Should NOT have marked complete
|
||||
expect(localStorage.getItem(WALKTHROUGH_KEY)).toBeNull();
|
||||
// Still running
|
||||
expect(screen.getByTestId('joyride-mock')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/** Build the minimal props required by WalkthroughTooltip without fighting the full TooltipRenderProps type. */
|
||||
function makeTooltipProps(
|
||||
overrides: {
|
||||
index?: number;
|
||||
size?: number;
|
||||
isLastStep?: boolean;
|
||||
continuous?: boolean;
|
||||
title?: string;
|
||||
content?: string;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
index = 0,
|
||||
size = 3,
|
||||
isLastStep = false,
|
||||
continuous = true,
|
||||
title = 'Step title',
|
||||
content = 'Step content',
|
||||
} = overrides;
|
||||
// Cast to unknown then to the component's expected props to avoid fighting
|
||||
// the exhaustive TooltipRenderProps type in test code.
|
||||
return {
|
||||
continuous,
|
||||
index,
|
||||
size,
|
||||
isLastStep,
|
||||
step: { title, content, target: 'body' },
|
||||
backProps: {
|
||||
'aria-label': 'Back',
|
||||
onClick: vi.fn(),
|
||||
role: 'button',
|
||||
title: 'Back',
|
||||
'data-action': 'back',
|
||||
},
|
||||
primaryProps: {
|
||||
'aria-label': 'Next',
|
||||
onClick: vi.fn(),
|
||||
role: 'button',
|
||||
title: 'Next',
|
||||
'data-action': 'primary',
|
||||
},
|
||||
skipProps: {
|
||||
'aria-label': 'Skip',
|
||||
onClick: vi.fn(),
|
||||
role: 'button',
|
||||
title: 'Skip',
|
||||
'data-action': 'skip',
|
||||
},
|
||||
tooltipProps: { role: 'tooltip' },
|
||||
closeProps: {
|
||||
'aria-label': 'Close',
|
||||
onClick: vi.fn(),
|
||||
role: 'button',
|
||||
title: 'Close',
|
||||
'data-action': 'close',
|
||||
},
|
||||
} as unknown as Parameters<typeof WalkthroughTooltip>[0];
|
||||
}
|
||||
|
||||
describe('WalkthroughTooltip', () => {
|
||||
it('renders step title and content', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps()} />);
|
||||
|
||||
expect(screen.getByText('Step title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step counter showing current step of total', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 1, size: 6 })} />);
|
||||
|
||||
expect(screen.getByText('2 of 6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Skip button when not on last step', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ isLastStep: false })} />);
|
||||
|
||||
expect(screen.getByText('Skip tour')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Skip button on the last step', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ isLastStep: true })} />);
|
||||
|
||||
expect(screen.queryByText('Skip tour')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Finish on the last step', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ isLastStep: true })} />);
|
||||
|
||||
expect(screen.getByText("Let's go!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Next on non-last steps', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ isLastStep: false })} />);
|
||||
|
||||
expect(screen.getByText('Next →')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Back button on the first step (index 0)', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 0 })} />);
|
||||
|
||||
expect(screen.queryByText('Back')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Back button after the first step', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 1 })} />);
|
||||
|
||||
expect(screen.getByText('Back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders progress bar', () => {
|
||||
const { container } = render(
|
||||
<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 6 })} />
|
||||
);
|
||||
|
||||
// Gradient progress bar fills based on step progress
|
||||
const bar = container.querySelector('div.bg-gradient-to-r');
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar?.getAttribute('style')).toContain('width: 50%');
|
||||
});
|
||||
});
|
||||
|
||||
// ── walkthroughSteps tests ────────────────────────────────────────────────
|
||||
|
||||
describe('WALKTHROUGH_STEPS', () => {
|
||||
it('has 6 steps', () => {
|
||||
expect(WALKTHROUGH_STEPS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('first step targets home-card and disables beacon', () => {
|
||||
const first = WALKTHROUGH_STEPS[0];
|
||||
expect(first.target).toBe('[data-walkthrough="home-card"]');
|
||||
expect(first.skipBeacon).toBe(true);
|
||||
});
|
||||
|
||||
it('last step targets tab-settings', () => {
|
||||
const last = WALKTHROUGH_STEPS[WALKTHROUGH_STEPS.length - 1];
|
||||
expect(last.target).toBe('[data-walkthrough="tab-settings"]');
|
||||
});
|
||||
|
||||
it('all steps have a title and content', () => {
|
||||
for (const step of WALKTHROUGH_STEPS) {
|
||||
expect(step.title).toBeTruthy();
|
||||
expect(step.content).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Step } from 'react-joyride';
|
||||
|
||||
/**
|
||||
* Step definitions for the post-onboarding product walkthrough.
|
||||
* Targets must match `data-walkthrough="..."` attributes in the DOM.
|
||||
*
|
||||
* Copy is conversational and warm — matching OpenHuman's "calm sophistication"
|
||||
* design language. Each step has an emoji accent for visual interest.
|
||||
*/
|
||||
export const WALKTHROUGH_STEPS: Step[] = [
|
||||
{
|
||||
target: '[data-walkthrough="home-card"]',
|
||||
title: 'Your command center',
|
||||
content:
|
||||
"Everything starts here — your connections, your conversations, your AI. Think of this as mission control. Let's take a quick look around.",
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-walkthrough="home-cta"]',
|
||||
title: 'Say hello',
|
||||
content:
|
||||
"This is the fastest way to talk to your AI assistant. Try asking it to summarize your emails, draft a message, or just say hi — it's surprisingly good at small talk.",
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-walkthrough="tab-chat"]',
|
||||
title: 'Conversations that remember',
|
||||
content:
|
||||
'Every chat is saved and searchable. Your assistant remembers context across conversations, so you can pick up right where you left off.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-walkthrough="tab-skills"]',
|
||||
title: 'Supercharge your assistant',
|
||||
content:
|
||||
'Connect Gmail, Slack, WhatsApp, and more. The more you connect, the more your assistant can actually do — not just talk about doing.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-walkthrough="tab-automation"]',
|
||||
title: 'Set it and forget it',
|
||||
content:
|
||||
'Morning briefings, scheduled check-ins, proactive alerts. Your assistant can work for you even when you are not looking.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-walkthrough="tab-settings"]',
|
||||
title: "You're in control",
|
||||
content:
|
||||
"That's the quick tour! You can always find settings, billing, and preferences here. Now go explore — your assistant is ready when you are.",
|
||||
placement: 'top',
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Label applied to the welcome thread created when the user finishes the
|
||||
* desktop onboarding wizard. The thread is deleted once the welcome agent
|
||||
* calls `complete_onboarding(action: "complete")`. While it exists, the label
|
||||
* lets the UI hide all other threads during welcome lockdown and show a stable
|
||||
* "Onboarding" title.
|
||||
*/
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// /**
|
||||
// * Label applied to the welcome thread created when the user finishes the
|
||||
// * desktop onboarding wizard. The thread is deleted once the welcome agent
|
||||
// * calls `complete_onboarding(action: "complete")`. While it exists, the label
|
||||
// * lets the UI hide all other threads during welcome lockdown and show a stable
|
||||
// * "Onboarding" title.
|
||||
// */
|
||||
// export const ONBOARDING_WELCOME_THREAD_LABEL = 'onboarding';
|
||||
|
||||
/** @deprecated [#1123] — kept for any remaining imports; use empty string as placeholder */
|
||||
export const ONBOARDING_WELCOME_THREAD_LABEL = 'onboarding';
|
||||
|
||||
@@ -16,20 +16,24 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
// [#1123] isWelcomeLocked now always returns false — welcome-agent onboarding
|
||||
// replaced by Joyride walkthrough. Tests updated to reflect the new behavior.
|
||||
describe('isWelcomeLocked', () => {
|
||||
it('locks when authenticated user finished the wizard but chat onboarding is still false', () => {
|
||||
expect(isWelcomeLocked(makeSnapshot())).toBe(true);
|
||||
it('[#1123] always returns false — welcome lockdown replaced by Joyride walkthrough', () => {
|
||||
// Previously returned true when onboardingCompleted=true and chatOnboardingCompleted=false.
|
||||
// Now always returns false since the welcome-lock UI was removed.
|
||||
expect(isWelcomeLocked(makeSnapshot())).toBe(false);
|
||||
});
|
||||
|
||||
it('unlocks once chat onboarding completes', () => {
|
||||
it('returns false once chat onboarding completes', () => {
|
||||
expect(isWelcomeLocked(makeSnapshot({ chatOnboardingCompleted: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('stays unlocked while the wizard is still up — the /onboarding route owns that gate', () => {
|
||||
it('returns false while the wizard is still up', () => {
|
||||
expect(isWelcomeLocked(makeSnapshot({ onboardingCompleted: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it('stays unlocked when signed out so the signed-out first paint does not flicker', () => {
|
||||
it('returns false when signed out', () => {
|
||||
expect(
|
||||
isWelcomeLocked(
|
||||
makeSnapshot({
|
||||
|
||||
@@ -90,24 +90,26 @@ export function setCoreStateSnapshot(next: CoreState): void {
|
||||
/**
|
||||
* Is the UI currently locked to the welcome-agent conversation? (#883)
|
||||
*
|
||||
* [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough.
|
||||
* Function body always returns `false` so existing callers compile without
|
||||
* changes. The welcome-lock UI affordances are also commented out at each
|
||||
* call site but the function signature is preserved to avoid import errors.
|
||||
*
|
||||
* Original implementation:
|
||||
* 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).
|
||||
* not yet finalized (`chatOnboardingCompleted === false`).
|
||||
*/
|
||||
export function isWelcomeLocked(snapshot: CoreAppSnapshot): boolean {
|
||||
return (
|
||||
snapshot.auth.isAuthenticated &&
|
||||
snapshot.onboardingCompleted &&
|
||||
!snapshot.chatOnboardingCompleted
|
||||
);
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
export function isWelcomeLocked(_snapshot: CoreAppSnapshot): boolean {
|
||||
// [#1123] Always return false — welcome-lock replaced by Joyride walkthrough
|
||||
return false;
|
||||
// Original implementation:
|
||||
// return (
|
||||
// snapshot.auth.isAuthenticated &&
|
||||
// snapshot.onboardingCompleted &&
|
||||
// !snapshot.chatOnboardingCompleted
|
||||
// );
|
||||
}
|
||||
|
||||
export function patchCoreStateSnapshot(patch: {
|
||||
|
||||
+45
-47
@@ -4,8 +4,10 @@ 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';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { isWelcomeLocked } from '../lib/coreState/store';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import {
|
||||
hideWebviewAccount,
|
||||
purgeWebviewAccount,
|
||||
@@ -79,8 +81,9 @@ 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);
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// const { snapshot } = useCoreState();
|
||||
// const welcomeLocked = isWelcomeLocked(snapshot);
|
||||
// Respond-queue selectors disabled while RespondQueuePanel is hidden.
|
||||
// const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
|
||||
// const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
|
||||
@@ -94,15 +97,16 @@ const Accounts = () => {
|
||||
startWebviewAccountService();
|
||||
}, []);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// 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(() => {
|
||||
// if (welcomeLocked && activeAccountId !== AGENT_ID) {
|
||||
// dispatch(setActiveAccount(AGENT_ID));
|
||||
// }
|
||||
// }, [welcomeLocked, activeAccountId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchRespondQueue());
|
||||
@@ -122,11 +126,13 @@ const Accounts = () => {
|
||||
[accounts]
|
||||
);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// 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 selectedId = welcomeLocked ? AGENT_ID : (activeAccountId ?? AGENT_ID);
|
||||
const selectedId = activeAccountId ?? AGENT_ID;
|
||||
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
|
||||
const isAgentSelected = selectedId === AGENT_ID;
|
||||
|
||||
@@ -196,45 +202,37 @@ const Accounts = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full gap-3 overflow-hidden">
|
||||
{/* Narrow icon rail — always rendered as a floating card alongside
|
||||
the main content 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 my-3 ml-3 rounded-2xl border border-stone-200/70 shadow-soft">
|
||||
<RailButton active={isAgentSelected} onClick={selectAgent} tooltip="Agent">
|
||||
<AgentIcon className="h-9 w-9 rounded-lg" />
|
||||
{/* Narrow icon rail — always rendered. */}
|
||||
{/* [#1123] welcomeLocked guard removed — welcome-agent onboarding replaced by Joyride walkthrough */}
|
||||
<aside className="z-30 flex w-16 flex-none flex-col items-center gap-2 bg-white/60 py-3 backdrop-blur-md my-3 ml-3 rounded-2xl border border-stone-200/70 shadow-soft">
|
||||
<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" />
|
||||
</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" />
|
||||
</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>
|
||||
)}
|
||||
<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">
|
||||
|
||||
+286
-273
@@ -10,11 +10,14 @@ import PillTabBar from '../components/PillTabBar';
|
||||
import UpsellBanner from '../components/upsell/UpsellBanner';
|
||||
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
||||
import UsageLimitModal from '../components/upsell/UsageLimitModal';
|
||||
import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat';
|
||||
import { useStickToBottom } from '../hooks/useStickToBottom';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
// [#1123] getCoreStateSnapshot and isWelcomeLocked commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
|
||||
import { store } from '../store';
|
||||
import {
|
||||
@@ -92,30 +95,31 @@ export function isComposerInteractionBlocked(args: {
|
||||
return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending;
|
||||
}
|
||||
|
||||
function WelcomeThinkingTypewriter() {
|
||||
const text = 'Your agent is thinking...';
|
||||
const [visibleChars, setVisibleChars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const isComplete = visibleChars >= text.length;
|
||||
const delayMs = isComplete ? 950 : 42;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisibleChars(current => (current >= text.length ? 0 : current + 1));
|
||||
}, delayMs);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [text.length, visibleChars]);
|
||||
|
||||
return (
|
||||
<p className="flex items-center text-sm text-stone-600 font-mono tracking-tight">
|
||||
<span>{text.slice(0, visibleChars)}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-0.5 inline-block h-4 w-px bg-stone-400 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// function WelcomeThinkingTypewriter() {
|
||||
// const text = 'Your agent is thinking...';
|
||||
// const [visibleChars, setVisibleChars] = useState(0);
|
||||
//
|
||||
// useEffect(() => {
|
||||
// const isComplete = visibleChars >= text.length;
|
||||
// const delayMs = isComplete ? 950 : 42;
|
||||
// const timeoutId = window.setTimeout(() => {
|
||||
// setVisibleChars(current => (current >= text.length ? 0 : current + 1));
|
||||
// }, delayMs);
|
||||
//
|
||||
// return () => window.clearTimeout(timeoutId);
|
||||
// }, [text.length, visibleChars]);
|
||||
//
|
||||
// return (
|
||||
// <p className="flex items-center text-sm text-stone-600 font-mono tracking-tight">
|
||||
// <span>{text.slice(0, visibleChars)}</span>
|
||||
// <span
|
||||
// aria-hidden="true"
|
||||
// className="ml-0.5 inline-block h-4 w-px bg-stone-400 animate-pulse"
|
||||
// />
|
||||
// </p>
|
||||
// );
|
||||
// }
|
||||
|
||||
const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -127,24 +131,28 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
isLoadingMessages,
|
||||
messagesError,
|
||||
activeThreadId,
|
||||
welcomeThreadId,
|
||||
// [#1123] welcomeThreadId commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// welcomeThreadId,
|
||||
} = useAppSelector(state => state.thread);
|
||||
|
||||
const { snapshot } = useCoreState();
|
||||
const welcomeLocked = isWelcomeLocked(snapshot);
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// const { snapshot } = useCoreState();
|
||||
// const welcomeLocked = isWelcomeLocked(snapshot);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// While the proactive welcome agent is running and hasn't published its
|
||||
// first message yet, hide the composer (and a few other non-message
|
||||
// chrome bits) so the user just sees the "Your agent is thinking..."
|
||||
// loader. Flips off the moment the first agent message arrives.
|
||||
const welcomePending =
|
||||
!!welcomeThreadId && selectedThreadId === welcomeThreadId && messages.length === 0;
|
||||
const chatOnboardingCompleted = snapshot.chatOnboardingCompleted;
|
||||
const previousChatOnboardingCompletedRef = useRef<boolean | null>(null);
|
||||
// const welcomePending =
|
||||
// !!welcomeThreadId && selectedThreadId === welcomeThreadId && messages.length === 0;
|
||||
// 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 skipInitialThreadSelectionRef = useRef(false);
|
||||
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -234,18 +242,22 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
void dispatch(loadThreads())
|
||||
.unwrap()
|
||||
.then(data => {
|
||||
if (cancelled || skipInitialThreadSelectionRef.current) return;
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// if (cancelled || skipInitialThreadSelectionRef.current) return;
|
||||
if (cancelled) return;
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// Always prefer the welcome thread during lockdown regardless of
|
||||
// whether the server list is empty or not. Without this guard the
|
||||
// stale `.then` could select a pre-existing thread from a prior
|
||||
// session and pull the user out of the welcome conversation.
|
||||
const snapForSelect = getCoreStateSnapshot().snapshot;
|
||||
// const snapForSelect = getCoreStateSnapshot().snapshot;
|
||||
// const threadStateForSelect = store.getState().thread;
|
||||
// if (isWelcomeLocked(snapForSelect) && threadStateForSelect.welcomeThreadId) {
|
||||
// dispatch(setSelectedThread(threadStateForSelect.welcomeThreadId));
|
||||
// void dispatch(loadThreadMessages(threadStateForSelect.welcomeThreadId));
|
||||
// return;
|
||||
// }
|
||||
const threadStateForSelect = store.getState().thread;
|
||||
if (isWelcomeLocked(snapForSelect) && threadStateForSelect.welcomeThreadId) {
|
||||
dispatch(setSelectedThread(threadStateForSelect.welcomeThreadId));
|
||||
void dispatch(loadThreadMessages(threadStateForSelect.welcomeThreadId));
|
||||
return;
|
||||
}
|
||||
if (data.threads.length > 0) {
|
||||
// Prefer the thread the user was last viewing (persisted across
|
||||
// reloads via redux-persist on the `thread` slice). Only fall
|
||||
@@ -275,27 +287,28 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
}
|
||||
}, [selectedThreadId, dispatch]);
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// 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(() => {
|
||||
// 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]);
|
||||
|
||||
const location = useLocation();
|
||||
const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom(
|
||||
@@ -467,13 +480,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
const handleSlashCommand = (command: string): boolean => {
|
||||
const cmd = command.toLowerCase();
|
||||
if (cmd === '/new' || cmd === '/clear') {
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// 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;
|
||||
}
|
||||
// if (welcomeLocked) {
|
||||
// setInputValue('');
|
||||
// return true;
|
||||
// }
|
||||
setInputValue('');
|
||||
void handleCreateNewThread();
|
||||
return true;
|
||||
@@ -824,9 +838,10 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
// Blocks all composer interaction while a turn is in-flight, the
|
||||
// proactive welcome opener is pending, or Rust chat is unavailable.
|
||||
// isSending: the *selected* thread is in-flight (drives selected-thread UI only).
|
||||
// [#1123] welcomePending removed — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
const composerInteractionBlocked = isComposerInteractionBlocked({
|
||||
activeThreadId,
|
||||
welcomePending,
|
||||
welcomePending: false,
|
||||
rustChat,
|
||||
});
|
||||
const isSending = Boolean(
|
||||
@@ -842,17 +857,19 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
if (selectedLabel === 'all') return true;
|
||||
return t.labels?.includes(selectedLabel);
|
||||
});
|
||||
if (!welcomeLocked) return base;
|
||||
// During welcome lockdown only the onboarding welcome thread should
|
||||
// appear — not stray blank threads from races or proactive:* handling.
|
||||
if (welcomeThreadId) {
|
||||
return base.filter(t => t.id === welcomeThreadId);
|
||||
}
|
||||
// Fallback: welcomeThreadId not yet set but the server already returned the
|
||||
// thread (e.g. hot-reload). Keep only onboarding-labelled threads so the
|
||||
// welcome thread is visible rather than hidden behind the empty-state message.
|
||||
return base.filter(t => (t.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL));
|
||||
}, [threads, selectedLabel, welcomeLocked, welcomeThreadId]);
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// if (!welcomeLocked) return base;
|
||||
// // During welcome lockdown only the onboarding welcome thread should
|
||||
// // appear — not stray blank threads from races or proactive:* handling.
|
||||
// if (welcomeThreadId) {
|
||||
// return base.filter(t => t.id === welcomeThreadId);
|
||||
// }
|
||||
// // Fallback: welcomeThreadId not yet set but the server already returned the
|
||||
// // thread (e.g. hot-reload). Keep only onboarding-labelled threads so the
|
||||
// // welcome thread is visible rather than hidden behind the empty-state message.
|
||||
// return base.filter(t => (t.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL));
|
||||
return base;
|
||||
}, [threads, selectedLabel]);
|
||||
|
||||
const sortedThreads = useMemo(() => {
|
||||
return [...filteredThreads].sort(
|
||||
@@ -881,24 +898,26 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
}, [allLabels, selectedLabel]);
|
||||
|
||||
const isSidebar = variant === 'sidebar';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// During welcome lockdown keep the sidebar forced open so the user always
|
||||
// sees the single onboarding thread entry and cannot accidentally close the
|
||||
// panel via the toggle (leaving themselves with no thread list).
|
||||
const effectiveShowSidebar = welcomeLocked ? true : showSidebar;
|
||||
// const effectiveShowSidebar = welcomeLocked ? true : showSidebar;
|
||||
const effectiveShowSidebar = showSidebar;
|
||||
|
||||
// Stable title resolver used by both the sidebar thread list and the header.
|
||||
// Returns "Onboarding" for the welcome thread while welcome-locked; falls back
|
||||
// to the thread's server-side title or a placeholder.
|
||||
// [#1123] welcome-lock title override removed — Joyride walkthrough replaced welcome-agent
|
||||
const resolveThreadDisplayTitle = (threadId: string | null): string => {
|
||||
if (!threadId) return 'Select a thread';
|
||||
const t = threads.find(thr => thr.id === threadId);
|
||||
if (
|
||||
welcomeLocked &&
|
||||
t?.id === welcomeThreadId &&
|
||||
(t?.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL)
|
||||
) {
|
||||
return 'Onboarding';
|
||||
}
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// if (
|
||||
// welcomeLocked &&
|
||||
// t?.id === welcomeThreadId &&
|
||||
// (t?.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL)
|
||||
// ) {
|
||||
// return 'Onboarding';
|
||||
// }
|
||||
return t?.title ?? 'Select a thread';
|
||||
};
|
||||
|
||||
@@ -917,32 +936,30 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
<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>
|
||||
{!welcomeLocked ? (
|
||||
<button
|
||||
onClick={() => void handleCreateNewThread()}
|
||||
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="New thread">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{/* [#1123] welcomeLocked guard removed — always show new thread button */}
|
||||
<button
|
||||
onClick={() => void handleCreateNewThread()}
|
||||
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="New thread">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* [#1123] welcomeLocked guard removed — always show label filter */}
|
||||
<div className="px-4 py-2 border-b border-stone-50">
|
||||
<PillTabBar
|
||||
items={labelTabs}
|
||||
selected={selectedLabel}
|
||||
onChange={setSelectedLabel}
|
||||
containerClassName="flex gap-1 overflow-x-auto py-1 scrollbar-hide"
|
||||
/>
|
||||
</div>
|
||||
{!welcomeLocked ? (
|
||||
<div className="px-4 py-2 border-b border-stone-50">
|
||||
<PillTabBar
|
||||
items={labelTabs}
|
||||
selected={selectedLabel}
|
||||
onChange={setSelectedLabel}
|
||||
containerClassName="flex gap-1 overflow-x-auto py-1 scrollbar-hide"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedThreads.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-stone-400 text-center">
|
||||
@@ -980,39 +997,38 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
}`}>
|
||||
{resolveThreadDisplayTitle(thread.id)}
|
||||
</p>
|
||||
{!(welcomeLocked && thread.id === welcomeThreadId) ? (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
title: 'Delete thread',
|
||||
message: `Are you sure you want to delete "${thread.title || 'Untitled thread'}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
void dispatch(deleteThread(thread.id));
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
}}
|
||||
className="ml-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-stone-200 text-stone-400 hover:text-coral-500 transition-all flex-shrink-0"
|
||||
title="Delete thread">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{/* [#1123] welcomeLocked guard removed — always show delete button */}
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
title: 'Delete thread',
|
||||
message: `Are you sure you want to delete "${thread.title || 'Untitled thread'}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
void dispatch(deleteThread(thread.id));
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
}}
|
||||
className="ml-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-stone-200 text-stone-400 hover:text-coral-500 transition-all flex-shrink-0"
|
||||
title="Delete thread">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* <div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-stone-400">
|
||||
@@ -1060,19 +1076,16 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
<h3 className="text-sm font-medium text-stone-700 truncate flex-1">
|
||||
{resolveThreadDisplayTitle(selectedThreadId)}
|
||||
</h3>
|
||||
{!welcomeLocked ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
{/* [#1123] welcomeLocked guard removed — always show token usage + new thread button */}
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6]">
|
||||
@@ -1381,20 +1394,21 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : welcomeThreadId && selectedThreadId === welcomeThreadId ? (
|
||||
// Welcome thread, no messages yet — the proactive welcome agent
|
||||
// is running in the background. Show a friendly loader until
|
||||
// the first agent message lands (which flips us into the
|
||||
// `hasVisibleMessages` branch above).
|
||||
<div className="flex-1 flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
<WelcomeThinkingTypewriter />
|
||||
</div>
|
||||
) : (
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// ) : welcomeThreadId && selectedThreadId === welcomeThreadId ? (
|
||||
// // Welcome thread, no messages yet — the proactive welcome agent
|
||||
// // is running in the background. Show a friendly loader until
|
||||
// // the first agent message lands (which flips us into the
|
||||
// // `hasVisibleMessages` branch above).
|
||||
// <div className="flex-1 flex flex-col items-center justify-center h-full gap-3">
|
||||
// <div className="flex items-center gap-1">
|
||||
// <span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:0ms]" />
|
||||
// <span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:150ms]" />
|
||||
// <span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:300ms]" />
|
||||
// </div>
|
||||
// <WelcomeThinkingTypewriter />
|
||||
// </div>
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<p className="text-sm text-stone-600">No messages yet</p>
|
||||
</div>
|
||||
@@ -1402,131 +1416,130 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-stone-200 px-4 py-3">
|
||||
{!welcomeLocked && !welcomePending && (
|
||||
<>
|
||||
{isNearLimit &&
|
||||
!isAtLimit &&
|
||||
isFreeTier &&
|
||||
shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && (
|
||||
<div className="mb-3">
|
||||
<UpsellBanner
|
||||
variant="warning"
|
||||
title="Approaching usage limit"
|
||||
message={`You've used ${Math.round(Math.max(usagePct10h, usagePct7d) * 100)}% of your inference budget. Upgrade for higher limits.`}
|
||||
ctaLabel="Upgrade"
|
||||
onCtaClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
dismissible
|
||||
onDismiss={() => dismissBanner('conversations-warning')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && (
|
||||
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-coral-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-coral-600 truncate">
|
||||
{shouldShowBudgetCompletedMessage
|
||||
? teamUsage.cycleBudgetUsd > 0
|
||||
? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
|
||||
: 'Your included budget is complete. Add credits or upgrade to continue.'
|
||||
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
{shouldShowBudgetCompletedMessage && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
|
||||
Top Up
|
||||
</button>
|
||||
)}
|
||||
{/* [#1123] welcomeLocked and welcomePending guards removed — Joyride walkthrough replaced welcome-agent */}
|
||||
<>
|
||||
{isNearLimit &&
|
||||
!isAtLimit &&
|
||||
isFreeTier &&
|
||||
shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && (
|
||||
<div className="mb-3">
|
||||
<UpsellBanner
|
||||
variant="warning"
|
||||
title="Approaching usage limit"
|
||||
message={`You've used ${Math.round(Math.max(usagePct10h, usagePct7d) * 100)}% of your inference budget. Upgrade for higher limits.`}
|
||||
ctaLabel="Upgrade"
|
||||
onCtaClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
dismissible
|
||||
onDismiss={() => dismissBanner('conversations-warning')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && (
|
||||
<div className="mb-3 p-3 rounded-xl bg-coral-50 border border-coral-200 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-coral-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-xs text-coral-600 truncate">
|
||||
{shouldShowBudgetCompletedMessage
|
||||
? teamUsage.cycleBudgetUsd > 0
|
||||
? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
|
||||
: 'Your included budget is complete. Add credits or upgrade to continue.'
|
||||
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
{shouldShowBudgetCompletedMessage && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
|
||||
Top Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota / usage pills — hidden during welcome lockdown so the
|
||||
{/* Quota / usage pills — hidden during welcome lockdown so the
|
||||
onboarding chat doesn't surface billing affordances. */}
|
||||
<div className="flex items-center justify-end gap-2 mb-2">
|
||||
{(isLoadingBudget || teamUsage) && (
|
||||
<div className="relative group">
|
||||
{teamUsage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{!teamUsage.bypassCycleLimit && (
|
||||
<LimitPill
|
||||
label="5h"
|
||||
usedPct={
|
||||
teamUsage.fiveHourCapUsd > 0
|
||||
? Math.min(1, teamUsage.cycleLimit5hr / teamUsage.fiveHourCapUsd)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 mb-2">
|
||||
{(isLoadingBudget || teamUsage) && (
|
||||
<div className="relative group">
|
||||
{teamUsage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{!teamUsage.bypassCycleLimit && (
|
||||
<LimitPill
|
||||
label="7d"
|
||||
label="5h"
|
||||
usedPct={
|
||||
teamUsage.cycleBudgetUsd > 0
|
||||
? Math.min(
|
||||
1,
|
||||
(teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) /
|
||||
teamUsage.cycleBudgetUsd
|
||||
)
|
||||
teamUsage.fiveHourCapUsd > 0
|
||||
? Math.min(1, teamUsage.cycleLimit5hr / teamUsage.fiveHourCapUsd)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-stone-400 animate-pulse">loading…</span>
|
||||
)}
|
||||
{teamUsage && (
|
||||
<div className="absolute bottom-full right-0 mb-2 hidden group-hover:block z-50">
|
||||
<div className="bg-stone-900 text-white text-[10px] rounded-lg px-3 py-2 shadow-lg whitespace-nowrap space-y-1.5">
|
||||
{!teamUsage.bypassCycleLimit && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-stone-400">5-hour limit</span>
|
||||
<span>
|
||||
${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
|
||||
{teamUsage.fiveHourResetsAt && (
|
||||
<span className="text-stone-400 ml-1">
|
||||
— resets {formatResetTime(teamUsage.fiveHourResetsAt)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<LimitPill
|
||||
label="7d"
|
||||
usedPct={
|
||||
teamUsage.cycleBudgetUsd > 0
|
||||
? Math.min(
|
||||
1,
|
||||
(teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) /
|
||||
teamUsage.cycleBudgetUsd
|
||||
)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-stone-400 animate-pulse">loading…</span>
|
||||
)}
|
||||
{teamUsage && (
|
||||
<div className="absolute bottom-full right-0 mb-2 hidden group-hover:block z-50">
|
||||
<div className="bg-stone-900 text-white text-[10px] rounded-lg px-3 py-2 shadow-lg whitespace-nowrap space-y-1.5">
|
||||
{!teamUsage.bypassCycleLimit && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-stone-400">Weekly limit</span>
|
||||
<span className="text-stone-400">5-hour limit</span>
|
||||
<span>
|
||||
${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left
|
||||
{teamUsage.cycleEndsAt && (
|
||||
${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
|
||||
{teamUsage.fiveHourResetsAt && (
|
||||
<span className="text-stone-400 ml-1">
|
||||
— resets {formatResetTime(teamUsage.cycleEndsAt)}
|
||||
— resets {formatResetTime(teamUsage.fiveHourResetsAt)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-stone-400">Weekly limit</span>
|
||||
<span>
|
||||
${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left
|
||||
{teamUsage.cycleEndsAt && (
|
||||
<span className="text-stone-400 ml-1">
|
||||
— resets {formatResetTime(teamUsage.cycleEndsAt)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{sendAdvisory && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -145,8 +145,10 @@ const Home = () => {
|
||||
|
||||
{showPromoBanner && <PromotionalCreditsBanner promoCredits={promoCredits} />}
|
||||
|
||||
{/* Main card */}
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
|
||||
{/* Main card — data-walkthrough target for step 1 */}
|
||||
<div
|
||||
data-walkthrough="home-card"
|
||||
className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
|
||||
{/* Header row: logo + version + settings */}
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<span className="text-xs text-center text-stone-400">v{APP_VERSION}</span>
|
||||
@@ -170,8 +172,9 @@ const Home = () => {
|
||||
"Connecting" / "Disconnected". */}
|
||||
<p className="text-sm text-stone-500 text-center mb-6 leading-relaxed">{statusCopy}</p>
|
||||
|
||||
{/* CTA button */}
|
||||
{/* CTA button — data-walkthrough target for step 2 */}
|
||||
<button
|
||||
data-walkthrough="home-cta"
|
||||
onClick={handleStartCooking}
|
||||
className="w-full py-3 bg-primary-500 hover:bg-primary-600 text-white font-medium rounded-xl transition-colors duration-200">
|
||||
Message OpenHuman
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Smoke render tests for Conversations.tsx — covers new lines added in #1123
|
||||
* (welcome-lock removal: unconditional sidebar, label filter, effectiveShowSidebar,
|
||||
* quota usage pills, etc.).
|
||||
*
|
||||
* These tests intentionally do not test complex user interactions; they verify
|
||||
* that the key JSX branches render without crashing, driving coverage of the
|
||||
* previously-blocked lines that are now always rendered.
|
||||
*/
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
|
||||
import socketReducer from '../../store/socketSlice';
|
||||
import threadReducer from '../../store/threadSlice';
|
||||
import type { Thread } from '../../types/thread';
|
||||
|
||||
// ── Hoisted mock state ─────────────────────────────────────────────────────
|
||||
|
||||
const { mockGetThreads, mockGetThreadMessages, mockUseUsageState } = vi.hoisted(() => ({
|
||||
mockGetThreads: vi.fn().mockResolvedValue({ threads: [], count: 0 }),
|
||||
mockGetThreadMessages: vi.fn().mockResolvedValue({ messages: [], count: 0 }),
|
||||
mockUseUsageState: vi.fn(() => ({
|
||||
teamUsage: null as null | {
|
||||
cycleBudgetUsd: number;
|
||||
remainingUsd: number;
|
||||
fiveHourCapUsd: number;
|
||||
cycleLimit5hr: number;
|
||||
bypassCycleLimit: boolean;
|
||||
fiveHourResetsAt: string | null;
|
||||
cycleEndsAt: string | null;
|
||||
},
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as 'FREE' | 'BASIC' | 'PRO',
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0,
|
||||
usagePct7d: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Module mocks ───────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../services/chatService', () => ({
|
||||
chatCancel: vi.fn(),
|
||||
chatSend: vi.fn().mockResolvedValue(undefined),
|
||||
subscribeChatEvents: vi.fn(() => () => {}),
|
||||
useRustChat: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/threadApi', () => ({
|
||||
threadApi: {
|
||||
createNewThread: vi.fn().mockResolvedValue({ id: 'new-thread', labels: [] }),
|
||||
getThreads: mockGetThreads,
|
||||
getThreadMessages: mockGetThreadMessages,
|
||||
appendMessage: vi.fn().mockResolvedValue({}),
|
||||
deleteThread: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
generateTitleIfNeeded: vi.fn().mockResolvedValue({}),
|
||||
updateMessage: vi.fn().mockResolvedValue({}),
|
||||
purge: vi.fn().mockResolvedValue({}),
|
||||
updateLabels: vi.fn().mockResolvedValue({}),
|
||||
persistReaction: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
|
||||
|
||||
// useStickToBottom returns refs; mock it so layout-effects don't fire in jsdom.
|
||||
vi.mock('../../hooks/useStickToBottom', () => ({
|
||||
useStickToBottom: vi.fn(() => ({ containerRef: { current: null }, endRef: { current: null } })),
|
||||
}));
|
||||
|
||||
// useAutocompleteSkillStatus may make API calls; stub it.
|
||||
vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({
|
||||
useAutocompleteSkillStatus: vi.fn(() => ({ status: 'idle', skills: [] })),
|
||||
}));
|
||||
|
||||
// openUrl uses Tauri; stub it.
|
||||
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
// coreState/store: getCoreStateSnapshot used by selectSocketStatus.
|
||||
vi.mock('../../lib/coreState/store', () => ({
|
||||
getCoreStateSnapshot: vi.fn(() => ({
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
snapshot: {
|
||||
auth: { isAuthenticated: false, userId: null, user: null, profileId: null },
|
||||
sessionToken: null,
|
||||
currentUser: null,
|
||||
onboardingCompleted: true,
|
||||
chatOnboardingCompleted: true,
|
||||
analyticsEnabled: false,
|
||||
localState: {},
|
||||
runtime: {},
|
||||
},
|
||||
})),
|
||||
isWelcomeLocked: vi.fn(() => false),
|
||||
setCoreStateSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildStore(preload: Record<string, unknown> = {}) {
|
||||
return configureStore({
|
||||
reducer: combineReducers({
|
||||
thread: threadReducer,
|
||||
socket: socketReducer,
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
}),
|
||||
preloadedState: preload as never,
|
||||
});
|
||||
}
|
||||
|
||||
function makeThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: 't-1',
|
||||
title: 'Test thread',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
labels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderConversations(preload: Record<string, unknown> = {}) {
|
||||
const store = buildStore(preload);
|
||||
const { default: Conversations } = await import('../Conversations');
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/conversations']}>
|
||||
<Conversations />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// Default empty state
|
||||
const emptyThreadState = {
|
||||
threads: [],
|
||||
selectedThreadId: null,
|
||||
activeThreadId: null,
|
||||
welcomeThreadId: null,
|
||||
messagesByThreadId: {},
|
||||
messages: [],
|
||||
isLoadingThreads: false,
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
};
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the mock to defaults for each test
|
||||
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0,
|
||||
usagePct7d: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
// Covers line 906: const effectiveShowSidebar = showSidebar;
|
||||
// Covers line 941: <div className="flex-1 overflow-y-auto"> (always rendered in page mode)
|
||||
it('renders the Threads sidebar header in page mode', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// The "Threads" header is always rendered in page mode (sidebar guard removed)
|
||||
expect(screen.getByText('Threads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers line 941 empty branch
|
||||
it('shows "No threads yet" when thread list is empty', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers lines 1002-1004, 1007, 1011-1012, 1014: thread list items rendered unconditionally
|
||||
it('renders thread list items when threads are pre-loaded', async () => {
|
||||
const threads = [
|
||||
makeThread({ id: 't-1', title: 'Thread Alpha' }),
|
||||
makeThread({ id: 't-2', title: 'Thread Beta' }),
|
||||
];
|
||||
|
||||
// Return the threads from the API so the useEffect loadThreads picks them up
|
||||
mockGetThreads.mockResolvedValue({ threads, count: 2 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Wait for loadThreads to complete and the thread list to render.
|
||||
// Use getAllByText because the title may appear in both the sidebar list
|
||||
// and the conversation header (both are rendered).
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Thread Alpha').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText('Thread Beta').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Covers line 1083: messagesError branch renders error state
|
||||
it('renders the error icon section when loadThreadMessages rejects', async () => {
|
||||
// Make loadThreadMessages always fail so messagesError is set in the store
|
||||
mockGetThreadMessages.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// Return one thread so the component selects it and loads messages
|
||||
const thread = makeThread({ id: 't-2', title: 'Error Thread' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// After the failed load, messagesError is set in state — the error branch renders.
|
||||
// This covers line 1083 (the error container div).
|
||||
await waitFor(() => {
|
||||
// The error branch renders "Failed to load messages" static text
|
||||
expect(screen.getByText('Failed to load messages')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Covers lines 1455-1483: quota pill loading state
|
||||
it('renders "loading…" quota pill when isLoadingBudget=true', async () => {
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0,
|
||||
usagePct7d: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: true,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
expect(screen.getByText('loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers lines 1417-1439: budget banner + lines 1455-1516: LimitPill + tooltip
|
||||
it('renders budget-limit banner and limit pills when teamUsage is present', async () => {
|
||||
// cycleBudgetUsd: 0 → renders "Your included budget is complete" branch
|
||||
const teamUsage = {
|
||||
cycleBudgetUsd: 0,
|
||||
remainingUsd: 0,
|
||||
fiveHourCapUsd: 5,
|
||||
cycleLimit5hr: 5,
|
||||
bypassCycleLimit: false,
|
||||
fiveHourResetsAt: null,
|
||||
cycleEndsAt: null,
|
||||
};
|
||||
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage,
|
||||
currentPlan: null,
|
||||
currentTier: 'PRO' as const,
|
||||
isFreeTier: false,
|
||||
usagePct10h: 1.0,
|
||||
usagePct7d: 1.0,
|
||||
isNearLimit: true,
|
||||
isAtLimit: true,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: true,
|
||||
shouldShowBudgetCompletedMessage: true,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Budget-exceeded banner (lines 1417-1439) — cycleBudgetUsd=0 gives "included budget" message
|
||||
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
|
||||
|
||||
// LimitPill components (lines 1459-1480) — their label text
|
||||
expect(screen.getByText('7d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers line 247: if (cancelled) return — the non-cancelled path through loadThreads callback
|
||||
it('selects first thread after loadThreads resolves (non-cancelled path)', async () => {
|
||||
const threads = [makeThread({ id: 't-1', title: 'First Thread' })];
|
||||
mockGetThreads.mockResolvedValue({ threads, count: 1 });
|
||||
|
||||
let resolvedStore: ReturnType<typeof buildStore> | undefined;
|
||||
await act(async () => {
|
||||
resolvedStore = await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// After loadThreads resolves and cancelled=false, the first thread is selected.
|
||||
// This exercises line 247 (the if (cancelled) return check runs and is false).
|
||||
await waitFor(() => {
|
||||
const state = resolvedStore?.getState() as { thread: { selectedThreadId: string | null } };
|
||||
expect(state.thread.selectedThreadId).toBe('t-1');
|
||||
});
|
||||
});
|
||||
|
||||
// Covers line 919: onClick={() => void handleCreateNewThread()} — sidebar "New thread" button
|
||||
// Covers line 1061: onClick={() => void handleCreateNewThread()} — header "+ New" button
|
||||
it('clicking "New thread" sidebar button calls handleCreateNewThread', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// The sidebar "New thread" button has title="New thread"
|
||||
const newThreadBtn = screen.getByTitle('New thread');
|
||||
await act(async () => {
|
||||
fireEvent.click(newThreadBtn);
|
||||
});
|
||||
|
||||
// createNewThread was called — verifies line 919 callback executed
|
||||
const { threadApi } = await import('../../services/api/threadApi');
|
||||
expect(threadApi.createNewThread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clicking "+ New" header button calls handleCreateNewThread', async () => {
|
||||
// Need a selected thread so the header renders
|
||||
const threads = [makeThread({ id: 't-1', title: 'Header Thread' })];
|
||||
mockGetThreads.mockResolvedValue({ threads, count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Wait for thread to be selected so the header with "+ New" button renders
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('New thread (/new)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const headerNewBtn = screen.getByTitle('New thread (/new)');
|
||||
await act(async () => {
|
||||
fireEvent.click(headerNewBtn);
|
||||
});
|
||||
|
||||
// createNewThread was called — verifies line 1061 callback executed
|
||||
const { threadApi } = await import('../../services/api/threadApi');
|
||||
expect(threadApi.createNewThread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Covers lines 981, 982: e.stopPropagation() and setDeleteModal(...) inside delete onClick
|
||||
it('clicking delete button on a thread opens the delete modal', async () => {
|
||||
const threads = [makeThread({ id: 't-del', title: 'Deletable Thread' })];
|
||||
mockGetThreads.mockResolvedValue({ threads, count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Wait for the thread to appear in the sidebar
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Deletable Thread').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The delete button has title="Delete thread"
|
||||
const deleteBtn = screen.getByTitle('Delete thread');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteBtn);
|
||||
});
|
||||
|
||||
// The modal should now be open — "Are you sure you want to delete" text
|
||||
// This verifies lines 981, 982, 985 inside the delete onClick callback executed
|
||||
expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers lines 1399, 1409-1410: isNearLimit UpsellBanner render + onCtaClick
|
||||
it('renders near-limit UpsellBanner and clicking Upgrade calls openUrl', async () => {
|
||||
const { openUrl } = await import('../../utils/openUrl');
|
||||
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0.85,
|
||||
usagePct7d: 0.85,
|
||||
isNearLimit: true,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// UpsellBanner renders with "Approaching usage limit" (line 1399 branch)
|
||||
expect(screen.getByText('Approaching usage limit')).toBeInTheDocument();
|
||||
|
||||
// Click the "Upgrade" button — covers line 1409-1410 (onCtaClick callback)
|
||||
const upgradeBtn = screen.getByText('Upgrade');
|
||||
await act(async () => {
|
||||
fireEvent.click(upgradeBtn);
|
||||
});
|
||||
|
||||
expect(openUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Covers line 1413: onDismiss callback inside UpsellBanner
|
||||
it('dismissing the near-limit UpsellBanner writes to localStorage (onDismiss executes)', async () => {
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0.9,
|
||||
usagePct7d: 0.9,
|
||||
isNearLimit: true,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// UpsellBanner renders
|
||||
expect(screen.getByText('Approaching usage limit')).toBeInTheDocument();
|
||||
|
||||
// Click dismiss button (aria-label="Dismiss") — covers line 1413 (onDismiss callback)
|
||||
const dismissBtn = screen.getByRole('button', { name: 'Dismiss' });
|
||||
await act(async () => {
|
||||
fireEvent.click(dismissBtn);
|
||||
});
|
||||
|
||||
// dismissBanner writes to localStorage with the banner key — confirms line 1413 executed
|
||||
expect(localStorage.getItem('openhuman:upsell:conversations-warning')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Covers line 1443: onClick inside "Top Up" button in budget-exceeded banner
|
||||
it('clicking "Top Up" in the budget banner calls openUrl', async () => {
|
||||
const { openUrl } = await import('../../utils/openUrl');
|
||||
|
||||
const teamUsage = {
|
||||
cycleBudgetUsd: 10,
|
||||
remainingUsd: 0,
|
||||
fiveHourCapUsd: 5,
|
||||
cycleLimit5hr: 5,
|
||||
bypassCycleLimit: false,
|
||||
fiveHourResetsAt: null,
|
||||
cycleEndsAt: null,
|
||||
};
|
||||
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage,
|
||||
currentPlan: null,
|
||||
currentTier: 'PRO' as const,
|
||||
isFreeTier: false,
|
||||
usagePct10h: 1.0,
|
||||
usagePct7d: 1.0,
|
||||
isNearLimit: true,
|
||||
isAtLimit: true,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: true,
|
||||
shouldShowBudgetCompletedMessage: true,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Budget banner renders — cycleBudgetUsd: 10 > 0 → "You've hit your weekly limit"
|
||||
expect(screen.getByText(/You've hit your weekly limit/i)).toBeInTheDocument();
|
||||
|
||||
// Click "Top Up" button — covers line 1442-1443 (onClick callback)
|
||||
const topUpBtn = screen.getByText('Top Up');
|
||||
await act(async () => {
|
||||
fireEvent.click(topUpBtn);
|
||||
});
|
||||
|
||||
expect(openUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Covers line 1437: rate-limit message branch (isRateLimited=true, shouldShowBudgetCompletedMessage=false)
|
||||
it('renders rate-limit message in budget banner when isRateLimited=true', async () => {
|
||||
const teamUsage = {
|
||||
cycleBudgetUsd: 10,
|
||||
remainingUsd: 5,
|
||||
fiveHourCapUsd: 5,
|
||||
cycleLimit5hr: 5,
|
||||
bypassCycleLimit: false,
|
||||
fiveHourResetsAt: null,
|
||||
cycleEndsAt: null,
|
||||
};
|
||||
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage,
|
||||
currentPlan: null,
|
||||
currentTier: 'PRO' as const,
|
||||
isFreeTier: false,
|
||||
usagePct10h: 1.0,
|
||||
usagePct7d: 0.5,
|
||||
isNearLimit: true,
|
||||
isAtLimit: false,
|
||||
isRateLimited: true,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// isRateLimited=true, shouldShowBudgetCompletedMessage=false → rate-limit branch (line 1437)
|
||||
expect(screen.getByText(/10-hour rate limit reached/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,717 +1,60 @@
|
||||
/**
|
||||
* Tests for the welcome-lockdown features added in PR #1116:
|
||||
* - filteredThreads: during lockdown only the welcome thread (or onboarding-
|
||||
* labelled threads) appear in the sidebar
|
||||
* - resolveThreadDisplayTitle: returns "Onboarding" for the welcome thread
|
||||
* while locked, falls back to server title otherwise
|
||||
* - effectiveShowSidebar: sidebar is clamped to open during lockdown
|
||||
* - delete button hidden for welcome thread during lockdown
|
||||
* - "New thread" button hidden during lockdown
|
||||
* - Tab-bar label filter hidden during lockdown
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
// [#1123] All welcome-lock UI behavior was removed when the welcome-agent
|
||||
// onboarding was replaced by a Joyride walkthrough. This file covers the
|
||||
// unlocked behavior that replaced the removed code.
|
||||
//
|
||||
// Previously this file tested welcome-lock features (filtered thread list,
|
||||
// "Onboarding" title override, forced sidebar, hidden delete buttons). Those
|
||||
// are gone. What remains:
|
||||
// - Conversations composer is accessible regardless of chatOnboardingCompleted
|
||||
// - isComposerInteractionBlocked respects the unlocked path correctly
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../constants/onboardingChat';
|
||||
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
|
||||
import socketReducer from '../../store/socketSlice';
|
||||
import threadReducer from '../../store/threadSlice';
|
||||
import type { Thread } from '../../types/thread';
|
||||
import { isComposerInteractionBlocked } from '../Conversations';
|
||||
|
||||
// ── Module-level mocks ─────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
|
||||
vi.mock('../../lib/coreState/store', () => ({
|
||||
isWelcomeLocked: vi.fn(),
|
||||
getCoreStateSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/chatService', () => ({
|
||||
chatSend: vi.fn(),
|
||||
chatCancel: vi.fn(),
|
||||
useRustChat: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useUsageState', () => ({
|
||||
useUsageState: () => ({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'free',
|
||||
isFreeTier: true,
|
||||
usagePct10h: 0,
|
||||
usagePct7d: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isRateLimited: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useStickToBottom', () => ({
|
||||
useStickToBottom: () => ({ containerRef: { current: null }, endRef: { current: null } }),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/chat/TokenUsagePill', () => ({
|
||||
default: () => <span data-testid="token-usage-pill" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/intelligence/ConfirmationModal', () => ({
|
||||
ConfirmationModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PillTabBar', () => ({
|
||||
default: ({ items }: { items: { label: string; value: string }[] }) => (
|
||||
<div data-testid="pill-tab-bar">
|
||||
{items.map(i => (
|
||||
<span key={i.value}>{i.label}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/upsell/UpsellBanner', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../../components/upsell/UsageLimitModal', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../../components/upsell/upsellDismissState', () => ({
|
||||
shouldShowBanner: vi.fn(() => false),
|
||||
dismissBanner: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
vi.mock('./conversations/components/AgentMessageBubble', () => ({
|
||||
AgentMessageBubble: () => null,
|
||||
BubbleMarkdown: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./conversations/components/CitationChips', () => ({ CitationChips: () => null }));
|
||||
|
||||
vi.mock('./conversations/components/LimitPill', () => ({ LimitPill: () => null }));
|
||||
|
||||
vi.mock('./conversations/components/ToolTimelineBlock', () => ({ ToolTimelineBlock: () => null }));
|
||||
|
||||
vi.mock('./conversations/utils/format', () => ({
|
||||
buildAcceptedInlineCompletion: vi.fn(() => ''),
|
||||
formatRelativeTime: vi.fn(() => ''),
|
||||
formatResetTime: vi.fn(() => ''),
|
||||
getInlineCompletionSuffix: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
// Mock the async thunks so they don't make real API calls.
|
||||
// We return no-op thunk functions that resolve immediately so the
|
||||
// component's useEffect can complete without errors.
|
||||
vi.mock('../../services/api/threadApi', () => ({
|
||||
threadApi: {
|
||||
createNewThread: vi.fn().mockResolvedValue({ id: 'new-t', labels: [] }),
|
||||
getThreads: vi.fn().mockResolvedValue({ threads: [], count: 0 }),
|
||||
getThreadMessages: vi.fn().mockResolvedValue({ messages: [], count: 0 }),
|
||||
appendMessage: vi.fn().mockResolvedValue({}),
|
||||
deleteThread: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
generateTitleIfNeeded: vi.fn().mockResolvedValue({}),
|
||||
updateMessage: vi.fn().mockResolvedValue({}),
|
||||
purge: vi.fn().mockResolvedValue({}),
|
||||
updateLabels: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeThread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: 'thread-1',
|
||||
title: 'My Thread',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
labels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStore(overrides: {
|
||||
threads?: Thread[];
|
||||
selectedThreadId?: string | null;
|
||||
welcomeThreadId?: string | null;
|
||||
}) {
|
||||
const { threads = [], selectedThreadId = null, welcomeThreadId = null } = overrides;
|
||||
|
||||
return configureStore({
|
||||
reducer: { thread: threadReducer, chatRuntime: chatRuntimeReducer, socket: socketReducer },
|
||||
// Cast via unknown to avoid strict preloadedState type mismatch in tests
|
||||
preloadedState: {
|
||||
thread: {
|
||||
threads,
|
||||
selectedThreadId,
|
||||
welcomeThreadId,
|
||||
activeThreadId: null,
|
||||
messagesByThreadId: {},
|
||||
messages: [],
|
||||
isLoadingThreads: false,
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
},
|
||||
} as unknown as Parameters<typeof configureStore>[0]['preloadedState'],
|
||||
});
|
||||
}
|
||||
|
||||
async function renderConversations(opts: {
|
||||
welcomeLocked: boolean;
|
||||
threads?: Thread[];
|
||||
selectedThreadId?: string | null;
|
||||
welcomeThreadId?: string | null;
|
||||
}) {
|
||||
const { welcomeLocked, threads = [], selectedThreadId = null, welcomeThreadId = null } = opts;
|
||||
|
||||
const { useCoreState } = await import('../../providers/CoreStateProvider');
|
||||
const coreStateModule = await import('../../lib/coreState/store');
|
||||
|
||||
const snapshot = {
|
||||
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
|
||||
sessionToken: null,
|
||||
currentUser: null,
|
||||
onboardingCompleted: welcomeLocked,
|
||||
chatOnboardingCompleted: !welcomeLocked,
|
||||
analyticsEnabled: false,
|
||||
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
};
|
||||
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot,
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
setOnboardingCompletedFlag: vi.fn(),
|
||||
setOnboardingTasks: vi.fn(),
|
||||
refreshSnapshot: vi.fn(),
|
||||
} as never);
|
||||
|
||||
vi.mocked(coreStateModule.isWelcomeLocked).mockReturnValue(welcomeLocked);
|
||||
vi.mocked(coreStateModule.getCoreStateSnapshot).mockReturnValue({
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
snapshot,
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
describe('[#1123] Conversations — unlocked flow (welcome-lock removed)', () => {
|
||||
// When chatOnboardingCompleted=false in the old flow, welcome-lock would
|
||||
// block the composer and redirect routes. With welcome-lock removed, the
|
||||
// composer should be accessible as long as there is no active thread and
|
||||
// the rust chat transport is available.
|
||||
it('allows composer interaction when chatOnboardingCompleted=false (welcome-lock removed)', () => {
|
||||
// The welcome-lock previously would have been active here
|
||||
// (chatOnboardingCompleted=false → welcomeLocked=true → composer blocked).
|
||||
// After #1123 there is no welcomeLocked state, so the composer is unblocked.
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
const store = buildStore({ threads, selectedThreadId, welcomeThreadId });
|
||||
|
||||
// Import Conversations after mocks are wired so the module sees them
|
||||
const { default: Conversations } = await import('../Conversations');
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/conversations']}>
|
||||
<Conversations variant="page" />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
return { store };
|
||||
}
|
||||
|
||||
// ── filteredThreads ────────────────────────────────────────────────────────
|
||||
|
||||
describe('filteredThreads — welcome lockdown', () => {
|
||||
it('shows only the welcome thread when welcomeLocked=true and welcomeThreadId is set', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
const otherThread = makeThread({ id: 'other-1', title: 'Other' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread, otherThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// The welcome thread title is replaced by "Onboarding" — see resolveThreadDisplayTitle.
|
||||
// It may appear in both the sidebar list and the header (getAllByText handles multiples).
|
||||
expect(screen.getAllByText('Onboarding').length).toBeGreaterThanOrEqual(1);
|
||||
// The other thread must not appear
|
||||
expect(screen.queryByText('Other')).not.toBeInTheDocument();
|
||||
it('still blocks when an agent thread is actively running (not a welcome-lock concern)', () => {
|
||||
expect(
|
||||
isComposerInteractionBlocked({
|
||||
activeThreadId: 'thread-xyz',
|
||||
welcomePending: false,
|
||||
rustChat: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to onboarding-labelled threads when welcomeThreadId is null but welcomeLocked=true', async () => {
|
||||
const labelledThread = makeThread({
|
||||
id: 'wt-2',
|
||||
title: 'Labelled Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
const unlabelledThread = makeThread({ id: 'plain-1', title: 'Plain' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [labelledThread, unlabelledThread],
|
||||
selectedThreadId: 'wt-2',
|
||||
welcomeThreadId: null, // not yet set
|
||||
});
|
||||
|
||||
// Labelled thread title is NOT replaced (welcomeThreadId is null, so the
|
||||
// label-only guard runs — it doesn't rename to "Onboarding").
|
||||
// getAllByText handles potential multi-occurrence (sidebar + header).
|
||||
expect(screen.getAllByText('Labelled Welcome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText('Plain')).not.toBeInTheDocument();
|
||||
it('still blocks when welcomePending=true (onboarding completion in progress)', () => {
|
||||
// welcomePending refers to the brief period while onboarding_completed is
|
||||
// being written — not the same as the old welcome-lock.
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: true, rustChat: true })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows all threads when welcomeLocked=false', async () => {
|
||||
const thread1 = makeThread({ id: 't-1', title: 'Thread One' });
|
||||
const thread2 = makeThread({ id: 't-2', title: 'Thread Two' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread1, thread2],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('Thread One').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Thread Two').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveThreadDisplayTitle ──────────────────────────────────────────────
|
||||
|
||||
describe('resolveThreadDisplayTitle — welcome lockdown', () => {
|
||||
it('shows "Onboarding" title for the welcome thread when locked', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Do not show me',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// Thread list entry should read "Onboarding", not the raw server title
|
||||
expect(screen.getAllByText('Onboarding').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText('Do not show me')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows server-side title for a non-welcome thread when NOT locked', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'My Real Title', labels: [] });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('My Real Title').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows "Onboarding" in the chat header when the welcome thread is selected and locked', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Hidden Server Title',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// The chat header h3 also uses resolveThreadDisplayTitle
|
||||
const headerEl = document.querySelector('h3.text-sm.font-medium');
|
||||
expect(headerEl?.textContent).toBe('Onboarding');
|
||||
});
|
||||
|
||||
it('returns "Select a thread" when no thread is selected', async () => {
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [],
|
||||
selectedThreadId: null,
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// Header shows the placeholder
|
||||
const headerEl = document.querySelector('h3.text-sm.font-medium');
|
||||
expect(headerEl?.textContent).toBe('Select a thread');
|
||||
});
|
||||
});
|
||||
|
||||
// ── effectiveShowSidebar ───────────────────────────────────────────────────
|
||||
|
||||
describe('effectiveShowSidebar — welcome lockdown clamp', () => {
|
||||
it('sidebar is rendered (clamped open) during welcome lockdown', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// Sidebar header "Threads" is rendered, proving effectiveShowSidebar=true
|
||||
expect(screen.getByText('Threads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sidebar can be toggled when NOT locked (showSidebar defaults to true on mount)', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Normal Thread' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// Sidebar starts open by default
|
||||
expect(screen.getByText('Threads')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Welcome thread delete button ───────────────────────────────────────────
|
||||
|
||||
describe('delete button visibility during welcome lockdown', () => {
|
||||
it('hides the delete button for the welcome thread when locked', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
expect(screen.queryByTitle('Delete thread')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the delete button for regular threads when NOT locked', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Regular Thread' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
expect(screen.getByTitle('Delete thread')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── New thread / tab-bar affordances hidden during lockdown ────────────────
|
||||
|
||||
describe('sidebar affordances hidden during welcome lockdown', () => {
|
||||
it('hides the "New thread" button in the sidebar header when locked', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
expect(screen.queryByTitle('New thread')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the label-filter tab bar during lockdown', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('pill-tab-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "New thread" button and tab bar when NOT locked', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Regular' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
expect(screen.getByTitle('New thread')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('pill-tab-bar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadThreads guard branch (lines 243-245) ──────────────────────────────
|
||||
// When loadThreads resolves while welcome-locked with a welcomeThreadId, the
|
||||
// component should dispatch setSelectedThread to the welcome thread instead of
|
||||
// the first returned thread.
|
||||
|
||||
describe('loadThreads guard — welcome-locked branch', () => {
|
||||
it('selects the welcome thread after loadThreads resolves when locked', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
const otherThread = makeThread({ id: 'other-1', title: 'Other Thread' });
|
||||
|
||||
// threadApi.getThreads will resolve with both threads, but the guard
|
||||
// should keep the selection on the welcome thread.
|
||||
const { threadApi } = await import('../../services/api/threadApi');
|
||||
vi.mocked(threadApi.getThreads).mockResolvedValueOnce({
|
||||
threads: [otherThread, welcomeThread],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// After the async thunk resolves the guard on lines 242-245 fires.
|
||||
// The welcome thread title should still read "Onboarding" (not "Other Thread").
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Other Thread')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText('Onboarding').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveThreadDisplayTitle — fallback case (line 868) ──────────────────
|
||||
// When the welcome thread has the label but welcomeThreadId in Redux is set to
|
||||
// a different thread, the label guard on line 868 is NOT satisfied, so the
|
||||
// function falls back to the raw title.
|
||||
|
||||
describe('resolveThreadDisplayTitle — label guard (line 868)', () => {
|
||||
it('shows raw title when welcomeThreadId does not match the labelled thread', async () => {
|
||||
const labelledThread = makeThread({
|
||||
id: 'wt-labelled',
|
||||
title: 'Raw Server Title',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [labelledThread],
|
||||
selectedThreadId: 'wt-labelled',
|
||||
// welcomeThreadId is a *different* id — so the id guard on line 867 fails
|
||||
welcomeThreadId: 'some-other-id',
|
||||
});
|
||||
|
||||
// Falls through to line 872 (raw title)
|
||||
expect(screen.getAllByText('Raw Server Title').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText('Onboarding')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete button click handler (lines 955-967) ───────────────────────────
|
||||
|
||||
describe('delete button click — opens confirmation modal', () => {
|
||||
it('clicking delete on a regular thread while NOT locked sets modal state', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Thread To Delete' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// The delete button should be visible (lines 954-970)
|
||||
const deleteBtn = screen.getByTitle('Delete thread');
|
||||
expect(deleteBtn).toBeInTheDocument();
|
||||
|
||||
// Click to trigger the onClick handler (lines 955-968)
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
// The ConfirmationModal is mocked to render null, but the state
|
||||
// transition exercised lines 957-967 (setDeleteModal call).
|
||||
// The delete button is still in the DOM (modal renders null).
|
||||
expect(deleteBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the delete button for a non-welcome thread even when welcomeThreadId is set', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
const regularThread = makeThread({ id: 'reg-1', title: 'Regular Thread' });
|
||||
|
||||
// When locked, filteredThreads only shows the welcome thread — so to
|
||||
// exercise the delete button for a regular thread, we render NOT locked.
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [welcomeThread, regularThread],
|
||||
selectedThreadId: 'reg-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// Both threads are shown; both get a delete button (line 953 condition
|
||||
// only hides it for `welcomeThreadId` when locked).
|
||||
const deleteBtns = screen.getAllByTitle('Delete thread');
|
||||
expect(deleteBtns.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Click one of the delete buttons to cover lines 955-967
|
||||
fireEvent.click(deleteBtns[0]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sidebar toggle button + header new-thread button (lines 1018, 1020, 1037)
|
||||
|
||||
describe('page-mode header buttons', () => {
|
||||
it('clicking the sidebar toggle button toggles sidebar visibility (line 1018)', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Thread A' });
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// Sidebar starts open — "Threads" header is visible
|
||||
expect(screen.getByText('Threads')).toBeInTheDocument();
|
||||
|
||||
// The toggle button has dynamic title (line 1020)
|
||||
const toggleBtn = screen.getByTitle('Hide sidebar');
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
|
||||
// Click to collapse the sidebar (exercises line 1018 onClick)
|
||||
fireEvent.click(toggleBtn);
|
||||
|
||||
// After click, sidebar should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Threads')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Title flips to "Show sidebar" (the other branch of line 1020)
|
||||
expect(screen.getByTitle('Show sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "+ New" in page header triggers handleCreateNewThread (line 1037)', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Thread A' });
|
||||
|
||||
const { threadApi } = await import('../../services/api/threadApi');
|
||||
vi.mocked(threadApi.createNewThread).mockResolvedValue({
|
||||
id: 'new-thread',
|
||||
title: 'New Thread',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
labels: [],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// The "+ New" button in the chat header (line 1037-1041)
|
||||
const newBtn = screen.getByTitle('New thread (/new)');
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
|
||||
// Click to cover the onClick on line 1037
|
||||
fireEvent.click(newBtn);
|
||||
|
||||
// createNewThread may be called asynchronously; just verify no crash
|
||||
await waitFor(() => {
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only TokenUsagePill (no "+ New" button) in header when locked (line 1033 branch)', async () => {
|
||||
const welcomeThread = makeThread({
|
||||
id: 'wt-1',
|
||||
title: 'Welcome',
|
||||
labels: [ONBOARDING_WELCOME_THREAD_LABEL],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: true,
|
||||
threads: [welcomeThread],
|
||||
selectedThreadId: 'wt-1',
|
||||
welcomeThreadId: 'wt-1',
|
||||
});
|
||||
|
||||
// When locked the else branch renders only <TokenUsagePill /> (line 1044)
|
||||
expect(screen.queryByTitle('New thread (/new)')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('token-usage-pill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking new-thread button in sidebar covers sidebar onClick (line 892)', async () => {
|
||||
const thread = makeThread({ id: 't-1', title: 'Thread A' });
|
||||
|
||||
const { threadApi } = await import('../../services/api/threadApi');
|
||||
vi.mocked(threadApi.createNewThread).mockResolvedValue({
|
||||
id: 'new-thread-2',
|
||||
title: 'New Thread 2',
|
||||
chatId: null,
|
||||
isActive: false,
|
||||
messageCount: 0,
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
labels: [],
|
||||
});
|
||||
|
||||
await renderConversations({
|
||||
welcomeLocked: false,
|
||||
threads: [thread],
|
||||
selectedThreadId: 't-1',
|
||||
welcomeThreadId: null,
|
||||
});
|
||||
|
||||
// The sidebar "New thread" button (lines 891-895) — title is "New thread"
|
||||
const sidebarNewBtn = screen.getByTitle('New thread');
|
||||
expect(sidebarNewBtn).toBeInTheDocument();
|
||||
|
||||
// Click to exercise the onClick handler on line 892
|
||||
fireEvent.click(sidebarNewBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sidebarNewBtn).toBeInTheDocument();
|
||||
});
|
||||
it('resolves thread display title to thread title (no "Onboarding" override)', () => {
|
||||
// The old welcome-lock overrode the thread display title to "Onboarding"
|
||||
// for the welcome thread. After #1123 titles are always the thread's own title.
|
||||
// This verifies the resolveThreadDisplayTitle function is not clamping titles.
|
||||
// We test the pure logic by importing the helper indirectly through the
|
||||
// isComposerInteractionBlocked export to avoid a full component mount.
|
||||
//
|
||||
// The title override was in the component body (not exported separately)
|
||||
// so this test simply confirms the exported composer gate does not
|
||||
// special-case any thread as a "welcome thread".
|
||||
expect(
|
||||
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: true })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../constants/onboardingChat';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { chatSend } from '../../services/chatService';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { useAppDispatch } from '../../store/hooks';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { createNewThread, setSelectedThread, setWelcomeThreadId } from '../../store/threadSlice';
|
||||
import { setWalkthroughPending } from '../../components/walkthrough/AppWalkthrough';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../constants/onboardingChat';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { userApi } from '../../services/api/userApi';
|
||||
import { chatSend } from '../../services/chatService';
|
||||
import { useAppDispatch } from '../../store/hooks';
|
||||
import { createNewThread, setSelectedThread, setWelcomeThreadId } from '../../store/threadSlice';
|
||||
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
|
||||
import BetaBanner from './components/BetaBanner';
|
||||
import { OnboardingContext, type OnboardingDraft } from './OnboardingContext';
|
||||
|
||||
/**
|
||||
* Synthetic "user" message handed to the welcome agent on the first turn
|
||||
* after onboarding completes. Routed through the normal `chat_send`
|
||||
* dispatch path (instead of an out-of-band `agent.run_single` proactive
|
||||
* bypass) so the welcome agent's reply lands in the thread's per-sender
|
||||
* history cache. Subsequent real user messages then see the full prior
|
||||
* turn and continue the conversation rather than starting fresh.
|
||||
*
|
||||
* The welcome agent's `prompt.md` matches on this exact string and
|
||||
* applies its opening voice. Don't change without updating the
|
||||
* prompt's "Proactive opening" section.
|
||||
*
|
||||
* The trigger is **not** persisted as a user-side bubble (we skip
|
||||
* `addMessageLocal`), so the user only sees the agent's reply.
|
||||
*/
|
||||
const WELCOME_TRIGGER_MESSAGE =
|
||||
'the user just finished the desktop onboarding wizard. welcome the user. say something interesting from the profile information above';
|
||||
|
||||
/**
|
||||
* Model id used for the welcome trigger send. Mirrors the constant in
|
||||
* `pages/Conversations.tsx` (`CHAT_MODEL_ID`); duplicated here to avoid
|
||||
* pulling the entire conversations module into onboarding.
|
||||
*/
|
||||
const WELCOME_TRIGGER_MODEL = 'reasoning-v1';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// /**
|
||||
// * Synthetic "user" message handed to the welcome agent on the first turn
|
||||
// * after onboarding completes. Routed through the normal `chat_send`
|
||||
// * dispatch path (instead of an out-of-band `agent.run_single` proactive
|
||||
// * bypass) so the welcome agent's reply lands in the thread's per-sender
|
||||
// * history cache. Subsequent real user messages then see the full prior
|
||||
// * turn and continue the conversation rather than starting fresh.
|
||||
// *
|
||||
// * The welcome agent's `prompt.md` matches on this exact string and
|
||||
// * applies its opening voice. Don't change without updating the
|
||||
// * prompt's "Proactive opening" section.
|
||||
// *
|
||||
// * The trigger is **not** persisted as a user-side bubble (we skip
|
||||
// * `addMessageLocal`), so the user only sees the agent's reply.
|
||||
// */
|
||||
// const WELCOME_TRIGGER_MESSAGE =
|
||||
// 'the user just finished the desktop onboarding wizard. welcome the user. say something interesting from the profile information above';
|
||||
//
|
||||
// /**
|
||||
// * Model id used for the welcome trigger send. Mirrors the constant in
|
||||
// * `pages/Conversations.tsx` (`CHAT_MODEL_ID`); duplicated here to avoid
|
||||
// * pulling the entire conversations module into onboarding.
|
||||
// */
|
||||
// const WELCOME_TRIGGER_MODEL = 'reasoning-v1';
|
||||
|
||||
/**
|
||||
* Full-page chrome for the onboarding flow. Hosts the shared draft + the
|
||||
@@ -43,7 +49,8 @@ const WELCOME_TRIGGER_MODEL = 'reasoning-v1';
|
||||
*/
|
||||
const OnboardingLayout = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// const dispatch = useAppDispatch();
|
||||
const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState();
|
||||
const [draft, setDraftState] = useState<OnboardingDraft>({ connectedSources: [] });
|
||||
|
||||
@@ -80,6 +87,7 @@ const OnboardingLayout = () => {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// Open a fresh chat thread for the welcome conversation so the
|
||||
// welcome opener doesn't pile onto whatever thread the user had
|
||||
// open before onboarding. We then fire the welcome trigger through
|
||||
@@ -92,39 +100,48 @@ const OnboardingLayout = () => {
|
||||
// the welcome again by sending their first message in chat (which
|
||||
// routes to welcome while `chat_onboarding_completed` is still
|
||||
// false).
|
||||
let welcomeThread: { id: string } | null = null;
|
||||
try {
|
||||
const newThread = await dispatch(createNewThread([ONBOARDING_WELCOME_THREAD_LABEL])).unwrap();
|
||||
dispatch(setSelectedThread(newThread.id));
|
||||
// Track this thread so the post-onboarding watcher can delete it
|
||||
// once `chat_onboarding_completed` flips. The welcome conversation
|
||||
// is transient — we don't keep it in the user's thread list.
|
||||
dispatch(setWelcomeThreadId(newThread.id));
|
||||
welcomeThread = { id: newThread.id };
|
||||
} catch (e) {
|
||||
console.warn('[onboarding] failed to create welcome thread; skipping welcome trigger', e);
|
||||
}
|
||||
// let welcomeThread: { id: string } | null = null;
|
||||
// try {
|
||||
// const newThread = await dispatch(createNewThread([ONBOARDING_WELCOME_THREAD_LABEL])).unwrap();
|
||||
// dispatch(setSelectedThread(newThread.id));
|
||||
// // Track this thread so the post-onboarding watcher can delete it
|
||||
// // once `chat_onboarding_completed` flips. The welcome conversation
|
||||
// // is transient — we don't keep it in the user's thread list.
|
||||
// dispatch(setWelcomeThreadId(newThread.id));
|
||||
// welcomeThread = { id: newThread.id };
|
||||
// } catch (e) {
|
||||
// console.warn('[onboarding] failed to create welcome thread; skipping welcome trigger', e);
|
||||
// }
|
||||
//
|
||||
// if (welcomeThread) {
|
||||
// try {
|
||||
// // NB: deliberately *not* calling `addMessageLocal` for the
|
||||
// // trigger so it doesn't render as a user-side bubble. The agent
|
||||
// // response comes back via socket → `addInferenceResponse` and
|
||||
// // is the first thing the user sees in the welcome thread.
|
||||
// await chatSend({
|
||||
// threadId: welcomeThread.id,
|
||||
// message: WELCOME_TRIGGER_MESSAGE,
|
||||
// model: WELCOME_TRIGGER_MODEL,
|
||||
// });
|
||||
// } catch (e) {
|
||||
// console.warn('[onboarding] failed to fire welcome trigger', e);
|
||||
// }
|
||||
// }
|
||||
|
||||
if (welcomeThread) {
|
||||
try {
|
||||
// NB: deliberately *not* calling `addMessageLocal` for the
|
||||
// trigger so it doesn't render as a user-side bubble. The agent
|
||||
// response comes back via socket → `addInferenceResponse` and
|
||||
// is the first thing the user sees in the welcome thread.
|
||||
await chatSend({
|
||||
threadId: welcomeThread.id,
|
||||
message: WELCOME_TRIGGER_MESSAGE,
|
||||
model: WELCOME_TRIGGER_MODEL,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[onboarding] failed to fire welcome trigger', e);
|
||||
}
|
||||
// Flag the Joyride walkthrough as pending so it auto-starts on /home.
|
||||
// Best-effort: localStorage failures must not block navigation.
|
||||
try {
|
||||
setWalkthroughPending();
|
||||
console.debug('[onboarding:layout] walkthrough pending flag set — navigating to /home');
|
||||
} catch (e) {
|
||||
console.warn('[onboarding:layout] could not set walkthrough pending flag; continuing', e);
|
||||
}
|
||||
|
||||
navigate('/home', { replace: true });
|
||||
}, [
|
||||
draft.connectedSources,
|
||||
dispatch,
|
||||
// [#1123] dispatch removed — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
navigate,
|
||||
setOnboardingCompletedFlag,
|
||||
setOnboardingTasks,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Tests for OnboardingLayout — specifically verifies that line 97 (the
|
||||
* createNewThread call with the ONBOARDING_WELCOME_THREAD_LABEL) is executed
|
||||
* when `completeAndExit` runs successfully.
|
||||
* Tests for OnboardingLayout — verifies that completeAndExit:
|
||||
* - does NOT create a welcome thread (welcome-agent replaced by Joyride walkthrough)
|
||||
* - does NOT call chatSend
|
||||
* - DOES set the walkthrough pending flag in localStorage
|
||||
* - DOES call setOnboardingCompletedFlag(true)
|
||||
*
|
||||
* [#1123] Old assertions about welcome thread creation were replaced.
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
@@ -9,19 +13,29 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../../constants/onboardingChat';
|
||||
import socketReducer from '../../../store/socketSlice';
|
||||
import threadReducer from '../../../store/threadSlice';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
|
||||
// ── Module-level mocks ─────────────────────────────────────────────────────
|
||||
|
||||
// [#1123] Mock setWalkthroughPending to allow per-test override (e.g. throw),
|
||||
// while writing to localStorage by default so existing assertions still pass.
|
||||
// Covers the catch block in completeAndExit (OnboardingLayout.tsx:138).
|
||||
const mockSetWalkthroughPending = vi.fn(() => {
|
||||
localStorage.setItem('openhuman:walkthrough_pending', 'true');
|
||||
});
|
||||
vi.mock('../../../components/walkthrough/AppWalkthrough', () => ({
|
||||
setWalkthroughPending: () => mockSetWalkthroughPending(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
|
||||
|
||||
vi.mock('../../../services/api/userApi', () => ({
|
||||
userApi: { onboardingComplete: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
// [#1123] chatSend should NOT be called — walkthrough replaced welcome-agent
|
||||
vi.mock('../../../services/chatService', () => ({
|
||||
chatSend: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -30,7 +44,7 @@ vi.mock('../../../utils/toolDefinitions', () => ({ getDefaultEnabledTools: vi.fn
|
||||
|
||||
vi.mock('../components/BetaBanner', () => ({ default: () => <div data-testid="beta-banner" /> }));
|
||||
|
||||
// ── Spy on threadSlice actions dispatched ──────────────────────────────────
|
||||
// ── Spy on threadApi ───────────────────────────────────────────────────────
|
||||
|
||||
const mockCreateNewThreadArg = vi.fn();
|
||||
|
||||
@@ -131,19 +145,24 @@ async function setupLayout() {
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OnboardingLayout — createNewThread with onboarding label', () => {
|
||||
describe('OnboardingLayout — Joyride walkthrough integration (#1123)', () => {
|
||||
beforeEach(() => {
|
||||
mockCreateNewThreadArg.mockClear();
|
||||
// Reset call history only — restore the default implementation (writes localStorage)
|
||||
mockSetWalkthroughPending.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('calls createNewThread with the onboarding welcome label on completeAndExit', async () => {
|
||||
// [#1123] Replaced old test: no welcome thread creation
|
||||
it('does NOT create a welcome thread on completeAndExit', async () => {
|
||||
await setupLayout();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('complete-btn'));
|
||||
});
|
||||
|
||||
expect(mockCreateNewThreadArg).toHaveBeenCalledWith([ONBOARDING_WELCOME_THREAD_LABEL]);
|
||||
// [#1123] Welcome thread creation is no longer part of the flow
|
||||
expect(mockCreateNewThreadArg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setOnboardingCompletedFlag(true) during completeAndExit', async () => {
|
||||
@@ -156,15 +175,57 @@ describe('OnboardingLayout — createNewThread with onboarding label', () => {
|
||||
expect(mockSetOnboardingCompletedFlag).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('records the welcome thread id in the Redux store after thread creation', async () => {
|
||||
it('sets the walkthrough pending flag in localStorage after completeAndExit', async () => {
|
||||
await setupLayout();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('complete-btn'));
|
||||
});
|
||||
|
||||
// [#1123] Walkthrough pending flag should be set instead of welcome thread
|
||||
expect(localStorage.getItem('openhuman:walkthrough_pending')).toBe('true');
|
||||
});
|
||||
|
||||
// [#1123] Old test — welcome thread in Redux state — replaced:
|
||||
// it('records the welcome thread id in the Redux store after thread creation', ...)
|
||||
// The welcome thread is no longer stored in Redux.
|
||||
it('does NOT set welcomeThreadId in Redux store on completeAndExit', async () => {
|
||||
const { store } = await setupLayout();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('complete-btn'));
|
||||
});
|
||||
|
||||
// The dispatch(setWelcomeThreadId(newThread.id)) should have updated state
|
||||
const { thread } = store.getState() as { thread: { welcomeThreadId: string | null } };
|
||||
expect(thread.welcomeThreadId).toBe('welcome-thread-id');
|
||||
expect(thread.welcomeThreadId).toBeNull();
|
||||
});
|
||||
|
||||
// [#1123] Explicit guard: chatSend must never be called in the Joyride flow
|
||||
it('does NOT call chatSend on completeAndExit', async () => {
|
||||
await setupLayout();
|
||||
const { chatSend } = await import('../../../services/chatService');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('complete-btn'));
|
||||
});
|
||||
|
||||
expect(chatSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Covers the catch branch in completeAndExit (OnboardingLayout.tsx:138):
|
||||
// when setWalkthroughPending throws, navigation still proceeds to /home.
|
||||
it('still navigates to /home when setWalkthroughPending throws', async () => {
|
||||
// Override default impl to throw for this one test invocation
|
||||
mockSetWalkthroughPending.mockImplementationOnce(() => {
|
||||
throw new Error('storage unavailable');
|
||||
});
|
||||
await setupLayout();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('complete-btn'));
|
||||
});
|
||||
|
||||
// Navigation should still proceed even when the flag cannot be written.
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import threadReducer, {
|
||||
loadThreads,
|
||||
setActiveThread,
|
||||
setSelectedThread,
|
||||
setWelcomeThreadId,
|
||||
} from '../threadSlice';
|
||||
|
||||
vi.mock('../../services/api/threadApi', () => ({
|
||||
@@ -75,6 +76,14 @@ describe('threadSlice synchronous reducers', () => {
|
||||
expect(state.isLoadingMessages).toBe(false);
|
||||
});
|
||||
|
||||
// [#1123] setWelcomeThreadId is now a true no-op — kept for TS compat but
|
||||
// state.welcomeThreadId must never be mutated by this action.
|
||||
it('setWelcomeThreadId is a no-op — state.welcomeThreadId stays null', () => {
|
||||
const store = createStore();
|
||||
store.dispatch(setWelcomeThreadId());
|
||||
expect(store.getState().thread.welcomeThreadId).toBeNull();
|
||||
});
|
||||
|
||||
it('setSelectedThread copies cached messages into the visible list', async () => {
|
||||
const store = createStore();
|
||||
const cached = [makeMessage({ id: 'm-1' }), makeMessage({ id: 'm-2' })];
|
||||
|
||||
@@ -9,13 +9,16 @@ interface ThreadState {
|
||||
threads: Thread[];
|
||||
selectedThreadId: string | null;
|
||||
activeThreadId: string | null;
|
||||
/**
|
||||
* Thread created by `OnboardingLayout` to host the proactive welcome
|
||||
* conversation. Tracked so we can delete it once the welcome agent
|
||||
* calls `complete_onboarding` and `chat_onboarding_completed` flips —
|
||||
* the welcome thread is transient onboarding chat, not history we
|
||||
* want to clutter the user's thread list with.
|
||||
*/
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// /**
|
||||
// * Thread created by `OnboardingLayout` to host the proactive welcome
|
||||
// * conversation. Tracked so we can delete it once the welcome agent
|
||||
// * calls `complete_onboarding` and `chat_onboarding_completed` flips —
|
||||
// * the welcome thread is transient onboarding chat, not history we
|
||||
// * want to clutter the user's thread list with.
|
||||
// */
|
||||
// welcomeThreadId: string | null;
|
||||
/** @deprecated [#1123] — welcome-agent replaced by Joyride walkthrough; kept for TS compat */
|
||||
welcomeThreadId: string | null;
|
||||
messagesByThreadId: Record<string, ThreadMessage[]>;
|
||||
messages: ThreadMessage[];
|
||||
@@ -276,8 +279,11 @@ const threadSlice = createSlice({
|
||||
state.activeThreadId = null;
|
||||
state.welcomeThreadId = null;
|
||||
},
|
||||
setWelcomeThreadId: (state, action: { payload: string | null }) => {
|
||||
state.welcomeThreadId = action.payload;
|
||||
// [#1123] True no-op — welcome-agent onboarding replaced by Joyride walkthrough.
|
||||
// Kept to avoid breaking existing imports; state.welcomeThreadId is never
|
||||
// mutated because the welcome-agent flow no longer runs.
|
||||
setWelcomeThreadId: () => {
|
||||
// intentional no-op
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
|
||||
Generated
+118
-7
@@ -84,6 +84,9 @@ importers:
|
||||
react-icons:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(react@19.2.5)
|
||||
react-joyride:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
|
||||
@@ -207,7 +210,7 @@ importers:
|
||||
version: 28.1.0(@noble/hashes@2.2.0)
|
||||
knip:
|
||||
specifier: ^6.3.1
|
||||
version: 6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
version: 6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.10
|
||||
@@ -592,6 +595,35 @@ packages:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@fastify/deepmerge@3.2.1':
|
||||
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||
|
||||
'@floating-ui/dom@1.7.6':
|
||||
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.8':
|
||||
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.11':
|
||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||
|
||||
'@gilbarbara/deep-equal@0.4.1':
|
||||
resolution: {integrity: sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==}
|
||||
|
||||
'@gilbarbara/hooks@0.11.0':
|
||||
resolution: {integrity: sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==}
|
||||
peerDependencies:
|
||||
react: 16.8 - 19
|
||||
|
||||
'@gilbarbara/types@0.2.2':
|
||||
resolution: {integrity: sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3490,6 +3522,9 @@ packages:
|
||||
is-hexadecimal@2.0.1:
|
||||
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
|
||||
|
||||
is-lite@2.0.0:
|
||||
resolution: {integrity: sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==}
|
||||
|
||||
is-map@2.0.3:
|
||||
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4465,6 +4500,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-innertext@1.1.5:
|
||||
resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=0.0.0 <=99'
|
||||
react: '>=0.0.0 <=99'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -4474,6 +4515,12 @@ packages:
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
react-joyride@3.1.0:
|
||||
resolution: {integrity: sha512-+UEDpNsYSHhhSW/OQcNl6+oODYx20EP6TykSD45if0MqAAZMYD+3DU64w9wP3fBjQswvq5BgK99w3rw6ing69g==}
|
||||
peerDependencies:
|
||||
react: 16.8 - 19
|
||||
react-dom: 16.8 - 19
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@@ -4720,6 +4767,12 @@ packages:
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
scroll@3.0.1:
|
||||
resolution: {integrity: sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==}
|
||||
|
||||
scrollparent@2.1.0:
|
||||
resolution: {integrity: sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -5864,6 +5917,36 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
|
||||
'@fastify/deepmerge@3.2.1': {}
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.11
|
||||
|
||||
'@floating-ui/dom@1.7.6':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
'@floating-ui/utils': 0.2.11
|
||||
|
||||
'@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@gilbarbara/deep-equal@0.4.1': {}
|
||||
|
||||
'@gilbarbara/hooks@0.11.0(react@19.2.5)':
|
||||
dependencies:
|
||||
'@gilbarbara/deep-equal': 0.4.1
|
||||
react: 19.2.5
|
||||
|
||||
'@gilbarbara/types@0.2.2':
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -6216,9 +6299,9 @@ snapshots:
|
||||
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
|
||||
optional: true
|
||||
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
@@ -9012,6 +9095,8 @@ snapshots:
|
||||
|
||||
is-hexadecimal@2.0.1: {}
|
||||
|
||||
is-lite@2.0.0: {}
|
||||
|
||||
is-map@2.0.3: {}
|
||||
|
||||
is-nan@1.3.2:
|
||||
@@ -9243,7 +9328,7 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
knip@6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
knip@6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
formatly: 0.3.0
|
||||
@@ -9251,7 +9336,7 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
minimist: 1.2.8
|
||||
oxc-parser: 0.127.0
|
||||
oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
picomatch: 4.0.4
|
||||
smol-toml: 1.6.1
|
||||
strip-json-comments: 5.0.3
|
||||
@@ -9900,7 +9985,7 @@ snapshots:
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.127.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.127.0
|
||||
|
||||
oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
|
||||
oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
|
||||
optionalDependencies:
|
||||
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
|
||||
'@oxc-resolver/binding-android-arm64': 11.19.1
|
||||
@@ -9918,7 +10003,7 @@ snapshots:
|
||||
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
|
||||
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
|
||||
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
|
||||
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
||||
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
|
||||
@@ -10208,12 +10293,34 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
react-innertext@1.1.5(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.5
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-joyride@3.1.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
'@fastify/deepmerge': 3.2.1
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@gilbarbara/deep-equal': 0.4.1
|
||||
'@gilbarbara/hooks': 0.11.0(react@19.2.5)
|
||||
'@gilbarbara/types': 0.2.2
|
||||
is-lite: 2.0.0
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
react-innertext: 1.1.5(@types/react@19.2.14)(react@19.2.5)
|
||||
scroll: 3.0.1
|
||||
scrollparent: 2.1.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -10538,6 +10645,10 @@ snapshots:
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
scroll@3.0.1: {}
|
||||
|
||||
scrollparent@2.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
@@ -31,7 +31,25 @@ const CONFIG_LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(
|
||||
/// indefinitely if disk I/O is blocked.
|
||||
pub async fn load_config_with_timeout() -> Result<Config, String> {
|
||||
match tokio::time::timeout(CONFIG_LOAD_TIMEOUT, Config::load_or_init()).await {
|
||||
Ok(Ok(config)) => Ok(config),
|
||||
Ok(Ok(mut config)) => {
|
||||
// [#1123] Normalize legacy configs at load time: existing users who
|
||||
// completed onboarding before the Joyride migration may have
|
||||
// onboarding_completed=true but chat_onboarding_completed=false.
|
||||
// Without this, pick_target_agent_id() still routes them to the
|
||||
// welcome agent on every chat message.
|
||||
if config.onboarding_completed && !config.chat_onboarding_completed {
|
||||
tracing::info!(
|
||||
"[config] normalizing legacy onboarding state: setting \
|
||||
chat_onboarding_completed=true (Joyride migration)"
|
||||
);
|
||||
config.chat_onboarding_completed = true;
|
||||
// Best-effort persist — don't fail the load if save errors.
|
||||
if let Err(e) = config.save().await {
|
||||
tracing::warn!("[config] failed to persist onboarding normalization: {e}");
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
Ok(Err(e)) => Err(e.to_string()),
|
||||
Err(_) => Err("Config loading timed out".to_string()),
|
||||
}
|
||||
@@ -596,17 +614,31 @@ pub async fn get_onboarding_completed() -> Result<RpcOutcome<bool>, String> {
|
||||
/// real thread session and subsequent user messages continue the same
|
||||
/// conversation with full prior context.
|
||||
///
|
||||
/// **`chat_onboarding_completed` is NOT flipped here.** That flag is
|
||||
/// the exclusive responsibility of the welcome agent: it is set to
|
||||
/// `true` only after the user has had a meaningful onboarding
|
||||
/// conversation (via `complete_onboarding`). See
|
||||
/// [`crate::openhuman::tools::impl::agent::complete_onboarding`] for
|
||||
/// the guard criteria.
|
||||
/// **[#1123] `chat_onboarding_completed` IS now flipped here** on the
|
||||
/// false→true transition. The welcome-agent onboarding flow was replaced
|
||||
/// by a Joyride walkthrough in the frontend, so the chat flag no longer
|
||||
/// needs the welcome agent to set it via `complete_onboarding`.
|
||||
pub async fn set_onboarding_completed(value: bool) -> Result<RpcOutcome<bool>, String> {
|
||||
tracing::debug!(value, "[onboarding] set_onboarding_completed called");
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
let was_completed = config.onboarding_completed;
|
||||
config.onboarding_completed = value;
|
||||
|
||||
// [#1123] On a false→true transition, also flip chat_onboarding_completed=true
|
||||
// so the UI never enters the old welcome-lock state. The Joyride walkthrough
|
||||
// replaced the welcome-agent flow; chat_onboarding_completed no longer needs
|
||||
// to be driven by the welcome agent calling complete_onboarding.
|
||||
if value && !was_completed {
|
||||
tracing::debug!(
|
||||
"[onboarding] false→true transition: setting chat_onboarding_completed=true \
|
||||
(welcome-agent replaced by Joyride walkthrough — skipping lockdown)"
|
||||
);
|
||||
config.chat_onboarding_completed = true;
|
||||
}
|
||||
|
||||
// [#1123] Legacy normalization moved to load_config_with_timeout() so it
|
||||
// catches ALL code paths (routing, snapshots, etc.), not just this function.
|
||||
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if value && !was_completed {
|
||||
|
||||
Reference in New Issue
Block a user