diff --git a/apps/code/.storybook/preview.tsx b/apps/code/.storybook/preview.tsx index 2e3787723..d53ba4046 100644 --- a/apps/code/.storybook/preview.tsx +++ b/apps/code/.storybook/preview.tsx @@ -30,7 +30,7 @@ const preview: Preview = { grayColor="slate" panelBackground="solid" radius="none" - scaling="105%" + scaling="100%" > diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 1aefdf978..ef2831b8d 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -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"; @@ -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(); @@ -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]); @@ -102,6 +119,7 @@ export function MainLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> + ); diff --git a/apps/code/src/renderer/components/ThemeWrapper.tsx b/apps/code/src/renderer/components/ThemeWrapper.tsx index 97dd6286d..bb3d6f72d 100644 --- a/apps/code/src/renderer/components/ThemeWrapper.tsx +++ b/apps/code/src/renderer/components/ThemeWrapper.tsx @@ -27,7 +27,7 @@ export function ThemeWrapper({ children }: { children: React.ReactNode }) { grayColor="slate" panelBackground="solid" radius="medium" - scaling="105%" + scaling="100%" > {children}
diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx index 90dc47a07..4f01a6f5f 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -1,6 +1,8 @@ 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"; @@ -22,7 +24,22 @@ export function AdvancedSettings() { + + + diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 6b0604cf6..1c5bd0784 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -394,24 +394,37 @@ export function TaskInput({ align="center" style={{ minWidth: 0, overflow: "hidden" }} > - {workspaceMode === "cloud" ? ( - - ) : ( - - )} + + {workspaceMode === "cloud" ? ( + + ) : ( + + )} + + {targetRect && ( + + )} + , + 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 | 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 ( + <> + + {isActive && ( + + )} + + ); +} diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx new file mode 100644 index 000000000..29394107e --- /dev/null +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -0,0 +1,214 @@ +import { Button, Flex, Text, Theme } from "@radix-ui/themes"; +import { useThemeStore } from "@stores/themeStore"; +import { AnimatePresence, motion, useAnimationControls } from "framer-motion"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import type { TourStep } from "../types"; + +interface TourTooltipProps { + step: TourStep; + stepNumber: number; + totalSteps: number; + onDismiss: () => void; +} + +const HOG_SIZE = 64; +const CARET_SIZE = 12; +const CARET_INNER = 11; + +const talkingAnimation = { + rotate: [0, -3, 3, -2, 2, 0], + y: [0, -2, 0, -1, 0], + transition: { + duration: 0.4, + repeat: Infinity, + repeatDelay: 0.1, + }, +}; + +const bubbleVariants = { + initial: { opacity: 0, scale: 0.92, x: 20 }, + animate: { + opacity: 1, + scale: 1, + x: 0, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, + exit: { + opacity: 0, + scale: 0.95, + x: 10, + transition: { duration: 0.15 }, + }, +}; + +const hogEntranceVariants = { + initial: { opacity: 0, scale: 0.5 }, + animate: { + opacity: 1, + scale: 1, + transition: { + type: "spring" as const, + stiffness: 400, + damping: 18, + delay: 0.15, + }, + }, + exit: { + opacity: 0, + scale: 0.5, + transition: { duration: 0.1 }, + }, +}; + +function RightCaret() { + const borderColor = "var(--gray-a5)"; + const fillColor = "var(--color-panel-solid)"; + + const base: React.CSSProperties = { + position: "absolute", + width: 0, + height: 0, + }; + + return ( + <> +
+
+ + ); +} + +export function TourTooltip({ + step, + stepNumber, + totalSteps, + onDismiss, +}: TourTooltipProps) { + const isDarkMode = useThemeStore((s) => s.isDarkMode); + const controls = useAnimationControls(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: restart animation on step change + useEffect(() => { + controls.stop(); + const timer = setTimeout(() => { + controls.start(talkingAnimation); + }, 500); + return () => clearTimeout(timer); + }, [controls, step.id]); + + return createPortal( + + +
+ + + + + {step.message} + + + + {stepNumber}/{totalSteps} + + + + + + + + + +
+
+
, + document.body, + ); +} diff --git a/apps/code/src/renderer/features/tour/hooks/useElementRect.ts b/apps/code/src/renderer/features/tour/hooks/useElementRect.ts new file mode 100644 index 000000000..3b0e01a7b --- /dev/null +++ b/apps/code/src/renderer/features/tour/hooks/useElementRect.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +function rectsEqual(a: DOMRect | null, b: DOMRect | null): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + Math.abs(a.top - b.top) < 1 && + Math.abs(a.left - b.left) < 1 && + Math.abs(a.width - b.width) < 1 && + Math.abs(a.height - b.height) < 1 + ); +} + +export function useElementRect(selector: string | null): DOMRect | null { + const [rect, setRect] = useState(null); + + useEffect(() => { + if (!selector) { + setRect(null); + return; + } + + let frameId: number; + let prevRect: DOMRect | null = null; + + const poll = () => { + const el = document.querySelector(selector); + const nextRect = el?.getBoundingClientRect() ?? null; + + if (!rectsEqual(nextRect, prevRect)) { + prevRect = nextRect; + setRect(nextRect ? DOMRect.fromRect(nextRect) : null); + } + + frameId = requestAnimationFrame(poll); + }; + + frameId = requestAnimationFrame(poll); + return () => cancelAnimationFrame(frameId); + }, [selector]); + + return rect; +} diff --git a/apps/code/src/renderer/features/tour/hooks/useTour.ts b/apps/code/src/renderer/features/tour/hooks/useTour.ts new file mode 100644 index 000000000..3f9a7e435 --- /dev/null +++ b/apps/code/src/renderer/features/tour/hooks/useTour.ts @@ -0,0 +1,13 @@ +import { useTourStore } from "../stores/tourStore"; + +export function useTour() { + const startTour = useTourStore((s) => s.startTour); + const dismiss = useTourStore((s) => s.dismiss); + const completedTourIds = useTourStore((s) => s.completedTourIds); + + return { + startTour, + dismiss, + isCompleted: (tourId: string) => completedTourIds.includes(tourId), + }; +} diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts new file mode 100644 index 000000000..d6292d591 --- /dev/null +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { TOUR_REGISTRY } from "../tours/tourRegistry"; + +interface TourStoreState { + completedTourIds: string[]; + activeTourId: string | null; + activeStepIndex: number; +} + +interface TourStoreActions { + startTour: (tourId: string) => void; + advance: (tourId: string, stepId: string) => void; + completeTour: (tourId: string) => void; + dismiss: () => void; + resetTours: () => void; +} + +type TourStore = TourStoreState & TourStoreActions; + +export const useTourStore = create()( + persist( + (set, get) => ({ + completedTourIds: [], + activeTourId: null, + activeStepIndex: 0, + + startTour: (tourId) => { + if (get().completedTourIds.includes(tourId)) return; + set({ activeTourId: tourId, activeStepIndex: 0 }); + }, + + advance: (tourId, stepId) => { + const { activeTourId, activeStepIndex } = get(); + if (activeTourId !== tourId) return; + + const tour = TOUR_REGISTRY[activeTourId]; + if (!tour) return; + + const currentStep = tour.steps[activeStepIndex]; + if (!currentStep || currentStep.id !== stepId) return; + + if (activeStepIndex >= tour.steps.length - 1) { + set((state) => ({ + completedTourIds: [...state.completedTourIds, activeTourId], + activeTourId: null, + activeStepIndex: 0, + })); + } else { + set({ activeStepIndex: activeStepIndex + 1 }); + } + }, + + completeTour: (tourId) => { + const { activeTourId, completedTourIds } = get(); + if (activeTourId !== tourId) return; + if (completedTourIds.includes(tourId)) return; + set({ + completedTourIds: [...completedTourIds, tourId], + activeTourId: null, + activeStepIndex: 0, + }); + }, + + dismiss: () => { + const { activeTourId } = get(); + if (!activeTourId) return; + set((state) => ({ + completedTourIds: [...state.completedTourIds, activeTourId], + activeTourId: null, + activeStepIndex: 0, + })); + }, + + resetTours: () => { + set({ completedTourIds: [], activeTourId: null, activeStepIndex: 0 }); + }, + }), + { + name: "tour-store", + partialize: (state) => ({ + completedTourIds: state.completedTourIds, + }), + }, + ), +); diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts new file mode 100644 index 000000000..aed17c5ad --- /dev/null +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -0,0 +1,35 @@ +import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; +import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; +import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; +import type { TourDefinition } from "../types"; + +export const createFirstTaskTour: TourDefinition = { + id: "create-first-task", + steps: [ + { + id: "folder-picker", + target: "folder-picker", + + hogSrc: explorerHog, + message: "Pick a repo to work with. This tells me where your code lives!", + advanceOn: { type: "action" }, + }, + { + id: "task-editor", + target: "task-input-editor", + + hogSrc: builderHog, + message: + "Describe what you want to build or fix. Be as specific as you like!", + advanceOn: { type: "action" }, + }, + { + id: "submit-button", + target: "submit-button", + + hogSrc: happyHog, + message: "Hit send or press Enter to launch your first agent!", + advanceOn: { type: "click" }, + }, + ], +}; diff --git a/apps/code/src/renderer/features/tour/tours/tourRegistry.ts b/apps/code/src/renderer/features/tour/tours/tourRegistry.ts new file mode 100644 index 000000000..c5c4b0f94 --- /dev/null +++ b/apps/code/src/renderer/features/tour/tours/tourRegistry.ts @@ -0,0 +1,6 @@ +import type { TourDefinition } from "../types"; +import { createFirstTaskTour } from "./createFirstTaskTour"; + +export const TOUR_REGISTRY: Record = { + [createFirstTaskTour.id]: createFirstTaskTour, +}; diff --git a/apps/code/src/renderer/features/tour/types.ts b/apps/code/src/renderer/features/tour/types.ts new file mode 100644 index 000000000..0ba139ccb --- /dev/null +++ b/apps/code/src/renderer/features/tour/types.ts @@ -0,0 +1,14 @@ +export type TourStepAdvance = { type: "action" } | { type: "click" }; + +export interface TourStep { + id: string; + target: string; + hogSrc: string; + message: string; + advanceOn: TourStepAdvance; +} + +export interface TourDefinition { + id: string; + steps: TourStep[]; +}