Skip to content

Commit 3f127f2

Browse files
feat(dashboard): multi-step onboarding wizard - visual first-run experience
Adds OnboardingWizard.tsx (4-step interactive first-run experience), OnboardingWizard.test.tsx. Updates App.tsx and SettingsPage. Recovery from botched remerge of #3291.
1 parent 9d0a9c8 commit 3f127f2

4 files changed

Lines changed: 393 additions & 3 deletions

File tree

dashboard/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp';
1111
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
1212
import { useDrawerStore } from './store/useDrawerStore';
1313
import { FirstRunTour, isTourCompleted } from './components/tour/FirstRunTour';
14-
import { OnboardingScreen } from './components/brand/OnboardingScreen';
14+
import { OnboardingWizard } from './components/brand/OnboardingWizard';
1515
import { useAuthStore } from './store/useAuthStore';
1616

1717
const AuditPage = lazy(() => import('./pages/AuditPage'));
@@ -106,7 +106,7 @@ export default function App() {
106106
});
107107

108108
if (isAuthenticated && showOnboarding) {
109-
return <OnboardingScreen onComplete={() => {
109+
return <OnboardingWizard onComplete={() => {
110110
setShowOnboarding(false);
111111
if (!isTourCompleted()) setShowTour(true);
112112
}} />;
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/**
2+
* pages/OnboardingPage.tsx — Multi-step first-run onboarding wizard.
3+
* Shows on first authenticated visit. Stores completion in localStorage.
4+
*
5+
* Steps:
6+
* 1. Welcome — Aegis branding + intro
7+
* 2. Connect — guide user to run `ag init` or connect CC session
8+
* 3. First Session — walk through creating a session
9+
* 4. Explore — highlight key dashboard pages
10+
*/
11+
12+
import { useState, useCallback, useEffect } from 'react';
13+
import {
14+
Rocket,
15+
Terminal,
16+
Play,
17+
Compass,
18+
ChevronRight,
19+
ChevronLeft,
20+
Copy,
21+
Check,
22+
X,
23+
} from 'lucide-react';
24+
25+
const TOTAL_STEPS = 4;
26+
27+
const STEPS = [
28+
{
29+
id: 1,
30+
icon: Rocket,
31+
title: 'Welcome to Aegis',
32+
description:
33+
'Your Claude Code orchestration hub. Monitor sessions, manage permissions, and track costs — all from one dashboard.',
34+
},
35+
{
36+
id: 2,
37+
icon: Terminal,
38+
title: 'Connect Your Environment',
39+
description:
40+
'Run the CLI init command to connect your Claude Code sessions to Aegis. Copy the snippet below and paste it into your terminal.',
41+
snippet: 'npx @anthropic-ai/claude-code@latest && ag init',
42+
},
43+
{
44+
id: 3,
45+
icon: Play,
46+
title: 'Create Your First Session',
47+
description:
48+
'Once connected, create a new Claude Code session from the dashboard. Aegis will handle permissions, audit logging, and real-time monitoring automatically.',
49+
},
50+
{
51+
id: 4,
52+
icon: Compass,
53+
title: 'Explore the Dashboard',
54+
description:
55+
'You\'re all set! Key pages to check out:\n\n• Sessions — live session monitoring and history\n• Analytics — usage metrics and agent contributions\n• Cost — token tracking and budget alerts\n• Settings — configure preferences and API keys',
56+
},
57+
] as const;
58+
59+
function CopyButton({ text }: { text: string }) {
60+
const [copied, setCopied] = useState(false);
61+
62+
const handleCopy = useCallback(async () => {
63+
try {
64+
await navigator.clipboard.writeText(text);
65+
setCopied(true);
66+
setTimeout(() => setCopied(false), 2000);
67+
} catch {
68+
// Clipboard API unavailable
69+
}
70+
}, [text]);
71+
72+
return (
73+
<button
74+
type="button"
75+
onClick={handleCopy}
76+
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--color-accent-cyan)] px-3 py-1.5 text-xs font-medium text-[var(--color-void-dark)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-cyan)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
77+
aria-label={copied ? 'Copied to clipboard' : 'Copy command to clipboard'}
78+
>
79+
{copied ? (
80+
<>
81+
<Check className="h-3.5 w-3.5" />
82+
Copied
83+
</>
84+
) : (
85+
<>
86+
<Copy className="h-3.5 w-3.5" />
87+
Copy
88+
</>
89+
)}
90+
</button>
91+
);
92+
}
93+
94+
function StepContent({
95+
step,
96+
}: {
97+
step: (typeof STEPS)[number];
98+
}) {
99+
const Icon = step.icon;
100+
101+
return (
102+
<div className="flex flex-col items-center text-center">
103+
<div
104+
className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--color-accent-cyan)]/10 ring-1 ring-[var(--color-accent-cyan)]/20"
105+
aria-hidden="true"
106+
>
107+
<Icon className="h-8 w-8 text-[var(--color-accent-cyan)]" />
108+
</div>
109+
<h2 className="mb-3 text-2xl font-bold text-[var(--color-text-primary)]">
110+
{step.title}
111+
</h2>
112+
<p className="mx-auto max-w-md text-sm leading-relaxed text-[var(--color-text-muted)] whitespace-pre-line">
113+
{step.description}
114+
</p>
115+
{'snippet' in step && step.snippet && (
116+
<div className="mt-6 w-full max-w-md">
117+
<div className="flex items-center justify-between gap-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-void-dark)] p-3">
118+
<code className="flex-1 overflow-x-auto text-left text-xs font-mono text-[var(--color-accent-cyan)]">
119+
{step.snippet}
120+
</code>
121+
<CopyButton text={step.snippet} />
122+
</div>
123+
</div>
124+
)}
125+
</div>
126+
);
127+
}
128+
129+
function ProgressIndicator({ current }: { current: number }) {
130+
return (
131+
<div className="flex items-center gap-2" role="progressbar" aria-valuenow={current} aria-valuemin={1} aria-valuemax={TOTAL_STEPS} aria-label={`Step ${current} of ${TOTAL_STEPS}`}>
132+
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
133+
const stepNum = i + 1;
134+
const isActive = stepNum === current;
135+
const isComplete = stepNum < current;
136+
137+
return (
138+
<div key={stepNum} className="flex items-center gap-2">
139+
<div
140+
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition-colors ${
141+
isActive
142+
? 'bg-[var(--color-accent-cyan)] text-[var(--color-void-dark)]'
143+
: isComplete
144+
? 'bg-[var(--color-accent-cyan)]/20 text-[var(--color-accent-cyan)]'
145+
: 'bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]'
146+
}`}
147+
aria-current={isActive ? 'step' : undefined}
148+
>
149+
{isComplete ? <Check className="h-4 w-4" /> : stepNum}
150+
</div>
151+
{stepNum < TOTAL_STEPS && (
152+
<div
153+
className={`h-0.5 w-8 rounded transition-colors ${
154+
isComplete
155+
? 'bg-[var(--color-accent-cyan)]'
156+
: 'bg-[var(--color-border-strong)]'
157+
}`}
158+
aria-hidden="true"
159+
/>
160+
)}
161+
</div>
162+
);
163+
})}
164+
</div>
165+
);
166+
}
167+
168+
interface OnboardingWizardProps {
169+
onComplete: () => void;
170+
}
171+
172+
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
173+
const [currentStep, setCurrentStep] = useState(1);
174+
const step = STEPS[currentStep - 1];
175+
176+
const isLastStep = currentStep === TOTAL_STEPS;
177+
178+
const handleNext = useCallback(() => {
179+
if (isLastStep) {
180+
onComplete();
181+
} else {
182+
setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS));
183+
}
184+
}, [isLastStep, onComplete]);
185+
186+
const handleBack = useCallback(() => {
187+
setCurrentStep((s) => Math.max(s - 1, 1));
188+
}, []);
189+
190+
const handleSkip = useCallback(() => {
191+
onComplete();
192+
}, [onComplete]);
193+
194+
// Keyboard navigation
195+
useEffect(() => {
196+
const handler = (e: globalThis.KeyboardEvent) => {
197+
if (e.key === 'ArrowRight' || e.key === 'Enter') handleNext();
198+
if (e.key === 'ArrowLeft') handleBack();
199+
};
200+
window.addEventListener('keydown', handler);
201+
return () => window.removeEventListener('keydown', handler);
202+
}, [handleNext, handleBack]);
203+
204+
// Announce step changes to screen readers
205+
const [announcement, setAnnouncement] = useState('');
206+
useEffect(() => {
207+
setAnnouncement(`Step ${currentStep} of ${TOTAL_STEPS}: ${step.title}`);
208+
}, [currentStep, step.title]);
209+
210+
return (
211+
<div
212+
className="flex min-h-screen flex-col items-center justify-center bg-[var(--color-void-dark)] p-6"
213+
role="region"
214+
aria-label="Onboarding wizard"
215+
aria-live="polite"
216+
>
217+
{/* Screen reader announcement */}
218+
<div className="sr-only" aria-live="assertive" role="status">
219+
{announcement}
220+
</div>
221+
222+
{/* Skip button — top right */}
223+
<button
224+
type="button"
225+
onClick={handleSkip}
226+
className="absolute right-4 top-4 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-lg text-sm text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-cyan)]"
227+
aria-label="Skip onboarding"
228+
>
229+
<X className="h-5 w-5" />
230+
</button>
231+
232+
{/* Wizard card */}
233+
<div className="w-full max-w-lg rounded-2xl border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-8 shadow-2xl">
234+
{/* Progress */}
235+
<div className="mb-8 flex justify-center">
236+
<ProgressIndicator current={currentStep} />
237+
</div>
238+
239+
{/* Step content */}
240+
<div className="mb-8 min-h-[240px] flex items-center">
241+
<StepContent step={step} />
242+
</div>
243+
244+
{/* Actions */}
245+
<div className="flex items-center justify-between">
246+
<button
247+
type="button"
248+
onClick={handleBack}
249+
disabled={currentStep === 1}
250+
className="flex min-h-[44px] items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:text-[var(--color-text-muted)] disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-cyan)]"
251+
aria-label="Go to previous step"
252+
>
253+
<ChevronLeft className="h-4 w-4" />
254+
Back
255+
</button>
256+
257+
<button
258+
type="button"
259+
onClick={handleSkip}
260+
className="text-sm text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-cyan)]"
261+
>
262+
Skip tour
263+
</button>
264+
265+
<button
266+
type="button"
267+
onClick={handleNext}
268+
className="flex min-h-[44px] items-center gap-1.5 rounded-lg bg-[var(--color-accent-cyan)] px-4 py-2 text-sm font-bold text-[var(--color-void-dark)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-cyan)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
269+
aria-label={isLastStep ? 'Complete onboarding and go to dashboard' : 'Go to next step'}
270+
>
271+
{isLastStep ? 'Get Started' : 'Next'}
272+
{!isLastStep && <ChevronRight className="h-4 w-4" />}
273+
</button>
274+
</div>
275+
</div>
276+
277+
{/* Footer branding */}
278+
<p className="mt-6 text-xs text-[var(--color-text-muted)]">
279+
Aegis — Claude Code Orchestration Hub
280+
</p>
281+
</div>
282+
);
283+
}
284+
285+
export default OnboardingWizard;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* OnboardingWizard.test.tsx — Tests for the multi-step onboarding wizard.
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach } from 'vitest';
6+
import { render, screen, fireEvent } from '@testing-library/react';
7+
import { OnboardingWizard } from '../OnboardingWizard';
8+
9+
describe('OnboardingWizard', () => {
10+
const onComplete = vi.fn();
11+
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
it('renders step 1 (Welcome) by default', () => {
17+
render(<OnboardingWizard onComplete={onComplete} />);
18+
expect(screen.getByText('Welcome to Aegis')).toBeDefined();
19+
expect(screen.getByRole('progressbar')).toBeDefined();
20+
});
21+
22+
it('shows progress indicator with 4 steps', () => {
23+
render(<OnboardingWizard onComplete={onComplete} />);
24+
const progressbar = screen.getByRole('progressbar');
25+
expect(progressbar.getAttribute('aria-valuemax')).toBe('4');
26+
expect(progressbar.getAttribute('aria-valuenow')).toBe('1');
27+
});
28+
29+
it('navigates to next step on Next click', () => {
30+
render(<OnboardingWizard onComplete={onComplete} />);
31+
fireEvent.click(screen.getByLabelText('Go to next step'));
32+
expect(screen.getByText('Connect Your Environment')).toBeDefined();
33+
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('2');
34+
});
35+
36+
it('shows copy-to-clipboard button on step 2', () => {
37+
render(<OnboardingWizard onComplete={onComplete} />);
38+
fireEvent.click(screen.getByLabelText('Go to next step'));
39+
expect(screen.getByLabelText('Copy command to clipboard')).toBeDefined();
40+
expect(screen.getByText(/npx @anthropic-ai/)).toBeDefined();
41+
});
42+
43+
it('navigates to previous step on Back click', () => {
44+
render(<OnboardingWizard onComplete={onComplete} />);
45+
fireEvent.click(screen.getByLabelText('Go to next step'));
46+
fireEvent.click(screen.getByLabelText('Go to previous step'));
47+
expect(screen.getByText('Welcome to Aegis')).toBeDefined();
48+
});
49+
50+
it('disables Back button on step 1', () => {
51+
render(<OnboardingWizard onComplete={onComplete} />);
52+
expect((screen.getByLabelText('Go to previous step') as HTMLButtonElement).disabled).toBe(true);
53+
});
54+
55+
it('calls onComplete on last step', () => {
56+
render(<OnboardingWizard onComplete={onComplete} />);
57+
fireEvent.click(screen.getByLabelText('Go to next step'));
58+
fireEvent.click(screen.getByLabelText('Go to next step'));
59+
fireEvent.click(screen.getByLabelText('Go to next step'));
60+
fireEvent.click(screen.getByLabelText('Complete onboarding and go to dashboard'));
61+
expect(onComplete).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('calls onComplete on Skip click', () => {
65+
render(<OnboardingWizard onComplete={onComplete} />);
66+
fireEvent.click(screen.getByLabelText('Skip onboarding'));
67+
expect(onComplete).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it('calls onComplete on Skip tour button', () => {
71+
render(<OnboardingWizard onComplete={onComplete} />);
72+
fireEvent.click(screen.getByText('Skip tour'));
73+
expect(onComplete).toHaveBeenCalledTimes(1);
74+
});
75+
76+
it('announces step changes to screen readers', () => {
77+
render(<OnboardingWizard onComplete={onComplete} />);
78+
const status = screen.getByRole('status');
79+
expect(status.textContent).toContain('Step 1 of 4: Welcome to Aegis');
80+
fireEvent.click(screen.getByLabelText('Go to next step'));
81+
expect(status.textContent).toContain('Step 2 of 4: Connect Your Environment');
82+
});
83+
84+
it('has accessible region label', () => {
85+
render(<OnboardingWizard onComplete={onComplete} />);
86+
expect(screen.getByRole('region', { name: 'Onboarding wizard' })).toBeDefined();
87+
});
88+
});

0 commit comments

Comments
 (0)