diff --git a/frontend/src/app/components/nudges/RevealHero.tsx b/frontend/src/app/components/nudges/RevealHero.tsx new file mode 100644 index 00000000..47085f09 --- /dev/null +++ b/frontend/src/app/components/nudges/RevealHero.tsx @@ -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 ; + if (kind === 'research') return ; + if (kind === 'schedule') return ; + return ; +} + +// 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( +
+ + {open && ( + +
+
+ +
+
+
+ {userName ? `${userName}, while you set up I got to work` : `While you set up, I got to work`} +
+
+ {doneCount === jobs.length ? `All done, here on your canvas` : `${doneCount} of ${jobs.length} done, the rest are running`} +
+
+ +
+ +
+ {jobs.map((job) => { + const st = statusOf(job); + return ( +
+
+ {iconFor(job.kind)} +
+
+ {lineFor(job, st)} +
+ {st === 'done' ? ( + + done + + ) : st === 'snag' ? ( + needs a look + ) : ( + + + working + + )} +
+ ); + })} +
+
+ It's all on your canvas below. Nothing's saved or deleted without you, keep it or clear it anytime. +
+ +
+ )} +
+
, + document.body, + ); +}; + +export default RevealHero; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 12a4a32c..2611e144 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -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 = ({ {/* One-shot dependency beat: first completed personalized starter offers to become a weekly job */} + {/* Unmissable top-center legend of the work the reveal did, with live working/done status */} + + {/* Accept-or-deny for the audit + app the flow started on the user's behalf */} diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts index 90c56c8c..c1e4c0a1 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts @@ -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(() => {