[eric] onboarding: stream the welcome greeting + stagger-pop the chips (greeting moves into the component, no seeded message)

This commit is contained in:
ciregenz
2026-06-14 01:06:49 -07:00
parent 665dec63e2
commit b2fabec4a4
3 changed files with 141 additions and 113 deletions
@@ -1520,6 +1520,15 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}}
>
<Box>
{/* First-run welcome: the greeting streams in, the chips pop in; vanishes the
moment a user message exists (i.e. once they answer). Pure UI, no run. */}
{session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && (
<WelcomeQuickReplies
c={c}
onPick={(p) => handleSend(p)}
onPickBuilder={(p) => chatInputRef.current?.setContent(p)}
/>
)}
{(session.mcp_suggestions && session.mcp_suggestions.length > 0) && (
<Box sx={{
mt: 1,
@@ -1849,15 +1858,6 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
</Box>
</Box>
)}
{/* First-run welcome: quick-reply chips under the seeded greeting; vanish the
moment a user message exists (i.e. once they answer). */}
{session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && (
<WelcomeQuickReplies
c={c}
onPick={(p) => handleSend(p)}
onPickBuilder={(p) => chatInputRef.current?.setContent(p)}
/>
)}
</Box>
</Box>
{showScrollButton && (
@@ -6,14 +6,33 @@ import { ArrowLeft } from 'lucide-react';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { STARTER_CATEGORIES } from '@/shared/starterCategories';
// Quick-reply chips shown under the welcome greeting inside the first-run chat. Two-level:
// pick a category, then a concrete prompt. Research/Write/Learn -> onPick (runs the agent);
// Build -> onPickBuilder (opens App Builder). Pure UI; no run until the parent fires onPick.
const GREETING = "Hi, I'm your AI team. What do you want done?";
// One-shot typewriter for a fixed string (no infinite loop; stops at the end).
function useTypewriter(text: string, speedMs = 26): { shown: string; done: boolean } {
const [shown, setShown] = React.useState('');
React.useEffect(() => {
setShown('');
let i = 0;
const id = window.setInterval(() => {
i += 1;
setShown(text.slice(0, i));
if (i >= text.length) window.clearInterval(id);
}, speedMs);
return () => window.clearInterval(id);
}, [text, speedMs]);
return { shown, done: shown.length >= text.length };
}
// The first-run welcome: the greeting streams in like typing, then the quick-reply chips
// pop in (staggered). Two-level: category -> concrete prompts. Research/Write/Learn -> onPick
// (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires.
const WelcomeQuickReplies: React.FC<{
c: ClaudeTokens;
onPick: (prompt: string) => void;
onPickBuilder: (prompt: string) => void;
}> = ({ c, onPick, onPickBuilder }) => {
const { shown: greeting, done: greetingDone } = useTypewriter(GREETING);
const [expanded, setExpanded] = React.useState<string | null>(null);
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
const isAppBuilder = currentCategory?.target === 'app-builder';
@@ -25,94 +44,113 @@ const WelcomeQuickReplies: React.FC<{
};
return (
<Box sx={{ px: 1.5, pb: 1.5, pt: 0.5, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<AnimatePresence mode="wait" initial={false}>
{expanded === null ? (
<motion.div
key="categories"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.22 }}
style={{ display: 'flex', flexDirection: 'column' }}
>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1 }}>
pick one, or just type below
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
{STARTER_CATEGORIES.map((cat) => (
<Box
component="button"
key={cat.id}
onClick={() => setExpanded(cat.id)}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 1.05,
borderRadius: 2.2,
border: `1px solid ${c.border.medium}`,
background: c.bg.surface,
color: c.text.secondary,
fontSize: '0.9rem', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 150ms, border-color 150ms',
'&:hover': { background: c.bg.elevated, borderColor: c.border.strong },
}}
>
<cat.Icon size={16} />
{cat.label}
</Box>
))}
</Box>
</motion.div>
) : (
<motion.div
key="specifics"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18 }}
style={{ display: 'flex', flexDirection: 'column' }}
>
<Box
component="button"
onClick={() => setExpanded(null)}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3,
border: 'none', background: 'transparent',
color: c.text.ghost, fontSize: '0.85rem',
cursor: 'pointer', fontFamily: 'inherit',
'&:hover': { color: c.text.secondary },
}}
>
<ArrowLeft size={14} /> back
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.7 }}>
{currentPrompts.map((prompt) => (
<Box
component="button"
key={prompt}
onClick={() => pick(prompt)}
sx={{
textAlign: 'left',
px: 1.4, py: 0.95,
borderRadius: 1.8,
border: `1px solid ${c.border.medium}`,
background: c.bg.surface,
color: c.text.secondary,
fontSize: '0.88rem',
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 150ms, border-color 150ms',
'&:hover': { background: c.bg.elevated, borderColor: c.border.strong },
}}
>
{prompt}
</Box>
))}
</Box>
</motion.div>
<Box sx={{ px: 1.5, pt: 1.5, pb: 1, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
{/* Greeting bubble, typed out like a real reply. */}
<Box
sx={{
maxWidth: '88%',
px: 1.6, py: 1.1, mb: 1.8,
borderRadius: '4px 14px 14px 14px',
border: `1px solid ${c.border.subtle}`,
background: c.bg.surface,
color: c.text.primary,
fontSize: '0.98rem',
minHeight: '1.4rem',
}}
>
{greeting}
{!greetingDone && (
<Box component="span" sx={{ ml: '1px', color: c.accent.primary }}></Box>
)}
</AnimatePresence>
</Box>
{/* Chips reveal only once the greeting finishes, each popping in with a stagger. */}
{greetingDone && (
<Box sx={{ width: '100%', alignSelf: 'stretch' }}>
<AnimatePresence mode="wait" initial={false}>
{expanded === null ? (
<motion.div key="categories" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1 }}>
pick one, or just type below
</Typography>
</motion.div>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
{STARTER_CATEGORIES.map((cat, i) => (
<motion.button
key={cat.id}
onClick={() => setExpanded(cat.id)}
initial={{ opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 520, damping: 24, delay: 0.08 + i * 0.07 }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',
borderRadius: 13,
border: `1px solid ${c.border.medium}`,
background: c.bg.surface,
color: c.text.secondary,
fontSize: '0.9rem', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<cat.Icon size={16} />
{cat.label}
</motion.button>
))}
</Box>
</motion.div>
) : (
<motion.div
key="specifics"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18 }}
style={{ display: 'flex', flexDirection: 'column' }}
>
<Box
component="button"
onClick={() => setExpanded(null)}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3,
border: 'none', background: 'transparent',
color: c.text.ghost, fontSize: '0.85rem',
cursor: 'pointer', fontFamily: 'inherit',
'&:hover': { color: c.text.secondary },
}}
>
<ArrowLeft size={14} /> back
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.7 }}>
{currentPrompts.map((prompt, i) => (
<motion.button
key={prompt}
onClick={() => pick(prompt)}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 520, damping: 26, delay: i * 0.05 }}
style={{
textAlign: 'left',
padding: '9px 14px',
borderRadius: 11,
border: `1px solid ${c.border.medium}`,
background: c.bg.surface,
color: c.text.secondary,
fontSize: '0.88rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{prompt}
</motion.button>
))}
</Box>
</motion.div>
)}
</AnimatePresence>
</Box>
)}
</Box>
);
};
@@ -1,6 +1,6 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, expandSession, type AgentMessage } from '@/shared/state/agentsSlice';
import { createDraftSession, expandSession } from '@/shared/state/agentsSlice';
import { placeCard, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
import { markWelcomeShown } from '@/shared/state/onboardingProgressSlice';
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
@@ -8,10 +8,6 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus';
type SpawnOrigin = { x: number; y: number; type?: 'branch' };
// The proactive first-run greeting (static, no LLM). Seeded into the draft only; it never
// reaches the backend (launchAndSendFirstMessage.fulfilled swaps the draft for the server session).
const GREETING = "Hi, I'm your AI team. What do you want done?";
interface Args {
dashboardId: string;
isActive: boolean;
@@ -46,16 +42,10 @@ export function useWelcomeDraft({
if (!isActive || !canvasEmpty || !eligible) return;
createdRef.current = true;
try {
const greeting: AgentMessage = {
id: 'welcome-greeting',
role: 'assistant',
content: GREETING,
timestamp: new Date().toISOString(),
branch_id: 'main',
parent_id: null,
};
// No seeded message: the greeting + chips render (and animate) inside the welcome chat
// via WelcomeQuickReplies, so nothing here can ever reach the backend.
const action = dispatch(
createDraftSession({ welcome: true, seededMessages: [greeting], model, mode: 'agent', dashboardId, setActive: true }),
createDraftSession({ welcome: true, model, mode: 'agent', dashboardId, setActive: true }),
);
const draftId = action.payload.draftId;