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 }) => {