From b2fabec4a40142bf3d86cfea398000ebdf862568 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:06:49 -0700 Subject: [PATCH] [eric] onboarding: stream the welcome greeting + stagger-pop the chips (greeting moves into the component, no seeded message) --- .../src/app/pages/AgentChat/AgentChat.tsx | 18 +- .../pages/AgentChat/WelcomeQuickReplies.tsx | 218 ++++++++++-------- .../hooks/lifecycle/useWelcomeDraft.ts | 18 +- 3 files changed, 141 insertions(+), 113 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 4ca802b0..57f599bb 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1520,6 +1520,15 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > + {/* 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') && ( + handleSend(p)} + onPickBuilder={(p) => chatInputRef.current?.setContent(p)} + /> + )} {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( = ({ sessionId: sessionIdProp, onClose )} - {/* 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') && ( - handleSend(p)} - onPickBuilder={(p) => chatInputRef.current?.setContent(p)} - /> - )} {showScrollButton && ( diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index 2b512b40..0e3795f3 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -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(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 ( - - - {expanded === null ? ( - - - pick one, or just type below - - - {STARTER_CATEGORIES.map((cat) => ( - 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.label} - - ))} - - - ) : ( - - 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 }, - }} - > - back - - - {currentPrompts.map((prompt) => ( - 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} - - ))} - - + + {/* Greeting bubble, typed out like a real reply. */} + + {greeting} + {!greetingDone && ( + )} - + + + {/* Chips reveal only once the greeting finishes, each popping in with a stagger. */} + {greetingDone && ( + + + {expanded === null ? ( + + + + pick one, or just type below + + + + {STARTER_CATEGORIES.map((cat, i) => ( + 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.label} + + ))} + + + ) : ( + + 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 }, + }} + > + back + + + {currentPrompts.map((prompt, i) => ( + 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} + + ))} + + + )} + + + )} ); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index 41db6bc6..3908e535 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -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;