Skip to content

Commit a2a95af

Browse files
authored
🤖 feat: add tutorial tooltip system for new user onboarding (#788)
## Summary Introduce a guided tutorial system that shows tooltips on first use to help new users discover key features. ## Tutorial Sequences 1. **Settings** (on app launch): Points to the settings gear icon 2. **Creation** (when creating workspace): Walks through Model selector → Exec/Plan mode → From branch → Runtime selector 3. **Workspace** (when entering a workspace): Highlights the terminal button ## Features - **Dismissible**: Each sequence marks as completed when dismissed or finished - **Disable option**: Users can select "Don't show again" to disable all tutorials - **Highlight effect**: Target elements get a pulsing accent border to draw attention - **Smart positioning**: Tooltips auto-flip to stay in viewport bounds ## Implementation - `TutorialState` persisted in localStorage with completion tracking per sequence - `TutorialProvider` context manages active sequence and step progression - `TutorialTooltip` renders portal-based tooltip with backdrop and arrow - Clean separation via `data-tutorial` attributes on target elements --- _Generated with `mux`_
1 parent 0220637 commit a2a95af

File tree

13 files changed

+788
-21
lines changed

13 files changed

+788
-21
lines changed

.storybook/preview.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@ import React from "react";
22
import type { Preview } from "@storybook/react-vite";
33
import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext";
44
import "../src/browser/styles/globals.css";
5+
import { TUTORIAL_STATE_KEY, type TutorialState } from "../src/common/constants/storage";
6+
7+
// Disable tutorials by default in Storybook to prevent them from interfering with stories
8+
// Individual stories can override this by setting localStorage before rendering
9+
function disableTutorials() {
10+
if (typeof localStorage !== "undefined") {
11+
const disabledState: TutorialState = {
12+
disabled: true,
13+
completed: { settings: true, creation: true, workspace: true },
14+
};
15+
localStorage.setItem(TUTORIAL_STATE_KEY, JSON.stringify(disabledState));
16+
}
17+
}
518

619
const preview: Preview = {
720
globalTypes: {
@@ -32,6 +45,11 @@ const preview: Preview = {
3245
document.documentElement.style.colorScheme = mode;
3346
}
3447

48+
// Disable tutorials by default unless explicitly enabled for this story
49+
if (!context.parameters?.tutorialEnabled) {
50+
disableTutorials();
51+
}
52+
3553
return (
3654
<ThemeProvider forcedTheme={mode}>
3755
<Story />

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}
@@ -958,7 +975,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
958975
</div>
959976
)}
960977

961-
<div className="ml-auto flex items-center gap-2" data-component="ModelControls">
978+
<div
979+
className="ml-auto flex items-center gap-2"
980+
data-component="ModelControls"
981+
data-tutorial="mode-selector"
982+
>
962983
<ModeSelector mode={mode} onChange={setMode} />
963984
<TooltipWrapper inline>
964985
<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: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { TutorialTooltip, type TutorialStep } from "./TutorialTooltip";
3+
import { TutorialProvider } from "@/browser/contexts/TutorialContext";
4+
import { TUTORIAL_STATE_KEY, type TutorialState } from "@/common/constants/storage";
5+
6+
// eslint-disable-next-line @typescript-eslint/no-empty-function
7+
const noop = () => {};
8+
9+
const meta = {
10+
title: "Components/TutorialTooltip",
11+
component: TutorialTooltip,
12+
parameters: {
13+
layout: "centered",
14+
// Enable tutorials for these stories
15+
tutorialEnabled: true,
16+
},
17+
tags: ["autodocs"],
18+
decorators: [
19+
(Story) => {
20+
// Reset tutorial state to not-disabled for these stories
21+
const enabledState: TutorialState = {
22+
disabled: false,
23+
completed: {},
24+
};
25+
localStorage.setItem(TUTORIAL_STATE_KEY, JSON.stringify(enabledState));
26+
27+
return (
28+
<TutorialProvider>
29+
<Story />
30+
</TutorialProvider>
31+
);
32+
},
33+
],
34+
} satisfies Meta<typeof TutorialTooltip>;
35+
36+
export default meta;
37+
type Story = StoryObj<typeof meta>;
38+
39+
// Mock target element for positioning
40+
const MockTargetWrapper: React.FC<{
41+
children: React.ReactNode;
42+
tutorialTarget: string;
43+
}> = ({ children, tutorialTarget }) => (
44+
<div className="bg-background flex h-[400px] w-[600px] items-center justify-center">
45+
<button
46+
data-tutorial={tutorialTarget}
47+
className="bg-accent rounded px-4 py-2 text-sm text-white"
48+
>
49+
Target Element
50+
</button>
51+
{children}
52+
</div>
53+
);
54+
55+
const sampleStep: TutorialStep = {
56+
target: "demo-target",
57+
title: "Welcome to Mux",
58+
content: "This is a tutorial tooltip that helps guide users through the application.",
59+
position: "bottom",
60+
};
61+
62+
export const SingleStep: Story = {
63+
args: {
64+
step: sampleStep,
65+
currentStep: 1,
66+
totalSteps: 1,
67+
onNext: noop,
68+
onDismiss: noop,
69+
onDisableTutorial: noop,
70+
},
71+
render: (args) => (
72+
<MockTargetWrapper tutorialTarget="demo-target">
73+
<TutorialTooltip {...args} />
74+
</MockTargetWrapper>
75+
),
76+
};
77+
78+
export const MultiStepFirst: Story = {
79+
args: {
80+
step: {
81+
target: "demo-target",
82+
title: "Choose Your Model",
83+
content:
84+
"Select which AI model to use. Different models have different capabilities and costs.",
85+
position: "bottom",
86+
},
87+
currentStep: 1,
88+
totalSteps: 4,
89+
onNext: noop,
90+
onDismiss: noop,
91+
onDisableTutorial: noop,
92+
},
93+
render: (args) => (
94+
<MockTargetWrapper tutorialTarget="demo-target">
95+
<TutorialTooltip {...args} />
96+
</MockTargetWrapper>
97+
),
98+
};
99+
100+
export const MultiStepMiddle: Story = {
101+
args: {
102+
step: {
103+
target: "demo-target",
104+
title: "Exec vs Plan Mode",
105+
content:
106+
"Exec mode lets the AI edit files and run commands. Plan mode is read-only—great for exploring ideas safely.",
107+
position: "top",
108+
},
109+
currentStep: 2,
110+
totalSteps: 4,
111+
onNext: noop,
112+
onDismiss: noop,
113+
onDisableTutorial: noop,
114+
},
115+
render: (args) => (
116+
<MockTargetWrapper tutorialTarget="demo-target">
117+
<TutorialTooltip {...args} />
118+
</MockTargetWrapper>
119+
),
120+
};
121+
122+
export const MultiStepLast: Story = {
123+
args: {
124+
step: {
125+
target: "demo-target",
126+
title: "Runtime Environment",
127+
content: "Run locally using git worktrees, or connect via SSH to work on a remote machine.",
128+
position: "bottom",
129+
},
130+
currentStep: 4,
131+
totalSteps: 4,
132+
onNext: noop,
133+
onDismiss: noop,
134+
onDisableTutorial: noop,
135+
},
136+
render: (args) => (
137+
<MockTargetWrapper tutorialTarget="demo-target">
138+
<TutorialTooltip {...args} />
139+
</MockTargetWrapper>
140+
),
141+
};
142+
143+
// Position variants
144+
const PositionWrapper: React.FC<{
145+
children: React.ReactNode;
146+
tutorialTarget: string;
147+
position: "center" | "top" | "bottom" | "left" | "right";
148+
}> = ({ children, tutorialTarget, position }) => {
149+
const positionClasses = {
150+
center: "items-center justify-center",
151+
top: "items-start justify-center pt-20",
152+
bottom: "items-end justify-center pb-20",
153+
left: "items-center justify-start pl-20",
154+
right: "items-center justify-end pr-20",
155+
};
156+
157+
return (
158+
<div className={`bg-background flex h-[400px] w-[600px] ${positionClasses[position]}`}>
159+
<button
160+
data-tutorial={tutorialTarget}
161+
className="bg-accent rounded px-4 py-2 text-sm text-white"
162+
>
163+
Target
164+
</button>
165+
{children}
166+
</div>
167+
);
168+
};
169+
170+
export const PositionBottom: Story = {
171+
args: {
172+
step: { ...sampleStep, position: "bottom" },
173+
currentStep: 1,
174+
totalSteps: 1,
175+
onNext: noop,
176+
onDismiss: noop,
177+
onDisableTutorial: noop,
178+
},
179+
render: (args) => (
180+
<PositionWrapper tutorialTarget="demo-target" position="top">
181+
<TutorialTooltip {...args} />
182+
</PositionWrapper>
183+
),
184+
};
185+
186+
export const PositionTop: Story = {
187+
args: {
188+
step: { ...sampleStep, position: "top" },
189+
currentStep: 1,
190+
totalSteps: 1,
191+
onNext: noop,
192+
onDismiss: noop,
193+
onDisableTutorial: noop,
194+
},
195+
render: (args) => (
196+
<PositionWrapper tutorialTarget="demo-target" position="bottom">
197+
<TutorialTooltip {...args} />
198+
</PositionWrapper>
199+
),
200+
};
201+
202+
export const PositionLeft: Story = {
203+
args: {
204+
step: { ...sampleStep, position: "left" },
205+
currentStep: 1,
206+
totalSteps: 1,
207+
onNext: noop,
208+
onDismiss: noop,
209+
onDisableTutorial: noop,
210+
},
211+
render: (args) => (
212+
<PositionWrapper tutorialTarget="demo-target" position="right">
213+
<TutorialTooltip {...args} />
214+
</PositionWrapper>
215+
),
216+
};
217+
218+
export const PositionRight: Story = {
219+
args: {
220+
step: { ...sampleStep, position: "right" },
221+
currentStep: 1,
222+
totalSteps: 1,
223+
onNext: noop,
224+
onDismiss: noop,
225+
onDisableTutorial: noop,
226+
},
227+
render: (args) => (
228+
<PositionWrapper tutorialTarget="demo-target" position="left">
229+
<TutorialTooltip {...args} />
230+
</PositionWrapper>
231+
),
232+
};

0 commit comments

Comments
 (0)