From fed5bbb29954a8a4460d6c3e44202ded024e184e Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Thu, 7 May 2026 16:33:45 +0530 Subject: [PATCH] feat(walkthrough): interactive tour gates with resume support Add 2 interactive gates to the guided tour that block progression until the user completes a real action (connect an app, send a message). Gates auto-skip when the action is already done, and each gate offers a skip button so users are never stuck. Tour progress persists in localStorage for resume after app restart. Closes #1215 --- .../components/walkthrough/AppWalkthrough.tsx | 60 +++- .../walkthrough/WalkthroughTooltip.tsx | 33 +- .../__tests__/AppWalkthrough.test.tsx | 286 +++++++++++++++++- .../__tests__/interactiveGates.test.ts | 101 +++++++ .../__tests__/useGatePoller.test.ts | 134 ++++++++ .../walkthrough/interactiveGates.ts | 55 ++++ .../components/walkthrough/useGatePoller.ts | 43 +++ .../walkthrough/walkthroughSteps.ts | 2 + 8 files changed, 700 insertions(+), 14 deletions(-) create mode 100644 app/src/components/walkthrough/__tests__/interactiveGates.test.ts create mode 100644 app/src/components/walkthrough/__tests__/useGatePoller.test.ts create mode 100644 app/src/components/walkthrough/interactiveGates.ts create mode 100644 app/src/components/walkthrough/useGatePoller.ts diff --git a/app/src/components/walkthrough/AppWalkthrough.tsx b/app/src/components/walkthrough/AppWalkthrough.tsx index db533d1cc..02897ad7d 100644 --- a/app/src/components/walkthrough/AppWalkthrough.tsx +++ b/app/src/components/walkthrough/AppWalkthrough.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; -import { type EventData, EVENTS, Joyride, STATUS } from 'react-joyride'; +import { type Controls, type EventData, EVENTS, Joyride, STATUS } from 'react-joyride'; import { useNavigate } from 'react-router-dom'; +import { getStepGate } from './interactiveGates'; import { createWalkthroughSteps } from './walkthroughSteps'; import WalkthroughTooltip from './WalkthroughTooltip'; @@ -9,6 +10,7 @@ import WalkthroughTooltip from './WalkthroughTooltip'; const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed'; const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending'; +export const WALKTHROUGH_STEP_KEY = 'openhuman:walkthrough_step'; /** * Returns `true` when the walkthrough should be shown. This is true when: @@ -58,6 +60,7 @@ export function markWalkthroughComplete(): void { try { localStorage.setItem(WALKTHROUGH_KEY, 'true'); localStorage.removeItem(WALKTHROUGH_PENDING_KEY); + localStorage.removeItem(WALKTHROUGH_STEP_KEY); console.debug('[walkthrough] marked as complete'); } catch (e) { console.warn('[walkthrough] could not mark walkthrough complete in localStorage', e); @@ -75,6 +78,7 @@ export function markWalkthroughComplete(): void { export function resetWalkthrough(): void { try { localStorage.removeItem(WALKTHROUGH_KEY); + localStorage.removeItem(WALKTHROUGH_STEP_KEY); localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true'); console.debug('[walkthrough] reset — pending flag set, completed flag removed'); } catch (e) { @@ -83,6 +87,33 @@ export function resetWalkthrough(): void { window.dispatchEvent(new CustomEvent('walkthrough:restart')); } +// ── Step persistence helpers ─────────────────────────────────────────────── + +function getSavedStepIndex(): number { + try { + const saved = localStorage.getItem(WALKTHROUGH_STEP_KEY); + return saved ? Math.max(0, parseInt(saved, 10) || 0) : 0; + } catch { + return 0; + } +} + +function saveStepIndex(index: number): void { + try { + localStorage.setItem(WALKTHROUGH_STEP_KEY, String(index)); + } catch (e) { + console.warn('[walkthrough] could not save step index', e); + } +} + +function clearStepIndex(): void { + try { + localStorage.removeItem(WALKTHROUGH_STEP_KEY); + } catch (e) { + console.warn('[walkthrough] could not clear step index', e); + } +} + // ── Component ────────────────────────────────────────────────────────────── /** @@ -103,6 +134,9 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => { // Using a lazy initializer keeps this stable across re-renders. const [run, setRun] = useState(() => isWalkthroughPending(onboarded)); + // Track the current step index for controlled mode — enables resume support. + const [stepIndex, setStepIndex] = useState(() => getSavedStepIndex()); + // Memoize steps so they are only recreated when `navigate` identity changes. const steps = useMemo(() => createWalkthroughSteps(navigate), [navigate]); @@ -111,6 +145,8 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => { useEffect(() => { const handleRestart = () => { console.debug('[walkthrough] restart event received — restarting tour'); + clearStepIndex(); + setStepIndex(0); setRun(true); }; window.addEventListener('walkthrough:restart', handleRestart); @@ -119,14 +155,33 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => { }; }, []); - const handleEvent = (data: EventData) => { + const handleEvent = (data: EventData, controls: Controls) => { const { type, status } = data; console.debug('[walkthrough] event', { type, status, index: data.index }); + // STEP_BEFORE: auto-skip gated steps whose gate is already satisfied. + if (type === EVENTS.STEP_BEFORE) { + const gate = getStepGate(steps[data.index]); + if (gate && gate.isComplete()) { + console.debug('[walkthrough] gate already complete, auto-skipping step', data.index); + // Use setTimeout to avoid calling controls.next() during the event handler. + setTimeout(() => controls.next(), 0); + return; + } + } + + // STEP_AFTER: persist the next step index so the tour can resume. + if (type === EVENTS.STEP_AFTER) { + const nextIndex = data.index + 1; + setStepIndex(nextIndex); + saveStepIndex(nextIndex); + } + // TOUR_END fires when the tour finishes or is skipped. if (type === EVENTS.TOUR_END) { if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { markWalkthroughComplete(); + clearStepIndex(); setRun(false); } } @@ -139,6 +194,7 @@ const AppWalkthrough = ({ onboarded = false }: { onboarded?: boolean }) => { {step.content} + {/* Gate prompt */} + {gateBlocking && ( +
{gate.label}
+ )} + {/* Actions */}
{/* Skip tour */} @@ -75,6 +88,21 @@ const WalkthroughTooltip = ({
+ {/* Gate status */} + {isGated && ( +
+ {gateBlocking ? ( + + ) : ( + ✓ Done! + )} +
+ )} + {/* Back */} {index > 0 && ( )} diff --git a/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx index 90301e2ff..b93351044 100644 --- a/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx +++ b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx @@ -1,6 +1,7 @@ /** * Tests for the Joyride walkthrough components introduced in #1123, - * extended in #1212 for multi-page guided tour. + * extended in #1212 for multi-page guided tour, + * extended in #1215 for interactive gate support. * * Verifies: * - isWalkthroughPending / setWalkthroughPending / markWalkthroughComplete helpers @@ -12,6 +13,8 @@ * - 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 + * - Gate-aware step index persistence (resume support) + * - Gate data attached to steps 3 and 4 */ import { act, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; @@ -22,6 +25,7 @@ import { markWalkthroughComplete, resetWalkthrough, setWalkthroughPending, + WALKTHROUGH_STEP_KEY, } from '../AppWalkthrough'; import { createWalkthroughSteps, waitForTarget } from '../walkthroughSteps'; // ── WalkthroughTooltip rendering tests ─────────────────────────────────── @@ -31,6 +35,7 @@ import WalkthroughTooltip from '../WalkthroughTooltip'; vi.mock('../../../store', () => ({ store: { dispatch: vi.fn(() => ({ unwrap: vi.fn().mockResolvedValue({ id: 'thread-welcome-123' }) })), + getState: vi.fn(() => ({ accounts: { order: [] }, thread: { messagesByThreadId: {} } })), }, })); @@ -40,24 +45,45 @@ vi.mock('../../../store/threadSlice', () => ({ addMessageLocal: vi.fn(() => ({ type: 'thread/addMessageLocal' })), })); +// ── Mock interactiveGates and useGatePoller so tooltip tests are isolated ── + +vi.mock('../interactiveGates', () => ({ + getStepGate: vi.fn(() => null), + GATE_CONNECT_SKILL: { id: 'connect-skill', label: '', skipLabel: '', isComplete: () => false }, + GATE_SEND_MESSAGE: { id: 'send-message', label: '', skipLabel: '', isComplete: () => false }, + GATES: {}, +})); + +vi.mock('../useGatePoller', () => ({ useGatePoller: vi.fn(() => true) })); + // ── 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). +// The mock captures the `onEvent` callback and `stepIndex` so individual +// tests can simulate tour events and verify controlled-mode props. type JoyrideMockProps = { run: boolean; - onEvent?: (data: { type: string; status: string; index: number }) => void; + stepIndex?: number; + onEvent?: ( + data: { type: string; status: string; index: number }, + controls: { next: () => void; go: (n: number) => void } + ) => void; }; let capturedOnEvent: JoyrideMockProps['onEvent'] | undefined; vi.mock('react-joyride', () => ({ - Joyride: ({ run, onEvent }: JoyrideMockProps) => { + Joyride: ({ run, onEvent, stepIndex }: JoyrideMockProps) => { capturedOnEvent = onEvent; - return
; + return ( +
+ ); }, - EVENTS: { TOUR_END: 'tour:end' }, + EVENTS: { TOUR_END: 'tour:end', STEP_BEFORE: 'step:before', STEP_AFTER: 'step:after' }, STATUS: { FINISHED: 'finished', SKIPPED: 'skipped' }, })); @@ -142,6 +168,12 @@ describe('markWalkthroughComplete', () => { expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull(); }); + it('clears step index key when marking complete', () => { + localStorage.setItem(WALKTHROUGH_STEP_KEY, '5'); + markWalkthroughComplete(); + expect(localStorage.getItem(WALKTHROUGH_STEP_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. @@ -212,6 +244,12 @@ describe('resetWalkthrough', () => { expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBe('true'); }); + it('clears step index key on reset', () => { + localStorage.setItem(WALKTHROUGH_STEP_KEY, '5'); + resetWalkthrough(); + expect(localStorage.getItem(WALKTHROUGH_STEP_KEY)).toBeNull(); + }); + it('dispatches walkthrough:restart CustomEvent on window', () => { const handler = vi.fn(); window.addEventListener('walkthrough:restart', handler); @@ -318,7 +356,10 @@ describe('AppWalkthrough component', () => { // Simulate TOUR_END with FINISHED status await act(async () => { - capturedOnEvent?.({ type: 'tour:end', status: 'finished', index: 8 }); + capturedOnEvent?.( + { type: 'tour:end', status: 'finished', index: 8 }, + { next: vi.fn(), go: vi.fn() } + ); }); // Walkthrough should be marked complete in localStorage @@ -340,7 +381,10 @@ describe('AppWalkthrough component', () => { // Simulate TOUR_END with SKIPPED status await act(async () => { - capturedOnEvent?.({ type: 'tour:end', status: 'skipped', index: 1 }); + capturedOnEvent?.( + { type: 'tour:end', status: 'skipped', index: 1 }, + { next: vi.fn(), go: vi.fn() } + ); }); expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true'); @@ -359,7 +403,10 @@ describe('AppWalkthrough component', () => { // Simulate a step:after event (not tour:end) await act(async () => { - capturedOnEvent?.({ type: 'step:after', status: 'running', index: 0 }); + capturedOnEvent?.( + { type: 'step:after', status: 'running', index: 0 }, + { next: vi.fn(), go: vi.fn() } + ); }); // Should NOT have marked complete @@ -392,6 +439,139 @@ describe('AppWalkthrough component', () => { // Component should now render the Joyride instance. expect(screen.getByTestId('joyride-mock')).toBeInTheDocument(); }); + + it('starts at saved step index when resuming', async () => { + setWalkthroughPending(); + localStorage.setItem(WALKTHROUGH_STEP_KEY, '4'); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + expect(screen.getByTestId('joyride-mock').getAttribute('data-step-index')).toBe('4'); + }); + + it('saves step index to localStorage on STEP_AFTER', async () => { + setWalkthroughPending(); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + const mockControls = { next: vi.fn(), go: vi.fn() }; + await act(async () => { + capturedOnEvent?.({ type: 'step:after', status: 'running', index: 2 }, mockControls); + }); + + expect(localStorage.getItem(WALKTHROUGH_STEP_KEY)).toBe('3'); + }); + + it('clears step index on tour completion', async () => { + setWalkthroughPending(); + localStorage.setItem(WALKTHROUGH_STEP_KEY, '5'); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + const mockControls = { next: vi.fn(), go: vi.fn() }; + await act(async () => { + capturedOnEvent?.({ type: 'tour:end', status: 'finished', index: 9 }, mockControls); + }); + + expect(localStorage.getItem(WALKTHROUGH_STEP_KEY)).toBeNull(); + }); + + it('auto-skips a gated step when the gate is already complete on STEP_BEFORE', async () => { + vi.useFakeTimers(); + setWalkthroughPending(); + + // Make getStepGate return a completed gate for any step + const { getStepGate } = await import('../interactiveGates'); + vi.mocked(getStepGate).mockReturnValue({ + id: 'test-gate', + label: 'Do it', + skipLabel: 'Skip', + isComplete: () => true, + }); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + const mockControls = { next: vi.fn(), go: vi.fn() }; + await act(async () => { + capturedOnEvent?.({ type: 'step:before', status: 'running', index: 2 }, mockControls); + }); + + // controls.next() is called via setTimeout + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(mockControls.next).toHaveBeenCalledTimes(1); + + vi.mocked(getStepGate).mockReturnValue(null); + vi.useRealTimers(); + }); + + it('does not auto-skip when gate is incomplete on STEP_BEFORE', async () => { + setWalkthroughPending(); + + const { getStepGate } = await import('../interactiveGates'); + vi.mocked(getStepGate).mockReturnValue({ + id: 'test-gate', + label: 'Do it', + skipLabel: 'Skip', + isComplete: () => false, + }); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + const mockControls = { next: vi.fn(), go: vi.fn() }; + await act(async () => { + capturedOnEvent?.({ type: 'step:before', status: 'running', index: 2 }, mockControls); + }); + + expect(mockControls.next).not.toHaveBeenCalled(); + + vi.mocked(getStepGate).mockReturnValue(null); + }); + + it('resets step index to 0 on walkthrough:restart', async () => { + setWalkthroughPending(); + localStorage.setItem(WALKTHROUGH_STEP_KEY, '7'); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render( + + + + ); + + await act(async () => { + window.dispatchEvent(new CustomEvent('walkthrough:restart')); + }); + + expect(localStorage.getItem(WALKTHROUGH_STEP_KEY)).toBeNull(); + }); }); /** Build the minimal props required by WalkthroughTooltip without fighting the full TooltipRenderProps type. */ @@ -403,6 +583,7 @@ function makeTooltipProps( continuous?: boolean; title?: string; content?: string; + data?: Record; } = {} ) { const { @@ -412,6 +593,7 @@ function makeTooltipProps( continuous = true, title = 'Step title', content = 'Step content', + data, } = overrides; // Cast to unknown then to the component's expected props to avoid fighting // the exhaustive TooltipRenderProps type in test code. @@ -420,7 +602,7 @@ function makeTooltipProps( index, size, isLastStep, - step: { title, content, target: 'body' }, + step: { title, content, target: 'body', data }, backProps: { 'aria-label': 'Back', onClick: vi.fn(), @@ -516,6 +698,76 @@ describe('WalkthroughTooltip', () => { }); }); +// ── WalkthroughTooltip gate behavior tests ──────────────────────────────── + +describe('WalkthroughTooltip — gate behavior', () => { + it('shows gate label and skip button when gate is blocking', async () => { + const { getStepGate } = await import('../interactiveGates'); + const { useGatePoller } = await import('../useGatePoller'); + + vi.mocked(getStepGate).mockReturnValue({ + id: 'test-gate', + label: 'Do the thing to continue', + skipLabel: 'Skip — later', + isComplete: () => false, + }); + vi.mocked(useGatePoller).mockReturnValue(false); + + render(); + + expect(screen.getByText('Do the thing to continue')).toBeInTheDocument(); + expect(screen.getByText('Skip — later')).toBeInTheDocument(); + }); + + it('shows done indicator when gate is complete', async () => { + const { getStepGate } = await import('../interactiveGates'); + const { useGatePoller } = await import('../useGatePoller'); + + vi.mocked(getStepGate).mockReturnValue({ + id: 'test-gate', + label: 'Do the thing', + skipLabel: 'Skip', + isComplete: () => true, + }); + vi.mocked(useGatePoller).mockReturnValue(true); + + render(); + + expect(screen.getByText('✓ Done!')).toBeInTheDocument(); + }); + + it('disables Next button when gate is blocking', async () => { + const { getStepGate } = await import('../interactiveGates'); + const { useGatePoller } = await import('../useGatePoller'); + + vi.mocked(getStepGate).mockReturnValue({ + id: 'test-gate', + label: 'Do it', + skipLabel: 'Skip', + isComplete: () => false, + }); + vi.mocked(useGatePoller).mockReturnValue(false); + + render(); + + const nextButton = screen.getByText('Next →'); + expect(nextButton).toBeDisabled(); + }); + + it('enables Next button when no gate', async () => { + const { getStepGate } = await import('../interactiveGates'); + const { useGatePoller } = await import('../useGatePoller'); + + vi.mocked(getStepGate).mockReturnValue(null); + vi.mocked(useGatePoller).mockReturnValue(true); + + render(); + + const nextButton = screen.getByText('Next →'); + expect(nextButton).not.toBeDisabled(); + }); +}); + // ── createWalkthroughSteps tests ────────────────────────────────────────── describe('createWalkthroughSteps', () => { @@ -569,6 +821,18 @@ describe('createWalkthroughSteps', () => { } }); + it('step 3 (chat) has send-message gate', () => { + const navigate = vi.fn(); + const steps = createWalkthroughSteps(navigate); + expect((steps[2] as any).data?.gateId).toBe('send-message'); + }); + + it('step 4 (skills) has connect-skill gate', () => { + const navigate = vi.fn(); + const steps = createWalkthroughSteps(navigate); + expect((steps[3] as any).data?.gateId).toBe('connect-skill'); + }); + it.each([ { idx: 2, route: '/chat', target: 'chat-agent-panel' }, { idx: 3, route: '/skills', target: 'skills-grid' }, diff --git a/app/src/components/walkthrough/__tests__/interactiveGates.test.ts b/app/src/components/walkthrough/__tests__/interactiveGates.test.ts new file mode 100644 index 000000000..dbf5c9b01 --- /dev/null +++ b/app/src/components/walkthrough/__tests__/interactiveGates.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Import the mock store for state manipulation +import { store } from '../../../store'; +import { GATE_CONNECT_SKILL, GATE_SEND_MESSAGE, getStepGate } from '../interactiveGates'; + +// Mock the store module +vi.mock('../../../store', () => { + let mockState = { accounts: { order: [] }, thread: { messagesByThreadId: {} } }; + return { + store: { + getState: vi.fn(() => mockState), + dispatch: vi.fn(), + // Expose a setter so tests can update the state + __setMockState: (s: typeof mockState) => { + mockState = s; + }, + }, + }; +}); + +const setMockState = (store as any).__setMockState; + +beforeEach(() => { + setMockState({ accounts: { order: [] }, thread: { messagesByThreadId: {} } }); +}); + +describe('GATE_CONNECT_SKILL', () => { + it('returns false when no accounts connected', () => { + expect(GATE_CONNECT_SKILL.isComplete()).toBe(false); + }); + + it('returns true when at least one account is connected', () => { + setMockState({ accounts: { order: ['acct-1'] }, thread: { messagesByThreadId: {} } }); + expect(GATE_CONNECT_SKILL.isComplete()).toBe(true); + }); +}); + +describe('GATE_SEND_MESSAGE', () => { + it('returns false when no messages exist', () => { + expect(GATE_SEND_MESSAGE.isComplete()).toBe(false); + }); + + it('returns false when only agent messages exist', () => { + setMockState({ + accounts: { order: [] }, + thread: { messagesByThreadId: { t1: [{ id: 'm1', sender: 'agent', content: 'Hello' }] } }, + }); + expect(GATE_SEND_MESSAGE.isComplete()).toBe(false); + }); + + it('returns true when a user message exists', () => { + setMockState({ + accounts: { order: [] }, + thread: { messagesByThreadId: { t1: [{ id: 'm1', sender: 'user', content: 'Hi there' }] } }, + }); + expect(GATE_SEND_MESSAGE.isComplete()).toBe(true); + }); + + it('returns true when user message exists among agent messages', () => { + setMockState({ + accounts: { order: [] }, + thread: { + messagesByThreadId: { + t1: [ + { id: 'm1', sender: 'agent', content: 'Hello' }, + { id: 'm2', sender: 'user', content: 'Hi' }, + ], + }, + }, + }); + expect(GATE_SEND_MESSAGE.isComplete()).toBe(true); + }); +}); + +describe('getStepGate', () => { + it('returns null when step has no data', () => { + const step = { target: 'body', content: 'test' } as any; + expect(getStepGate(step)).toBeNull(); + }); + + it('returns null when step data has no gateId', () => { + const step = { target: 'body', content: 'test', data: {} } as any; + expect(getStepGate(step)).toBeNull(); + }); + + it('returns null for unknown gateId', () => { + const step = { target: 'body', content: 'test', data: { gateId: 'nonexistent' } } as any; + expect(getStepGate(step)).toBeNull(); + }); + + it('returns GATE_CONNECT_SKILL for matching gateId', () => { + const step = { target: 'body', content: 'test', data: { gateId: 'connect-skill' } } as any; + expect(getStepGate(step)).toBe(GATE_CONNECT_SKILL); + }); + + it('returns GATE_SEND_MESSAGE for matching gateId', () => { + const step = { target: 'body', content: 'test', data: { gateId: 'send-message' } } as any; + expect(getStepGate(step)).toBe(GATE_SEND_MESSAGE); + }); +}); diff --git a/app/src/components/walkthrough/__tests__/useGatePoller.test.ts b/app/src/components/walkthrough/__tests__/useGatePoller.test.ts new file mode 100644 index 000000000..be1f91cc2 --- /dev/null +++ b/app/src/components/walkthrough/__tests__/useGatePoller.test.ts @@ -0,0 +1,134 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InteractiveGate } from '../interactiveGates'; +import { useGatePoller } from '../useGatePoller'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function makeGate(overrides: Partial = {}): InteractiveGate { + return { + id: 'test-gate', + label: 'Do the thing', + skipLabel: 'Skip', + isComplete: vi.fn(() => false), + pollIntervalMs: 500, + ...overrides, + }; +} + +describe('useGatePoller', () => { + it('returns true when gate is null (non-gated step)', () => { + const { result } = renderHook(() => useGatePoller(null)); + expect(result.current).toBe(true); + }); + + it('returns true immediately when gate is already complete', () => { + const gate = makeGate({ isComplete: () => true }); + const { result } = renderHook(() => useGatePoller(gate)); + expect(result.current).toBe(true); + }); + + it('returns false initially when gate is incomplete', () => { + const gate = makeGate({ isComplete: () => false }); + const { result } = renderHook(() => useGatePoller(gate)); + // After the effect runs, it should be false + act(() => { + vi.advanceTimersByTime(0); + }); + expect(result.current).toBe(false); + }); + + it('transitions to true when gate completes during polling', () => { + let complete = false; + const gate = makeGate({ isComplete: () => complete, pollIntervalMs: 500 }); + const { result } = renderHook(() => useGatePoller(gate)); + + act(() => { + vi.advanceTimersByTime(0); + }); + expect(result.current).toBe(false); + + // Simulate the action being completed + complete = true; + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current).toBe(true); + }); + + it('stops polling after gate completes', () => { + let complete = false; + const isCompleteFn = vi.fn(() => complete); + const gate = makeGate({ isComplete: isCompleteFn, pollIntervalMs: 500 }); + + renderHook(() => useGatePoller(gate)); + + act(() => { + vi.advanceTimersByTime(0); + }); + + // Complete the gate + complete = true; + act(() => { + vi.advanceTimersByTime(500); + }); + + // Record call count after completion + const callCountAfterComplete = isCompleteFn.mock.calls.length; + + // Advance more — should not call isComplete again + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(isCompleteFn.mock.calls.length).toBe(callCountAfterComplete); + }); + + it('uses default poll interval when not specified', () => { + let complete = false; + const isCompleteFn = vi.fn(() => complete); + const gate = makeGate({ isComplete: isCompleteFn }); + delete (gate as any).pollIntervalMs; + + renderHook(() => useGatePoller(gate)); + + act(() => { + vi.advanceTimersByTime(0); + }); + const callsAfterInit = isCompleteFn.mock.calls.length; + + // Default is 1000ms + act(() => { + vi.advanceTimersByTime(999); + }); + expect(isCompleteFn.mock.calls.length).toBe(callsAfterInit); + + complete = true; + act(() => { + vi.advanceTimersByTime(1); + }); + expect(isCompleteFn.mock.calls.length).toBeGreaterThan(callsAfterInit); + }); + + it('cleans up interval on unmount', () => { + const gate = makeGate({ isComplete: () => false, pollIntervalMs: 500 }); + const { unmount } = renderHook(() => useGatePoller(gate)); + + act(() => { + vi.advanceTimersByTime(0); + }); + unmount(); + + // No errors should be thrown when timers fire after unmount + act(() => { + vi.advanceTimersByTime(2000); + }); + }); +}); diff --git a/app/src/components/walkthrough/interactiveGates.ts b/app/src/components/walkthrough/interactiveGates.ts new file mode 100644 index 000000000..d0d27e18e --- /dev/null +++ b/app/src/components/walkthrough/interactiveGates.ts @@ -0,0 +1,55 @@ +import type { Step } from 'react-joyride'; + +import { store } from '../../store'; + +/** + * An interactive gate blocks the "Next" button on a tour step until the + * user completes a real action (or clicks "Skip this step"). + */ +export interface InteractiveGate { + /** Unique identifier, referenced by `step.data.gateId`. */ + id: string; + /** Prompt shown in the tooltip while the gate is blocking. */ + label: string; + /** Text for the skip button. */ + skipLabel: string; + /** Synchronous check against current Redux state. */ + isComplete: () => boolean; + /** How often (ms) the tooltip re-checks completion. Default 1000. */ + pollIntervalMs?: number; +} + +// ── Gate definitions ────────────────────────────────────────────────────── + +export const GATE_CONNECT_SKILL: InteractiveGate = { + id: 'connect-skill', + label: 'Connect at least one app to continue', + skipLabel: "Skip — I'll do this later", + isComplete: () => { + const state = store.getState(); + return state.accounts.order.length > 0; + }, +}; + +export const GATE_SEND_MESSAGE: InteractiveGate = { + id: 'send-message', + label: 'Send your first message to continue', + skipLabel: "Skip — I'll explore later", + isComplete: () => { + const state = store.getState(); + const allMessages = Object.values(state.thread.messagesByThreadId).flat(); + return allMessages.some(m => m.sender === 'user'); + }, +}; + +/** Registry of all gates, keyed by id. */ +export const GATES: Record = { + [GATE_CONNECT_SKILL.id]: GATE_CONNECT_SKILL, + [GATE_SEND_MESSAGE.id]: GATE_SEND_MESSAGE, +}; + +/** Look up the interactive gate attached to a Joyride step (if any). */ +export function getStepGate(step: Step): InteractiveGate | null { + const gateId = (step.data as { gateId?: string } | undefined)?.gateId; + return gateId ? (GATES[gateId] ?? null) : null; +} diff --git a/app/src/components/walkthrough/useGatePoller.ts b/app/src/components/walkthrough/useGatePoller.ts new file mode 100644 index 000000000..865c52cd7 --- /dev/null +++ b/app/src/components/walkthrough/useGatePoller.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +import type { InteractiveGate } from './interactiveGates'; + +const DEFAULT_POLL_MS = 1000; + +/** + * Polls `gate.isComplete()` at a regular interval and returns a reactive + * boolean. Returns `true` immediately when there is no gate (non-gated step). + * + * The store is read via `gate.isComplete()` which calls `store.getState()` + * directly — this avoids needing the Redux Provider context, which Joyride + * tooltip components don't have access to. + */ +export function useGatePoller(gate: InteractiveGate | null): boolean { + const [complete, setComplete] = useState(() => gate?.isComplete() ?? true); + + useEffect(() => { + if (!gate) { + setComplete(true); + return; + } + + // Immediately check — the gate might already be satisfied. + if (gate.isComplete()) { + setComplete(true); + return; + } + + setComplete(false); + + const interval = setInterval(() => { + if (gate.isComplete()) { + setComplete(true); + clearInterval(interval); + } + }, gate.pollIntervalMs ?? DEFAULT_POLL_MS); + + return () => clearInterval(interval); + }, [gate]); + + return complete; +} diff --git a/app/src/components/walkthrough/walkthroughSteps.ts b/app/src/components/walkthrough/walkthroughSteps.ts index fe071a0ac..7ded1a04e 100644 --- a/app/src/components/walkthrough/walkthroughSteps.ts +++ b/app/src/components/walkthrough/walkthroughSteps.ts @@ -80,6 +80,7 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] { content: 'This is where conversations happen. Ask questions, get summaries, or brainstorm. Everything stays searchable.', placement: 'bottom', + data: { gateId: 'send-message' }, skipBeacon: true, before: async () => { navigate('/chat'); @@ -94,6 +95,7 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] { content: 'Gmail, Slack, WhatsApp, and more — each connection gives your assistant superpowers.', placement: 'top', + data: { gateId: 'connect-skill' }, skipBeacon: true, before: async () => { navigate('/skills');