mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
feat(walkthrough): multi-page guided tour across key app sections (#1218)
This commit is contained in:
@@ -46,6 +46,7 @@ Quick reference for anyone starting with Claude on this project. Updated by the
|
||||
- **Notification z-index stacking** — ErrorReportNotification: z-[10000] bottom-right. OnboardingOverlay: z-[9999]. LocalAIDownloadSnackbar: z-[9998] bottom-left.
|
||||
- **React Compiler lint** — `useCallback` deps must match the full inferred closure. Using `user?._id` as dep when the closure captures `user` triggers `preserve-manual-memoization`. Use `user` as the dep instead.
|
||||
- **`setState` in effects** — ESLint `react-hooks/set-state-in-effect` catches synchronous setState in useEffect bodies. Use lazy initializers, compute at render, or event handlers instead.
|
||||
- **Walkthrough is multi-page (9 steps)** — Uses react-joyride v3 `Step.before` async hooks to navigate between pages (`/home → /chat → /skills → /intelligence → /settings → /home`). Steps factory: `createWalkthroughSteps(navigate)` in `walkthroughSteps.ts`. `waitForTarget(selector, timeout)` polls via rAF until DOM target appears. Re-trigger from Settings via `resetWalkthrough()` + `walkthrough:restart` CustomEvent. `AppWalkthrough` is mounted inside Router context (can use `useNavigate` directly). BottomTabBar attr is `tab-notifications` (not `tab-automation`).
|
||||
- **`OnboardingNextButton` is the shared primary CTA** — All onboarding steps use `app/src/pages/onboarding/components/OnboardingNextButton.tsx`. New steps must use this component for the primary navigation button.
|
||||
- **Onboarding is 3 steps: Welcome(0) → Skills(1) → ContextGathering(2)** — Referral step was removed (issue #752). `ReferralApplyStep.tsx` is preserved but unused. `referralApi` is still used on the Rewards page. `WelcomeStep` no longer has `nextDisabled`/`nextLoading`/`nextLoadingLabel` props (those gated on referral stats prefetch).
|
||||
- **Recovery Phrase moved to Settings** — MnemonicStep was removed from onboarding (was step 5). The same BIP39 generate/import functionality now lives in `app/src/components/settings/panels/RecoveryPhrasePanel.tsx`, accessible via Settings > Recovery Phrase. Onboarding completion logic moved into `handleSkillsNext` in `Onboarding.tsx`.
|
||||
|
||||
@@ -218,11 +218,11 @@ const BottomTabBar = () => {
|
||||
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).
|
||||
// Maps tab ids to their walkthrough target names.
|
||||
const walkthroughAttr: Record<string, string> = {
|
||||
chat: 'tab-chat',
|
||||
skills: 'tab-skills',
|
||||
notifications: 'tab-automation',
|
||||
notifications: 'tab-notifications',
|
||||
settings: 'tab-settings',
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { persistor } from '../../store';
|
||||
@@ -9,11 +10,13 @@ import {
|
||||
restartApp,
|
||||
scheduleCefProfilePurge,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { resetWalkthrough } from '../walkthrough/AppWalkthrough';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
import SettingsMenuItem from './components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
|
||||
|
||||
const SettingsHome = () => {
|
||||
const navigate = useNavigate();
|
||||
const { navigateToSettings } = useSettingsNavigation();
|
||||
const { clearSession, snapshot } = useCoreState();
|
||||
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
|
||||
@@ -200,6 +203,26 @@ const SettingsHome = () => {
|
||||
onClick: () => navigateToSettings('notification-routing'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'restart-tour',
|
||||
title: 'Restart Tour',
|
||||
description: 'Replay the product walkthrough from the beginning',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
resetWalkthrough();
|
||||
navigate('/home');
|
||||
},
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
title: 'About',
|
||||
@@ -276,7 +299,9 @@ const SettingsHome = () => {
|
||||
|
||||
return (
|
||||
<div className="z-10 relative">
|
||||
<SettingsHeader />
|
||||
<div data-walkthrough="settings-menu">
|
||||
<SettingsHeader />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Grouped Settings */}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { type EventData, EVENTS, Joyride, STATUS } from 'react-joyride';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { WALKTHROUGH_STEPS } from './walkthroughSteps';
|
||||
import { createWalkthroughSteps } from './walkthroughSteps';
|
||||
import WalkthroughTooltip from './WalkthroughTooltip';
|
||||
|
||||
// ── localStorage keys ──────────────────────────────────────────────────────
|
||||
@@ -63,23 +64,61 @@ export function markWalkthroughComplete(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the walkthrough so it will play again on next visit to /home.
|
||||
*
|
||||
* - Removes the completed flag from localStorage.
|
||||
* - Sets the pending flag so `isWalkthroughPending()` returns true.
|
||||
* - Dispatches a `CustomEvent('walkthrough:restart')` on `window` so any
|
||||
* mounted `AppWalkthrough` instance can react and restart immediately.
|
||||
*/
|
||||
export function resetWalkthrough(): void {
|
||||
try {
|
||||
localStorage.removeItem(WALKTHROUGH_KEY);
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
console.debug('[walkthrough] reset — pending flag set, completed flag removed');
|
||||
} catch (e) {
|
||||
console.warn('[walkthrough] could not reset walkthrough in localStorage', e);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('walkthrough:restart'));
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
* Mounts the Joyride instance when `isWalkthroughPending()` is true or when a
|
||||
* `walkthrough:restart` event is received. On finish or skip (EVENTS.TOUR_END),
|
||||
* calls `markWalkthroughComplete()` so the tour never shows again until reset.
|
||||
*
|
||||
* 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).
|
||||
* Mount this inside the Router context so `useNavigate` is available. The
|
||||
* steps include `before` hooks that navigate to other pages before focusing
|
||||
* the target element.
|
||||
*/
|
||||
const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 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));
|
||||
|
||||
// Memoize steps so they are only recreated when `navigate` identity changes.
|
||||
const steps = useMemo(() => createWalkthroughSteps(navigate), [navigate]);
|
||||
|
||||
// Listen for the `walkthrough:restart` custom event (dispatched by
|
||||
// `resetWalkthrough()`) and restart the tour immediately.
|
||||
useEffect(() => {
|
||||
const handleRestart = () => {
|
||||
console.debug('[walkthrough] restart event received — restarting tour');
|
||||
setRun(true);
|
||||
};
|
||||
window.addEventListener('walkthrough:restart', handleRestart);
|
||||
return () => {
|
||||
window.removeEventListener('walkthrough:restart', handleRestart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEvent = (data: EventData) => {
|
||||
const { type, status } = data;
|
||||
console.debug('[walkthrough] event', { type, status, index: data.index });
|
||||
@@ -98,7 +137,7 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => {
|
||||
|
||||
return (
|
||||
<Joyride
|
||||
steps={WALKTHROUGH_STEPS}
|
||||
steps={steps}
|
||||
run={run}
|
||||
continuous={true}
|
||||
tooltipComponent={WalkthroughTooltip}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { TooltipRenderProps } from 'react-joyride';
|
||||
|
||||
/** Emoji accents per step — adds visual personality to each tooltip. */
|
||||
const STEP_ICONS = ['🏠', '💬', '🧠', '⚡', '🤖', '🎉'];
|
||||
/** Emoji accents per step — adds visual personality to each tooltip.
|
||||
* 10 entries map to: home-card, home-cta, chat, integrations, channels,
|
||||
* intelligence, settings, quick-access tabs, notifications, final. */
|
||||
const STEP_ICONS = ['🏠', '💬', '🗨️', '🧩', '📱', '🧠', '⚙️', '⚡', '🔔', '🎉'];
|
||||
|
||||
/**
|
||||
* Premium tooltip for the post-onboarding Joyride walkthrough.
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
/**
|
||||
* Tests for the Joyride walkthrough components introduced in #1123.
|
||||
* Tests for the Joyride walkthrough components introduced in #1123,
|
||||
* extended in #1212 for multi-page guided tour.
|
||||
*
|
||||
* Verifies:
|
||||
* - isWalkthroughPending / setWalkthroughPending / markWalkthroughComplete helpers
|
||||
* - resetWalkthrough: localStorage changes + event dispatch
|
||||
* - AppWalkthrough renders only when pending
|
||||
* - AppWalkthrough does not render when already completed
|
||||
* - AppWalkthrough restarts when walkthrough:restart event fires
|
||||
* - Completing/skipping the tour calls markWalkthroughComplete (localStorage set)
|
||||
* - Step count matches WALKTHROUGH_STEPS
|
||||
* - createWalkthroughSteps: 9 steps, cross-page steps have before functions
|
||||
* - waitForTarget: resolves when element added, rejects on timeout
|
||||
* - WalkthroughTooltip renders step title, content, and navigation buttons
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isWalkthroughPending,
|
||||
markWalkthroughComplete,
|
||||
resetWalkthrough,
|
||||
setWalkthroughPending,
|
||||
} from '../AppWalkthrough';
|
||||
import { WALKTHROUGH_STEPS } from '../walkthroughSteps';
|
||||
import { createWalkthroughSteps, waitForTarget } from '../walkthroughSteps';
|
||||
// ── WalkthroughTooltip rendering tests ───────────────────────────────────
|
||||
|
||||
import WalkthroughTooltip from '../WalkthroughTooltip';
|
||||
@@ -181,6 +187,65 @@ describe('isWalkthroughPending — localStorage unavailable', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── resetWalkthrough tests ────────────────────────────────────────────────
|
||||
|
||||
describe('resetWalkthrough', () => {
|
||||
it('removes completed flag and sets pending flag in localStorage', () => {
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'false');
|
||||
|
||||
resetWalkthrough();
|
||||
|
||||
expect(localStorage.getItem(WALKTHROUGH_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('dispatches walkthrough:restart CustomEvent on window', () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('walkthrough:restart', handler);
|
||||
|
||||
try {
|
||||
resetWalkthrough();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.removeEventListener('walkthrough:restart', handler);
|
||||
}
|
||||
});
|
||||
|
||||
it('swallows localStorage errors but still dispatches the event', () => {
|
||||
const realStorage = globalThis.localStorage;
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('walkthrough:restart', handler);
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
...realStorage,
|
||||
removeItem() {
|
||||
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
||||
},
|
||||
setItem() {
|
||||
throw new DOMException('QuotaExceededError', 'QuotaExceededError');
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => resetWalkthrough()).not.toThrow();
|
||||
// Even if localStorage fails, the event must still be dispatched.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
window.removeEventListener('walkthrough:restart', handler);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: realStorage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── AppWalkthrough component tests ────────────────────────────────────────
|
||||
|
||||
describe('AppWalkthrough component', () => {
|
||||
@@ -188,7 +253,11 @@ describe('AppWalkthrough component', () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('joyride-mock')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true');
|
||||
@@ -198,7 +267,11 @@ describe('AppWalkthrough component', () => {
|
||||
// No pending flag set
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
const { container } = render(<AppWalkthrough />);
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
@@ -209,7 +282,11 @@ describe('AppWalkthrough component', () => {
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
const { container } = render(<AppWalkthrough />);
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
@@ -218,14 +295,18 @@ describe('AppWalkthrough component', () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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 });
|
||||
capturedOnEvent?.({ type: 'tour:end', status: 'finished', index: 8 });
|
||||
});
|
||||
|
||||
// Walkthrough should be marked complete in localStorage
|
||||
@@ -237,7 +318,11 @@ describe('AppWalkthrough component', () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true');
|
||||
|
||||
@@ -254,7 +339,11 @@ describe('AppWalkthrough component', () => {
|
||||
setWalkthroughPending();
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
render(<AppWalkthrough />);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Simulate a step:after event (not tour:end)
|
||||
await act(async () => {
|
||||
@@ -266,6 +355,31 @@ describe('AppWalkthrough component', () => {
|
||||
// Still running
|
||||
expect(screen.getByTestId('joyride-mock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restarts the tour when walkthrough:restart event is dispatched', async () => {
|
||||
// Start with walkthrough completed — component renders nothing initially.
|
||||
localStorage.setItem(WALKTHROUGH_KEY, 'true');
|
||||
|
||||
const { default: AppWalkthrough } = await import('../AppWalkthrough');
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<AppWalkthrough />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should not be rendering joyride since completed.
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
// Simulate resetWalkthrough() — clears completed, sets pending, fires event.
|
||||
await act(async () => {
|
||||
localStorage.removeItem(WALKTHROUGH_KEY);
|
||||
localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true');
|
||||
window.dispatchEvent(new CustomEvent('walkthrough:restart'));
|
||||
});
|
||||
|
||||
// Component should now render the Joyride instance.
|
||||
expect(screen.getByTestId('joyride-mock')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/** Build the minimal props required by WalkthroughTooltip without fighting the full TooltipRenderProps type. */
|
||||
@@ -336,9 +450,9 @@ describe('WalkthroughTooltip', () => {
|
||||
});
|
||||
|
||||
it('renders step counter showing current step of total', () => {
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 1, size: 6 })} />);
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 1, size: 9 })} />);
|
||||
|
||||
expect(screen.getByText('2 of 6')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 of 9')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Skip button when not on last step', () => {
|
||||
@@ -379,38 +493,137 @@ describe('WalkthroughTooltip', () => {
|
||||
|
||||
it('renders progress bar', () => {
|
||||
const { container } = render(
|
||||
<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 6 })} />
|
||||
<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 9 })} />
|
||||
);
|
||||
|
||||
// Gradient progress bar fills based on step progress
|
||||
// Gradient progress bar fills based on step progress (3/9 ≈ 33.33%)
|
||||
const bar = container.querySelector('div.bg-gradient-to-r');
|
||||
expect(bar).not.toBeNull();
|
||||
expect(bar?.getAttribute('style')).toContain('width: 50%');
|
||||
// width rounds to ~33.33% for step 3 of 9
|
||||
expect(bar?.getAttribute('style')).toMatch(/width:\s*33\.3/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── walkthroughSteps tests ────────────────────────────────────────────────
|
||||
// ── createWalkthroughSteps tests ──────────────────────────────────────────
|
||||
|
||||
describe('WALKTHROUGH_STEPS', () => {
|
||||
it('has 6 steps', () => {
|
||||
expect(WALKTHROUGH_STEPS).toHaveLength(6);
|
||||
describe('createWalkthroughSteps', () => {
|
||||
it('returns 10 steps', () => {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
expect(steps).toHaveLength(10);
|
||||
});
|
||||
|
||||
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('first step targets home-card', () => {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
expect(steps[0].target).toBe('[data-walkthrough="home-card"]');
|
||||
});
|
||||
|
||||
it('last step targets tab-settings', () => {
|
||||
const last = WALKTHROUGH_STEPS[WALKTHROUGH_STEPS.length - 1];
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
const last = steps[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) {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
for (const step of steps) {
|
||||
expect(step.title).toBeTruthy();
|
||||
expect(step.content).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('cross-page steps have before functions', () => {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
|
||||
// Steps: 2=chat, 3=integrations, 4=channels, 5=intelligence, 6=settings, 7=home-return
|
||||
const crossPageIndices = [2, 3, 4, 5, 6, 7];
|
||||
for (const idx of crossPageIndices) {
|
||||
expect(typeof steps[idx].before, `step[${idx}] should have a before fn`).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('home-only steps do not have before functions', () => {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
|
||||
// Steps: 0=home-card, 1=home-cta, 8=tab-notifications, 9=tab-settings
|
||||
const homeOnlyIndices = [0, 1, 8, 9];
|
||||
for (const idx of homeOnlyIndices) {
|
||||
expect(steps[idx].before, `step[${idx}] should not have a before fn`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ idx: 2, route: '/chat', target: 'chat-agent-panel' },
|
||||
{ idx: 3, route: '/skills', target: 'skills-grid' },
|
||||
{ idx: 4, route: null, target: 'skills-channels' },
|
||||
{ idx: 5, route: '/intelligence', target: 'intelligence-header' },
|
||||
{ idx: 6, route: '/settings', target: 'settings-menu' },
|
||||
{ idx: 7, route: '/home', target: 'tab-chat' },
|
||||
])('before hook for step $idx calls navigate("$route")', async ({ idx, route, target }) => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-walkthrough', target);
|
||||
document.body.appendChild(el);
|
||||
|
||||
try {
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
await (steps[idx].before as unknown as (() => Promise<void>) | undefined)?.();
|
||||
if (route) {
|
||||
expect(navigate).toHaveBeenCalledWith(route);
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── waitForTarget tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('waitForTarget', () => {
|
||||
it('resolves immediately when element already exists in the DOM', async () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-walkthrough', 'test-target');
|
||||
document.body.appendChild(el);
|
||||
|
||||
try {
|
||||
await expect(waitForTarget('test-target')).resolves.toBeUndefined();
|
||||
} finally {
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves when element is added to DOM after a delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-walkthrough', 'async-target');
|
||||
|
||||
const promise = waitForTarget('async-target', 500);
|
||||
|
||||
// Add element after 100ms (two poll intervals).
|
||||
setTimeout(() => document.body.appendChild(el), 100);
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
try {
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
if (el.parentNode) document.body.removeChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when element is not found before timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = waitForTarget('nonexistent-target', 100).catch((e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
const result = await promise;
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect((result as Error).message).toContain('[walkthrough] waitForTarget timed out');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,171 @@
|
||||
import type { Step } from 'react-joyride';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Step definitions for the post-onboarding product walkthrough.
|
||||
* Targets must match `data-walkthrough="..."` attributes in the DOM.
|
||||
* Polls via setTimeout until `[data-walkthrough="<selector>"]` appears in the
|
||||
* DOM, then resolves. Rejects after `timeout` ms (default 3000).
|
||||
*
|
||||
* Copy is conversational and warm — matching OpenHuman's "calm sophistication"
|
||||
* design language. Each step has an emoji accent for visual interest.
|
||||
* Uses setTimeout (not rAF) so tests can advance time with fake timers.
|
||||
*/
|
||||
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',
|
||||
},
|
||||
];
|
||||
export function waitForTarget(selector: string, timeout = 3000): Promise<void> {
|
||||
const POLL_INTERVAL = 50;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let elapsed = 0;
|
||||
|
||||
function check() {
|
||||
if (document.querySelector(`[data-walkthrough="${selector}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
elapsed += POLL_INTERVAL;
|
||||
if (elapsed >= timeout) {
|
||||
reject(
|
||||
new Error(`[walkthrough] waitForTarget timed out: [data-walkthrough="${selector}"]`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(check, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// Initial check — element may already be present.
|
||||
if (document.querySelector(`[data-walkthrough="${selector}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, POLL_INTERVAL);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that produces the 9-step walkthrough sequence.
|
||||
*
|
||||
* Steps that navigate to a different page receive a `before` async hook that
|
||||
* calls `navigate(path)` and then waits for the target element to appear in
|
||||
* the DOM via `waitForTarget`.
|
||||
*
|
||||
* All targets follow the `[data-walkthrough="<name>"]` convention — add the
|
||||
* attribute to the corresponding DOM element in the page/component.
|
||||
*/
|
||||
export function createWalkthroughSteps(navigate: NavigateFunction): Step[] {
|
||||
return [
|
||||
// ── Step 1 — /home ────────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="home-card"]',
|
||||
title: 'Your command center',
|
||||
content:
|
||||
"This is your home base — a quick snapshot of what's happening and what needs your attention.",
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
},
|
||||
|
||||
// ── Step 2 — /home ────────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="home-cta"]',
|
||||
title: 'Say hello',
|
||||
content: 'Tap here to start a conversation with your AI assistant anytime.',
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
},
|
||||
|
||||
// ── Step 3 — /chat ────────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="chat-agent-panel"]',
|
||||
title: 'Meet your AI',
|
||||
content:
|
||||
'This is where conversations happen. Ask questions, get summaries, or brainstorm. Everything stays searchable.',
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
navigate('/chat');
|
||||
await waitForTarget('chat-agent-panel');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 4 — /skills ──────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="skills-grid"]',
|
||||
title: 'Connect your world',
|
||||
content:
|
||||
'Gmail, Slack, WhatsApp, and more — each connection gives your assistant superpowers.',
|
||||
placement: 'top',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
navigate('/skills');
|
||||
await waitForTarget('skills-grid');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 5 — /skills (channels) ─────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="skills-channels"]',
|
||||
title: 'Chat where you already are',
|
||||
content:
|
||||
'WhatsApp, Telegram, Slack, Discord — connect your messaging apps so your assistant can reach you anywhere.',
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
await waitForTarget('skills-channels');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 6 — /intelligence ────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="intelligence-header"]',
|
||||
title: "Your assistant's brain",
|
||||
content:
|
||||
'This is where your assistant learns and remembers. It gets smarter the more you use it.',
|
||||
placement: 'bottom',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
navigate('/intelligence');
|
||||
await waitForTarget('intelligence-header');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 6 — /settings ────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="settings-menu"]',
|
||||
title: 'Make it yours',
|
||||
content:
|
||||
'Preferences, privacy, notifications — everything is here. You can restart this tour anytime from this page.',
|
||||
placement: 'top',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
navigate('/settings');
|
||||
await waitForTarget('settings-menu');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 7 — /home ────────────────────────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="tab-chat"]',
|
||||
title: 'Quick access',
|
||||
content: 'These tabs are your shortcuts — always one tap away.',
|
||||
placement: 'top',
|
||||
skipBeacon: true,
|
||||
before: async () => {
|
||||
navigate('/home');
|
||||
await waitForTarget('tab-chat');
|
||||
},
|
||||
},
|
||||
|
||||
// ── Step 8 — /home (already there) ───────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="tab-notifications"]',
|
||||
title: 'Stay in the loop',
|
||||
content: 'Alerts and automations live here — briefings, notifications, background activity.',
|
||||
placement: 'top',
|
||||
skipBeacon: true,
|
||||
},
|
||||
|
||||
// ── Step 9 — /home (already there) ───────────────────────────────────
|
||||
{
|
||||
target: '[data-walkthrough="tab-settings"]',
|
||||
title: "That's the tour!",
|
||||
content: "You're all set! Go explore. Restart this tour anytime from Settings.",
|
||||
placement: 'top',
|
||||
skipBeacon: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1061,7 +1061,9 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
|
||||
lockdown (#883) so the onboarding chat is just the conversation
|
||||
with no chrome around it. */}
|
||||
{!isSidebar && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-stone-100">
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 border-b border-stone-100"
|
||||
data-walkthrough="chat-agent-panel">
|
||||
<button
|
||||
onClick={() => setShowSidebar(prev => !prev)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
|
||||
|
||||
@@ -167,7 +167,11 @@ export default function Intelligence() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-stone-900">Intelligence</h1>
|
||||
<h1
|
||||
className="text-xl font-bold text-stone-900"
|
||||
data-walkthrough="intelligence-header">
|
||||
Intelligence
|
||||
</h1>
|
||||
{/* Header count badge was sourced from `stats.total` which
|
||||
in turn came from the legacy actionable-items pipeline
|
||||
(`filterItems(items, ...)`). The Memory tab now mounts
|
||||
|
||||
@@ -784,7 +784,9 @@ export default function Skills() {
|
||||
{channelsGroup && (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-stone-900">
|
||||
<h2
|
||||
className="flex items-center gap-2 text-sm font-semibold text-stone-900"
|
||||
data-walkthrough="skills-channels">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100">
|
||||
<SkillCategoryIcon
|
||||
category="Channels"
|
||||
@@ -817,7 +819,11 @@ export default function Skills() {
|
||||
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3 shadow-soft animate-fade-up">
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<h2 className="text-sm font-semibold text-stone-900">Integrations</h2>
|
||||
<h2
|
||||
className="text-sm font-semibold text-stone-900"
|
||||
data-walkthrough="skills-grid">
|
||||
Integrations
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500">
|
||||
Connect external apps. Connected services give your agent access to the tools
|
||||
it needs to perform tasks.
|
||||
|
||||
Reference in New Issue
Block a user