mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 14:32:22 +02:00
[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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user