mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 09:34:53 +02:00
[eric] onboarding: per-step soft unlocks, locked steps show a teaser, spotlight skips locked
This commit is contained in:
@@ -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<number>(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';
|
||||
|
||||
@@ -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 (
|
||||
<Box key={group.stage} sx={{ mb: 2 }}>
|
||||
<Box
|
||||
@@ -143,11 +142,7 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
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 = () => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
|
||||
{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 (
|
||||
<Box
|
||||
key={step.id}
|
||||
onClick={() => {
|
||||
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 ? (
|
||||
<LockIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
) : isDone ? (
|
||||
<CheckCircleIcon
|
||||
@@ -231,7 +229,7 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
>
|
||||
{step.title}
|
||||
</Typography>
|
||||
{isCurrent && (
|
||||
{isCurrent ? (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 10.5,
|
||||
@@ -243,7 +241,13 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
>
|
||||
current
|
||||
</Typography>
|
||||
)}
|
||||
) : lockHint ? (
|
||||
<Typography
|
||||
sx={{ fontSize: 10.5, color: c.text.tertiary, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{lockHint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<string, UnlockRule> = {
|
||||
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<string> {
|
||||
const key = useAppSelector((s) =>
|
||||
STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'),
|
||||
);
|
||||
return useMemo(() => new Set(key ? key.split('|') : []), [key]);
|
||||
}
|
||||
Reference in New Issue
Block a user