diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index d5661a8b..9d01abac 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -14,6 +14,7 @@ import { useAppDispatch } from '@/shared/hooks'; import { useOnboardingProgress } from './hooks/useOnboardingProgress'; import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice'; import { STEPS, findStepById } from './steps'; +import { useUnlockedStepIds } from './steps/stepUnlock'; import { STAGE_LABELS } from './steps/types'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; @@ -59,13 +60,26 @@ const OnboardingPanel: React.FC = () => { // Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls. const lastShowMeClickRef = useRef(0); + const unlockedIds = useUnlockedStepIds(); const currentStep = useMemo(() => { + // Spotlight only lands on an unlocked, not-yet-done step, so we never tell + // the user to "Show me" something they haven't unlocked yet. const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null; - if (explicit && !progress.completedSteps.includes(explicit.id)) return explicit; - return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null; - }, [progress.currentStepId, progress.completedSteps]); + if ( + explicit && + !progress.completedSteps.includes(explicit.id) && + unlockedIds.has(explicit.id) + ) { + return explicit; + } + return ( + STEPS.find( + (s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id), + ) ?? null + ); + }, [progress.currentStepId, progress.completedSteps, unlockedIds]); // Stage name labels the panel; the count + bar stay global so progress never resets between stages. const stageOf = currentStep?.stage ?? 'get_started'; diff --git a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx index 372fbd22..ad4022fa 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoadmapModal.tsx @@ -10,6 +10,7 @@ import CloseIcon from '@mui/icons-material/Close'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useOnboardingProgress } from './hooks/useOnboardingProgress'; import { STAGE_GROUPS, STEPS, findStepById } from './steps'; +import { useUnlockedStepIds, unlockHintFor } from './steps/stepUnlock'; import { STAGE_LABELS } from './steps/types'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; @@ -20,13 +21,16 @@ const OnboardingRoadmapModal: React.FC = () => { const open = progress.panelMode === 'roadmap'; const close = () => progress.setPanelMode('expanded'); - const stage1Done = STAGE_GROUPS[0].steps.every((s) => - progress.completedSteps.includes(s.id), - ); + const unlockedIds = useUnlockedStepIds(); - const currentStep = progress.currentStepId - ? findStepById(progress.currentStepId) - : STEPS.find((s) => !progress.completedSteps.includes(s.id)); + // Current = first unlocked, not-yet-done step (locked ones wait their turn). + const currentStep = (() => { + const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null; + if (explicit && !progress.completedSteps.includes(explicit.id) && unlockedIds.has(explicit.id)) { + return explicit; + } + return STEPS.find((s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id)); + })(); const totalDone = progress.completedSteps.length; const total = STEPS.length; @@ -120,13 +124,8 @@ const OnboardingRoadmapModal: React.FC = () => { const stageDone = group.steps.filter((s) => progress.completedSteps.includes(s.id), ).length; - const isLocked = gi === 1 && !stage1Done; - const isInProgress = !isLocked && stageDone < group.steps.length; - const stageLabel = isLocked - ? 'LOCKED' - : isInProgress - ? 'IN PROGRESS' - : 'COMPLETE'; + const isInProgress = stageDone < group.steps.length; + const stageLabel = isInProgress ? 'IN PROGRESS' : 'COMPLETE'; return ( { fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', - color: isLocked - ? c.text.tertiary - : isInProgress - ? c.accent.primary - : c.text.secondary, + color: isInProgress ? c.accent.primary : c.text.secondary, }} > STAGE {gi + 1} ยท {stageLabel} @@ -162,7 +157,7 @@ const OnboardingRoadmapModal: React.FC = () => { fontSize: 14, fontWeight: 600, mb: 0.8, - color: isLocked ? c.text.tertiary : c.text.primary, + color: c.text.primary, }} > {STAGE_LABELS[group.stage]} @@ -170,12 +165,15 @@ const OnboardingRoadmapModal: React.FC = () => { {group.steps.map((step) => { const isDone = progress.completedSteps.includes(step.id); - const isCurrent = currentStep?.id === step.id && !isDone; + const isStepLocked = !isDone && !unlockedIds.has(step.id); + const isCurrent = + currentStep?.id === step.id && !isDone && !isStepLocked; + const lockHint = isStepLocked ? unlockHintFor(step.id) : null; return ( { - if (isLocked) return; + if (isStepLocked) return; // Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees. if (progress.running) { onboardingDirector.cancelStep(); @@ -194,15 +192,15 @@ const OnboardingRoadmapModal: React.FC = () => { py: 0.45, px: 0.4, borderRadius: `${c.radius.sm}px`, - cursor: isLocked ? 'default' : 'pointer', - opacity: isLocked ? 0.55 : 1, + cursor: isStepLocked ? 'default' : 'pointer', + opacity: isStepLocked ? 0.55 : 1, transition: 'background 0.12s', - '&:hover': isLocked + '&:hover': isStepLocked ? {} : { bgcolor: c.bg.secondary }, }} > - {isLocked ? ( + {isStepLocked ? ( ) : isDone ? ( { > {step.title} - {isCurrent && ( + {isCurrent ? ( { > current - )} + ) : lockHint ? ( + + {lockHint} + + ) : null} ); })} diff --git a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts new file mode 100644 index 00000000..b0dc4bfd --- /dev/null +++ b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts @@ -0,0 +1,45 @@ +// Soft, earned unlocks for the onboarding panel. A locked step is still fully +// usable in the app, this only gates the guided spotlight + shows a lock icon +// with a one-line teaser, so the tour reveals things in an order you earn by +// doing real actions. Unlocks fire off the same milestone predicates the +// skipIf scanner uses, so exploring (e.g. opening a browser yourself) unlocks +// the next thing immediately, never punished for going off-script. + +import { useMemo } from 'react'; +import type { RootState } from '@/shared/state/store'; +import { useAppSelector } from '@/shared/hooks'; +import { hasAnyAgentLaunched, hasAnyBrowserSpawned } from './skipPredicates'; +import { STEPS } from './index'; + +interface UnlockRule { + by: (s: RootState) => boolean; + hint: string; +} + +// Steps without a rule are unlocked from the start (the get-started entry points). +const RULES: Record = { + enable_actions: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + use_browser: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + install_skill: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + make_app: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + agent_control_agents: { by: hasAnyAgentLaunched, hint: 'Run your first agent' }, + agent_use_browser: { by: hasAnyBrowserSpawned, hint: 'Open a browser' }, +}; + +export function isStepUnlocked(stepId: string, s: RootState): boolean { + const rule = RULES[stepId]; + return rule ? rule.by(s) : true; +} + +export function unlockHintFor(stepId: string): string | null { + return RULES[stepId]?.hint ?? null; +} + +/** Set of currently-unlocked step ids. Keyed on a stable string so the selector + * only re-renders when the unlock set actually changes. */ +export function useUnlockedStepIds(): Set { + const key = useAppSelector((s) => + STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'), + ); + return useMemo(() => new Set(key ? key.split('|') : []), [key]); +}