mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Typography from '@mui/material/Typography';
|
|
import Button from '@mui/material/Button';
|
|
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
|
import { report } from '@/shared/serviceClient';
|
|
|
|
export interface WalkthroughStep {
|
|
target: string; // data-onboarding="<value>" selector
|
|
title: string;
|
|
description: string;
|
|
placement: 'top' | 'bottom' | 'left' | 'right';
|
|
actionHint?: string; // e.g. "Click the + button"
|
|
waitForTarget?: boolean; // pause until target appears in DOM
|
|
}
|
|
|
|
const STEPS: WalkthroughStep[] = [
|
|
{
|
|
target: 'agent-card',
|
|
title: 'This is an AI conversation',
|
|
description: 'Each card is a chat with AI. You can ask questions, get help writing, research topics, or have it browse the web for you.',
|
|
placement: 'right',
|
|
actionHint: 'Click it to open',
|
|
},
|
|
{
|
|
target: 'new-agent-button',
|
|
title: 'Start a new conversation',
|
|
description: 'Click here to create a new AI assistant. You can have multiple conversations running at the same time, side by side.',
|
|
placement: 'top',
|
|
actionHint: 'Try clicking the + button below',
|
|
},
|
|
{
|
|
target: 'browser-button',
|
|
title: 'Browse the web',
|
|
description: 'Open a web browser right inside your workspace. Your AI assistants can see and interact with any website.',
|
|
placement: 'top',
|
|
},
|
|
{
|
|
target: 'canvas-controls',
|
|
title: 'Navigate your workspace',
|
|
description: 'Scroll to zoom in and out. Drag the background to pan around. Click any card to focus on it.',
|
|
placement: 'top',
|
|
},
|
|
{
|
|
target: 'sidebar-skills',
|
|
title: 'Skills',
|
|
description: 'Browse and install ready-made workflows \u2014 no coding needed. Skills teach your AI new abilities.',
|
|
placement: 'right',
|
|
},
|
|
{
|
|
target: 'sidebar-actions',
|
|
title: 'Connect Your Tools',
|
|
description: 'Link Google Docs, Notion, Reddit, and more. Your AI assistants can read, write, and interact with your favorite apps.',
|
|
placement: 'right',
|
|
},
|
|
{
|
|
target: 'sidebar-modes',
|
|
title: 'Assistant Types',
|
|
description: 'Customize how your AI behaves. Create specialized assistants for writing, research, coding, or any task.',
|
|
placement: 'right',
|
|
},
|
|
{
|
|
target: 'sidebar-apps',
|
|
title: 'Build Mini Apps',
|
|
description: 'Create simple apps powered by AI \u2014 dashboards, forms, data tools. Just describe what you want.',
|
|
placement: 'right',
|
|
},
|
|
];
|
|
|
|
interface Props {
|
|
onComplete: () => void;
|
|
}
|
|
|
|
interface SpotlightRect {
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
|
const c = useClaudeTokens();
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [spotlightRect, setSpotlightRect] = useState<SpotlightRect | null>(null);
|
|
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
|
const [visible, setVisible] = useState(false);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
const animFrameRef = useRef<number | null>(null);
|
|
|
|
const step = STEPS[currentStep];
|
|
const totalSteps = STEPS.length;
|
|
const isLastStep = currentStep === totalSteps - 1;
|
|
|
|
// Track walkthrough start on mount
|
|
useEffect(() => {
|
|
report('walkthrough', 'started');
|
|
}, []);
|
|
|
|
// Track each step viewed
|
|
useEffect(() => {
|
|
if (step) {
|
|
report('walkthrough', 'step_viewed', { step: currentStep, step_name: step.target || 'done' });
|
|
}
|
|
}, [currentStep, step]);
|
|
|
|
// Find target element and compute spotlight + tooltip position
|
|
const updatePosition = useCallback(() => {
|
|
if (!step) return;
|
|
|
|
const el = (
|
|
document.querySelector(`[data-onboarding="${step.target}"]`) ||
|
|
document.querySelector(`[data-select-type="${step.target}"]`)
|
|
) as HTMLElement | null;
|
|
if (!el) {
|
|
if (step.waitForTarget) {
|
|
// Retry next frame
|
|
animFrameRef.current = requestAnimationFrame(updatePosition);
|
|
return;
|
|
}
|
|
// Skip this step if target not found
|
|
if (currentStep < totalSteps - 1) {
|
|
setCurrentStep((s) => s + 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const pad = 8;
|
|
const sr: SpotlightRect = {
|
|
top: rect.top - pad,
|
|
left: rect.left - pad,
|
|
width: rect.width + pad * 2,
|
|
height: rect.height + pad * 2,
|
|
};
|
|
setSpotlightRect(sr);
|
|
|
|
// Position tooltip relative to spotlight
|
|
const tooltipW = 320;
|
|
const tooltipH = 180;
|
|
const gap = 16;
|
|
let tp = { top: 0, left: 0 };
|
|
|
|
// If target is in the lower half of the screen, anchor the tooltip at a
|
|
// fixed center-upper position so it doesn't shift between toolbar steps.
|
|
const isBottomTarget = sr.top > window.innerHeight * 0.5;
|
|
if (isBottomTarget) {
|
|
tp = {
|
|
top: Math.round(window.innerHeight * 0.35),
|
|
left: Math.round(window.innerWidth / 2 - tooltipW / 2),
|
|
};
|
|
} else {
|
|
switch (step.placement) {
|
|
case 'right':
|
|
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left + sr.width + gap };
|
|
break;
|
|
case 'left':
|
|
tp = { top: sr.top + sr.height / 2 - tooltipH / 2, left: sr.left - tooltipW - gap };
|
|
break;
|
|
case 'top':
|
|
tp = { top: sr.top - tooltipH - gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
|
|
break;
|
|
case 'bottom':
|
|
tp = { top: sr.top + sr.height + gap, left: sr.left + sr.width / 2 - tooltipW / 2 };
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Final clamp
|
|
tp.left = Math.max(8, Math.min(tp.left, window.innerWidth - tooltipW - 8));
|
|
tp.top = Math.max(8, Math.min(tp.top, window.innerHeight - tooltipH - 8));
|
|
|
|
setTooltipPos(tp);
|
|
setVisible(true);
|
|
}, [step, currentStep, totalSteps]);
|
|
|
|
useEffect(() => {
|
|
// Don't toggle visibility between steps — that fades the dark overlay out
|
|
// and back in, briefly showing the bright dashboard underneath (the "white
|
|
// flash"). Just update positions and let the existing CSS transitions
|
|
// smoothly animate the spotlight and tooltip to their new locations.
|
|
const timer = setTimeout(updatePosition, 0);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
};
|
|
}, [currentStep, updatePosition]);
|
|
|
|
// Recompute on resize
|
|
useEffect(() => {
|
|
const onResize = () => updatePosition();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [updatePosition]);
|
|
|
|
const handleNext = useCallback(() => {
|
|
if (isLastStep) {
|
|
report('walkthrough', 'completed', { steps_viewed: currentStep + 1 });
|
|
onComplete();
|
|
} else {
|
|
setCurrentStep((s) => s + 1);
|
|
}
|
|
}, [isLastStep, onComplete, currentStep]);
|
|
|
|
const handleBack = useCallback(() => {
|
|
setCurrentStep((s) => Math.max(0, s - 1));
|
|
}, []);
|
|
|
|
// Allow clicking the spotlight target to advance for action steps
|
|
useEffect(() => {
|
|
if (!step?.actionHint) return;
|
|
|
|
const el = (
|
|
document.querySelector(`[data-onboarding="${step.target}"]`) ||
|
|
document.querySelector(`[data-select-type="${step.target}"]`)
|
|
) as HTMLElement | null;
|
|
if (!el) return;
|
|
|
|
const handler = () => {
|
|
report('walkthrough', 'step_action', { step: currentStep, step_name: step.target });
|
|
setTimeout(() => handleNext(), 300);
|
|
};
|
|
el.addEventListener('click', handler, { once: true });
|
|
return () => el.removeEventListener('click', handler);
|
|
}, [step, handleNext]);
|
|
|
|
if (!step) return null;
|
|
|
|
// SVG mask for spotlight cutout
|
|
const clipPath = spotlightRect
|
|
? `polygon(
|
|
0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%,
|
|
${spotlightRect.left}px ${spotlightRect.top}px,
|
|
${spotlightRect.left}px ${spotlightRect.top + spotlightRect.height}px,
|
|
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top + spotlightRect.height}px,
|
|
${spotlightRect.left + spotlightRect.width}px ${spotlightRect.top}px,
|
|
${spotlightRect.left}px ${spotlightRect.top}px
|
|
)`
|
|
: undefined;
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 9999,
|
|
transition: 'opacity 0.3s ease',
|
|
opacity: visible ? 1 : 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{/* Dark overlay with spotlight cutout — clicks pass through the cutout */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
bgcolor: 'rgba(0, 0, 0, 0.65)',
|
|
clipPath: clipPath || 'none',
|
|
transition: 'clip-path 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
onClick={handleNext}
|
|
/>
|
|
|
|
{/* Spotlight ring glow */}
|
|
{spotlightRect && (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: spotlightRect.top - 2,
|
|
left: spotlightRect.left - 2,
|
|
width: spotlightRect.width + 4,
|
|
height: spotlightRect.height + 4,
|
|
borderRadius: '12px',
|
|
border: `2px solid ${c.accent.primary}`,
|
|
boxShadow: `0 0 20px ${c.accent.primary}40, inset 0 0 20px ${c.accent.primary}10`,
|
|
pointerEvents: 'none',
|
|
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Tooltip card */}
|
|
<Box
|
|
ref={tooltipRef}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: tooltipPos.top,
|
|
left: tooltipPos.left,
|
|
width: 320,
|
|
bgcolor: c.bg.surface,
|
|
border: `1px solid ${c.border.medium}`,
|
|
borderRadius: `${c.radius.xl}px`,
|
|
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
|
p: 2.5,
|
|
overflow: 'hidden',
|
|
transition: 'top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s',
|
|
opacity: visible ? 1 : 0,
|
|
pointerEvents: 'auto',
|
|
zIndex: 10000,
|
|
}}
|
|
>
|
|
{/* Step counter dots */}
|
|
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.5, justifyContent: 'center' }}>
|
|
{STEPS.map((_, i) => (
|
|
<Box
|
|
key={i}
|
|
sx={{
|
|
width: i === currentStep ? 16 : 5,
|
|
height: 5,
|
|
borderRadius: 3,
|
|
bgcolor: i === currentStep ? c.accent.primary : i < currentStep ? c.accent.primary + '60' : c.border.medium,
|
|
transition: 'all 0.3s',
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
<Typography
|
|
sx={{
|
|
fontSize: '1rem',
|
|
fontWeight: 700,
|
|
color: c.text.primary,
|
|
mb: 0.75,
|
|
fontFamily: c.font.sans,
|
|
}}
|
|
>
|
|
{step.title}
|
|
</Typography>
|
|
|
|
<Typography
|
|
sx={{
|
|
fontSize: '0.82rem',
|
|
color: c.text.secondary,
|
|
lineHeight: 1.5,
|
|
mb: step.actionHint ? 1 : 2,
|
|
fontFamily: c.font.sans,
|
|
}}
|
|
>
|
|
{step.description}
|
|
</Typography>
|
|
|
|
{step.actionHint && (
|
|
<Typography
|
|
sx={{
|
|
fontSize: '0.72rem',
|
|
color: c.accent.primary,
|
|
fontWeight: 600,
|
|
mb: 2,
|
|
fontFamily: c.font.sans,
|
|
}}
|
|
>
|
|
{step.actionHint}
|
|
</Typography>
|
|
)}
|
|
|
|
{/* Buttons */}
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Button
|
|
onClick={handleBack}
|
|
disabled={currentStep === 0}
|
|
sx={{
|
|
textTransform: 'none',
|
|
fontSize: '0.82rem',
|
|
fontWeight: 600,
|
|
color: c.text.tertiary,
|
|
borderRadius: `${c.radius.md}px`,
|
|
px: 2,
|
|
py: 0.75,
|
|
fontFamily: c.font.sans,
|
|
visibility: currentStep === 0 || step.target === 'new-agent-button' ? 'hidden' : 'visible',
|
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
|
|
}}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={handleNext}
|
|
sx={{
|
|
textTransform: 'none',
|
|
fontSize: '0.82rem',
|
|
fontWeight: 600,
|
|
bgcolor: c.accent.primary,
|
|
color: '#fff',
|
|
borderRadius: `${c.radius.md}px`,
|
|
px: 2.5,
|
|
py: 0.75,
|
|
fontFamily: c.font.sans,
|
|
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
|
|
}}
|
|
>
|
|
{isLastStep ? 'Get Started' : 'Next'}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default OnboardingWalkthrough;
|