mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-27 12:04:49 +02:00
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
// Top-level mount for onboarding-v2: hydrate state, attach Director, mount Panel + AC.
|
|
|
|
import React, { useEffect, useRef } from 'react';
|
|
import { useStore } from 'react-redux';
|
|
import type { Store } from '@reduxjs/toolkit';
|
|
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
|
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
|
import type { RootState } from '@/shared/state/store';
|
|
import {
|
|
hydrate,
|
|
init,
|
|
loadFromStorage,
|
|
persistToStorage,
|
|
markStepCompleted,
|
|
setPanelMode,
|
|
setCurrentStep,
|
|
markRevealedAfterWin,
|
|
} from '@/shared/state/onboardingProgressSlice';
|
|
import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor';
|
|
import { onboardingDirector } from './OnboardingDirector';
|
|
import { STEPS } from './steps';
|
|
import { hasAnyAgentCompleted, hasFreeTrialActive, hasModelConnected } from './steps/skipPredicates';
|
|
import OnboardingPanel from './OnboardingPanel';
|
|
import { onboardingBus } from './eventBus';
|
|
import { report } from './telemetry';
|
|
|
|
const PERSIST_DEBOUNCE_MS = 200;
|
|
|
|
const OnboardingRoot: React.FC = () => {
|
|
const acRef = useRef<AgenticCursorHandle | null>(null);
|
|
const dispatch = useAppDispatch();
|
|
const store = useStore<RootState>() as Store<RootState>;
|
|
const tokens = useClaudeTokens();
|
|
const progress = useAppSelector((s) => s.onboardingProgress);
|
|
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
|
const firstAgentDone = useAppSelector(hasAnyAgentCompleted);
|
|
|
|
// The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent actually finishes, so it never pops mid-run. Respects a panel they've hidden or already expanded themselves, and never re-fires (revealedAfterWin sticks).
|
|
useEffect(() => {
|
|
if (!progress.initialized || !firstAgentDone) return;
|
|
if (progress.revealedAfterWin || progress.panelMode !== 'pill') return;
|
|
dispatch(setPanelMode('expanded'));
|
|
dispatch(markRevealedAfterWin());
|
|
}, [
|
|
firstAgentDone,
|
|
progress.initialized,
|
|
progress.revealedAfterWin,
|
|
progress.panelMode,
|
|
dispatch,
|
|
]);
|
|
|
|
// The welcome chat IS the user launching their first agent, so once it finishes, mark launch_agent done. Without this the post-win nudge repeats "Launch your first Agent" they just did: the skipIf baseline-capture re-arms on every completedSteps change and tends to snapshot the real send as "pre-existing", which freezes the step as un-completable.
|
|
useEffect(() => {
|
|
if (!progress.initialized || !firstAgentDone) return;
|
|
if ((progress.completedSteps ?? []).includes('launch_agent')) return;
|
|
dispatch(markStepCompleted('launch_agent'));
|
|
}, [firstAgentDone, progress.initialized, progress.completedSteps, dispatch]);
|
|
|
|
// First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual New Agent click spawns the same welcome chat (handleNewAgent is welcome-aware).
|
|
const welcomeOpenReady = useAppSelector(
|
|
(s) =>
|
|
s.settings.loaded &&
|
|
(hasFreeTrialActive(s) || hasModelConnected(s)) &&
|
|
!s.onboardingProgress.welcomeShown &&
|
|
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') &&
|
|
Object.keys(s.agents?.sessions ?? {}).length === 0,
|
|
);
|
|
// Onboarding v3 owns the window on a fresh install; the v2 cursor tour stays dormant until the flow resolves.
|
|
const v3Owns = useAppSelector((s) => s.onboardingV3.flowActive);
|
|
const welcomeFiredRef = useRef(false);
|
|
const welcomeTimerRef = useRef<number | null>(null);
|
|
useEffect(() => {
|
|
if (v3Owns || welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return;
|
|
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
|
|
welcomeFiredRef.current = true;
|
|
welcomeTimerRef.current = window.setTimeout(() => {
|
|
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
|
|
// Pop near center, then the step walks the cursor down to the New Agent button and clicks.
|
|
onboardingDirector.startStep('welcome_open', { x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
|
}, 600);
|
|
return () => { if (welcomeTimerRef.current) window.clearTimeout(welcomeTimerRef.current); };
|
|
}, [progress.initialized, welcomeOpenReady, v3Owns]);
|
|
|
|
useEffect(() => {
|
|
if (progress.initialized) return;
|
|
if (!settingsLoaded) return;
|
|
|
|
const persisted = loadFromStorage();
|
|
if (persisted) {
|
|
dispatch(hydrate(persisted));
|
|
return;
|
|
}
|
|
|
|
// Start with no pre-completed steps; live subscriber handles skipIf after baseline capture.
|
|
dispatch(
|
|
init({
|
|
currentStepId: STEPS[0]?.id ?? null,
|
|
preCompleted: [],
|
|
disableSkipIf: false,
|
|
}),
|
|
);
|
|
}, [progress.initialized, settingsLoaded, dispatch, store]);
|
|
|
|
useEffect(() => {
|
|
if (!progress.initialized || !progress.currentStepId) return;
|
|
if (STEPS.some((step) => step.id === progress.currentStepId)) return;
|
|
const nextStep = STEPS.find((step) => !(progress.completedSteps ?? []).includes(step.id));
|
|
dispatch(setCurrentStep(nextStep?.id ?? null));
|
|
}, [progress.initialized, progress.currentStepId, progress.completedSteps, dispatch]);
|
|
|
|
// Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches.
|
|
useEffect(() => {
|
|
let last = new Set(progress.completedSteps);
|
|
let lastBrowserCount = Object.keys(
|
|
store.getState().dashboardLayout?.browserCards ?? {},
|
|
).length;
|
|
let lastSessionCount = Object.keys(
|
|
(store.getState() as any).agents?.sessions ?? {},
|
|
).length;
|
|
let lastOutputCount = Object.keys(
|
|
(store.getState() as any).outputs?.items ?? {},
|
|
).length;
|
|
|
|
// Snapshot pre-satisfied skipIf predicates after a 2s settle; those steps need real user action to mark.
|
|
let baselinePredicateMet: Set<string> | null = null;
|
|
const baselineCaptureAt = Date.now() + 2000;
|
|
let lastStatuses: Record<string, string> = {};
|
|
const seedStatuses = () => {
|
|
const sessions = (store.getState() as any).agents?.sessions ?? {};
|
|
const out: Record<string, string> = {};
|
|
for (const [id, s] of Object.entries(sessions)) {
|
|
const st = (s as any)?.status;
|
|
if (typeof st === 'string') out[id] = st;
|
|
}
|
|
lastStatuses = out;
|
|
};
|
|
seedStatuses();
|
|
|
|
let pending = false;
|
|
// Slice-ref identity check; Immer mutates only on write so this 5-pointer compare is sound and free.
|
|
let prevAgents: unknown = null;
|
|
let prevDashboardLayout: unknown = null;
|
|
let prevOutputs: unknown = null;
|
|
let prevSkills: unknown = null;
|
|
let prevSettings: unknown = null;
|
|
|
|
const runCheck = () => {
|
|
pending = false;
|
|
const state = store.getState();
|
|
// Early-out if no relevant slice reference moved; skips ~95% of microtask wakeups.
|
|
const sAgents = (state as any).agents;
|
|
const sLayout = state.dashboardLayout;
|
|
const sOutputs = (state as any).outputs;
|
|
const sSkills = (state as any).skills;
|
|
const sSettings = (state as any).settings;
|
|
const anyChanged =
|
|
sAgents !== prevAgents ||
|
|
sLayout !== prevDashboardLayout ||
|
|
sOutputs !== prevOutputs ||
|
|
sSkills !== prevSkills ||
|
|
sSettings !== prevSettings;
|
|
prevAgents = sAgents;
|
|
prevDashboardLayout = sLayout;
|
|
prevOutputs = sOutputs;
|
|
prevSkills = sSkills;
|
|
prevSettings = sSettings;
|
|
if (!anyChanged) return;
|
|
const suppressSkipIf = state.onboardingProgress?.disableSkipIf === true;
|
|
|
|
// Capture pre-satisfied predicates after the fetch settle; sticky for the run.
|
|
if (baselinePredicateMet === null && Date.now() >= baselineCaptureAt) {
|
|
baselinePredicateMet = new Set();
|
|
for (const s of STEPS) {
|
|
if (s.skipIf?.(state)) baselinePredicateMet.add(s.id);
|
|
}
|
|
}
|
|
|
|
const allSkippablesDone = STEPS.every(
|
|
(s) => !s.skipIf || last.has(s.id),
|
|
);
|
|
// Skip evaluation if suppressed, pre-baseline, or every skippable is already marked.
|
|
if (
|
|
!suppressSkipIf &&
|
|
!allSkippablesDone &&
|
|
baselinePredicateMet !== null
|
|
) {
|
|
for (const s of STEPS) {
|
|
if (last.has(s.id)) continue;
|
|
if (!s.skipIf) continue;
|
|
// Baseline-met predicates require real user action (bus events or outro) to mark.
|
|
if (baselinePredicateMet.has(s.id)) continue;
|
|
if (s.skipIf(state)) {
|
|
last = new Set([...Array.from(last), s.id]);
|
|
dispatch(markStepCompleted(s.id));
|
|
report('step_skipped_via_skipif', { step_id: s.id, stage: s.stage });
|
|
}
|
|
}
|
|
}
|
|
const bc = Object.keys(state.dashboardLayout?.browserCards ?? {}).length;
|
|
if (bc > lastBrowserCount) {
|
|
onboardingBus.emit('browser:spawned');
|
|
}
|
|
lastBrowserCount = bc;
|
|
|
|
const sessions = (state as any).agents?.sessions ?? {};
|
|
const sc = Object.keys(sessions).length;
|
|
if (sc > lastSessionCount) {
|
|
onboardingBus.emit('agent:spawned');
|
|
}
|
|
lastSessionCount = sc;
|
|
|
|
const outputs = (state as any).outputs?.items ?? {};
|
|
const oc = Object.keys(outputs).length;
|
|
if (oc > lastOutputCount) {
|
|
onboardingBus.emit('app:generation_done');
|
|
}
|
|
lastOutputCount = oc;
|
|
|
|
let nextStatuses: Record<string, string> | null = null;
|
|
for (const [id, s] of Object.entries(sessions)) {
|
|
const status = (s as any)?.status;
|
|
if (typeof status !== 'string') continue;
|
|
const prev = lastStatuses[id];
|
|
if (prev !== status) {
|
|
if (status === 'completed' && prev !== 'completed') {
|
|
onboardingBus.emit('agent:completed');
|
|
}
|
|
nextStatuses ??= { ...lastStatuses };
|
|
nextStatuses[id] = status;
|
|
}
|
|
}
|
|
if (nextStatuses) lastStatuses = nextStatuses;
|
|
};
|
|
|
|
return store.subscribe(() => {
|
|
// Coalesce N dispatches in the same microtask into 1 check.
|
|
if (pending) return;
|
|
pending = true;
|
|
queueMicrotask(runCheck);
|
|
});
|
|
}, [progress.completedSteps, dispatch, store]);
|
|
|
|
// Persist Redux progress to localStorage, debounced.
|
|
useEffect(() => {
|
|
if (!progress.initialized) return;
|
|
const t = window.setTimeout(() => {
|
|
persistToStorage(store.getState().onboardingProgress);
|
|
}, PERSIST_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(t);
|
|
}, [progress, store]);
|
|
|
|
useEffect(() => {
|
|
onboardingDirector.attach({
|
|
acRef,
|
|
store,
|
|
getAccentColor: () => tokens.accent.primary,
|
|
isDependencySatisfied: (depId) => {
|
|
// Step 4: browser card currently on canvas.
|
|
if (depId === 'use_browser') {
|
|
const cards = store.getState().dashboardLayout?.browserCards ?? {};
|
|
return Object.keys(cards).length > 0;
|
|
}
|
|
return false;
|
|
},
|
|
});
|
|
return () => onboardingDirector.detach();
|
|
}, [store, tokens.accent.primary]);
|
|
|
|
if (v3Owns) return null;
|
|
if (!settingsLoaded) return null;
|
|
if (!progress.initialized) return null;
|
|
|
|
return (
|
|
<>
|
|
<AgenticCursor ref={acRef} />
|
|
<OnboardingPanel />
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default OnboardingRoot;
|