Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import React from "react";
import type { Preview } from "@storybook/react-vite";
import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext";
import "../src/browser/styles/globals.css";
import { TUTORIAL_STATE_KEY, type TutorialState } from "../src/common/constants/storage";

// Disable tutorials by default in Storybook to prevent them from interfering with stories
// Individual stories can override this by setting localStorage before rendering
function disableTutorials() {
if (typeof localStorage !== "undefined") {
const disabledState: TutorialState = {
disabled: true,
completed: { settings: true, creation: true, workspace: true },
};
localStorage.setItem(TUTORIAL_STATE_KEY, JSON.stringify(disabledState));
}
}

const preview: Preview = {
globalTypes: {
Expand Down Expand Up @@ -32,6 +45,11 @@ const preview: Preview = {
document.documentElement.style.colorScheme = mode;
}

// Disable tutorials by default unless explicitly enabled for this story
if (!context.parameters?.tutorialEnabled) {
disableTutorials();
}

return (
<ThemeProvider forcedTheme={mode}>
<Story />
Expand Down
9 changes: 6 additions & 3 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStart

import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
import { SettingsModal } from "./components/Settings/SettingsModal";
import { TutorialProvider } from "./contexts/TutorialContext";

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

Expand Down Expand Up @@ -653,9 +654,11 @@ function App() {
return (
<ThemeProvider>
<SettingsProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
<TutorialProvider>
<CommandRegistryProvider>
<AppInner />
</CommandRegistryProvider>
</TutorialProvider>
</SettingsProvider>
</ThemeProvider>
);
Expand Down
12 changes: 10 additions & 2 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export function CreationControls(props: CreationControlsProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{/* Trunk Branch Selector */}
{props.branches.length > 0 && (
<div className="flex items-center gap-1" data-component="TrunkBranchGroup">
<div
className="flex items-center gap-1"
data-component="TrunkBranchGroup"
data-tutorial="trunk-branch"
>
<label htmlFor="trunk-branch" className="text-muted text-xs">
From:
</label>
Expand All @@ -39,7 +43,11 @@ export function CreationControls(props: CreationControlsProps) {
)}

{/* Runtime Selector */}
<div className="flex items-center gap-1" data-component="RuntimeSelectorGroup">
<div
className="flex items-center gap-1"
data-component="RuntimeSelectorGroup"
data-tutorial="runtime-selector"
>
<label className="text-muted text-xs">Runtime:</label>
<Select
value={props.runtimeMode}
Expand Down
25 changes: 23 additions & 2 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { CreationCenterContent } from "./CreationCenterContent";
import { cn } from "@/common/lib/utils";
import { CreationControls } from "./CreationControls";
import { useCreationWorkspace } from "./useCreationWorkspace";
import { useTutorial } from "@/browser/contexts/TutorialContext";

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

Expand Down Expand Up @@ -150,6 +151,18 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
listener: true,
});
const { startSequence: startTutorial } = useTutorial();

// Start creation tutorial when entering creation mode
useEffect(() => {
if (variant === "creation") {
// Small delay to ensure UI is rendered
const timer = setTimeout(() => {
startTutorial("creation");
}, 600);
return () => clearTimeout(timer);
}
}, [variant, startTutorial]);

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

<div className="@container flex flex-wrap items-center gap-x-3 gap-y-2">
{/* Model Selector - always visible */}
<div className="flex items-center" data-component="ModelSelectorGroup">
<div
className="flex items-center"
data-component="ModelSelectorGroup"
data-tutorial="model-selector"
>
<ModelSelector
ref={modelSelectorRef}
value={preferredModel}
Expand Down Expand Up @@ -959,7 +976,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
</div>
)}

<div className="ml-auto flex items-center gap-2" data-component="ModelControls">
<div
className="ml-auto flex items-center gap-2"
data-component="ModelControls"
data-tutorial="mode-selector"
>
<ModeSelector mode={mode} onChange={setMode} />
<TooltipWrapper inline>
<button
Expand Down
1 change: 1 addition & 0 deletions src/browser/components/SettingsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function SettingsButton() {
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"
aria-label="Open settings"
data-testid="settings-button"
data-tutorial="settings-button"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
</button>
Expand Down
11 changes: 11 additions & 0 deletions src/browser/components/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SettingsButton } from "./SettingsButton";
import { TooltipWrapper, Tooltip } from "./Tooltip";
import type { UpdateStatus } from "@/common/types/ipc";
import { isTelemetryEnabled } from "@/common/telemetry";
import { useTutorial } from "@/browser/contexts/TutorialContext";

// Update check intervals
const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
Expand Down Expand Up @@ -78,6 +79,16 @@ export function TitleBar() {
const [isCheckingOnHover, setIsCheckingOnHover] = useState(false);
const lastHoverCheckTime = useRef<number>(0);
const telemetryEnabled = isTelemetryEnabled();
const { startSequence } = useTutorial();

// Start settings tutorial on first launch
useEffect(() => {
// Small delay to ensure UI is rendered before showing tutorial
const timer = setTimeout(() => {
startSequence("settings");
}, 500);
return () => clearTimeout(timer);
}, [startSequence]);

useEffect(() => {
// Skip update checks if telemetry is disabled
Expand Down
232 changes: 232 additions & 0 deletions src/browser/components/TutorialTooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TutorialTooltip, type TutorialStep } from "./TutorialTooltip";
import { TutorialProvider } from "@/browser/contexts/TutorialContext";
import { TUTORIAL_STATE_KEY, type TutorialState } from "@/common/constants/storage";

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};

const meta = {
title: "Components/TutorialTooltip",
component: TutorialTooltip,
parameters: {
layout: "centered",
// Enable tutorials for these stories
tutorialEnabled: true,
},
tags: ["autodocs"],
decorators: [
(Story) => {
// Reset tutorial state to not-disabled for these stories
const enabledState: TutorialState = {
disabled: false,
completed: {},
};
localStorage.setItem(TUTORIAL_STATE_KEY, JSON.stringify(enabledState));

return (
<TutorialProvider>
<Story />
</TutorialProvider>
);
},
],
} satisfies Meta<typeof TutorialTooltip>;

export default meta;
type Story = StoryObj<typeof meta>;

// Mock target element for positioning
const MockTargetWrapper: React.FC<{
children: React.ReactNode;
tutorialTarget: string;
}> = ({ children, tutorialTarget }) => (
<div className="bg-background flex h-[400px] w-[600px] items-center justify-center">
<button
data-tutorial={tutorialTarget}
className="bg-accent rounded px-4 py-2 text-sm text-white"
>
Target Element
</button>
{children}
</div>
);

const sampleStep: TutorialStep = {
target: "demo-target",
title: "Welcome to Mux",
content: "This is a tutorial tooltip that helps guide users through the application.",
position: "bottom",
};

export const SingleStep: Story = {
args: {
step: sampleStep,
currentStep: 1,
totalSteps: 1,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<MockTargetWrapper tutorialTarget="demo-target">
<TutorialTooltip {...args} />
</MockTargetWrapper>
),
};

export const MultiStepFirst: Story = {
args: {
step: {
target: "demo-target",
title: "Choose Your Model",
content:
"Select which AI model to use. Different models have different capabilities and costs.",
position: "bottom",
},
currentStep: 1,
totalSteps: 4,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<MockTargetWrapper tutorialTarget="demo-target">
<TutorialTooltip {...args} />
</MockTargetWrapper>
),
};

export const MultiStepMiddle: Story = {
args: {
step: {
target: "demo-target",
title: "Exec vs Plan Mode",
content:
"Exec mode lets the AI edit files and run commands. Plan mode is read-only—great for exploring ideas safely.",
position: "top",
},
currentStep: 2,
totalSteps: 4,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<MockTargetWrapper tutorialTarget="demo-target">
<TutorialTooltip {...args} />
</MockTargetWrapper>
),
};

export const MultiStepLast: Story = {
args: {
step: {
target: "demo-target",
title: "Runtime Environment",
content: "Run locally using git worktrees, or connect via SSH to work on a remote machine.",
position: "bottom",
},
currentStep: 4,
totalSteps: 4,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<MockTargetWrapper tutorialTarget="demo-target">
<TutorialTooltip {...args} />
</MockTargetWrapper>
),
};

// Position variants
const PositionWrapper: React.FC<{
children: React.ReactNode;
tutorialTarget: string;
position: "center" | "top" | "bottom" | "left" | "right";
}> = ({ children, tutorialTarget, position }) => {
const positionClasses = {
center: "items-center justify-center",
top: "items-start justify-center pt-20",
bottom: "items-end justify-center pb-20",
left: "items-center justify-start pl-20",
right: "items-center justify-end pr-20",
};

return (
<div className={`bg-background flex h-[400px] w-[600px] ${positionClasses[position]}`}>
<button
data-tutorial={tutorialTarget}
className="bg-accent rounded px-4 py-2 text-sm text-white"
>
Target
</button>
{children}
</div>
);
};

export const PositionBottom: Story = {
args: {
step: { ...sampleStep, position: "bottom" },
currentStep: 1,
totalSteps: 1,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<PositionWrapper tutorialTarget="demo-target" position="top">
<TutorialTooltip {...args} />
</PositionWrapper>
),
};

export const PositionTop: Story = {
args: {
step: { ...sampleStep, position: "top" },
currentStep: 1,
totalSteps: 1,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<PositionWrapper tutorialTarget="demo-target" position="bottom">
<TutorialTooltip {...args} />
</PositionWrapper>
),
};

export const PositionLeft: Story = {
args: {
step: { ...sampleStep, position: "left" },
currentStep: 1,
totalSteps: 1,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<PositionWrapper tutorialTarget="demo-target" position="right">
<TutorialTooltip {...args} />
</PositionWrapper>
),
};

export const PositionRight: Story = {
args: {
step: { ...sampleStep, position: "right" },
currentStep: 1,
totalSteps: 1,
onNext: noop,
onDismiss: noop,
onDisableTutorial: noop,
},
render: (args) => (
<PositionWrapper tutorialTarget="demo-target" position="left">
<TutorialTooltip {...args} />
</PositionWrapper>
),
};
Loading