From fcf2880acabb08ec01d11aed5855e3e4bd8a39ad Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 7 May 2026 01:40:17 +0530 Subject: [PATCH] feat(onboarding): allow moving profile-building to background after 10s (#1295) --- .../onboarding/steps/ContextGatheringStep.tsx | 25 ++- .../__tests__/ContextGatheringStep.test.tsx | 172 +++++++++++++++++- 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index b91c524e2..94d9f60ef 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -153,6 +153,8 @@ const ContextGatheringStep = ({ ); const [finished, setFinished] = useState(false); const [hasError, setHasError] = useState(false); + const [showBackgroundLink, setShowBackgroundLink] = useState(false); + const backgroundClickedRef = useRef(false); const ranRef = useRef(false); const hasGmail = connectedSources.some(s => s.includes('gmail')); @@ -232,9 +234,9 @@ const ContextGatheringStep = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Auto-navigate on successful completion + // Auto-navigate on successful completion (skip if user already clicked background link) useEffect(() => { - if (finished && !hasError) { + if (finished && !hasError && !backgroundClickedRef.current) { const t = setTimeout(() => { void Promise.resolve(onNext()).catch(e => { console.warn('[onboarding:context] auto-advance failed', e); @@ -245,6 +247,13 @@ const ContextGatheringStep = ({ } }, [finished, hasError, onNext]); + // Show "Keep building in background" link after 10s + useEffect(() => { + if (!hasGmail || finished) return; + const t = setTimeout(() => setShowBackgroundLink(true), 10_000); + return () => clearTimeout(t); + }, [hasGmail, finished]); + if (finished && hasError) { return (
@@ -276,6 +285,18 @@ const ContextGatheringStep = ({
+ + {showBackgroundLink && ( + + )}
); diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index f4cc5e9f5..5e9414429 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -1,5 +1,5 @@ -import { act, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '../../../../test/test-utils'; import ContextGatheringStep from '../ContextGatheringStep'; @@ -124,6 +124,174 @@ describe('ContextGatheringStep', () => { await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 }); }); + describe('background continuation link', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not show background link before 10s threshold', async () => { + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + vi.advanceTimersByTime(9_999); + }); + expect(screen.queryByText(/keep building in background/i)).not.toBeInTheDocument(); + + // Cleanup + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('shows background link after 10s with fade-in animation', async () => { + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + const link = screen.getByText(/keep building in background/i); + expect(link).toBeInTheDocument(); + expect(link.className).toContain('animate-fade-in'); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('clicking background link calls onNext to complete onboarding', async () => { + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + const onNext = vi.fn().mockResolvedValue(undefined); + + renderWithProviders( + + ); + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + fireEvent.click(screen.getByText(/keep building in background/i)); + expect(onNext).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('does not show background link if pipeline finishes within 10s', async () => { + vi.useFakeTimers(); + callCoreRpc.mockResolvedValue({ successful: true, data: { messages: [] } }); + + renderWithProviders( + + ); + + // Let pipeline resolve (microtasks) + await act(async () => { + await Promise.resolve(); + }); + + // Advance past 10s — link should still not appear since finished is true + await act(async () => { + vi.advanceTimersByTime(11_000); + }); + expect(screen.queryByText(/keep building in background/i)).not.toBeInTheDocument(); + }); + + it('pipeline saves profile even after user clicks background link and component unmounts', async () => { + vi.useFakeTimers(); + let resolveScrape!: (v: unknown) => void; + let resolveSave!: (v: unknown) => void; + + callCoreRpc.mockImplementation(async (req: { method: string }) => { + if (req.method === 'openhuman.tools_composio_execute') { + return { + successful: true, + data: { messages: [{ messageText: 'https://www.linkedin.com/in/test-user' }] }, + }; + } + if (req.method === 'openhuman.tools_apify_linkedin_scrape') { + return new Promise(res => { + resolveScrape = res; + }); + } + if (req.method === 'openhuman.learning_save_profile') { + return new Promise(res => { + resolveSave = res; + }); + } + throw new Error(`unexpected RPC ${req.method}`); + }); + + const onNext = vi.fn().mockResolvedValue(undefined); + const { unmount } = renderWithProviders( + + ); + + // Wait for Gmail stage to complete and scrape to start + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Show background link after 10s + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + // User clicks background link → onNext called, then unmount + fireEvent.click(screen.getByText(/keep building in background/i)); + expect(onNext).toHaveBeenCalled(); + unmount(); + + // Resolve remaining pipeline stages after unmount + await act(async () => { + resolveScrape({ data: {}, markdown: '# Test User' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + resolveSave({ path: '/tmp/PROFILE.md', bytes: 128 }); + await Promise.resolve(); + }); + + // Verify save_profile was called — pipeline continued after unmount + const saveCalls = callCoreRpc.mock.calls.filter( + (c: Array<{ method: string }>) => c[0].method === 'openhuman.learning_save_profile' + ); + expect(saveCalls.length).toBe(1); + }); + }); + it('shows friendly error message when learning_save_profile rejects', async () => { const onNext = vi.fn().mockResolvedValue(undefined); callCoreRpc.mockImplementation(async (req: { method: string; params: unknown }) => {