[eric] onboarding: cursor pops in then clicks New Agent to spawn the welcome chat (not auto), with delays; much slower title/greeting stream; manual click is the fail-safe

This commit is contained in:
ciregenz
2026-06-14 01:53:12 -07:00
parent ba850e5fdc
commit 8863d58c20
7 changed files with 70 additions and 53 deletions
@@ -18,7 +18,7 @@ import {
import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor';
import { onboardingDirector } from './OnboardingDirector';
import { STEPS } from './steps';
import { hasAnyAgentCompleted } from './steps/skipPredicates';
import { hasAnyAgentCompleted, hasFreeTrialActive, hasModelConnected } from './steps/skipPredicates';
import OnboardingPanel from './OnboardingPanel';
import { onboardingBus } from './eventBus';
import { report } from './telemetry';
@@ -50,22 +50,31 @@ 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);
// First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent
// button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard
// with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual
// New Agent click spawns the same welcome chat (handleNewAgent is welcome-aware).
const welcomeOpenReady = useAppSelector(
(s) =>
s.settings.loaded &&
(hasFreeTrialActive(s) || hasModelConnected(s)) &&
!s.onboardingProgress.welcomeShown &&
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') &&
Object.keys(s.agents?.sessions ?? {}).length === 0,
);
const welcomeFiredRef = useRef(false);
const welcomeTimerRef = 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); };
}, []);
if (welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return;
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
welcomeFiredRef.current = true;
welcomeTimerRef.current = window.setTimeout(() => {
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
// Pop near center, then the step walks the cursor down to the New Agent button and clicks.
onboardingDirector.startStep('welcome_open', { x: window.innerWidth / 2, y: window.innerHeight / 2 });
}, 600);
return () => { if (welcomeTimerRef.current) window.clearTimeout(welcomeTimerRef.current); };
}, [progress.initialized, welcomeOpenReady]);
useEffect(() => {
if (progress.initialized) return;
@@ -7,7 +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';
import { welcomeOpenStep } 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).
@@ -25,7 +25,7 @@ export const STEPS: OnboardingStep[] = [
// 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];
const HIDDEN_STEPS: OnboardingStep[] = [welcomeOpenStep];
export function findStepById(id: string): OnboardingStep | undefined {
return STEPS.find((s) => s.id === id) ?? HIDDEN_STEPS.find((s) => s.id === id);
@@ -1,18 +1,20 @@
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',
// First-run, invisible to the roadmap: the cursor pops into existence (handled by fadeIn, with
// the orange spark), pauses a beat, then moves to and clicks the New Agent button, which spawns
// the welcome chat. Static, no LLM. The delays give the pop and the move room to breathe.
export const welcomeOpenStep: OnboardingStep = {
id: 'welcome_open',
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: 'delay', ms: 750 }, // let the pop + spark settle
{ kind: 'move_to', target: S.newAgentButton },
{ kind: 'delay', ms: 550 }, // pause, then click
{ kind: 'click', target: S.newAgentButton, simulate: true }, // spawns the welcome chat
{ kind: 'outro' },
],
};
@@ -36,9 +36,9 @@ const WelcomeQuickReplies: React.FC<{
onPick: (prompt: string) => void;
onPickBuilder: (prompt: string) => void;
}> = ({ c, onPick, onPickBuilder }) => {
// Slow + delayed so it reads as a sequence: card pops, the header title streams, THEN
// the greeting types, THEN the chips pop in.
const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 46, 650);
// Slow + delayed so it reads as a calm sequence: card pops, the header title streams, THEN
// the greeting types out unhurried, THEN the chips pop in.
const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 78, 850);
const [expanded, setExpanded] = React.useState<string | null>(null);
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
const isAppBuilder = currentCategory?.target === 'app-builder';
@@ -88,7 +88,7 @@ const WelcomeQuickReplies: React.FC<{
onClick={() => setExpanded(cat.id)}
initial={{ opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.18 + i * 0.14 }}
transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.25 + i * 0.18 }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',
@@ -38,6 +38,9 @@ interface UseAgentSpawnArgs {
setToolbarOpen: Dispatch<SetStateAction<boolean>>;
setAutoFocusSessionId: Dispatch<SetStateAction<string | null>>;
setPendingSelectSessionId: Dispatch<SetStateAction<string | null>>;
/** First run only: clicking New Agent spawns the welcome chat instead of the composer. */
welcomeEligible?: boolean;
onWelcomeNewAgent?: () => void;
}
export function useAgentSpawn({
@@ -54,6 +57,8 @@ export function useAgentSpawn({
setToolbarOpen,
setAutoFocusSessionId,
setPendingSelectSessionId,
welcomeEligible,
onWelcomeNewAgent,
}: UseAgentSpawnArgs) {
const dispatch = useAppDispatch();
@@ -100,8 +105,13 @@ export function useAgentSpawn({
);
const handleNewAgent = useCallback(() => {
// First run: spawn the welcome chat (cursor-clicked or hand-clicked) instead of the composer.
if (welcomeEligible && onWelcomeNewAgent) {
onWelcomeNewAgent();
return;
}
setToolbarOpen(true);
}, []);
}, [welcomeEligible, onWelcomeNewAgent, setToolbarOpen]);
const handleToolbarCancel = useCallback(() => {
setToolbarOpen(false);
@@ -1,16 +1,14 @@
import { useEffect, useRef, type RefObject } from 'react';
import { useCallback, type RefObject } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
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';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
type SpawnOrigin = { x: number; y: number; type?: 'branch' };
interface Args {
dashboardId: string;
isActive: boolean;
/** layoutInitialized && no sessions/views/browsers on the canvas. */
canvasEmpty: boolean;
expandedSessionIds: string[];
@@ -19,16 +17,14 @@ interface Args {
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.
// First-run welcome chat. NOT auto-created: the onboarding cursor clicks the New Agent button,
// which calls handleNewAgent -> createWelcomeDraft, so the chat is clicked into existence. The
// user clicking New Agent by hand spawns the same thing (fail-safe). Returns the gate + creator.
export function useWelcomeDraft({
dashboardId, isActive, canvasEmpty, expandedSessionIds,
viewportRef, canvasStateRef, spawnOriginsRef,
}: Args): void {
dashboardId, canvasEmpty, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef,
}: Args): { welcomeEligible: boolean; createWelcomeDraft: () => void } {
const dispatch = useAppDispatch();
const createdRef = useRef(false);
const eligible = useAppSelector(
const reduxEligible = useAppSelector(
(s) =>
s.settings.loaded &&
(hasFreeTrialActive(s) || hasModelConnected(s)) &&
@@ -36,20 +32,17 @@ export function useWelcomeDraft({
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent'),
);
const model = useAppSelector((s) => s.settings.data.default_model);
const welcomeEligible = reduxEligible && canvasEmpty;
useEffect(() => {
if (createdRef.current) return;
if (!isActive || !canvasEmpty || !eligible) return;
createdRef.current = true;
const createWelcomeDraft = useCallback(() => {
try {
// No seeded message: the greeting + chips render (and animate) inside the welcome chat
// via WelcomeQuickReplies, so nothing here can ever reach the backend.
// No seeded message: the greeting + chips render (and animate) inside the welcome chat,
// so nothing here can ever reach the backend.
const action = dispatch(
createDraftSession({ welcome: true, 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) {
@@ -68,9 +61,10 @@ export function useWelcomeDraft({
}
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]);
}, [dispatch, model, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef]);
return { welcomeEligible, createWelcomeDraft };
}
@@ -130,10 +130,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
restoredExpandedRef,
});
// First-run: auto-pop a welcome chat (seeded greeting + chips) on the empty dashboard.
useWelcomeDraft({
// First-run: the onboarding cursor clicks New Agent -> handleNewAgent -> createWelcomeDraft,
// spawning the welcome chat. A manual New Agent click does the same when eligible.
const { welcomeEligible, createWelcomeDraft } = useWelcomeDraft({
dashboardId,
isActive,
canvasEmpty,
expandedSessionIds,
viewportRef: canvas.viewportRef,
@@ -233,6 +233,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
setToolbarOpen,
setAutoFocusSessionId,
setPendingSelectSessionId,
welcomeEligible,
onWelcomeNewAgent: createWelcomeDraft,
});
const {