From 902d6c085e234f86f54e6209faa5ea66117f00a2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 1 Jul 2026 00:37:04 -0700 Subject: [PATCH] [eric] onboarding: streaming feel (shared StreamingText, thinking->stream on payoff), simplify task beat (cut 2 redundant meta-lines + lighter card); DRY consent onto StreamingText --- .../Onboarding/flow/OnboardingFlow.tsx | 17 ++++-- .../Onboarding/flow/StreamingText.tsx | 48 +++++++++++++++ .../Onboarding/flow/steps/PayoffTask.tsx | 50 +++++++++++----- .../flow/steps/PersonalizeConsent.tsx | 58 ++++--------------- .../Onboarding/flow/useOnboardingSuggest.ts | 20 +++++-- 5 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 frontend/src/app/components/Onboarding/flow/StreamingText.tsx diff --git a/frontend/src/app/components/Onboarding/flow/OnboardingFlow.tsx b/frontend/src/app/components/Onboarding/flow/OnboardingFlow.tsx index a292d6f7..c0333b81 100644 --- a/frontend/src/app/components/Onboarding/flow/OnboardingFlow.tsx +++ b/frontend/src/app/components/Onboarding/flow/OnboardingFlow.tsx @@ -38,24 +38,28 @@ export const OnboardingFlow: React.FC<{ onExit: () => void }> = ({ onExit }) => const onPayoff = step === 'greet' || step === 'task' || step === 'more'; const floor = useMemo(() => demoPayoff(persona), [persona]); - // Personalized payoff from the persona (cheap LLM); swaps in over the floor when ready. - const suggest = useOnboardingSuggest(useCase, name, onPayoff); + // Personalized payoff from the persona (cheap LLM); status drives the thinking -> stream feel. + const { result: suggest, status: suggestStatus } = useOnboardingSuggest(useCase, name, onPayoff); // Deeper: background read-only profiling (only if they consented); trumps the persona suggestion. const profile = useOnboardingProfile(name, consent, onPayoff); + const profileReady = !!(profile && profile.observation.trim() && profile.options.length > 0); // Merge into { insight, hero, more[] }. Priority: real-data profile > persona-generated > floor. const content = useMemo(() => { - if (profile && profile.observation.trim() && profile.options.length > 0) { + if (profile && profileReady) { const opts = profile.options.map((o, i) => ({ id: `p${i}`, icon: IDEA_ICONS[i % IDEA_ICONS.length], label: o.label, prompt: o.prompt })); return { insight: profile.observation, hero: opts[0], more: opts.slice(1) }; } - if (suggest && suggest.insight.trim() && suggest.task.trim() && suggest.options.length > 0) { + if (suggest && suggestStatus === 'ready') { const opts = suggest.options.map((o, i) => ({ id: `s${i}`, icon: IDEA_ICONS[i % IDEA_ICONS.length], label: o.label, prompt: o.prompt })); - const hero: PayoffIdea = { id: 'hero', icon: 'sun', label: opts[0].label, prompt: suggest.task }; + const hero: PayoffIdea = { id: 'hero', icon: opts[0].icon, label: opts[0].label, prompt: suggest.task }; return { insight: suggest.insight, hero, more: opts }; } return floor; - }, [profile, suggest, floor]); + }, [profile, profileReady, suggest, suggestStatus, floor]); + + // Still generating (no result yet): the task beat shows a brief "thinking" instead of the floor. + const generating = onPayoff && !profileReady && suggestStatus === 'loading'; // Tapping a task ends onboarding by DOING it: hand the prompt to the dashboard (it spawns the agent // with its own proven path), then close the overlay so the user watches it run. @@ -103,6 +107,7 @@ export const OnboardingFlow: React.FC<{ onExit: () => void }> = ({ onExit }) => launch(prompt)} onMore={() => setStep('more')} /> diff --git a/frontend/src/app/components/Onboarding/flow/StreamingText.tsx b/frontend/src/app/components/Onboarding/flow/StreamingText.tsx new file mode 100644 index 00000000..d0589541 --- /dev/null +++ b/frontend/src/app/components/Onboarding/flow/StreamingText.tsx @@ -0,0 +1,48 @@ +// Reveals text word-by-word (a gentle rise + fade), so generated lines feel like they stream in the +// way Claude's responses do. Reduced-motion shows it all at once. Shared by the consent + payoff. + +import React, { useEffect, useMemo, useState } from 'react'; +import { useReducedMotion } from '@/shared/hooks/useReducedMotion'; + +const WORD_STAGGER_S = 0.05; +const WORD_DUR_S = 0.45; + +export const StreamingText: React.FC<{ + text: string; + style?: React.CSSProperties; + onDone?: () => void; +}> = ({ text, style, onDone }) => { + const reduce = useReducedMotion(); + const words = useMemo(() => text.split(' '), [text]); + const [done, setDone] = useState(false); + + useEffect(() => { + setDone(false); + if (reduce) { setDone(true); onDone?.(); return; } + const totalMs = (words.length * WORD_STAGGER_S + WORD_DUR_S) * 1000; + const t = window.setTimeout(() => { setDone(true); onDone?.(); }, totalMs); + return () => window.clearTimeout(t); + // onDone intentionally excluded: callers pass fresh closures; keying on text is enough. + }, [text, reduce, words.length]); + + return ( + + {words.map((w, i) => ( + + + {w} + + {i < words.length - 1 ? ' ' : ''} + + ))} + + + ); +}; diff --git a/frontend/src/app/components/Onboarding/flow/steps/PayoffTask.tsx b/frontend/src/app/components/Onboarding/flow/steps/PayoffTask.tsx index 45979e4e..a752fd9c 100644 --- a/frontend/src/app/components/Onboarding/flow/steps/PayoffTask.tsx +++ b/frontend/src/app/components/Onboarding/flow/steps/PayoffTask.tsx @@ -1,41 +1,66 @@ -// Payoff beat 2: the insight + THE one task, as a single tappable card. Tap it and a real agent runs -// it (0 -> 1). A quiet "or something else" leads to the alternatives beat. +// Payoff beat 2: a short streamed insight + THE one task as a single tappable card (tap = run, 0->1). +// Minimal: no meta-labels stacked around the card. A quiet "or something else" leads to alternatives. +// While the LLM is still generating, a brief thinking state holds the space so nothing pops in raw. import React, { useState } from 'react'; import { useOnboardingSkin } from '../onboardingSkin'; import { LineIcon } from '../OnboardingIcons'; -import { Heading, GhostLink } from '../OnboardingAtoms'; +import { GhostLink } from '../OnboardingAtoms'; +import { StreamingText } from '../StreamingText'; import type { PayoffIdea } from '../onboardingFlowTypes'; +const Thinking: React.FC = () => { + const S = useOnboardingSkin(); + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} + +
+ ); +}; + export const PayoffTask: React.FC<{ insight: string; hero: PayoffIdea; + generating: boolean; onRun: (prompt: string) => void; onMore: () => void; -}> = ({ insight, hero, onRun, onMore }) => { +}> = ({ insight, hero, generating, onRun, onMore }) => { const S = useOnboardingSkin(); const [hover, setHover] = useState(false); + if (generating) return ; + return ( <> - {insight} -
Here's the one I'd start with:
+
onRun(hero.prompt)} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{ - marginTop: 22, + marginTop: 32, width: '100%', - maxWidth: 520, + maxWidth: 500, display: 'flex', alignItems: 'center', gap: 16, background: hover ? S.surfaceHover : S.surface, border: `1px solid ${hover ? S.borderStrong : S.border}`, borderRadius: S.radius, - padding: '22px 24px', + padding: '20px 22px', cursor: 'pointer', textAlign: 'left', transform: hover ? 'translateY(-2px)' : 'none', @@ -43,16 +68,15 @@ export const PayoffTask: React.FC<{ }} > - +
-
{hero.label}
-
{hero.prompt}
+
{hero.label}
+
{hero.prompt}
-
a real agent goes and does this, live
or something else ); diff --git a/frontend/src/app/components/Onboarding/flow/steps/PersonalizeConsent.tsx b/frontend/src/app/components/Onboarding/flow/steps/PersonalizeConsent.tsx index b8701ae3..fe6dca02 100644 --- a/frontend/src/app/components/Onboarding/flow/steps/PersonalizeConsent.tsx +++ b/frontend/src/app/components/Onboarding/flow/steps/PersonalizeConsent.tsx @@ -1,63 +1,28 @@ -// D3: the personalize consent. Short question reveals word-by-word, then a small explainer + Yes. -// "Yes" authorizes the (later) background profiling read; the sub-line says exactly what that means. +// D3: the personalize consent. The short question streams in (shared StreamingText), then a small +// explainer + Yes. "Yes" authorizes the (later) background profiling read; the sub-line says so. -import React, { useEffect, useMemo, useState } from 'react'; -import { useReducedMotion } from '@/shared/hooks/useReducedMotion'; +import React, { useState } from 'react'; import { useOnboardingSkin } from '../onboardingSkin'; import { PrimaryButton, GhostLink } from '../OnboardingAtoms'; +import { StreamingText } from '../StreamingText'; const LINE = 'Want me to make this yours?'; const SUB = "I'll take a quick look at what you connect, nothing else."; -const WORD_STAGGER_S = 0.06; -const WORD_DUR_S = 0.5; - export const PersonalizeConsent: React.FC<{ onConsent: (yes: boolean) => void }> = ({ onConsent }) => { - const reduce = useReducedMotion(); const S = useOnboardingSkin(); - const words = useMemo(() => LINE.split(' '), []); - const [done, setDone] = useState(reduce); - - useEffect(() => { - if (reduce) { setDone(true); return; } - setDone(false); - const totalMs = (words.length * WORD_STAGGER_S + WORD_DUR_S) * 1000; - const t = window.setTimeout(() => setDone(true), totalMs); - return () => window.clearTimeout(t); - }, [reduce, words.length]); + const [done, setDone] = useState(false); return ( <> -
- {words.map((w, i) => ( - - - {w} - - {i < words.length - 1 ? ' ' : ''} - - ))} -
- -
+ setDone(true)} + style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.25, color: S.text }} + /> +
{SUB}
-
void }> onConsent(true)}>Yes, get to know me onConsent(false)}>not now
- ); }; diff --git a/frontend/src/app/components/Onboarding/flow/useOnboardingSuggest.ts b/frontend/src/app/components/Onboarding/flow/useOnboardingSuggest.ts index 3e93b29d..cb12f52c 100644 --- a/frontend/src/app/components/Onboarding/flow/useOnboardingSuggest.ts +++ b/frontend/src/app/components/Onboarding/flow/useOnboardingSuggest.ts @@ -1,6 +1,6 @@ // Generates the payoff content (insight + task + 4 options) from the user's persona via the cheap -// free-trial LLM. Progressive: returns null until it lands, so the payoff shows its static floor -// first and swaps to the personalized version when ready. Fail-open on any miss. +// LLM. Exposes status so the payoff can show a brief "thinking" state, then stream the result in. +// Fail-open: on any miss the status goes 'failed' and the caller shows its static floor. import { useEffect, useState } from 'react'; import { API_BASE } from '@/shared/config'; @@ -11,12 +11,16 @@ export interface SuggestDto { options: { label: string; prompt: string }[]; } -export function useOnboardingSuggest(persona: string, name: string, active: boolean): SuggestDto | null { +export type SuggestStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +export function useOnboardingSuggest(persona: string, name: string, active: boolean): { result: SuggestDto | null; status: SuggestStatus } { const [result, setResult] = useState(null); + const [status, setStatus] = useState('idle'); useEffect(() => { if (!active || !persona) return; let cancelled = false; + setStatus('loading'); fetch(`${API_BASE}/agents/onboarding-suggest`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -24,13 +28,17 @@ export function useOnboardingSuggest(persona: string, name: string, active: bool }) .then((r) => (r.ok ? r.json() : null)) .then((data: SuggestDto | null) => { - if (!cancelled && data && data.insight?.trim() && data.task?.trim() && Array.isArray(data.options) && data.options.length > 0) { + if (cancelled) return; + if (data && data.insight?.trim() && data.task?.trim() && Array.isArray(data.options) && data.options.length > 0) { setResult(data); + setStatus('ready'); + } else { + setStatus('failed'); } }) - .catch(() => { /* fail-open: keep the floor */ }); + .catch(() => { if (!cancelled) setStatus('failed'); }); return () => { cancelled = true; }; }, [active, persona, name]); - return result; + return { result, status }; }