From 8863d58c20277f474aec3d8d6a27313410183aa0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:53:12 -0700 Subject: [PATCH] [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 --- .../components/Onboarding/OnboardingRoot.tsx | 41 +++++++++++-------- .../app/components/Onboarding/steps/index.ts | 4 +- .../Onboarding/steps/step00_welcomeNudge.ts | 16 ++++---- .../pages/AgentChat/WelcomeQuickReplies.tsx | 8 ++-- .../hooks/lifecycle/useAgentSpawn.ts | 12 +++++- .../hooks/lifecycle/useWelcomeDraft.ts | 34 +++++++-------- .../hooks/state/useDashboardController.ts | 8 ++-- 7 files changed, 70 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 508c9293..30fd30b4 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -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(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(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; diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 1fe0f152..4dad27b7 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -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); diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index a0591ad1..a8bba8e7 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -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' }, ], }; diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index d6a917b4..75b5cb52 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -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(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', diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 7a8c3a18..7d67962a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -38,6 +38,9 @@ interface UseAgentSpawnArgs { setToolbarOpen: Dispatch>; setAutoFocusSessionId: Dispatch>; setPendingSelectSessionId: Dispatch>; + /** 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); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index 3908e535..1c783e54 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -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>; } -// 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 }; } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 79dcb457..4184dede 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -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 {