From 5945dc91dc569964348754d36b0657b3064dbcab Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:29:51 -0700 Subject: [PATCH] [eric] onboarding: welcome greeting is now a real streamed assistant bubble (rides the streaming slice + smooth-reveal), chips sit below it --- .../src/app/pages/AgentChat/AgentChat.tsx | 20 +- .../pages/AgentChat/WelcomeQuickReplies.tsx | 216 +++++++----------- .../app/pages/AgentChat/useWelcomeGreeting.ts | 66 ++++++ 3 files changed, 165 insertions(+), 137 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index ed0a7f4d..d581c616 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -46,6 +46,7 @@ import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; import StreamingBubble from './bubbles/StreamingBubble'; import WelcomeQuickReplies from './WelcomeQuickReplies'; +import { useWelcomeGreeting } from './useWelcomeGreeting'; import MessageBubble from './bubbles/MessageBubble'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; @@ -326,6 +327,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [dropTargetIdx, setDropTargetIdx] = useState(null); const isDraft = session?.status === 'draft'; + const { greetingDone: welcomeGreetingDone } = useWelcomeGreeting(session, isDraft); useEffect(() => { if (!id || isDraft) return; @@ -1527,15 +1529,6 @@ 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 chips: sit UNDER the streamed greeting, appear once it finishes, + vanish the moment the user answers. The greeting itself is a real assistant bubble. */} + {session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && ( + handleSend(p)} + onPickBuilder={(p) => chatInputRef.current?.setContent(p)} + /> + )} {(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && ( { - setShown(''); - let interval: number | undefined; - const startTimer = window.setTimeout(() => { - let i = 0; - interval = window.setInterval(() => { - i += 1; - setShown(text.slice(0, i)); - if (i >= text.length) window.clearInterval(interval); - }, speedMs); - }, startDelayMs); - return () => { window.clearTimeout(startTimer); if (interval) window.clearInterval(interval); }; - }, [text, speedMs, startDelayMs]); - return { shown, done: shown.length >= text.length }; -} - -// First-run welcome. Two-level chips: category -> concrete prompts. Research/Write/Learn -> -// onPick (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires. +// Quick-reply chips that sit UNDER the streamed greeting bubble. Two levels: category -> +// concrete prompts. Research/Write/Learn -> onPick (real run); Build -> onPickBuilder (prefill). +// The greeting itself is a real streamed assistant message (see useWelcomeGreeting); this is just +// the follow-up affordance. 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 }) => { - // Sequence: card pops, header title streams, heading types, THEN body + chips slide in. - const { shown: heading, done: headingDone } = useTypewriter(HEADING, 38, 450); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -54,105 +26,93 @@ const WelcomeQuickReplies: React.FC<{ }; return ( - - - {heading} - - - {/* Body + chips slide up + fade in once the heading finishes. */} - {headingDone && ( - - - {BODY} - - - - {expanded === null ? ( - - - pick one, or just type below - - - {STARTER_CATEGORIES.map((cat, i) => ( - setExpanded(cat.id)} - initial={{ opacity: 0, scale: 0.86, y: 6 }} - animate={{ opacity: 1, scale: 1, y: 0 }} - transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.25 + i * 0.13 }} - 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', + + + {expanded === null ? ( + + + pick one, or just type below + + + {STARTER_CATEGORIES.map((cat, i) => ( + setExpanded(cat.id)} + initial={{ opacity: 0, scale: 0.9, y: 5 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + transition={{ type: 'spring', stiffness: 380, 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', - '&:hover': { color: c.text.secondary }, }} > - back - - - {currentPrompts.map((prompt, i) => ( - pick(prompt)} - initial={{ opacity: 0, scale: 0.92 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 420, damping: 26, delay: i * 0.06 }} - 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} - - ))} - - - )} - - - )} - + + {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.92 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 420, damping: 26, delay: i * 0.06 }} + 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/AgentChat/useWelcomeGreeting.ts b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts new file mode 100644 index 00000000..48b9959b --- /dev/null +++ b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { streamStart, streamDelta } from '@/shared/state/streamingSlice'; +import { addMessage, type AgentSession } from '@/shared/state/agentsSlice'; + +// The first thing a new user reads. Written as a normal assistant turn (prose, no headings) so it +// streams in exactly like a real reply. No em-dashes. +export const WELCOME_GREETING = + "Hi, I'm OpenSwarm, your personal AI team. I can do just about anything right on your laptop, " + + "so bring me anything: a tough problem, a half-formed idea, something you need to write. " + + "We'll figure it out together.\n\nWhere do you want to start?"; + +const GREETING_MSG_ID = 'welcome-greeting'; + +// Streams the first-run greeting in as a genuine assistant bubble: it rides the same streaming +// slice + smooth-reveal every real reply uses, then settles into a real message so the chips can +// follow. Pure UI, no LLM, no run: launchAndSendFirstMessage POSTs only the prompt, so this +// seeded message is dropped on the server swap and never reaches the backend. +export function useWelcomeGreeting( + session: AgentSession | undefined, + isDraft: boolean, +): { greetingDone: boolean } { + const dispatch = useAppDispatch(); + const [greetingDone, setGreetingDone] = useState(false); + const startedRef = useRef(false); + + const eligible = isDraft && !!session?.is_welcome_draft && (session?.messages?.length ?? 0) === 0; + const sessionId = session?.id; + const branchId = session?.active_branch_id || 'main'; + + useEffect(() => { + if (!eligible || !sessionId || startedRef.current) return; + startedRef.current = true; + + dispatch(streamStart({ sessionId, messageId: GREETING_MSG_ID, role: 'assistant' })); + + // Feed word-by-word at a real-reply cadence; useSmoothText trails it for the typed look. + const tokens = WELCOME_GREETING.split(/(\s+)/); + let i = 0; + const timer = window.setInterval(() => { + const chunk = (tokens[i] ?? '') + (tokens[i + 1] ?? ''); + i += 2; + if (chunk) dispatch(streamDelta({ sessionId, messageId: GREETING_MSG_ID, delta: chunk })); + if (i >= tokens.length) { + window.clearInterval(timer); + // Settle into a real message; addMessage's listener clears the matching stream entry. + dispatch(addMessage({ + sessionId, + message: { + id: GREETING_MSG_ID, + role: 'assistant', + content: WELCOME_GREETING, + timestamp: new Date().toISOString(), + branch_id: branchId, + parent_id: null, + }, + })); + setGreetingDone(true); + } + }, 50); + + return () => window.clearInterval(timer); + }, [eligible, sessionId, branchId, dispatch]); + + return { greetingDone }; +}