mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] onboarding: stream the welcome greeting + stagger-pop the chips (greeting moves into the component, no seeded message)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user