feat(walkthrough): multi-page guided tour across key app sections (#1218)

This commit is contained in:
Cyrus Gray
2026-05-05 11:03:08 -07:00
committed by GitHub
parent a01231f478
commit c234fc8e84
10 changed files with 500 additions and 91 deletions
+1
View File
@@ -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`.
+2 -2
View File
@@ -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 36).
// 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 (
+26 -1
View File
@@ -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,
},
];
}
+3 -1
View File
@@ -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"
+5 -1
View File
@@ -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
+8 -2
View File
@@ -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.