[eric] onboarding: welcome greeting is now a real streamed assistant bubble (rides the streaming slice + smooth-reveal), chips sit below it

This commit is contained in:
ciregenz
2026-06-14 03:29:51 -07:00
parent 80b40dc2b7
commit 5945dc91dc
3 changed files with 165 additions and 137 deletions
+11 -9
View File
@@ -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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const [dropTargetIdx, setDropTargetIdx] = useState<number | null>(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<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,
@@ -1829,6 +1822,15 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
/>
</Box>
)}
{/* 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') && (
<WelcomeQuickReplies
c={c}
onPick={(p) => handleSend(p)}
onPickBuilder={(p) => chatInputRef.current?.setContent(p)}
/>
)}
{(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
<Box sx={{ overflowAnchor: 'none' }}>
<ThinkingBubble
@@ -6,43 +6,15 @@ import { ArrowLeft } from 'lucide-react';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { STARTER_CATEGORIES } from '@/shared/starterCategories';
// Reads like a real chat welcome: a heading that types in, then a warm intro that leans on what
// only OpenSwarm can do (act right on your laptop), then the quick-reply chips. No em-dashes.
const HEADING = "Hi, I'm OpenSwarm, your personal AI team.";
const BODY =
"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. " +
'Where do you want to start?';
// One-shot typewriter for a fixed string (no infinite loop; stops at the end). startDelayMs
// holds the start so the header title can stream first (sequential reveal).
function useTypewriter(text: string, speedMs = 38, startDelayMs = 0): { shown: string; done: boolean } {
const [shown, setShown] = React.useState('');
React.useEffect(() => {
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<string | null>(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 (
<Box sx={{ px: 2.2, pt: 2.2, pb: 1.2, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Typography sx={{ fontSize: '1.18rem', fontWeight: 600, color: c.text.primary, mb: 1, minHeight: '1.7rem', lineHeight: 1.4 }}>
{heading}
</Typography>
{/* Body + chips slide up + fade in once the heading finishes. */}
{headingDone && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<Typography sx={{ fontSize: '0.96rem', color: c.text.secondary, lineHeight: 1.6, mb: 2.4 }}>
{BODY}
</Typography>
<AnimatePresence mode="wait" initial={false}>
{expanded === null ? (
<motion.div key="categories" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
pick one, or just type below
</Typography>
<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.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.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',
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
style={{ padding: '4px 18px 8px 18px', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<AnimatePresence mode="wait" initial={false}>
{expanded === null ? (
<motion.div key="categories" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
pick one, or just type below
</Typography>
<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.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 },
}}
>
<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.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}
</motion.button>
))}
</Box>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</Box>
<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.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}
</motion.button>
))}
</Box>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
@@ -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 };
}