mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-10-01 05:54:56 +02:00
[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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user