[eric] onboarding: reveal lands working (identity-profiled prep, first starter auto-runs as a live read-only audit)

This commit is contained in:
ciregenz
2026-07-15 13:07:45 -07:00
parent a771280fa9
commit 8f095505a0
7 changed files with 44 additions and 16 deletions
+1
View File
@@ -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):
+7 -4
View File
@@ -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."
)
@@ -48,12 +48,12 @@ export async function runScan(): Promise<ScanResult | null> {
}
}
export async function runPrep(scan: ScanResult | null, pickedApps: string[]): Promise<PrepResponse | null> {
export async function runPrep(scan: ScanResult | null, pickedApps: string[], identity: ProviderIdentity[]): Promise<PrepResponse | null> {
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;
@@ -14,12 +14,13 @@ export function useOnboardingV3Pipeline() {
const { accent, gradient } = useThemeAccent();
const { mode } = useThemeMode();
const [identity, setIdentity] = useState<ProviderIdentity[]>([]);
const identityRef = useRef<ProviderIdentity[]>([]);
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
const prepRef = useRef<Promise<PrepResponse | null> | null>(null);
const scanResultRef = useRef<ScanResult | null>(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]);
@@ -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<HTMLDivElement | null>;
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]);
}
@@ -168,6 +168,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
useOnboardingRevealSeed({
isActive,
canvasEmpty,
dashboardId,
expandedSessionIds,
viewportRef: canvas.viewportRef,
canvasStateRef,
createWelcomeDraft,
@@ -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<boolean>) {
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;
},
},
});