[eric] onboarding: composed reveal cluster + camera fit, app card born at the arc end

This commit is contained in:
ciregenz
2026-07-19 12:07:21 -07:00
parent 936bad0008
commit 8ef0cc1c5b
3 changed files with 116 additions and 30 deletions
@@ -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);
@@ -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<HTMLDivElement | null>;
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<Set<string>>(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]);
}
@@ -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.