Make Context Ready onboarding non-blocking (#1322)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-05-07 12:28:12 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent 2f241ce494
commit 3c4c988d5d
2 changed files with 113 additions and 88 deletions
@@ -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 (
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
@@ -263,7 +319,7 @@ const ContextGatheringStep = ({
We couldn&apos;t build your full profile right now, but that&apos;s okay you can
always update it later.
</p>
<OnboardingNextButton label="Continue" onClick={() => void onNext()} />
<OnboardingNextButton label="Continue to chat" onClick={continueToChat} />
</div>
</div>
);
@@ -286,16 +342,8 @@ const ContextGatheringStep = ({
<div className="h-3 w-1/2 rounded-full bg-gradient-to-r from-stone-300 via-stone-100 to-stone-300 bg-[length:200%_100%] animate-shimmer [animation-delay:300ms]" />
</div>
{showBackgroundLink && (
<button
type="button"
className="animate-fade-in text-sm text-ocean-600 hover:text-ocean-700 underline underline-offset-2 transition-colors mt-2"
onClick={() => {
backgroundClickedRef.current = true;
void onNext();
}}>
Keep building in background &amp; continue
</button>
{hasGmail && !finished && (
<OnboardingNextButton label="Continue to chat" onClick={continueToChat} />
)}
</div>
</div>
@@ -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', () => {
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
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(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={vi.fn()} />
);
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', () => {
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={onNext} />
);
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(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={onNext} />
);
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