mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 19:29:48 +02:00
[eric] onboarding: streaming feel (shared StreamingText, thinking->stream on payoff), simplify task beat (cut 2 redundant meta-lines + lighter card); DRY consent onto StreamingText
This commit is contained in:
@@ -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 }) =>
|
||||
<PayoffTask
|
||||
insight={content.insight}
|
||||
hero={content.hero}
|
||||
generating={generating}
|
||||
onRun={(prompt: string) => launch(prompt)}
|
||||
onMore={() => setStep('more')}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<span style={style}>
|
||||
{words.map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
opacity: reduce || done ? 1 : 0,
|
||||
animation: reduce ? undefined : `onboardingWordIn ${WORD_DUR_S}s cubic-bezier(0.16,1,0.3,1) forwards`,
|
||||
animationDelay: reduce ? undefined : `${i * WORD_STAGGER_S}s`,
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
{i < words.length - 1 ? ' ' : ''}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<style>{'@keyframes onboardingWordIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}'}</style>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', gap: 6, height: 40, alignItems: 'center' }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 7, height: 7, borderRadius: '50%', background: S.muted,
|
||||
animation: 'onboardingThink 1.2s ease-in-out infinite', animationDelay: `${i * 0.18}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{'@keyframes onboardingThink{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}'}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 <Thinking />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading>{insight}</Heading>
|
||||
<div style={{ marginTop: 14, fontSize: 15, color: S.muted }}>Here's the one I'd start with:</div>
|
||||
<StreamingText
|
||||
text={insight}
|
||||
style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 30, lineHeight: 1.2, letterSpacing: '-0.005em', color: S.text, maxWidth: 620 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => 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<{
|
||||
}}
|
||||
>
|
||||
<span style={{ color: S.accent, display: 'flex', flexShrink: 0 }}>
|
||||
<LineIcon name={hero.icon} size={26} />
|
||||
<LineIcon name={hero.icon} size={24} />
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 550 }}>{hero.label}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13.5, color: S.muted, lineHeight: 1.4 }}>{hero.prompt}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 550 }}>{hero.label}</div>
|
||||
<div style={{ marginTop: 3, fontSize: 13, color: S.muted, lineHeight: 1.4 }}>{hero.prompt}</div>
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', color: S.muted, flexShrink: 0 }}>→</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14, fontSize: 12.5, color: S.muted }}>a real agent goes and does this, live</div>
|
||||
<GhostLink onClick={onMore}>or something else</GhostLink>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.25, color: S.text }}>
|
||||
{words.map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
opacity: reduce ? 1 : 0,
|
||||
animation: reduce ? undefined : `onboardingWordIn ${WORD_DUR_S}s cubic-bezier(0.16,1,0.3,1) forwards`,
|
||||
animationDelay: reduce ? undefined : `${i * WORD_STAGGER_S}s`,
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
{i < words.length - 1 ? ' ' : ''}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
fontSize: 15,
|
||||
color: S.muted,
|
||||
opacity: done ? 1 : 0,
|
||||
transition: 'opacity .5s ease',
|
||||
}}
|
||||
>
|
||||
<StreamingText
|
||||
text={LINE}
|
||||
onDone={() => setDone(true)}
|
||||
style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.25, color: S.text }}
|
||||
/>
|
||||
<div style={{ marginTop: 14, fontSize: 15, color: S.muted, opacity: done ? 1 : 0, transition: 'opacity .5s ease' }}>
|
||||
{SUB}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 30,
|
||||
@@ -73,7 +38,6 @@ export const PersonalizeConsent: React.FC<{ onConsent: (yes: boolean) => void }>
|
||||
<PrimaryButton onClick={() => onConsent(true)}>Yes, get to know me</PrimaryButton>
|
||||
<GhostLink style={{ marginTop: 2 }} onClick={() => onConsent(false)}>not now</GhostLink>
|
||||
</div>
|
||||
<style>{'@keyframes onboardingWordIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}'}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SuggestDto | null>(null);
|
||||
const [status, setStatus] = useState<SuggestStatus>('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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user