Skip to content

Commit 8590a5e

Browse files
committed
🤖 feat: add tutorial tooltip system for new user onboarding
Introduce a guided tutorial system that shows tooltips on first use: - Settings tooltip on app launch (points to gear icon) - Creation flow tooltips (Model, Exec/Plan, From branch, Runtime) - Workspace tutorial (Terminal icon) Each sequence can be dismissed individually, and users can opt to disable all tutorials via 'Don't show again' option. Implementation: - TutorialState persisted in localStorage with completion tracking - TutorialProvider context manages active sequence and step progression - TutorialTooltip renders portal-based tooltip with highlight effect - data-tutorial attributes on target elements for clean separation _Generated with mux_
1 parent d7560e1 commit 8590a5e

File tree

11 files changed

+539
-21
lines changed

11 files changed

+539
-21
lines changed

src/browser/App.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStart
3636

3737
import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
3838
import { SettingsModal } from "./components/Settings/SettingsModal";
39+
import { TutorialProvider } from "./contexts/TutorialContext";
3940

4041
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
4142

@@ -653,9 +654,11 @@ function App() {
653654
return (
654655
<ThemeProvider>
655656
<SettingsProvider>
656-
<CommandRegistryProvider>
657-
<AppInner />
658-
</CommandRegistryProvider>
657+
<TutorialProvider>
658+
<CommandRegistryProvider>
659+
<AppInner />
660+
</CommandRegistryProvider>
661+
</TutorialProvider>
659662
</SettingsProvider>
660663
</ThemeProvider>
661664
);

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export function CreationControls(props: CreationControlsProps) {
2323
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
2424
{/* Trunk Branch Selector */}
2525
{props.branches.length > 0 && (
26-
<div className="flex items-center gap-1" data-component="TrunkBranchGroup">
26+
<div
27+
className="flex items-center gap-1"
28+
data-component="TrunkBranchGroup"
29+
data-tutorial="trunk-branch"
30+
>
2731
<label htmlFor="trunk-branch" className="text-muted text-xs">
2832
From:
2933
</label>
@@ -39,7 +43,11 @@ export function CreationControls(props: CreationControlsProps) {
3943
)}
4044

4145
{/* Runtime Selector */}
42-
<div className="flex items-center gap-1" data-component="RuntimeSelectorGroup">
46+
<div
47+
className="flex items-center gap-1"
48+
data-component="RuntimeSelectorGroup"
49+
data-tutorial="runtime-selector"
50+
>
4351
<label className="text-muted text-xs">Runtime:</label>
4452
<Select
4553
value={props.runtimeMode}

src/browser/components/ChatInput/index.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { CreationCenterContent } from "./CreationCenterContent";
6363
import { cn } from "@/common/lib/utils";
6464
import { CreationControls } from "./CreationControls";
6565
import { useCreationWorkspace } from "./useCreationWorkspace";
66+
import { useTutorial } from "@/browser/contexts/TutorialContext";
6667

6768
const LEADING_COMMAND_NOISE = /^(?:\s|\u200B|\u200C|\u200D|\u200E|\u200F|\uFEFF)+/;
6869

@@ -150,6 +151,18 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
150151
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
151152
listener: true,
152153
});
154+
const { startSequence: startTutorial } = useTutorial();
155+
156+
// Start creation tutorial when entering creation mode
157+
useEffect(() => {
158+
if (variant === "creation") {
159+
// Small delay to ensure UI is rendered
160+
const timer = setTimeout(() => {
161+
startTutorial("creation");
162+
}, 600);
163+
return () => clearTimeout(timer);
164+
}
165+
}, [variant, startTutorial]);
153166

154167
// Get current send message options from shared hook (must be at component top level)
155168
// For creation variant, use project-scoped key; for workspace, use workspace ID
@@ -898,7 +911,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
898911

899912
<div className="@container flex flex-wrap items-center gap-x-3 gap-y-2">
900913
{/* Model Selector - always visible */}
901-
<div className="flex items-center" data-component="ModelSelectorGroup">
914+
<div
915+
className="flex items-center"
916+
data-component="ModelSelectorGroup"
917+
data-tutorial="model-selector"
918+
>
902919
<ModelSelector
903920
ref={modelSelectorRef}
904921
value={preferredModel}
@@ -959,7 +976,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
959976
</div>
960977
)}
961978

962-
<div className="ml-auto flex items-center gap-2" data-component="ModelControls">
979+
<div
980+
className="ml-auto flex items-center gap-2"
981+
data-component="ModelControls"
982+
data-tutorial="mode-selector"
983+
>
963984
<ModeSelector mode={mode} onChange={setMode} />
964985
<TooltipWrapper inline>
965986
<button

src/browser/components/SettingsButton.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function SettingsButton() {
1414
className="border-border-light text-muted-foreground hover:border-border-medium/80 hover:bg-toggle-bg/70 focus-visible:ring-border-medium flex h-5 w-5 items-center justify-center rounded-md border bg-transparent transition-colors duration-150 focus-visible:ring-1"
1515
aria-label="Open settings"
1616
data-testid="settings-button"
17+
data-tutorial="settings-button"
1718
>
1819
<Settings className="h-3.5 w-3.5" aria-hidden />
1920
</button>

src/browser/components/TitleBar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SettingsButton } from "./SettingsButton";
55
import { TooltipWrapper, Tooltip } from "./Tooltip";
66
import type { UpdateStatus } from "@/common/types/ipc";
77
import { isTelemetryEnabled } from "@/common/telemetry";
8+
import { useTutorial } from "@/browser/contexts/TutorialContext";
89

910
// Update check intervals
1011
const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
@@ -78,6 +79,16 @@ export function TitleBar() {
7879
const [isCheckingOnHover, setIsCheckingOnHover] = useState(false);
7980
const lastHoverCheckTime = useRef<number>(0);
8081
const telemetryEnabled = isTelemetryEnabled();
82+
const { startSequence } = useTutorial();
83+
84+
// Start settings tutorial on first launch
85+
useEffect(() => {
86+
// Small delay to ensure UI is rendered before showing tutorial
87+
const timer = setTimeout(() => {
88+
startSequence("settings");
89+
}, 500);
90+
return () => clearTimeout(timer);
91+
}, [startSequence]);
8192

8293
useEffect(() => {
8394
// Skip update checks if telemetry is disabled
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import React, { useState, useRef, useLayoutEffect } from "react";
2+
import { createPortal } from "react-dom";
3+
import { cn } from "@/common/lib/utils";
4+
5+
export interface TutorialStep {
6+
target: string; // data-tutorial attribute value
7+
title: string;
8+
content: React.ReactNode;
9+
position?: "top" | "bottom" | "left" | "right";
10+
}
11+
12+
interface TutorialTooltipProps {
13+
step: TutorialStep;
14+
currentStep: number;
15+
totalSteps: number;
16+
onNext: () => void;
17+
onDismiss: () => void;
18+
onDisableTutorial: () => void;
19+
}
20+
21+
interface TooltipPosition {
22+
top: number;
23+
left: number;
24+
arrowStyle: React.CSSProperties;
25+
actualPosition: "top" | "bottom" | "left" | "right";
26+
}
27+
28+
export const TutorialTooltip: React.FC<TutorialTooltipProps> = ({
29+
step,
30+
currentStep,
31+
totalSteps,
32+
onNext,
33+
onDismiss,
34+
onDisableTutorial,
35+
}) => {
36+
const tooltipRef = useRef<HTMLDivElement>(null);
37+
const [position, setPosition] = useState<TooltipPosition | null>(null);
38+
const [showDisableOption, setShowDisableOption] = useState(false);
39+
40+
useLayoutEffect(() => {
41+
const targetEl = document.querySelector(`[data-tutorial="${step.target}"]`);
42+
if (!targetEl || !tooltipRef.current) {
43+
return;
44+
}
45+
46+
const calculatePosition = () => {
47+
const target = targetEl.getBoundingClientRect();
48+
const tooltip = tooltipRef.current!.getBoundingClientRect();
49+
const viewportWidth = window.innerWidth;
50+
const viewportHeight = window.innerHeight;
51+
const gap = 12;
52+
53+
const preferredPosition = step.position ?? "bottom";
54+
let actualPosition = preferredPosition;
55+
let top: number;
56+
let left: number;
57+
58+
// Try preferred position, flip if it doesn't fit
59+
if (preferredPosition === "bottom" || preferredPosition === "top") {
60+
if (preferredPosition === "bottom") {
61+
top = target.bottom + gap;
62+
if (top + tooltip.height > viewportHeight) {
63+
actualPosition = "top";
64+
top = target.top - tooltip.height - gap;
65+
}
66+
} else {
67+
top = target.top - tooltip.height - gap;
68+
if (top < 0) {
69+
actualPosition = "bottom";
70+
top = target.bottom + gap;
71+
}
72+
}
73+
// Center horizontally relative to target
74+
left = target.left + target.width / 2 - tooltip.width / 2;
75+
} else {
76+
// left or right
77+
if (preferredPosition === "right") {
78+
left = target.right + gap;
79+
if (left + tooltip.width > viewportWidth) {
80+
actualPosition = "left";
81+
left = target.left - tooltip.width - gap;
82+
}
83+
} else {
84+
left = target.left - tooltip.width - gap;
85+
if (left < 0) {
86+
actualPosition = "right";
87+
left = target.right + gap;
88+
}
89+
}
90+
// Center vertically relative to target
91+
top = target.top + target.height / 2 - tooltip.height / 2;
92+
}
93+
94+
// Clamp to viewport bounds
95+
const minMargin = 8;
96+
left = Math.max(minMargin, Math.min(viewportWidth - tooltip.width - minMargin, left));
97+
top = Math.max(minMargin, Math.min(viewportHeight - tooltip.height - minMargin, top));
98+
99+
// Calculate arrow position
100+
const arrowStyle: React.CSSProperties = {};
101+
if (actualPosition === "bottom" || actualPosition === "top") {
102+
const arrowLeft = target.left + target.width / 2 - left;
103+
arrowStyle.left = `${Math.max(12, Math.min(tooltip.width - 12, arrowLeft))}px`;
104+
if (actualPosition === "bottom") {
105+
arrowStyle.top = "-6px";
106+
arrowStyle.borderWidth = "0 6px 6px 6px";
107+
arrowStyle.borderColor = "transparent transparent var(--color-accent) transparent";
108+
} else {
109+
arrowStyle.bottom = "-6px";
110+
arrowStyle.borderWidth = "6px 6px 0 6px";
111+
arrowStyle.borderColor = "var(--color-accent) transparent transparent transparent";
112+
}
113+
} else {
114+
const arrowTop = target.top + target.height / 2 - top;
115+
arrowStyle.top = `${Math.max(12, Math.min(tooltip.height - 12, arrowTop))}px`;
116+
if (actualPosition === "right") {
117+
arrowStyle.left = "-6px";
118+
arrowStyle.borderWidth = "6px 6px 6px 0";
119+
arrowStyle.borderColor = "transparent var(--color-accent) transparent transparent";
120+
} else {
121+
arrowStyle.right = "-6px";
122+
arrowStyle.borderWidth = "6px 0 6px 6px";
123+
arrowStyle.borderColor = "transparent transparent transparent var(--color-accent)";
124+
}
125+
}
126+
127+
setPosition({ top, left, arrowStyle, actualPosition });
128+
};
129+
130+
calculatePosition();
131+
132+
// Recalculate on resize
133+
window.addEventListener("resize", calculatePosition);
134+
return () => window.removeEventListener("resize", calculatePosition);
135+
}, [step.target, step.position]);
136+
137+
// Add highlight to target element
138+
useLayoutEffect(() => {
139+
const targetEl = document.querySelector(`[data-tutorial="${step.target}"]`);
140+
if (!targetEl) return;
141+
142+
targetEl.classList.add("tutorial-highlight");
143+
return () => {
144+
targetEl.classList.remove("tutorial-highlight");
145+
};
146+
}, [step.target]);
147+
148+
const isLastStep = currentStep === totalSteps;
149+
150+
const handleDismissClick = () => {
151+
if (showDisableOption) {
152+
onDismiss();
153+
} else {
154+
setShowDisableOption(true);
155+
}
156+
};
157+
158+
return createPortal(
159+
<>
160+
{/* Backdrop - subtle overlay */}
161+
<div
162+
className="fixed inset-0 z-[9998] bg-black/20"
163+
data-testid="tutorial-backdrop"
164+
onClick={onDismiss}
165+
/>
166+
{/* Tooltip */}
167+
<div
168+
ref={tooltipRef}
169+
style={{
170+
position: "fixed",
171+
top: position?.top ?? -9999,
172+
left: position?.left ?? -9999,
173+
visibility: position ? "visible" : "hidden",
174+
}}
175+
className={cn(
176+
"z-[9999] w-72 rounded-lg border-2 border-accent bg-modal-bg p-4 shadow-lg",
177+
"text-foreground"
178+
)}
179+
>
180+
{/* Arrow */}
181+
<div className="absolute h-0 w-0 border-solid" style={position?.arrowStyle} />
182+
183+
{/* Header */}
184+
<div className="mb-2 flex items-start justify-between">
185+
<h3 className="text-sm font-semibold">{step.title}</h3>
186+
<span className="text-muted text-xs">
187+
{currentStep}/{totalSteps}
188+
</span>
189+
</div>
190+
191+
{/* Content */}
192+
<p className="text-muted mb-4 text-xs leading-relaxed">{step.content}</p>
193+
194+
{/* Actions */}
195+
<div className="flex items-center justify-between">
196+
<div>
197+
{showDisableOption ? (
198+
<button
199+
onClick={onDisableTutorial}
200+
className="text-muted hover:text-foreground text-[10px] underline transition-colors"
201+
>
202+
Don&apos;t show tutorials again
203+
</button>
204+
) : (
205+
<button
206+
onClick={handleDismissClick}
207+
className="text-muted hover:text-foreground text-xs transition-colors"
208+
>
209+
Skip
210+
</button>
211+
)}
212+
</div>
213+
<button
214+
onClick={isLastStep ? onDismiss : onNext}
215+
className="bg-accent rounded px-3 py-1.5 text-xs font-medium text-white transition-colors hover:opacity-90"
216+
>
217+
{isLastStep ? "Done" : "Next"}
218+
</button>
219+
</div>
220+
</div>
221+
</>,
222+
document.body
223+
);
224+
};

0 commit comments

Comments
 (0)