From 8ef0cc1c5b8c4313393fe515128bcce2489551d7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 19 Jul 2026 12:07:21 -0700 Subject: [PATCH] [eric] onboarding: composed reveal cluster + camera fit, app card born at the arc end --- .../hooks/lifecycle/useDashboardLifecycle.ts | 20 ++- .../lifecycle/useOnboardingRevealSeed.ts | 125 ++++++++++++++---- .../hooks/state/useDashboardController.ts | 1 + 3 files changed, 116 insertions(+), 30 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 40472bcd..50e2572b 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type MutableRefObject } from 'react'; import { report } from '@/shared/serviceClient'; import { store } from '@/shared/state/store'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { revealAppSpot } from './useOnboardingRevealSeed'; import { fetchSessions, fetchHistory, @@ -344,14 +345,27 @@ export function useDashboardLifecycle({ if (sess.dashboard_id !== dashboardId) continue; autoOpenedOutputsRef.current.add(output.id); if (viewCards[output.id]) continue; - dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid })); + // The onboarding reveal's app is born at its composed arc-end spot (right of the note), not in + // the parent's column, so no post-hoc move ever races layout persistence. One-shot per reveal. + const v3 = store.getState().onboardingV3; + const revealSpot = (v3.revealAnchor && v3.prepped.some((j) => j.kind === 'app' && j.sessionId === sid)) + ? revealAppSpot(v3.revealAnchor) + : null; + if (revealSpot) { + dispatch(addViewCard({ outputId: output.id, expandedSessionIds, x: revealSpot.x, y: revealSpot.y })); + } else { + dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid })); + } const outputId = output.id; setTimeout(() => { const vc = store.getState().dashboardLayout.viewCards[outputId]; if (!vc) return; const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }]; - const ac = store.getState().dashboardLayout.cards[sid]; - if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height }); + // Reveal spot: frame just the app (the note edges into frame on its left); otherwise include the parent chat. + if (!revealSpot) { + const ac = store.getState().dashboardLayout.cards[sid]; + if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height }); + } canvasActions.fitToCards(rects, 1.15, true); handleHighlightCard(outputId); }, 200); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts index c6f1e05d..90c56c8c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts @@ -1,7 +1,11 @@ -import { useEffect, useRef, type RefObject } from 'react'; +import { useCallback, useEffect, useRef, type RefObject } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { addNote, placeCard, DEFAULT_CARD_W, DEFAULT_CARD_H } from '@/shared/state/dashboardLayoutSlice'; -import { clearReveal } from '@/shared/state/onboardingV3Slice'; +import { store } from '@/shared/state/store'; +import { + addNote, setNoteSize, 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'; interface Args { isActive: boolean; @@ -10,10 +14,20 @@ interface Args { viewportRef: RefObject; canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; createWelcomeDraft: () => void; + fitToCards: (rects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean, minZoom?: number, centered?: boolean) => void; } -// The reveal: onboarding v3 finished behind the curtain and the prepped agents (audit + app build) have been running since mid-flow. Compose the canvas: welcome chat center, running jobs stacked left, the "while you were setting up" note right. The keep/discard toast owns the jobs' fate afterward. -export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft }: Args): void { +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. */ +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 }; +} + +// 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); @@ -21,7 +35,34 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession const prepped = useAppSelector((s) => s.onboardingV3.prepped); const settingsLoaded = useAppSelector((s) => s.settings.loaded); const seededRef = useRef(false); + // Jobs launch async at prep-resolve, so some land in `prepped` a beat AFTER the curtain lifts. The + // anchor + placed-set let us keep dropping those cards in the same left stack instead of losing them. + const anchorRef = useRef<{ cx: number; cy: number } | null>(null); + const placedRef = useRef>(new Set()); + const placeJobs = useCallback(() => { + const a = anchorRef.current; + if (!a) return; + prepped.forEach((job) => { + const key = job.workflowId || job.sessionId; + if (placedRef.current.has(key)) return; + const i = placedRef.current.size; + // Left column, top-aligned with the expanded welcome chat, tight vertical rhythm. + const x = a.cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W; + const y = a.cy - EXPANDED_CARD_MIN_H / 2 + i * (DEFAULT_CARD_H + 24); + if (job.kind === 'schedule' && job.workflowId) { + // The scheduled task is a workflow, not an agent session: place its workflow card in the same stack. + dispatch(addWorkflowCard({ workflowId: job.workflowId, expandedSessionIds })); + dispatch(setWorkflowCardPosition({ workflowId: job.workflowId, x, y })); + } else { + dispatch(placeCard({ sessionId: job.sessionId, x, y, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H, expandedSessionIds, exact: true })); + } + placedRef.current.add(key); + }); + }, [prepped, dispatch, expandedSessionIds]); + + // One-time: fix the anchor, seed the welcome chat + the "head start" note, place jobs present so far, + // then frame the camera on the whole cluster so the curtain lifts onto a composed, readable scene. useEffect(() => { if (!revealPending || seededRef.current || !isActive || !settingsLoaded) return; seededRef.current = true; @@ -32,33 +73,63 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession const vr = vp.getBoundingClientRect(); const cx = (vr.width / 2 - cs.panX) / cs.zoom; 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 jobLine = (j: typeof prepped[number]) => (j.reason ? `- ${j.title}: ${j.reason}` : `- ${j.title}`); - const lines: string[] = ['While you were setting up, I got a head start.']; + 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}.`); - const cards: string[] = []; - if (audit) cards.push(jobLine(audit)); - if (app) cards.push(jobLine(app)); - if (cards.length > 0) lines.push(`So here is what I already have going (cards on your left):\n${cards.join('\n')}`); - if (starters.length > 0) lines.push(`Ready whenever you want:\n${starters.slice(audit ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`); - lines.push('Nothing is saved or deleted without you. Keep it going or clear it anytime.'); - dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') })); - prepped.forEach((job, i) => { - dispatch(placeCard({ - sessionId: job.sessionId, - x: cx - DEFAULT_CARD_W * 1.5 - 48, - y: cy - DEFAULT_CARD_H / 2 + i * (DEFAULT_CARD_H + 32), - width: DEFAULT_CARD_W, - height: DEFAULT_CARD_H, - expandedSessionIds, - exact: true, - })); - }); + // 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 })); + 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 + // arc-end spot now; the birth-position path in useDashboardLifecycle covers late arrivals. + if (app?.sessionId) { + const now = store.getState(); + const out = Object.values(now.outputs.items).find((o) => o.session_id === app.sessionId); + if (out && now.dashboardLayout.viewCards[out.id]) { + const spot = revealAppSpot({ cx, cy }); + dispatch(setViewCardPosition({ outputId: out.id, x: spot.x, y: spot.y })); + } + } + createWelcomeDraft(); + // Frame the whole cluster (jobs column + chat + note) so the reveal is readable, not scattered. + 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) }], + 0.9, + true, + ); + } else { + createWelcomeDraft(); } - createWelcomeDraft(); } finally { dispatch(clearReveal()); } - }, [revealPending, isActive, settingsLoaded, starters, scanSummary, prepped, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, dispatch]); + }, [revealPending, isActive, settingsLoaded, starters, scanSummary, prepped, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards, dispatch, placeJobs]); + + // Jobs that launched after the curtain lifted: drop their cards in as they arrive. + useEffect(() => { + if (seededRef.current) placeJobs(); + }, [prepped, placeJobs]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index cc6a837d..c7f5af3d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -173,6 +173,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { viewportRef: canvas.viewportRef, canvasStateRef, createWelcomeDraft, + fitToCards: canvas.actions.fitToCards, }); // Silently harvest the user's provider chat history the first time they open ChatGPT/Claude in-app, then sharpen their saved suggestions.