From 3c4c988d5d299e9a6bcef2b97aae47056e5dd0fb Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Thu, 7 May 2026 12:28:12 -0700 Subject: [PATCH] Make Context Ready onboarding non-blocking (#1322) Co-authored-by: Jwalin Shah --- .../onboarding/steps/ContextGatheringStep.tsx | 110 +++++++++++++----- .../__tests__/ContextGatheringStep.test.tsx | 91 ++++++--------- 2 files changed, 113 insertions(+), 88 deletions(-) diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 94d9f60ef..a84d6b729 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -59,6 +59,20 @@ function canonicalLinkedInUrl(slug: string): string { return `https://www.linkedin.com/in/${slug}`; } +function stageNow(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +function durationMs(startedAt: number): number { + return Math.round(stageNow() - startedAt); +} + +function errorReason(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + if (typeof error === 'string' && error.trim()) return error; + return 'unknown'; +} + /** URL-safe base64 → utf-8 string (Gmail body parts arrive in this form). */ function decodeBase64Url(s: string): string { try { @@ -153,72 +167,121 @@ 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')); - const setStage = (id: Stage['id'], status: StageStatus) => { + const setStage = (id: Stage['id'], status: StageStatus, duration?: number, reason?: string) => { stageStatusesRef.current = { ...stageStatusesRef.current, [id]: status }; + console.debug('[onboarding:context] stage status', { + stage: id, + status, + durationMs: duration, + reason, + }); }; async function runPipeline() { - console.debug('[onboarding:context] runPipeline started'); + const pipelineStartedAt = stageNow(); + console.debug('[onboarding:context] pipeline started'); // Stage 1 — Gmail + const gmailStartedAt = stageNow(); setStage('gmail-search', 'active'); let profileUrl: string | null; try { profileUrl = await findLinkedInUrlViaComposio(); if (profileUrl) { - setStage('gmail-search', 'done'); + setStage('gmail-search', 'done', durationMs(gmailStartedAt)); } else { - setStage('gmail-search', 'skipped'); + setStage('gmail-search', 'skipped', durationMs(gmailStartedAt), 'no_linkedin_url'); setStage('linkedin-scrape', 'skipped'); setStage('build-profile', 'skipped'); + console.debug('[onboarding:context] pipeline finished', { + durationMs: durationMs(pipelineStartedAt), + result: 'no_profile_url', + }); setFinished(true); return; } } catch (e) { - console.warn('[onboarding:context] gmail stage failed', e); - setStage('gmail-search', 'error'); + const reason = errorReason(e); + console.warn('[onboarding:context] gmail stage failed', { + durationMs: durationMs(gmailStartedAt), + reason, + }); + setStage('gmail-search', 'error', durationMs(gmailStartedAt), reason); setStage('linkedin-scrape', 'skipped'); setStage('build-profile', 'skipped'); + console.debug('[onboarding:context] pipeline finished', { + durationMs: durationMs(pipelineStartedAt), + result: 'gmail_error', + reason, + }); setHasError(true); setFinished(true); return; } // Stage 2 — Apify LinkedIn scrape + const scrapeStartedAt = stageNow(); setStage('linkedin-scrape', 'active'); let scrapedMarkdown = ''; try { scrapedMarkdown = await apifyScrapeLinkedIn(profileUrl); - setStage('linkedin-scrape', scrapedMarkdown.trim() ? 'done' : 'skipped'); + setStage( + 'linkedin-scrape', + scrapedMarkdown.trim() ? 'done' : 'skipped', + durationMs(scrapeStartedAt), + scrapedMarkdown.trim() ? undefined : 'empty_markdown' + ); } catch (e) { - console.warn('[onboarding:context] apify_linkedin_scrape stage failed', e); - setStage('linkedin-scrape', 'error'); + const reason = errorReason(e); + console.warn('[onboarding:context] apify_linkedin_scrape stage failed', { + durationMs: durationMs(scrapeStartedAt), + reason, + }); + setStage('linkedin-scrape', 'error', durationMs(scrapeStartedAt), reason); // Continue — save_profile can still write a URL-only file. } // Stage 3 — summarize + persist via core LLM compressor + const profileStartedAt = stageNow(); setStage('build-profile', 'active'); try { const body = scrapedMarkdown.trim() ? scrapedMarkdown : `# User Profile\n\nLinkedIn: ${profileUrl}\n\n_Scrape returned no data._`; await saveProfile(body); - setStage('build-profile', 'done'); + setStage('build-profile', 'done', durationMs(profileStartedAt)); } catch (e) { - console.warn('[onboarding:context] save_profile failed', e); - setStage('build-profile', 'error'); + const reason = errorReason(e); + console.warn('[onboarding:context] save_profile failed', { + durationMs: durationMs(profileStartedAt), + reason, + }); + setStage('build-profile', 'error', durationMs(profileStartedAt), reason); setHasError(true); } + console.debug('[onboarding:context] pipeline finished', { + durationMs: durationMs(pipelineStartedAt), + result: stageStatusesRef.current['build-profile'] === 'error' ? 'profile_error' : 'completed', + }); setFinished(true); } + const continueToChat = () => { + backgroundClickedRef.current = true; + console.debug('[onboarding:context] user continued before pipeline completion', { + finished, + hasError, + stages: stageStatusesRef.current, + }); + void onNext(); + }; + // Auto-start pipeline on mount useEffect(() => { if (ranRef.current) return; @@ -247,13 +310,6 @@ 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 (
@@ -263,7 +319,7 @@ const ContextGatheringStep = ({ We couldn't build your full profile right now, but that's okay — you can always update it later.

- void onNext()} /> +
); @@ -286,16 +342,8 @@ const ContextGatheringStep = ({
- {showBackgroundLink && ( - + {hasGmail && !finished && ( + )} diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index 5e9414429..3b2074d93 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -124,13 +124,12 @@ describe('ContextGatheringStep', () => { await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 }); }); - describe('background continuation link', () => { + describe('non-blocking continuation', () => { afterEach(() => { vi.useRealTimers(); }); - it('does not show background link before 10s threshold', async () => { - vi.useFakeTimers(); + it('lets users continue to chat immediately while integration work is slow', async () => { let resolveGmail!: (v: unknown) => void; callCoreRpc.mockImplementation( () => @@ -143,45 +142,14 @@ describe('ContextGatheringStep', () => { ); - 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'); + expect(screen.getByRole('button', { name: /continue to chat/i })).toBeInTheDocument(); await act(async () => { resolveGmail({ successful: true, data: { messages: [] } }); }); }); - it('clicking background link calls onNext to complete onboarding', async () => { - vi.useFakeTimers(); + it('clicking continue calls onNext before the pipeline finishes', async () => { let resolveGmail!: (v: unknown) => void; callCoreRpc.mockImplementation( () => @@ -195,10 +163,7 @@ describe('ContextGatheringStep', () => { ); - await act(async () => { - vi.advanceTimersByTime(10_000); - }); - fireEvent.click(screen.getByText(/keep building in background/i)); + fireEvent.click(screen.getByRole('button', { name: /continue to chat/i })); expect(onNext).toHaveBeenCalledTimes(1); await act(async () => { @@ -206,8 +171,7 @@ describe('ContextGatheringStep', () => { }); }); - it('does not show background link if pipeline finishes within 10s', async () => { - vi.useFakeTimers(); + it('hides the manual continue button if the pipeline finishes quickly', async () => { callCoreRpc.mockResolvedValue({ successful: true, data: { messages: [] } }); renderWithProviders( @@ -219,15 +183,10 @@ describe('ContextGatheringStep', () => { 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(); + expect(screen.queryByRole('button', { name: /continue to chat/i })).not.toBeInTheDocument(); }); - it('pipeline saves profile even after user clicks background link and component unmounts', async () => { - vi.useFakeTimers(); + it('pipeline saves profile even after user continues and component unmounts', async () => { let resolveScrape!: (v: unknown) => void; let resolveSave!: (v: unknown) => void; @@ -262,13 +221,8 @@ describe('ContextGatheringStep', () => { 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)); + // User continues while the scrape is still running, then the route unmounts. + fireEvent.click(screen.getByRole('button', { name: /continue to chat/i })); expect(onNext).toHaveBeenCalled(); unmount(); @@ -292,6 +246,29 @@ describe('ContextGatheringStep', () => { }); }); + it('treats Gmail insufficient-scope failures as recoverable and non-blocking', async () => { + const onNext = vi.fn().mockResolvedValue(undefined); + callCoreRpc.mockResolvedValueOnce({ + successful: false, + data: null, + error: 'Request had insufficient authentication scopes.', + }); + + renderWithProviders( + + ); + + await waitFor(() => { + expect( + screen.getByText(/we couldn't build your full profile right now/i) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /continue to chat/i })); + expect(onNext).toHaveBeenCalledTimes(1); + expect(callCoreRpc).toHaveBeenCalledTimes(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 }) => { @@ -320,7 +297,7 @@ describe('ContextGatheringStep', () => { ).toBeInTheDocument(); }); - expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /continue to chat/i })).toBeInTheDocument(); expect(screen.queryByText('disk full')).not.toBeInTheDocument(); // fireEvent not needed — onNext is available via the button but user can also