mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user