From 8f095505a0fdcf3b37124885cd90b9e734e3a9b9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 13:07:45 -0700 Subject: [PATCH] [eric] onboarding: reveal lands working (identity-profiled prep, first starter auto-runs as a live read-only audit) --- backend/apps/onboarding/models.py | 1 + backend/apps/onboarding/prep.py | 11 +++++--- .../OnboardingV3/onboardingV3Api.ts | 4 +-- .../OnboardingV3/useOnboardingV3Pipeline.ts | 9 ++++--- .../lifecycle/useOnboardingRevealSeed.ts | 26 ++++++++++++++----- .../hooks/state/useDashboardController.ts | 2 ++ .../src/shared/state/onboardingV3Slice.ts | 7 ++++- 7 files changed, 44 insertions(+), 16 deletions(-) diff --git a/backend/apps/onboarding/models.py b/backend/apps/onboarding/models.py index cd6019a2..756bd977 100644 --- a/backend/apps/onboarding/models.py +++ b/backend/apps/onboarding/models.py @@ -45,6 +45,7 @@ class PrepRequest(BaseModel): scan: Optional[ScanResult] = None picked_apps: List[str] = Field(default_factory=list) + identity: List[ProviderIdentity] = Field(default_factory=list) class PrepResponse(BaseModel): diff --git a/backend/apps/onboarding/prep.py b/backend/apps/onboarding/prep.py index b9a62905..c1f840c5 100644 --- a/backend/apps/onboarding/prep.py +++ b/backend/apps/onboarding/prep.py @@ -26,12 +26,15 @@ P_SYSTEM = ( "organize local files, browse the web in a real browser, build small apps, and run agents in parallel. " "Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: " '{"greeting": string, "starters": [{"title": string, "prompt": string}]}. ' + "First, silently infer a short profile of this user from the facts (developer or designer or student, work or " + "personal, from apps, folders, plan tier, and email domain); tune every task to that profile and do not output it. " "Exactly 4 starters. Each title is 2-5 words. Each prompt is a concrete, safe, immediately runnable task " "referencing the user's real folders, file counts, or picked apps when possible; never invent facts, never " - "propose deleting anything without review. Make the FIRST starter a bigger multi-part task whose parts can " - "run in parallel (for example: research something on the web WHILE organizing a folder AND drafting a summary), " - "phrased as one natural request. Every starter must produce a tangible result the user can see (a sorted folder, " - "a report, a working page); never propose setup, documentation of preferences, or planning-only tasks. " + "propose deleting anything without review. The FIRST starter must be an audit sized for parallel sub-work " + "(inspect folders, cross-reference, produce one report); it may create ONE new report file but must never " + "modify or delete existing files, because it may be run automatically on the user's behalf. Every starter " + "must produce a tangible result the user can see (a sorted " + "folder, a report, a working page); never propose setup, documentation of preferences, or planning-only tasks. " "The greeting is one warm sentence that names 2-3 specific things " "found on the machine. Never use em-dashes or en-dashes anywhere. No markdown, no commentary, JSON only." ) diff --git a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts index 03fe84f8..1cad9b47 100644 --- a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts +++ b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts @@ -48,12 +48,12 @@ export async function runScan(): Promise { } } -export async function runPrep(scan: ScanResult | null, pickedApps: string[]): Promise { +export async function runPrep(scan: ScanResult | null, pickedApps: string[], identity: ProviderIdentity[]): Promise { try { const res = await fetch(`${API_BASE}/onboarding/prep`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scan, picked_apps: pickedApps }), + body: JSON.stringify({ scan, picked_apps: pickedApps, identity }), }); if (!res.ok) return null; const data = (await res.json()) as PrepResponse; diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 80f107c3..ad865e98 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -14,12 +14,13 @@ export function useOnboardingV3Pipeline() { const { accent, gradient } = useThemeAccent(); const { mode } = useThemeMode(); const [identity, setIdentity] = useState([]); + const identityRef = useRef([]); const scanRef = useRef | null>(null); const prepRef = useRef | null>(null); const scanResultRef = useRef(null); const kickIdentity = useCallback(() => { - fetchIdentity().then(setIdentity).catch(() => {}); + fetchIdentity().then((ids) => { identityRef.current = ids; setIdentity(ids); }).catch(() => {}); }, []); const kickScan = useCallback((consented: boolean) => { @@ -32,7 +33,7 @@ export function useOnboardingV3Pipeline() { const kickPrep = useCallback((pickedApps: string[]) => { if (prepRef.current) return; const scanPromise = scanRef.current ?? Promise.resolve(null); - prepRef.current = scanPromise.then((scan) => runPrep(scan, pickedApps)).catch(() => null); + prepRef.current = scanPromise.then((scan) => runPrep(scan, pickedApps, identityRef.current)).catch(() => null); }, []); const finish = useCallback(async (outcome: 'done' | 'skipped') => { @@ -46,6 +47,8 @@ export function useOnboardingV3Pipeline() { const prep = await Promise.race([prepRef.current ?? Promise.resolve(null), timeout]); const greeting = prep?.greeting?.trim() || null; const starters = prep?.starters ?? []; + // Auto-run only a REAL prep result's first starter (the prompt contract makes it a read-only audit); the static fallback never auto-runs. + const autoPrompt = greeting && starters.length > 0 ? starters[0].prompt : null; // Await the PATCH so personalized_greeting/starters are IN settings before the reveal seeds the welcome chat; the greeting stream snapshots settings at mount. try { await dispatch(updateSettingsPatch({ @@ -57,7 +60,7 @@ export function useOnboardingV3Pipeline() { personalized_starters: starters, })).unwrap(); } catch {} - dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current) })); + dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current), autoPrompt })); dispatch(setFlowActive(false)); }, [dispatch, accent, gradient, mode]); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts index 1f3c137d..518d7c37 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOnboardingRevealSeed.ts @@ -1,24 +1,30 @@ import { useEffect, useRef, type RefObject } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { addNote, DEFAULT_CARD_W } from '@/shared/state/dashboardLayoutSlice'; +import { addNote, placeCard, DEFAULT_CARD_W, DEFAULT_CARD_H } from '@/shared/state/dashboardLayoutSlice'; +import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice'; +import { hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; import { clearReveal } from '@/shared/state/onboardingV3Slice'; interface Args { isActive: boolean; canvasEmpty: boolean; + dashboardId: string; + expandedSessionIds: string[]; viewportRef: RefObject; canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; createWelcomeDraft: () => void; } -// The reveal: onboarding v3 finished behind the curtain, so dress the canvas BEFORE the overlay's exit fade lands. Welcome chat (with personalized greeting + chips) at center, a "while you were setting up" note docked beside it. Everything seeded is a draft or a note; nothing runs until the user clicks. -export function useOnboardingRevealSeed({ isActive, canvasEmpty, viewportRef, canvasStateRef, createWelcomeDraft }: Args): void { +// The reveal: onboarding v3 finished behind the curtain, so dress the canvas BEFORE the overlay's exit fade lands. Welcome chat (personalized greeting + chips) at center, the read-only audit RUNNING as a real agent on the left (only on a real connected sub; the fragile free-trial pool never carries the demo), and a "while you were setting up" note on the right. Nothing seeded can mutate the user's files. +export function useOnboardingRevealSeed({ isActive, canvasEmpty, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft }: Args): void { const dispatch = useAppDispatch(); const revealPending = useAppSelector((s) => s.onboardingV3.revealPending); - const greeting = useAppSelector((s) => s.onboardingV3.greeting); const starters = useAppSelector((s) => s.onboardingV3.starters); const scanSummary = useAppSelector((s) => s.onboardingV3.scanSummary); + const autoPrompt = useAppSelector((s) => s.onboardingV3.autoPrompt); const settingsLoaded = useAppSelector((s) => s.settings.loaded); + const model = useAppSelector((s) => s.settings.data.default_model); + const connected = useAppSelector((s) => hasModelConnected(s)); const seededRef = useRef(false); useEffect(() => { @@ -27,18 +33,26 @@ export function useOnboardingRevealSeed({ isActive, canvasEmpty, viewportRef, ca try { const vp = viewportRef.current; const cs = canvasStateRef.current; + const autoRun = !!autoPrompt && connected; if (vp && cs) { const vr = vp.getBoundingClientRect(); const cx = (vr.width / 2 - cs.panX) / cs.zoom; const cy = (vr.height / 2 - cs.panY) / cs.zoom; const lines: string[] = ['While you were setting up, I got some ideas ready.']; if (scanSummary) lines.push(`Spotted on this Mac: ${scanSummary}.`); - if (starters.length > 0) lines.push(`Ready to run:\n${starters.map((s) => `- ${s.title}`).join('\n')}`); + if (autoRun && starters.length > 0) lines.push(`Already running: ${starters[0].title}.`); + if (starters.length > 0) lines.push(`Ready to run:\n${starters.slice(autoRun ? 1 : 0).map((s) => `- ${s.title}`).join('\n')}`); dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') })); + if (autoRun) { + const config: AgentConfig = { name: starters[0]?.title || 'First look', model, mode: 'agent', dashboard_id: dashboardId }; + const draftId = dispatch(createDraftSession({ mode: 'agent', model, dashboardId, setActive: false })).payload.draftId; + dispatch(placeCard({ sessionId: draftId, x: cx - DEFAULT_CARD_W * 1.5 - 48, y: cy - DEFAULT_CARD_H / 2, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H, expandedSessionIds, exact: true })); + void dispatch(launchAndSendFirstMessage({ draftId, config, prompt: autoPrompt, mode: 'agent', model })); + } } createWelcomeDraft(); } finally { dispatch(clearReveal()); } - }, [revealPending, isActive, canvasEmpty, settingsLoaded, greeting, starters, scanSummary, viewportRef, canvasStateRef, createWelcomeDraft, dispatch]); + }, [revealPending, isActive, canvasEmpty, settingsLoaded, starters, scanSummary, autoPrompt, connected, model, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, dispatch]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index c523af08..a0e1eaae 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -168,6 +168,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { useOnboardingRevealSeed({ isActive, canvasEmpty, + dashboardId, + expandedSessionIds, viewportRef: canvas.viewportRef, canvasStateRef, createWelcomeDraft, diff --git a/frontend/src/shared/state/onboardingV3Slice.ts b/frontend/src/shared/state/onboardingV3Slice.ts index 306ca3bb..8bdf1918 100644 --- a/frontend/src/shared/state/onboardingV3Slice.ts +++ b/frontend/src/shared/state/onboardingV3Slice.ts @@ -11,6 +11,8 @@ export interface OnboardingV3State { greeting: string | null; starters: PersonalizedStarter[]; scanSummary: string | null; + /** Read-only audit prompt to launch as a live agent at reveal; null = chips only. */ + autoPrompt: string | null; } const initialState: OnboardingV3State = { @@ -19,6 +21,7 @@ const initialState: OnboardingV3State = { greeting: null, starters: [], scanSummary: null, + autoPrompt: null, }; const onboardingV3Slice = createSlice({ @@ -28,15 +31,17 @@ const onboardingV3Slice = createSlice({ setFlowActive(state, action: PayloadAction) { state.flowActive = action.payload; }, - stageReveal(state, action: PayloadAction<{ greeting: string | null; starters: PersonalizedStarter[]; scanSummary: string | null }>) { + stageReveal(state, action: PayloadAction<{ greeting: string | null; starters: PersonalizedStarter[]; scanSummary: string | null; autoPrompt: string | null }>) { state.revealPending = true; state.greeting = action.payload.greeting; state.starters = action.payload.starters; state.scanSummary = action.payload.scanSummary; + state.autoPrompt = action.payload.autoPrompt; }, clearReveal(state) { state.revealPending = false; state.scanSummary = null; + state.autoPrompt = null; }, }, });