mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 22:42:23 +02:00
[eric] onboarding: first-run proactive welcome chat (seeded greeting + in-chat chips, zero run) + cursor pop-nudge at Continue
This commit is contained in:
@@ -16,6 +16,7 @@ import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice';
|
||||
import { STEPS, findStepById } from './steps';
|
||||
import { useUnlockedStepIds } from './steps/stepUnlock';
|
||||
import { STAGE_LABELS } from './steps/types';
|
||||
import { S } from './selectors';
|
||||
import { onboardingDirector } from './OnboardingDirector';
|
||||
import { report } from './telemetry';
|
||||
import { cursorStore } from './ac/cursorStore';
|
||||
@@ -166,6 +167,7 @@ const OnboardingPanel: React.FC = () => {
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<ButtonBase
|
||||
data-onboarding={S.onboardingContinueButton}
|
||||
onClick={() => {
|
||||
report('panel_expanded', { from: 'pill' });
|
||||
progress.setPanelMode('expanded');
|
||||
|
||||
@@ -50,6 +50,23 @@ const OnboardingRoot: React.FC = () => {
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
// First-run cursor nudge: a beat after the welcome chat pops, the cursor points at the
|
||||
// top-right "Continue" pill. Armed by the once-ever 'welcome:shown' event (so it never
|
||||
// re-fires across reloads), fail-safe (a missing AC / off-dashboard route just no-ops).
|
||||
const nudgeFiredRef = useRef(false);
|
||||
const nudgeTimerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const off = onboardingBus.on('welcome:shown', () => {
|
||||
if (nudgeFiredRef.current || !window.location.hash.includes('/dashboard/')) return;
|
||||
nudgeFiredRef.current = true;
|
||||
nudgeTimerRef.current = window.setTimeout(() => {
|
||||
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
|
||||
onboardingDirector.startStep('welcome_nudge', { x: window.innerWidth - 120, y: 80 });
|
||||
}, 1000);
|
||||
});
|
||||
return () => { off(); if (nudgeTimerRef.current) window.clearTimeout(nudgeTimerRef.current); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress.initialized) return;
|
||||
if (!settingsLoaded) return;
|
||||
|
||||
@@ -95,12 +95,16 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
|
||||
async fadeIn(from) {
|
||||
stopTrackingInternal();
|
||||
writePos(from.x, from.y, true);
|
||||
controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.5 });
|
||||
controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.3 });
|
||||
setVisible(true);
|
||||
// A little "pop!": scale overshoots past 1 then settles, while opacity snaps in.
|
||||
await controls.start({
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.32, ease: 'easeOut' },
|
||||
scale: [0.3, 1.15, 1],
|
||||
transition: {
|
||||
opacity: { duration: 0.16, ease: 'easeOut' },
|
||||
scale: { duration: 0.36, ease: 'easeOut', times: [0, 0.62, 1] },
|
||||
},
|
||||
});
|
||||
},
|
||||
async moveTo(x, y, transition) {
|
||||
|
||||
@@ -14,7 +14,8 @@ export type OnboardingEvent =
|
||||
| 'element_selection:toggled'
|
||||
| 'agent:spawned'
|
||||
| 'agent:completed'
|
||||
| 'agent:attached_to_browser';
|
||||
| 'agent:attached_to_browser'
|
||||
| 'welcome:shown';
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export const S = {
|
||||
newAgentButton: 'new-agent-button',
|
||||
browserButton: 'browser-button',
|
||||
canvasControls: 'canvas-controls',
|
||||
/** The top-right onboarding pill ("Continue"); the first-run welcome nudge points here. */
|
||||
onboardingContinueButton: 'onboarding-continue-button',
|
||||
|
||||
dashboardToolbarApps: 'dashboard-toolbar-apps',
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { step05 } from './step05_agentUseBrowser';
|
||||
import { step06 } from './step06_agentControlAgents';
|
||||
import { step07 } from './step07_installSkill';
|
||||
import { step08 } from './step08_makeApp';
|
||||
import { welcomeNudgeStep } from './step00_welcomeNudge';
|
||||
|
||||
// Value-first order: launch an agent (step03) FIRST so a brand-new user sees
|
||||
// the product work on the free trial, then connect-your-own-model (step01).
|
||||
@@ -22,8 +23,12 @@ export const STEPS: OnboardingStep[] = [
|
||||
step08,
|
||||
];
|
||||
|
||||
// Resolvable by the Director but kept OUT of STEPS, so they never appear in the roadmap,
|
||||
// the panel count, or the unlock chain. The first-run welcome nudge lives here.
|
||||
const HIDDEN_STEPS: OnboardingStep[] = [welcomeNudgeStep];
|
||||
|
||||
export function findStepById(id: string): OnboardingStep | undefined {
|
||||
return STEPS.find((s) => s.id === id);
|
||||
return STEPS.find((s) => s.id === id) ?? HIDDEN_STEPS.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
export const STAGE_GROUPS: { stage: StepStage; steps: OnboardingStep[] }[] = [
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OnboardingStep } from './types';
|
||||
import { S } from '../selectors';
|
||||
|
||||
// Invisible first-run nudge (not part of the numbered tour): a beat after the welcome chat
|
||||
// pops, the cursor points at the top-right "Continue" pill so the user knows the guided tour
|
||||
// is there, then retreats. No click, no wait_user, so it's a one-shot gentle gesture.
|
||||
export const welcomeNudgeStep: OnboardingStep = {
|
||||
id: 'welcome_nudge',
|
||||
stage: 'get_started',
|
||||
index: 0,
|
||||
title: 'Welcome',
|
||||
description: '',
|
||||
ops: [
|
||||
{ kind: 'move_to', target: S.onboardingContinueButton },
|
||||
{ kind: 'popup', text: 'Want a quick tour? Tap Continue any time.' },
|
||||
{ kind: 'outro' },
|
||||
],
|
||||
};
|
||||
@@ -45,6 +45,7 @@ import { store } from '@/shared/state/store';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager';
|
||||
import StreamingBubble from './bubbles/StreamingBubble';
|
||||
import WelcomeQuickReplies from './WelcomeQuickReplies';
|
||||
import MessageBubble from './bubbles/MessageBubble';
|
||||
import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure';
|
||||
import CompactionMarker from './bubbles/CompactionMarker';
|
||||
@@ -1848,6 +1849,15 @@ 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 && (
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
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 WelcomeQuickReplies: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onPick: (prompt: string) => void;
|
||||
onPickBuilder: (prompt: string) => void;
|
||||
}> = ({ c, onPick, onPickBuilder }) => {
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
|
||||
const isAppBuilder = currentCategory?.target === 'app-builder';
|
||||
const currentPrompts = currentCategory?.prompts ?? [];
|
||||
|
||||
const pick = (prompt: string) => {
|
||||
if (isAppBuilder) onPickBuilder(prompt);
|
||||
else onPick(prompt);
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeQuickReplies;
|
||||
@@ -2,118 +2,42 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Search, Hammer, PenLine, GraduationCap, ArrowLeft } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
hasModelConnected,
|
||||
hasFreeTrialActive,
|
||||
} from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { STARTER_CATEGORIES } from '@/shared/starterCategories';
|
||||
|
||||
// The static self-intro the welcome types out (no LLM); the user answers in the chat.
|
||||
const INTRO = "Hi, I'm your AI team. What do you want done?";
|
||||
|
||||
// One-shot typewriter for a fixed string. enabled=false shows it instantly (returning
|
||||
// users); enabled=true reveals it char-by-char once. No infinite loop: it stops at the end.
|
||||
function useTypewriter(text: string, enabled: boolean, speedMs = 34): { shown: string; done: boolean } {
|
||||
const [shown, setShown] = React.useState(enabled ? '' : text);
|
||||
React.useEffect(() => {
|
||||
if (!enabled) { setShown(text); return; }
|
||||
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, enabled, speedMs]);
|
||||
return { shown, done: shown.length >= text.length };
|
||||
}
|
||||
|
||||
// Two-level starters: pick a category, then its concrete prompts spawn. Every
|
||||
// prompt is one-click-runnable (no [placeholders]) and free-trial-safe, it
|
||||
// touches the web or the App Builder sandbox, never the user's files.
|
||||
// target 'app-builder' opens the App Builder (live preview) with the prompt
|
||||
// auto-sent; the rest run as a normal agent on the dashboard.
|
||||
type StarterCategory = { id: string; label: string; Icon: LucideIcon; prompts: string[]; target?: 'app-builder' };
|
||||
const STARTER_CATEGORIES: StarterCategory[] = [
|
||||
{
|
||||
id: 'research', label: 'Research', Icon: Search,
|
||||
prompts: [
|
||||
'Find today\'s top news and summarize it for me',
|
||||
'Compare the 3 best standing desks and recommend one',
|
||||
'Plan a weekend trip to Tokyo with a day-by-day itinerary',
|
||||
'Find the strangest world record I could actually break',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'build', label: 'Build', Icon: Hammer, target: 'app-builder',
|
||||
prompts: [
|
||||
'Build a focus timer that dings when the break starts',
|
||||
'Make a tip calculator that splits the bill',
|
||||
'Create a Snake game I can play right now',
|
||||
'Build a tiny Minecraft-style block world I can walk around in',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'write', label: 'Write', Icon: PenLine,
|
||||
prompts: [
|
||||
'Write a friendly email introducing myself to a new client',
|
||||
'Turn my rough notes into a polished update',
|
||||
'Write a product description for a coffee mug',
|
||||
'Write my morning routine as an epic fantasy quest',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'learn', label: 'Learn', Icon: GraduationCap,
|
||||
prompts: [
|
||||
'Explain how AI chatbots actually work, in plain English',
|
||||
'Teach me the basics of investing in 5 minutes',
|
||||
'Explain the stock market like I\'m five',
|
||||
'What would happen if the moon disappeared tomorrow?',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Returning-user empty state (the first-run greeting now lives in the auto-popped welcome
|
||||
// chat). Quiet: a one-line prompt + the shared starter chips for users who can run, or a
|
||||
// connect-a-model hint for users who can't. Two-level: category -> concrete prompts.
|
||||
const DashboardEmptyState: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onLaunch?: (prompt: string, mode: string, model: string) => void;
|
||||
// Open the composer with the prompt typed in (translucent, unsent) on click.
|
||||
// Optional mode opens it in a specific mode (Build -> 'view-builder').
|
||||
onStarter?: (prompt: string, mode?: string) => void;
|
||||
}> = ({ c, onLaunch, onStarter }) => {
|
||||
const model = useAppSelector((s) => s.settings.data.default_model);
|
||||
const mode = useAppSelector((s) => s.settings.data.default_mode);
|
||||
const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s));
|
||||
// Type the intro only for a fresh user (first agent not launched yet); returning
|
||||
// users see it instantly so it isn't re-typed on every empty dashboard.
|
||||
const introTyping = useAppSelector(
|
||||
(s) => !(s.onboardingProgress?.completedSteps ?? []).includes('launch_agent'),
|
||||
);
|
||||
const { shown: introShown, done: introDone } = useTypewriter(INTRO, introTyping);
|
||||
const [launching, setLaunching] = React.useState(false);
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
|
||||
const currentPrompts = currentCategory?.prompts ?? [];
|
||||
|
||||
// Only offer chips once a run can actually succeed (free trial armed or a real
|
||||
// model connected); otherwise fall back to the plain hint.
|
||||
const showChips = !!onLaunch && canRun;
|
||||
|
||||
const isAppBuilder = currentCategory?.target === 'app-builder';
|
||||
|
||||
const launch = (prompt: string) => {
|
||||
if (launching) return;
|
||||
// App Builder opens its own surface, so prefill its composer (the user reviews,
|
||||
// then builds). Everything else fires immediately: a pick is one tap to a running agent.
|
||||
if (isAppBuilder) {
|
||||
if (onStarter) onStarter(prompt, 'view-builder');
|
||||
return;
|
||||
}
|
||||
if (onLaunch) {
|
||||
setLaunching(true); // empty state unmounts on first session, but guard a fast double-click
|
||||
setLaunching(true);
|
||||
onLaunch(prompt, mode, model);
|
||||
return;
|
||||
}
|
||||
@@ -132,31 +56,11 @@ const DashboardEmptyState: React.FC<{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`@keyframes welcome-caret { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }`}</style>
|
||||
{/* The welcome pops in (scale + fade), then the intro types itself out. */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 460, damping: 26 }}
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary, fontSize: '1.45rem', fontWeight: 500, mb: 2.4,
|
||||
minHeight: '1.9rem', textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{introShown}
|
||||
{!introDone && (
|
||||
<Box component="span" sx={{ ml: '1px', color: c.accent.primary, animation: 'welcome-caret 1s step-end infinite' }}>
|
||||
▌
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</motion.div>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '1.3rem', fontWeight: 500, mb: 2.2, textAlign: 'center' }}>
|
||||
What do you want done?
|
||||
</Typography>
|
||||
|
||||
{showChips && introDone ? (
|
||||
// Chips reveal only after the intro finishes typing, so it reads as one beat.
|
||||
{showChips ? (
|
||||
<Box sx={{ width: '100%', maxWidth: 560, pointerEvents: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{expanded === null ? (
|
||||
@@ -165,7 +69,7 @@ const DashboardEmptyState: React.FC<{
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.9rem', mb: 1.4 }}>
|
||||
@@ -250,11 +154,9 @@ const DashboardEmptyState: React.FC<{
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
) : (
|
||||
introDone && (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.95rem' }}>
|
||||
Connect a model in Settings to get started.
|
||||
</Typography>
|
||||
)
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.95rem' }}>
|
||||
Connect a model in Settings to get started.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { createDraftSession, expandSession, type AgentMessage } 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';
|
||||
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;
|
||||
/** layoutInitialized && no sessions/views/browsers on the canvas. */
|
||||
canvasEmpty: boolean;
|
||||
expandedSessionIds: string[];
|
||||
viewportRef: RefObject<HTMLDivElement | null>;
|
||||
canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>;
|
||||
spawnOriginsRef: RefObject<Record<string, SpawnOrigin>>;
|
||||
}
|
||||
|
||||
// Once ever, for a genuinely fresh user with a way to run, auto-open a welcome chat card on the
|
||||
// empty dashboard: a seeded greeting + quick-reply chips, ZERO run consumed until they answer.
|
||||
// Fail-safe: any throw bails and the dashboard/chat keep working by hand.
|
||||
export function useWelcomeDraft({
|
||||
dashboardId, isActive, canvasEmpty, expandedSessionIds,
|
||||
viewportRef, canvasStateRef, spawnOriginsRef,
|
||||
}: Args): void {
|
||||
const dispatch = useAppDispatch();
|
||||
const createdRef = useRef(false);
|
||||
const eligible = useAppSelector(
|
||||
(s) =>
|
||||
s.settings.loaded &&
|
||||
(hasFreeTrialActive(s) || hasModelConnected(s)) &&
|
||||
!s.onboardingProgress.welcomeShown &&
|
||||
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent'),
|
||||
);
|
||||
const model = useAppSelector((s) => s.settings.data.default_model);
|
||||
|
||||
useEffect(() => {
|
||||
if (createdRef.current) return;
|
||||
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,
|
||||
};
|
||||
const action = dispatch(
|
||||
createDraftSession({ welcome: true, seededMessages: [greeting], model, mode: 'agent', dashboardId, setActive: true }),
|
||||
);
|
||||
const draftId = action.payload.draftId;
|
||||
|
||||
// Center the card in the current viewport (canvas coords) so it pops in front of the user.
|
||||
const vp = viewportRef.current;
|
||||
const cs = canvasStateRef.current;
|
||||
if (vp && cs) {
|
||||
const vr = vp.getBoundingClientRect();
|
||||
const cx = (vr.width / 2 - cs.panX) / cs.zoom;
|
||||
const cy = (vr.height / 2 - cs.panY) / cs.zoom;
|
||||
if (spawnOriginsRef.current) spawnOriginsRef.current[draftId] = { x: cx, y: cy };
|
||||
dispatch(placeCard({
|
||||
sessionId: draftId,
|
||||
x: cx - DEFAULT_CARD_W / 2,
|
||||
y: cy - EXPANDED_CARD_MIN_H / 2,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: EXPANDED_CARD_MIN_H,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(expandSession(draftId));
|
||||
dispatch(markWelcomeShown());
|
||||
onboardingBus.emit('welcome:shown');
|
||||
} catch (err) {
|
||||
console.error('[welcome-draft] create failed', err);
|
||||
}
|
||||
}, [isActive, canvasEmpty, eligible, model, dashboardId, expandedSessionIds, dispatch, viewportRef, canvasStateRef, spawnOriginsRef]);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useDashboardClipboard } from '../interaction/useDashboardClipboard';
|
||||
import { useCardDrag } from '../interaction/useCardDrag';
|
||||
import { useSubAgentLifecycle } from '../lifecycle/useSubAgentLifecycle';
|
||||
import { useDashboardLifecycle } from '../lifecycle/useDashboardLifecycle';
|
||||
import { useWelcomeDraft } from '../lifecycle/useWelcomeDraft';
|
||||
import { useDashboardThumbnail } from './useDashboardThumbnail';
|
||||
import { useSiblingRestack } from '../lifecycle/useSiblingRestack';
|
||||
import { useAgentSpawn } from '../lifecycle/useAgentSpawn';
|
||||
@@ -129,6 +130,17 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
restoredExpandedRef,
|
||||
});
|
||||
|
||||
// First-run: auto-pop a welcome chat (seeded greeting + chips) on the empty dashboard.
|
||||
useWelcomeDraft({
|
||||
dashboardId,
|
||||
isActive,
|
||||
canvasEmpty,
|
||||
expandedSessionIds,
|
||||
viewportRef: canvas.viewportRef,
|
||||
canvasStateRef,
|
||||
spawnOriginsRef,
|
||||
});
|
||||
|
||||
// ---- Auto-reveal / collapse / unreveal sub-agent cards ----
|
||||
useSubAgentLifecycle({
|
||||
isActive,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Search, Hammer, PenLine, GraduationCap } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// Two-level starters shared by the empty-state and the first-run welcome chat: pick a
|
||||
// category, then its concrete prompts. Every prompt is one-click-runnable (no [placeholders])
|
||||
// and free-trial-safe, it touches the web or the App Builder sandbox, never the user's files.
|
||||
// target 'app-builder' opens the App Builder (live preview); the rest run as a normal agent.
|
||||
export type StarterCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
prompts: string[];
|
||||
target?: 'app-builder';
|
||||
};
|
||||
|
||||
export const STARTER_CATEGORIES: StarterCategory[] = [
|
||||
{
|
||||
id: 'research', label: 'Research', Icon: Search,
|
||||
prompts: [
|
||||
'Find today\'s top news and summarize it for me',
|
||||
'Compare the 3 best standing desks and recommend one',
|
||||
'Plan a weekend trip to Tokyo with a day-by-day itinerary',
|
||||
'Find the strangest world record I could actually break',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'build', label: 'Build', Icon: Hammer, target: 'app-builder',
|
||||
prompts: [
|
||||
'Build a focus timer that dings when the break starts',
|
||||
'Make a tip calculator that splits the bill',
|
||||
'Create a Snake game I can play right now',
|
||||
'Build a tiny Minecraft-style block world I can walk around in',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'write', label: 'Write', Icon: PenLine,
|
||||
prompts: [
|
||||
'Write a friendly email introducing myself to a new client',
|
||||
'Turn my rough notes into a polished update',
|
||||
'Write a product description for a coffee mug',
|
||||
'Write my morning routine as an epic fantasy quest',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'learn', label: 'Learn', Icon: GraduationCap,
|
||||
prompts: [
|
||||
'Explain how AI chatbots actually work, in plain English',
|
||||
'Teach me the basics of investing in 5 minutes',
|
||||
'Explain the stock market like I\'m five',
|
||||
'What would happen if the moon disappeared tomorrow?',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -101,6 +101,9 @@ export interface AgentSession {
|
||||
connection_state?: 'live' | 'reconnecting';
|
||||
/** Aux-LLM verb-phrase for the current turn; ThinkingBubble swaps in then back when turn ends. */
|
||||
turn_label?: { label: string; turn_id: string } | null;
|
||||
/** Frontend-only: this draft is the first-run welcome (seeded greeting + quick-reply chips).
|
||||
* Dropped on the server swap in launchAndSendFirstMessage.fulfilled, so it never persists. */
|
||||
is_welcome_draft?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
@@ -508,8 +511,8 @@ const agentsSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
createDraftSession: {
|
||||
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) {
|
||||
const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel } = action.payload;
|
||||
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }>) {
|
||||
const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel, seededMessages, welcome, dashboardId } = action.payload;
|
||||
state.sessions[draftId] = {
|
||||
id: draftId,
|
||||
name: 'New chat',
|
||||
@@ -526,13 +529,17 @@ const agentsSlice = createSlice({
|
||||
created_at: new Date().toISOString(),
|
||||
cost_usd: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
messages: [],
|
||||
// A seeded greeting is purely cosmetic: launchAndSendFirstMessage.fulfilled deletes this
|
||||
// draft and swaps in the raw server session, so seeded messages never reach the backend.
|
||||
messages: seededMessages ?? [],
|
||||
pending_approvals: [],
|
||||
branches: { main: { id: 'main', parent_branch_id: null, fork_point_message_id: null, created_at: new Date().toISOString() } },
|
||||
active_branch_id: 'main',
|
||||
target_directory: targetDirectory || null,
|
||||
tool_group_meta: {},
|
||||
thinking_level: thinkingLevel,
|
||||
dashboard_id: dashboardId,
|
||||
is_welcome_draft: welcome === true,
|
||||
};
|
||||
if (setActive) {
|
||||
state.activeSessionId = draftId;
|
||||
@@ -541,7 +548,7 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }) {
|
||||
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }) {
|
||||
return {
|
||||
payload: {
|
||||
draftId: `draft-${Date.now().toString(36)}`,
|
||||
@@ -551,6 +558,9 @@ const agentsSlice = createSlice({
|
||||
model: opts?.model,
|
||||
provider: opts?.provider,
|
||||
thinkingLevel: opts?.thinkingLevel,
|
||||
seededMessages: opts?.seededMessages,
|
||||
welcome: opts?.welcome,
|
||||
dashboardId: opts?.dashboardId,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface OnboardingProgressState {
|
||||
disableSkipIf: boolean;
|
||||
/** True once we've gently auto-opened the panel after the first agent win (once, ever). */
|
||||
revealedAfterWin?: boolean;
|
||||
/** True once the first-run welcome chat has been created (once ever, survives reload). */
|
||||
welcomeShown?: boolean;
|
||||
}
|
||||
|
||||
export function loadFromStorage(): OnboardingProgressState | null {
|
||||
@@ -53,6 +55,8 @@ export function loadFromStorage(): OnboardingProgressState | null {
|
||||
initialized: true,
|
||||
justCompletedStepId: null,
|
||||
disableSkipIf: Boolean((parsed as any).disableSkipIf),
|
||||
revealedAfterWin: Boolean((parsed as any).revealedAfterWin),
|
||||
welcomeShown: Boolean((parsed as any).welcomeShown),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -101,6 +105,7 @@ const initialState: OnboardingProgressState = {
|
||||
justCompletedStepId: null,
|
||||
disableSkipIf: false,
|
||||
revealedAfterWin: false,
|
||||
welcomeShown: false,
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
@@ -129,6 +134,7 @@ const slice = createSlice({
|
||||
state.initialized = true;
|
||||
state.disableSkipIf = Boolean(action.payload.disableSkipIf);
|
||||
state.revealedAfterWin = false;
|
||||
state.welcomeShown = false;
|
||||
},
|
||||
hydrate(state, action: PayloadAction<OnboardingProgressState>) {
|
||||
Object.assign(state, action.payload, { running: false, initialized: true });
|
||||
@@ -162,6 +168,9 @@ const slice = createSlice({
|
||||
markRevealedAfterWin(state) {
|
||||
state.revealedAfterWin = true;
|
||||
},
|
||||
markWelcomeShown(state) {
|
||||
state.welcomeShown = true;
|
||||
},
|
||||
unmarkStepCompleted(state, action: PayloadAction<string>) {
|
||||
state.completedSteps = state.completedSteps.filter((id) => id !== action.payload);
|
||||
},
|
||||
@@ -186,6 +195,7 @@ const slice = createSlice({
|
||||
state.running = false;
|
||||
state.startedAt = Date.now();
|
||||
state.revealedAfterWin = false;
|
||||
state.welcomeShown = false;
|
||||
// Explicit restart: suppress skipIf so residual prior-tour data can't auto-mark.
|
||||
state.disableSkipIf = true;
|
||||
},
|
||||
@@ -200,6 +210,7 @@ export const {
|
||||
markStepCompleted,
|
||||
clearJustCompleted,
|
||||
markRevealedAfterWin,
|
||||
markWelcomeShown,
|
||||
unmarkStepCompleted,
|
||||
setRunning,
|
||||
recordMultiChoice,
|
||||
|
||||
Reference in New Issue
Block a user