[eric] onboarding: add unmissable 'here's what I did' reveal hero (centered, live working/done status); drop the missable canvas note it replaces

This commit is contained in:
ciregenz
2026-07-19 18:31:10 -07:00
parent 547ca0d13b
commit 37560f8ae3
3 changed files with 162 additions and 32 deletions
@@ -0,0 +1,151 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, FolderCheck, LayoutDashboard, Search, CalendarClock, Check, X } from 'lucide-react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import type { PreppedJob } from '@/shared/state/onboardingV3Slice';
// The unmissable anchor of the reveal: a top-center panel that says, in plain language, exactly what
// OpenSwarm did while the user set up, with LIVE status (working -> done) on each item. The scattered
// cards are the real clickable work; this is the legend that makes the whole thing instantly readable.
type JobStatus = 'working' | 'done' | 'snag';
function iconFor(kind: PreppedJob['kind']): React.ReactNode {
const size = 17;
if (kind === 'app') return <LayoutDashboard size={size} />;
if (kind === 'research') return <Search size={size} />;
if (kind === 'schedule') return <CalendarClock size={size} />;
return <FolderCheck size={size} />;
}
// Plain-language line, present-continuous while working, past once done. Super easy to grok at a glance.
function lineFor(job: PreppedJob, status: JobStatus): string {
const t = job.title;
if (job.kind === 'app') return status === 'done' ? `Built you a dashboard of your world` : `Building you a dashboard of your world`;
if (job.kind === 'research') return status === 'done' ? `Looked into ${t} for you` : `Looking into ${t} for you`;
if (job.kind === 'schedule') return `Set up ${t} to run on its own`;
return status === 'done' ? `Tidied up your files (nothing moved or deleted)` : `Tidying up your files (nothing moved or deleted)`;
}
const RevealHero: React.FC = () => {
const c = useClaudeTokens();
const [dismissed, setDismissed] = useState(false);
const prepped = useAppSelector((s) => s.onboardingV3.prepped);
const flowActive = useAppSelector((s) => s.onboardingV3.flowActive);
const revealPending = useAppSelector((s) => s.onboardingV3.revealPending);
const sessions = useAppSelector((s) => s.agents.sessions);
const userName = useAppSelector((s) => s.settings.data.user_name);
// Dashboard-first order (the star), then research, cleanup, and the recurring task.
const order: PreppedJob['kind'][] = ['app', 'research', 'audit', 'schedule'];
const jobs = useMemo(
() => [...prepped].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind)),
[prepped],
);
const statusOf = (job: PreppedJob): JobStatus => {
if (job.kind === 'schedule') return 'done'; // a workflow: created instantly
const s = job.sessionId ? sessions[job.sessionId] : undefined;
if (!s) return 'working';
if (s.status === 'completed' || s.status === 'stopped') return 'done';
if (s.status === 'error') return 'snag';
return 'working';
};
const open = !dismissed && !flowActive && !revealPending && jobs.length > 0;
const doneCount = jobs.filter((j) => statusOf(j) === 'done').length;
// Portal to body: the dashboard canvas is a transformed ancestor, so a position:fixed child would
// anchor to IT (drifting off-center), not the window. The OUTER div owns the fixed top-center
// placement (translateX(-50%)); the inner motion.div owns the entrance (framer drives its transform,
// which would clobber the centering translate if they shared one element).
return createPortal(
<div style={{ position: 'fixed', top: 58, left: '50%', transform: 'translateX(-50%)', zIndex: 1300, width: 'min(460px, calc(100vw - 48px))', pointerEvents: 'none' }}>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, transition: { duration: 0.25 } }}
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
style={{
width: '100%', pointerEvents: 'auto',
borderRadius: 18, overflow: 'hidden',
background: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: '0 20px 60px rgba(20,16,60,0.20)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '16px 18px 12px' }}>
<div style={{
width: 34, height: 34, borderRadius: 10, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `linear-gradient(135deg, ${c.accent.primary}, ${c.accent.pressed})`, color: '#fff',
}}>
<Sparkles size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: c.text.primary, letterSpacing: '-0.01em' }}>
{userName ? `${userName}, while you set up I got to work` : `While you set up, I got to work`}
</div>
<div style={{ fontSize: '0.8rem', color: c.text.tertiary, marginTop: 2 }}>
{doneCount === jobs.length ? `All done, here on your canvas` : `${doneCount} of ${jobs.length} done, the rest are running`}
</div>
</div>
<button
onClick={() => setDismissed(true)}
aria-label="Dismiss"
style={{ border: 'none', background: 'transparent', color: c.text.ghost, cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0 }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', padding: '0 10px 12px' }}>
{jobs.map((job) => {
const st = statusOf(job);
return (
<div key={job.workflowId || job.sessionId || job.kind}
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '9px 10px', borderRadius: 11 }}>
<div style={{
width: 30, height: 30, borderRadius: 9, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: st === 'done' ? `${c.status.success}18` : `${c.accent.primary}14`,
color: st === 'done' ? c.status.success : c.accent.primary,
}}>
{iconFor(job.kind)}
</div>
<div style={{ flex: 1, minWidth: 0, fontSize: '0.9rem', fontWeight: 500, color: c.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{lineFor(job, st)}
</div>
{st === 'done' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.74rem', fontWeight: 600, color: c.status.success, flexShrink: 0 }}>
<Check size={13} /> done
</span>
) : st === 'snag' ? (
<span style={{ fontSize: '0.74rem', fontWeight: 600, color: c.status.warning, flexShrink: 0 }}>needs a look</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', fontWeight: 600, color: c.text.tertiary, flexShrink: 0 }}>
<span style={{
width: 7, height: 7, borderRadius: 999, background: c.accent.primary,
animation: 'revealHeroPulse 1.3s ease-in-out infinite',
}} />
working
</span>
)}
</div>
);
})}
</div>
<div style={{ padding: '9px 18px 13px', borderTop: `1px solid ${c.border.subtle}`, fontSize: '0.76rem', color: c.text.ghost }}>
It's all on your canvas below. Nothing's saved or deleted without you, keep it or clear it anytime.
</div>
<style>{`@keyframes revealHeroPulse { 0%,100% { opacity: 0.35; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1); } }`}</style>
</motion.div>
)}
</AnimatePresence>
</div>,
document.body,
);
};
export default RevealHero;
@@ -9,6 +9,7 @@ import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast';
import ScheduleOfferToast from '@/app/components/nudges/ScheduleOfferToast';
import PrepKeepToast from '@/app/components/nudges/PrepKeepToast';
import RevealHero from '@/app/components/nudges/RevealHero';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -162,6 +163,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* One-shot dependency beat: first completed personalized starter offers to become a weekly job */}
<ScheduleOfferToast dashboardId={dashboardId} />
{/* Unmissable top-center legend of the work the reveal did, with live working/done status */}
<RevealHero />
{/* Accept-or-deny for the audit + app the flow started on the user's behalf */}
<PrepKeepToast />
</>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import {
addNote, setNoteSize, placeCard, addWorkflowCard, setWorkflowCardPosition, setViewCardPosition,
placeCard, addWorkflowCard, setWorkflowCardPosition, setViewCardPosition,
DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H,
} from '@/shared/state/dashboardLayoutSlice';
import { clearReveal, setRevealAnchor } from '@/shared/state/onboardingV3Slice';
@@ -18,20 +18,16 @@ interface Args {
}
const GAP = 48;
const NOTE_W = 340;
const NOTE_H = 440;
/** Where the reveal's app view card is born: the end of the reading arc, jobs -> chat -> note -> APP, top-aligned. */
/** Where the reveal's app view card is born: right of the welcome chat, top-aligned. The "here's what I did" legend is the fixed RevealHero panel, not a canvas note, so the app sits right next to the chat. */
export function revealAppSpot(anchor: { cx: number; cy: number }): { x: number; y: number } {
return { x: anchor.cx + DEFAULT_CARD_W / 2 + GAP + NOTE_W + GAP, y: anchor.cy - EXPANDED_CARD_MIN_H / 2 };
return { x: anchor.cx + DEFAULT_CARD_W / 2 + GAP, y: anchor.cy - EXPANDED_CARD_MIN_H / 2 };
}
// The reveal: onboarding v3 finished behind the curtain and the prepped work (a personal dashboard app, a live web-research dig, a read-only file tidy-up, and one recurring task) has been running since mid-flow. Compose one tight readable cluster: welcome chat center, jobs stacked left, the plain-English note right; the app view card is born at the arc end (via revealAnchor + the lifecycle auto-add) and the camera glides to it when it arrives. The keep/discard toast owns the jobs' fate afterward.
export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards }: Args): void {
const dispatch = useAppDispatch();
const revealPending = useAppSelector((s) => s.onboardingV3.revealPending);
const starters = useAppSelector((s) => s.onboardingV3.starters);
const scanSummary = useAppSelector((s) => s.onboardingV3.scanSummary);
const prepped = useAppSelector((s) => s.onboardingV3.prepped);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const seededRef = useRef(false);
@@ -75,30 +71,9 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
const cy = (vr.height / 2 - cs.panY) / cs.zoom;
anchorRef.current = { cx, cy };
dispatch(setRevealAnchor({ cx, cy }));
const audit = prepped.find((j) => j.kind === 'audit');
const app = prepped.find((j) => j.kind === 'app');
const research = prepped.find((j) => j.kind === 'research');
const schedule = prepped.find((j) => j.kind === 'schedule');
// Lead every card with a plain everyday label so a first-time non-dev instantly gets WHAT each
// one is. Phrased as work in motion: at curtain-lift these jobs are usually still running, and
// "already done" would be a lie for a few minutes. The cards themselves show live status.
const jobSentence = (j: typeof prepped[number]): string => {
if (j.kind === 'app') return `App: I'm building you "${j.title}", a dashboard of everything you're working on, in one place. It opens right here when it's ready.`;
if (j.kind === 'research') return `Research: I'm looking into "${j.title}" for you on the live web and writing up what's worth knowing.`;
if (j.kind === 'schedule') return `Auto-task: "${j.title}" now runs by itself on a schedule, so you don't have to.`;
return `Cleanup: I'm going through your files and writing you a plan for what to keep or toss. Nothing gets moved or deleted.`;
};
const lines: string[] = [`While you were setting up, I got a head start.`];
if (scanSummary) lines.push(`I looked around your Mac and saw ${scanSummary}.`);
// Dashboard first (the star), then the web research, the tidy-up, and the recurring task.
const jobs = [app, research, audit, schedule].filter((j): j is typeof prepped[number] => Boolean(j));
if (jobs.length > 0) lines.push(`Here's what I already have going for you, on the cards to your left:\n${jobs.map((j) => `- ${jobSentence(j)}`).join('\n')}`);
if (starters.length > 0) lines.push(`A few more things I can do whenever you want:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`);
lines.push(`Nothing's ever saved or deleted without you. Keep any of it going, or clear it anytime.`);
dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + GAP, y: cy - EXPANDED_CARD_MIN_H / 2, color: 'yellow', content: lines.join('\n\n') }));
// addNote mints its own id; it lands in pendingFocusNoteId, which nothing else consumes.
const noteId = store.getState().dashboardLayout.pendingFocusNoteId;
if (noteId) dispatch(setNoteSize({ noteId, width: NOTE_W, height: NOTE_H }));
// The "here's what I did" legend is the fixed RevealHero panel (top-center, unmissable, live
// status), not a canvas note, so there is no wall-of-text sticky to miss here anymore.
placeJobs();
// The app agent often creates its output BEFORE the curtain lifts (it gets a head start at
// connect), so its view card was auto-added with no anchor to stage against. Move it to the
@@ -116,7 +91,7 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
const left = cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W;
const top = cy - EXPANDED_CARD_MIN_H / 2;
fitToCards(
[{ x: left, y: top, width: (DEFAULT_CARD_W * 2) + NOTE_W + (GAP * 2), height: Math.max(EXPANDED_CARD_MIN_H, DEFAULT_CARD_H * 3 + 48) }],
[{ x: left, y: top, width: (DEFAULT_CARD_W * 2) + (GAP * 2), height: Math.max(EXPANDED_CARD_MIN_H, DEFAULT_CARD_H * 3 + 48) }],
0.9,
true,
);
@@ -126,7 +101,7 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
} finally {
dispatch(clearReveal());
}
}, [revealPending, isActive, settingsLoaded, starters, scanSummary, prepped, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards, dispatch, placeJobs]);
}, [revealPending, isActive, settingsLoaded, prepped, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards, dispatch, placeJobs]);
// Jobs that launched after the curtain lifted: drop their cards in as they arrive.
useEffect(() => {