Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/code/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const preview: Preview = {
grayColor="slate"
panelBackground="solid"
radius="none"
scaling="105%"
scaling="100%"
>
<Story />
</Theme>
Expand Down
18 changes: 18 additions & 0 deletions apps/code/src/renderer/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import { CommandCenterView } from "@features/command-center/components/CommandCe
import { InboxView } from "@features/inbox/components/InboxView";
import { FolderSettingsView } from "@features/settings/components/FolderSettingsView";
import { SettingsDialog } from "@features/settings/components/SettingsDialog";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import { SetupView } from "@features/setup/components/SetupView";
import { MainSidebar } from "@features/sidebar/components/MainSidebar";
import { SkillsView } from "@features/skills/components/SkillsView";
import { TaskDetail } from "@features/task-detail/components/TaskDetail";
import { TaskInput } from "@features/task-detail/components/TaskInput";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { TourOverlay } from "@features/tour/components/TourOverlay";
import { useTourStore } from "@features/tour/stores/tourStore";
import { createFirstTaskTour } from "@features/tour/tours/createFirstTaskTour";
import { useConnectivity } from "@hooks/useConnectivity";
import { useIntegrations } from "@hooks/useIntegrations";
import { Box, Flex } from "@radix-ui/themes";
Expand All @@ -40,6 +44,11 @@ export function MainLayout() {
const { data: tasks } = useTasks();
const { showPrompt, isChecking, check, dismiss } = useConnectivity();

const startTour = useTourStore((s) => s.startTour);
const isFirstTaskTourDone = useTourStore((s) =>
s.completedTourIds.includes(createFirstTaskTour.id),
);

useIntegrations();
useTaskDeepLink();

Expand All @@ -55,6 +64,14 @@ export function MainLayout() {
}
}, [view, navigateToTaskInput]);

const settingsOpen = useSettingsDialogStore((s) => s.isOpen);

useEffect(() => {
if (isFirstTaskTourDone || settingsOpen) return;
const timer = setTimeout(() => startTour(createFirstTaskTour.id), 600);
return () => clearTimeout(timer);
}, [isFirstTaskTourDone, settingsOpen, startTour]);

const handleToggleCommandMenu = useCallback(() => {
toggleCommandMenu();
}, [toggleCommandMenu]);
Expand Down Expand Up @@ -102,6 +119,7 @@ export function MainLayout() {
onToggleShortcutsSheet={toggleShortcutsSheet}
/>
<SettingsDialog />
<TourOverlay />
<HedgehogMode />
</Flex>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/code/src/renderer/components/ThemeWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function ThemeWrapper({ children }: { children: React.ReactNode }) {
grayColor="slate"
panelBackground="solid"
radius="medium"
scaling="105%"
scaling="100%"
>
{children}
<div ref={portalRef} id="portal-container" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"
import { SettingRow } from "@features/settings/components/SettingRow";
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import { useSettingsStore } from "@features/settings/stores/settingsStore";
import { useTourStore } from "@features/tour/stores/tourStore";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { Button, Flex, Switch } from "@radix-ui/themes";
import { clearApplicationStorage } from "@utils/clearStorage";
Expand Down Expand Up @@ -31,6 +32,18 @@ export function AdvancedSettings() {
Reset
</Button>
</SettingRow>
<SettingRow
label="Reset product tours"
description="Re-run product tours on next app restart"
>
<Button
variant="soft"
size="1"
onClick={() => useTourStore.getState().resetTours()}
>
Reset
</Button>
</SettingRow>
<SettingRow
label="Clear application storage"
description="This will remove all locally stored application data"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,19 @@ export function TaskInput({
disabled={isCreatingTask}
/>
)}
<ButtonGroup ref={buttonGroupRef}>
<ButtonGroup
ref={buttonGroupRef}
data-tour="folder-picker"
data-tour-ready={
(
workspaceMode === "cloud"
? selectedRepository
: selectedDirectory
)
? "true"
: undefined
}
>
{workspaceMode === "cloud" ? (
<GitHubRepoPicker
value={selectedRepository}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from "@features/message-editor/utils/content";
import { useSettingsStore } from "@features/settings/stores/settingsStore";
import { useCreateTask } from "@features/tasks/hooks/useTasks";
import { useTourStore } from "@features/tour/stores/tourStore";
import { createFirstTaskTour } from "@features/tour/tours/createFirstTaskTour";
import { useConnectivity } from "@hooks/useConnectivity";
import type { WorkspaceMode } from "@main/services/workspace/schemas";
import { get } from "@renderer/di/container";
Expand Down Expand Up @@ -170,6 +172,7 @@ export function useTaskCreation({
} else {
navigateToTask(output.task);
}
useTourStore.getState().completeTour(createFirstTaskTour.id);
editor.clear();
});

Expand Down
149 changes: 149 additions & 0 deletions apps/code/src/renderer/features/tour/components/TourOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useElementRect } from "../hooks/useElementRect";
import { useTourStore } from "../stores/tourStore";
import { TOUR_REGISTRY } from "../tours/tourRegistry";
import { TourTooltip } from "./TourTooltip";

const SPOTLIGHT_PADDING = 6;
const SPOTLIGHT_RADIUS = 8;

function SpotlightOverlay({ targetRect }: { targetRect: DOMRect | null }) {
return createPortal(
<AnimatePresence>
{targetRect && (
<motion.div
key="spotlight"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
top: targetRect.top - SPOTLIGHT_PADDING,
left: targetRect.left - SPOTLIGHT_PADDING,
width: targetRect.width + SPOTLIGHT_PADDING * 2,
height: targetRect.height + SPOTLIGHT_PADDING * 2,
}}
exit={{ opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
style={{
position: "fixed",
borderRadius: SPOTLIGHT_RADIUS,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.5)",
zIndex: 199,
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>,
document.body,
);
}

export function TourOverlay() {
const activeTourId = useTourStore((s) => s.activeTourId);
const activeStepIndex = useTourStore((s) => s.activeStepIndex);
const advance = useTourStore((s) => s.advance);
const dismiss = useTourStore((s) => s.dismiss);

const tour = activeTourId ? TOUR_REGISTRY[activeTourId] : null;
const step = tour?.steps[activeStepIndex] ?? null;

const selector = step ? `[data-tour="${step.target}"]` : null;
const targetRect = useElementRect(selector);

const advancedRef = useRef(false);

// biome-ignore lint/correctness/useExhaustiveDependencies: reset on step change
useEffect(() => {
advancedRef.current = false;
}, [activeStepIndex]);

useEffect(() => {
if (!step || !activeTourId || step.advanceOn.type !== "click" || !selector)
return;

const el = document.querySelector(selector);
if (!el) return;

const tourId = activeTourId;
const stepId = step.id;
const handler = () => {
if (!advancedRef.current) {
advancedRef.current = true;
setTimeout(() => advance(tourId, stepId), 0);
}
};

el.addEventListener("click", handler, { capture: true });
return () => el.removeEventListener("click", handler, { capture: true });
}, [step, selector, advance, activeTourId]);

useEffect(() => {
if (!step || !activeTourId || step.advanceOn.type !== "action" || !selector)
return;

const tourId = activeTourId;
const stepId = step.id;
const SETTLE_MS = 2000;
let settleTimer: ReturnType<typeof setTimeout> | null = null;

const tryAdvance = () => {
const el = document.querySelector(selector);
if (
el?.getAttribute("data-tour-ready") === "true" &&
!advancedRef.current
) {
advancedRef.current = true;
advance(tourId, stepId);
}
};

const resetTimer = () => {
if (settleTimer) clearTimeout(settleTimer);
const el = document.querySelector(selector);
if (el?.getAttribute("data-tour-ready") === "true") {
settleTimer = setTimeout(tryAdvance, SETTLE_MS);
}
};

const observer = new MutationObserver(resetTimer);

const el = document.querySelector(selector);
if (el) {
observer.observe(el, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
});
resetTimer();
}

return () => {
observer.disconnect();
if (settleTimer) clearTimeout(settleTimer);
};
}, [step, selector, advance, activeTourId]);

const settingsOpen = useSettingsDialogStore((s) => s.isOpen);
const commandMenuOpen = useCommandMenuStore((s) => s.isOpen);
const overlayBlocked = settingsOpen || commandMenuOpen;
const isActive = !!(tour && step && targetRect && !overlayBlocked);

return (
<>
<SpotlightOverlay targetRect={isActive ? targetRect : null} />
{isActive && (
<TourTooltip
step={step}
stepNumber={activeStepIndex + 1}
totalSteps={tour.steps.length}
onDismiss={dismiss}
targetRect={targetRect}
/>
)}
</>
);
}
Loading
Loading