From 70d6c73a20927497078b118b0dbd4b5607478b0d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 17:59:42 -0700 Subject: [PATCH 01/22] Product tours --- apps/code/.storybook/preview.tsx | 2 +- .../src/renderer/components/MainLayout.tsx | 14 + .../src/renderer/components/ThemeWrapper.tsx | 2 +- .../task-detail/components/TaskInput.tsx | 2 +- .../features/tour/components/TourOverlay.tsx | 74 ++++ .../features/tour/components/TourTooltip.tsx | 330 ++++++++++++++++++ .../features/tour/hooks/useElementRect.ts | 43 +++ .../renderer/features/tour/hooks/useTour.ts | 13 + .../features/tour/stores/tourStore.ts | 66 ++++ .../tour/tours/createFirstTaskTour.ts | 35 ++ .../features/tour/tours/tourRegistry.ts | 6 + apps/code/src/renderer/features/tour/types.ts | 17 + 12 files changed, 601 insertions(+), 3 deletions(-) create mode 100644 apps/code/src/renderer/features/tour/components/TourOverlay.tsx create mode 100644 apps/code/src/renderer/features/tour/components/TourTooltip.tsx create mode 100644 apps/code/src/renderer/features/tour/hooks/useElementRect.ts create mode 100644 apps/code/src/renderer/features/tour/hooks/useTour.ts create mode 100644 apps/code/src/renderer/features/tour/stores/tourStore.ts create mode 100644 apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts create mode 100644 apps/code/src/renderer/features/tour/tours/tourRegistry.ts create mode 100644 apps/code/src/renderer/features/tour/types.ts 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 f49e76d66..b0bd6351e 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -14,6 +14,8 @@ 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 { useConnectivity } from "@hooks/useConnectivity"; import { useIntegrations } from "@hooks/useIntegrations"; import { Box, Flex } from "@radix-ui/themes"; @@ -39,6 +41,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("create-first-task"), + ); + useIntegrations(); useTaskDeepLink(); @@ -54,6 +61,12 @@ export function MainLayout() { } }, [view, navigateToTaskInput]); + useEffect(() => { + if (isFirstTaskTourDone) return; + const timer = setTimeout(() => startTour("create-first-task"), 600); + return () => clearTimeout(timer); + }, [isFirstTaskTourDone, startTour]); + const handleToggleCommandMenu = useCallback(() => { toggleCommandMenu(); }, [toggleCommandMenu]); @@ -99,6 +112,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/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 60bc232c4..f18c90c9d 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -443,7 +443,7 @@ export function TaskInput({ disabled={isCreatingTask} /> )} - + {workspaceMode === "cloud" ? ( 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); + + useEffect(() => { + advancedRef.current = false; + }, []); + + useEffect(() => { + if (!step || step.advanceOn.type !== "click" || !selector) return; + + const el = document.querySelector(selector); + if (!el) return; + + const handler = () => { + if (!advancedRef.current) { + advancedRef.current = true; + setTimeout(advance, 0); + } + }; + + el.addEventListener("click", handler, { capture: true }); + return () => el.removeEventListener("click", handler, { capture: true }); + }, [step, selector, advance]); + + useEffect(() => { + if (!step || step.advanceOn.type !== "action" || !selector) return; + + let frameId: number; + + const poll = () => { + const el = document.querySelector(selector); + if (el?.getAttribute("data-tour-ready") === "true") { + if (!advancedRef.current) { + advancedRef.current = true; + advance(); + } + return; + } + frameId = requestAnimationFrame(poll); + }; + + frameId = requestAnimationFrame(poll); + return () => cancelAnimationFrame(frameId); + }, [step, selector, advance]); + + if (!tour || !step || !targetRect) return null; + + return ( + + ); +} 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..842673301 --- /dev/null +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -0,0 +1,330 @@ +import { Button, Flex, Text, Theme } from "@radix-ui/themes"; +import { useThemeStore } from "@stores/themeStore"; +import { AnimatePresence, motion, useAnimationControls } from "framer-motion"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; +import type { CaretDirection, TourStep } from "../types"; + +interface TourTooltipProps { + step: TourStep; + stepNumber: number; + totalSteps: number; + targetRect: DOMRect; + onDismiss: () => void; +} + +const TOOLTIP_GAP = 16; +const HOG_SIZE = 56; +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, + }, +}; + +function CaretPair({ direction }: { direction: CaretDirection }) { + const borderColor = "var(--gray-a5)"; + const fillColor = "var(--color-panel-solid)"; + + const base: React.CSSProperties = { + position: "absolute", + width: 0, + height: 0, + }; + + let borderStyle: React.CSSProperties; + let fillStyle: React.CSSProperties; + + switch (direction) { + case "left": + borderStyle = { + ...base, + top: "50%", + marginTop: -CARET_SIZE, + left: -CARET_SIZE, + borderTop: `${CARET_SIZE}px solid transparent`, + borderBottom: `${CARET_SIZE}px solid transparent`, + borderRight: `${CARET_SIZE}px solid ${borderColor}`, + }; + fillStyle = { + ...base, + top: "50%", + marginTop: -CARET_INNER, + left: -CARET_INNER, + borderTop: `${CARET_INNER}px solid transparent`, + borderBottom: `${CARET_INNER}px solid transparent`, + borderRight: `${CARET_INNER}px solid ${fillColor}`, + }; + break; + case "right": + borderStyle = { + ...base, + top: "50%", + marginTop: -CARET_SIZE, + right: -CARET_SIZE, + borderTop: `${CARET_SIZE}px solid transparent`, + borderBottom: `${CARET_SIZE}px solid transparent`, + borderLeft: `${CARET_SIZE}px solid ${borderColor}`, + }; + fillStyle = { + ...base, + top: "50%", + marginTop: -CARET_INNER, + right: -CARET_INNER, + borderTop: `${CARET_INNER}px solid transparent`, + borderBottom: `${CARET_INNER}px solid transparent`, + borderLeft: `${CARET_INNER}px solid ${fillColor}`, + }; + break; + case "top": + borderStyle = { + ...base, + top: -CARET_SIZE, + left: 32, + borderLeft: `${CARET_SIZE}px solid transparent`, + borderRight: `${CARET_SIZE}px solid transparent`, + borderBottom: `${CARET_SIZE}px solid ${borderColor}`, + }; + fillStyle = { + ...base, + top: -CARET_INNER, + left: 33, + borderLeft: `${CARET_INNER}px solid transparent`, + borderRight: `${CARET_INNER}px solid transparent`, + borderBottom: `${CARET_INNER}px solid ${fillColor}`, + }; + break; + case "bottom": + borderStyle = { + ...base, + bottom: -CARET_SIZE, + left: 32, + borderLeft: `${CARET_SIZE}px solid transparent`, + borderRight: `${CARET_SIZE}px solid transparent`, + borderTop: `${CARET_SIZE}px solid ${borderColor}`, + }; + fillStyle = { + ...base, + bottom: -CARET_INNER, + left: 33, + borderLeft: `${CARET_INNER}px solid transparent`, + borderRight: `${CARET_INNER}px solid transparent`, + borderTop: `${CARET_INNER}px solid ${fillColor}`, + }; + break; + } + + return ( + <> +
+
+ + ); +} + +const CARET_OFFSET = 32; + +function computePosition( + targetRect: DOMRect, + caretDirection: CaretDirection, +): { top: number; left: number } { + const centerX = targetRect.left + targetRect.width / 2; + const centerY = targetRect.top + targetRect.height / 2; + + switch (caretDirection) { + case "bottom": + return { + top: targetRect.top - TOOLTIP_GAP - CARET_SIZE, + left: centerX - CARET_OFFSET - CARET_SIZE, + }; + case "top": + return { + top: targetRect.bottom + TOOLTIP_GAP + CARET_SIZE, + left: centerX - CARET_OFFSET - CARET_SIZE, + }; + case "left": + return { + top: centerY, + left: targetRect.right + TOOLTIP_GAP + CARET_SIZE, + }; + case "right": + return { + top: centerY, + left: targetRect.left - TOOLTIP_GAP - CARET_SIZE, + }; + } +} + +function getTransform(caretDirection: CaretDirection): string { + switch (caretDirection) { + case "bottom": + return "translateY(-100%)"; + case "top": + return "translateY(0)"; + case "left": + return "translateY(-50%)"; + case "right": + return "translate(-100%, -50%)"; + } +} + +function getMotionProps(caretDirection: CaretDirection) { + const slide = 10; + switch (caretDirection) { + case "bottom": + return { + initial: { opacity: 0, y: slide }, + animate: { opacity: 1, y: 0 }, + }; + case "top": + return { + initial: { opacity: 0, y: -slide }, + animate: { opacity: 1, y: 0 }, + }; + case "left": + return { + initial: { opacity: 0, x: -slide }, + animate: { opacity: 1, x: 0 }, + }; + case "right": + return { + initial: { opacity: 0, x: slide }, + animate: { opacity: 1, x: 0 }, + }; + } +} + +export function TourTooltip({ + step, + stepNumber, + totalSteps, + targetRect, + onDismiss, +}: TourTooltipProps) { + const isDarkMode = useThemeStore((s) => s.isDarkMode); + const controls = useAnimationControls(); + const isHovering = useRef(false); + + useEffect(() => { + const startTimer = setTimeout(() => { + controls.start(talkingAnimation); + }, 500); + const stopTimer = setTimeout(() => { + if (!isHovering.current) { + controls.stop(); + controls.set({ rotate: 0, y: 0 }); + } + }, 5500); + return () => { + clearTimeout(startTimer); + clearTimeout(stopTimer); + }; + }, [controls]); + + const handleMouseEnter = useCallback(() => { + isHovering.current = true; + controls.start(talkingAnimation); + }, [controls]); + + const handleMouseLeave = useCallback(() => { + isHovering.current = false; + controls.stop(); + controls.set({ rotate: 0, y: 0 }); + }, [controls]); + + const pos = computePosition(targetRect, step.caretDirection); + const motionProps = getMotionProps(step.caretDirection); + + const anchorStyle = useMemo( + (): React.CSSProperties => ({ + position: "fixed", + zIndex: 200, + pointerEvents: "auto", + top: pos.top, + left: pos.left, + transform: getTransform(step.caretDirection), + }), + [pos.top, pos.left, step.caretDirection], + ); + + 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..3698de6e6 --- /dev/null +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -0,0 +1,66 @@ +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: () => void; + dismiss: () => 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: () => { + const { activeTourId, activeStepIndex } = get(); + if (!activeTourId) return; + + const tour = TOUR_REGISTRY[activeTourId]; + if (!tour) return; + + if (activeStepIndex >= tour.steps.length - 1) { + set((state) => ({ + completedTourIds: [...state.completedTourIds, activeTourId], + activeTourId: null, + activeStepIndex: 0, + })); + } else { + set({ activeStepIndex: activeStepIndex + 1 }); + } + }, + + dismiss: () => { + const { activeTourId } = get(); + if (!activeTourId) return; + set((state) => ({ + completedTourIds: [...state.completedTourIds, activeTourId], + 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..cf845990a --- /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", + caretDirection: "bottom", + hogSrc: explorerHog, + message: "Pick a repo to work with. This tells me where your code lives!", + advanceOn: { type: "click" }, + }, + { + id: "task-editor", + target: "task-input-editor", + caretDirection: "bottom", + 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", + caretDirection: "left", + hogSrc: happyHog, + message: "Hit send to launch your first task!", + 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..687c06201 --- /dev/null +++ b/apps/code/src/renderer/features/tour/types.ts @@ -0,0 +1,17 @@ +export type CaretDirection = "top" | "right" | "bottom" | "left"; + +export type TourStepAdvance = { type: "action" } | { type: "click" }; + +export interface TourStep { + id: string; + target: string; + caretDirection: CaretDirection; + hogSrc: string; + message: string; + advanceOn: TourStepAdvance; +} + +export interface TourDefinition { + id: string; + steps: TourStep[]; +} From f1bd3a2703a255fc6e89e241002b837161462dc5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 18:20:54 -0700 Subject: [PATCH 02/22] Latest --- .../src/renderer/components/MainLayout.tsx | 7 +- .../components/sections/AdvancedSettings.tsx | 13 + .../task-detail/components/TaskInput.tsx | 10 +- .../features/tour/components/TourOverlay.tsx | 105 ++++- .../features/tour/components/TourTooltip.tsx | 380 ++++++------------ .../features/tour/stores/tourStore.ts | 5 + .../tour/tours/createFirstTaskTour.ts | 4 +- 7 files changed, 251 insertions(+), 273 deletions(-) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index b0bd6351e..9e6c9f330 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -9,6 +9,7 @@ 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 { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { SkillsView } from "@features/skills/components/SkillsView"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; @@ -61,11 +62,13 @@ export function MainLayout() { } }, [view, navigateToTaskInput]); + const settingsOpen = useSettingsDialogStore((s) => s.isOpen); + useEffect(() => { - if (isFirstTaskTourDone) return; + if (isFirstTaskTourDone || settingsOpen) return; const timer = setTimeout(() => startTour("create-first-task"), 600); return () => clearTimeout(timer); - }, [isFirstTaskTourDone, startTour]); + }, [isFirstTaskTourDone, settingsOpen, startTour]); const handleToggleCommandMenu = useCallback(() => { toggleCommandMenu(); 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 40c1d4d37..4544d6902 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -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"; @@ -31,6 +32,18 @@ export function AdvancedSettings() { Reset + + + )} - + {workspaceMode === "cloud" ? ( + {targetRect && ( + + )} + , + document.body, + ); +} + export function TourOverlay() { const activeTourId = useTourStore((s) => s.activeTourId); const activeStepIndex = useTourStore((s) => s.activeStepIndex); @@ -20,7 +57,7 @@ export function TourOverlay() { useEffect(() => { advancedRef.current = false; - }, []); + }, [activeStepIndex]); useEffect(() => { if (!step || step.advanceOn.type !== "click" || !selector) return; @@ -42,33 +79,63 @@ export function TourOverlay() { useEffect(() => { if (!step || step.advanceOn.type !== "action" || !selector) return; - let frameId: number; + const SETTLE_MS = 2000; + let settleTimer: ReturnType | null = null; - const poll = () => { + const tryAdvance = () => { + const el = document.querySelector(selector); + if ( + el?.getAttribute("data-tour-ready") === "true" && + !advancedRef.current + ) { + advancedRef.current = true; + advance(); + } + }; + + const resetTimer = () => { + if (settleTimer) clearTimeout(settleTimer); const el = document.querySelector(selector); if (el?.getAttribute("data-tour-ready") === "true") { - if (!advancedRef.current) { - advancedRef.current = true; - advance(); - } - return; + settleTimer = setTimeout(tryAdvance, SETTLE_MS); } - frameId = requestAnimationFrame(poll); }; - frameId = requestAnimationFrame(poll); - return () => cancelAnimationFrame(frameId); + 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]); - if (!tour || !step || !targetRect) return null; + 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 index 842673301..b9920738a 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -1,20 +1,18 @@ import { Button, Flex, Text, Theme } from "@radix-ui/themes"; import { useThemeStore } from "@stores/themeStore"; import { AnimatePresence, motion, useAnimationControls } from "framer-motion"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useEffect } from "react"; import { createPortal } from "react-dom"; -import type { CaretDirection, TourStep } from "../types"; +import type { TourStep } from "../types"; interface TourTooltipProps { step: TourStep; stepNumber: number; totalSteps: number; - targetRect: DOMRect; onDismiss: () => void; } -const TOOLTIP_GAP = 16; -const HOG_SIZE = 56; +const HOG_SIZE = 64; const CARET_SIZE = 12; const CARET_INNER = 11; @@ -28,7 +26,42 @@ const talkingAnimation = { }, }; -function CaretPair({ direction }: { direction: CaretDirection }) { +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)"; @@ -38,221 +71,50 @@ function CaretPair({ direction }: { direction: CaretDirection }) { height: 0, }; - let borderStyle: React.CSSProperties; - let fillStyle: React.CSSProperties; - - switch (direction) { - case "left": - borderStyle = { - ...base, - top: "50%", - marginTop: -CARET_SIZE, - left: -CARET_SIZE, - borderTop: `${CARET_SIZE}px solid transparent`, - borderBottom: `${CARET_SIZE}px solid transparent`, - borderRight: `${CARET_SIZE}px solid ${borderColor}`, - }; - fillStyle = { - ...base, - top: "50%", - marginTop: -CARET_INNER, - left: -CARET_INNER, - borderTop: `${CARET_INNER}px solid transparent`, - borderBottom: `${CARET_INNER}px solid transparent`, - borderRight: `${CARET_INNER}px solid ${fillColor}`, - }; - break; - case "right": - borderStyle = { - ...base, - top: "50%", - marginTop: -CARET_SIZE, - right: -CARET_SIZE, - borderTop: `${CARET_SIZE}px solid transparent`, - borderBottom: `${CARET_SIZE}px solid transparent`, - borderLeft: `${CARET_SIZE}px solid ${borderColor}`, - }; - fillStyle = { - ...base, - top: "50%", - marginTop: -CARET_INNER, - right: -CARET_INNER, - borderTop: `${CARET_INNER}px solid transparent`, - borderBottom: `${CARET_INNER}px solid transparent`, - borderLeft: `${CARET_INNER}px solid ${fillColor}`, - }; - break; - case "top": - borderStyle = { - ...base, - top: -CARET_SIZE, - left: 32, - borderLeft: `${CARET_SIZE}px solid transparent`, - borderRight: `${CARET_SIZE}px solid transparent`, - borderBottom: `${CARET_SIZE}px solid ${borderColor}`, - }; - fillStyle = { - ...base, - top: -CARET_INNER, - left: 33, - borderLeft: `${CARET_INNER}px solid transparent`, - borderRight: `${CARET_INNER}px solid transparent`, - borderBottom: `${CARET_INNER}px solid ${fillColor}`, - }; - break; - case "bottom": - borderStyle = { - ...base, - bottom: -CARET_SIZE, - left: 32, - borderLeft: `${CARET_SIZE}px solid transparent`, - borderRight: `${CARET_SIZE}px solid transparent`, - borderTop: `${CARET_SIZE}px solid ${borderColor}`, - }; - fillStyle = { - ...base, - bottom: -CARET_INNER, - left: 33, - borderLeft: `${CARET_INNER}px solid transparent`, - borderRight: `${CARET_INNER}px solid transparent`, - borderTop: `${CARET_INNER}px solid ${fillColor}`, - }; - break; - } - return ( <> -
-
+
+
); } -const CARET_OFFSET = 32; - -function computePosition( - targetRect: DOMRect, - caretDirection: CaretDirection, -): { top: number; left: number } { - const centerX = targetRect.left + targetRect.width / 2; - const centerY = targetRect.top + targetRect.height / 2; - - switch (caretDirection) { - case "bottom": - return { - top: targetRect.top - TOOLTIP_GAP - CARET_SIZE, - left: centerX - CARET_OFFSET - CARET_SIZE, - }; - case "top": - return { - top: targetRect.bottom + TOOLTIP_GAP + CARET_SIZE, - left: centerX - CARET_OFFSET - CARET_SIZE, - }; - case "left": - return { - top: centerY, - left: targetRect.right + TOOLTIP_GAP + CARET_SIZE, - }; - case "right": - return { - top: centerY, - left: targetRect.left - TOOLTIP_GAP - CARET_SIZE, - }; - } -} - -function getTransform(caretDirection: CaretDirection): string { - switch (caretDirection) { - case "bottom": - return "translateY(-100%)"; - case "top": - return "translateY(0)"; - case "left": - return "translateY(-50%)"; - case "right": - return "translate(-100%, -50%)"; - } -} - -function getMotionProps(caretDirection: CaretDirection) { - const slide = 10; - switch (caretDirection) { - case "bottom": - return { - initial: { opacity: 0, y: slide }, - animate: { opacity: 1, y: 0 }, - }; - case "top": - return { - initial: { opacity: 0, y: -slide }, - animate: { opacity: 1, y: 0 }, - }; - case "left": - return { - initial: { opacity: 0, x: -slide }, - animate: { opacity: 1, x: 0 }, - }; - case "right": - return { - initial: { opacity: 0, x: slide }, - animate: { opacity: 1, x: 0 }, - }; - } -} - export function TourTooltip({ step, stepNumber, totalSteps, - targetRect, onDismiss, }: TourTooltipProps) { const isDarkMode = useThemeStore((s) => s.isDarkMode); const controls = useAnimationControls(); - const isHovering = useRef(false); useEffect(() => { - const startTimer = setTimeout(() => { + const timer = setTimeout(() => { controls.start(talkingAnimation); }, 500); - const stopTimer = setTimeout(() => { - if (!isHovering.current) { - controls.stop(); - controls.set({ rotate: 0, y: 0 }); - } - }, 5500); - return () => { - clearTimeout(startTimer); - clearTimeout(stopTimer); - }; - }, [controls]); - - const handleMouseEnter = useCallback(() => { - isHovering.current = true; - controls.start(talkingAnimation); - }, [controls]); - - const handleMouseLeave = useCallback(() => { - isHovering.current = false; - controls.stop(); - controls.set({ rotate: 0, y: 0 }); + return () => clearTimeout(timer); }, [controls]); - const pos = computePosition(targetRect, step.caretDirection); - const motionProps = getMotionProps(step.caretDirection); - - const anchorStyle = useMemo( - (): React.CSSProperties => ({ - position: "fixed", - zIndex: 200, - pointerEvents: "auto", - top: pos.top, - left: pos.left, - transform: getTransform(step.caretDirection), - }), - [pos.top, pos.left, step.caretDirection], - ); - return createPortal( - -
- - - - - - {step.message} + + + + {step.message} + + + + {stepNumber}/{totalSteps} - - - {stepNumber}/{totalSteps} - - - + -
-
+ + + + + +
, document.body, diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts index 3698de6e6..901564af2 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -12,6 +12,7 @@ interface TourStoreActions { startTour: (tourId: string) => void; advance: () => void; dismiss: () => void; + resetTours: () => void; } type TourStore = TourStoreState & TourStoreActions; @@ -55,6 +56,10 @@ export const useTourStore = create()( activeStepIndex: 0, })); }, + + resetTours: () => { + set({ completedTourIds: [], activeTourId: null, activeStepIndex: 0 }); + }, }), { name: "tour-store", diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts index cf845990a..c3dd54d28 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -12,7 +12,7 @@ export const createFirstTaskTour: TourDefinition = { caretDirection: "bottom", hogSrc: explorerHog, message: "Pick a repo to work with. This tells me where your code lives!", - advanceOn: { type: "click" }, + advanceOn: { type: "action" }, }, { id: "task-editor", @@ -28,7 +28,7 @@ export const createFirstTaskTour: TourDefinition = { target: "submit-button", caretDirection: "left", hogSrc: happyHog, - message: "Hit send to launch your first task!", + message: "Hit send or press Enter to launch your first task!", advanceOn: { type: "click" }, }, ], From b919eecf0218d28ac33da3788f43708fbfb46c95 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 18:22:44 -0700 Subject: [PATCH 03/22] lint --- .../renderer/features/task-detail/components/TaskInput.tsx | 6 +++++- .../src/renderer/features/tour/components/TourOverlay.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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 2584fb5c3..6fdc1eeda 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -447,7 +447,11 @@ export function TaskInput({ ref={buttonGroupRef} data-tour="folder-picker" data-tour-ready={ - (workspaceMode === "cloud" ? selectedRepository : selectedDirectory) + ( + workspaceMode === "cloud" + ? selectedRepository + : selectedDirectory + ) ? "true" : undefined } diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 9e67ae8bc..1335ab36e 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -57,7 +57,7 @@ export function TourOverlay() { useEffect(() => { advancedRef.current = false; - }, [activeStepIndex]); + }, []); useEffect(() => { if (!step || step.advanceOn.type !== "click" || !selector) return; From 21340b36ebb9bf37e04f350d290591e7e0d54358 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 19:02:39 -0700 Subject: [PATCH 04/22] Fix tour completion on task creation and step transitions --- .../src/renderer/features/task-detail/hooks/useTaskCreation.ts | 2 ++ apps/code/src/renderer/features/tour/components/TourTooltip.tsx | 1 + .../src/renderer/features/tour/tours/createFirstTaskTour.ts | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index c2d2c9baa..9ddbb4c2b 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -8,6 +8,7 @@ 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 { useConnectivity } from "@hooks/useConnectivity"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { get } from "@renderer/di/container"; @@ -170,6 +171,7 @@ export function useTaskCreation({ } else { navigateToTask(output.task); } + useTourStore.getState().advance(); editor.clear(); }); diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx index b9920738a..02c298764 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -109,6 +109,7 @@ export function TourTooltip({ const controls = useAnimationControls(); useEffect(() => { + controls.stop(); const timer = setTimeout(() => { controls.start(talkingAnimation); }, 500); diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts index c3dd54d28..7e98a6d89 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -28,7 +28,7 @@ export const createFirstTaskTour: TourDefinition = { target: "submit-button", caretDirection: "left", hogSrc: happyHog, - message: "Hit send or press Enter to launch your first task!", + message: "Hit send or press Enter to launch your first agent!", advanceOn: { type: "click" }, }, ], From 2e875b343ce22f4631ee55a0364ca0b2fb81e2cd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 19:03:13 -0700 Subject: [PATCH 05/22] Suppress biome lint for intentional step-change deps --- .../code/src/renderer/features/tour/components/TourOverlay.tsx | 3 ++- .../code/src/renderer/features/tour/components/TourTooltip.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 1335ab36e..04463a16d 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -55,9 +55,10 @@ export function TourOverlay() { const advancedRef = useRef(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on step change useEffect(() => { advancedRef.current = false; - }, []); + }, [activeStepIndex]); useEffect(() => { if (!step || step.advanceOn.type !== "click" || !selector) return; diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx index 02c298764..29394107e 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -108,13 +108,14 @@ export function TourTooltip({ 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]); + }, [controls, step.id]); return createPortal( Date: Tue, 14 Apr 2026 19:12:33 -0700 Subject: [PATCH 06/22] Guard tour advance with expected tour and step IDs --- .../src/renderer/components/MainLayout.tsx | 5 +++-- .../task-detail/hooks/useTaskCreation.ts | 3 ++- .../features/tour/components/TourOverlay.tsx | 18 ++++++++++------ .../features/tour/stores/tourStore.ts | 21 ++++++++++++++++--- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 9e6c9f330..b45f5fa56 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -17,6 +17,7 @@ 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"; @@ -44,7 +45,7 @@ export function MainLayout() { const startTour = useTourStore((s) => s.startTour); const isFirstTaskTourDone = useTourStore((s) => - s.completedTourIds.includes("create-first-task"), + s.completedTourIds.includes(createFirstTaskTour.id), ); useIntegrations(); @@ -66,7 +67,7 @@ export function MainLayout() { useEffect(() => { if (isFirstTaskTourDone || settingsOpen) return; - const timer = setTimeout(() => startTour("create-first-task"), 600); + const timer = setTimeout(() => startTour(createFirstTaskTour.id), 600); return () => clearTimeout(timer); }, [isFirstTaskTourDone, settingsOpen, startTour]); diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 9ddbb4c2b..3fae7a2cd 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -9,6 +9,7 @@ import { 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"; @@ -171,7 +172,7 @@ export function useTaskCreation({ } else { navigateToTask(output.task); } - useTourStore.getState().advance(); + useTourStore.getState().completeTour(createFirstTaskTour.id); editor.clear(); }); diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 04463a16d..315211528 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -61,25 +61,31 @@ export function TourOverlay() { }, [activeStepIndex]); useEffect(() => { - if (!step || step.advanceOn.type !== "click" || !selector) return; + 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, 0); + setTimeout(() => advance(tourId, stepId), 0); } }; el.addEventListener("click", handler, { capture: true }); return () => el.removeEventListener("click", handler, { capture: true }); - }, [step, selector, advance]); + }, [step, selector, advance, activeTourId]); useEffect(() => { - if (!step || step.advanceOn.type !== "action" || !selector) return; + 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; @@ -90,7 +96,7 @@ export function TourOverlay() { !advancedRef.current ) { advancedRef.current = true; - advance(); + advance(tourId, stepId); } }; @@ -119,7 +125,7 @@ export function TourOverlay() { observer.disconnect(); if (settleTimer) clearTimeout(settleTimer); }; - }, [step, selector, advance]); + }, [step, selector, advance, activeTourId]); const settingsOpen = useSettingsDialogStore((s) => s.isOpen); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts index 901564af2..d6292d591 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -10,7 +10,8 @@ interface TourStoreState { interface TourStoreActions { startTour: (tourId: string) => void; - advance: () => void; + advance: (tourId: string, stepId: string) => void; + completeTour: (tourId: string) => void; dismiss: () => void; resetTours: () => void; } @@ -29,13 +30,16 @@ export const useTourStore = create()( set({ activeTourId: tourId, activeStepIndex: 0 }); }, - advance: () => { + advance: (tourId, stepId) => { const { activeTourId, activeStepIndex } = get(); - if (!activeTourId) return; + 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], @@ -47,6 +51,17 @@ export const useTourStore = create()( } }, + 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; From cc456de96ddf46b16c47ea06c6b47a32999bf8b9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Apr 2026 22:37:46 -0700 Subject: [PATCH 07/22] Remove unused caretDirection from tour types --- .../src/renderer/features/tour/tours/createFirstTaskTour.ts | 6 +++--- apps/code/src/renderer/features/tour/types.ts | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts index 7e98a6d89..aed17c5ad 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -9,7 +9,7 @@ export const createFirstTaskTour: TourDefinition = { { id: "folder-picker", target: "folder-picker", - caretDirection: "bottom", + hogSrc: explorerHog, message: "Pick a repo to work with. This tells me where your code lives!", advanceOn: { type: "action" }, @@ -17,7 +17,7 @@ export const createFirstTaskTour: TourDefinition = { { id: "task-editor", target: "task-input-editor", - caretDirection: "bottom", + hogSrc: builderHog, message: "Describe what you want to build or fix. Be as specific as you like!", @@ -26,7 +26,7 @@ export const createFirstTaskTour: TourDefinition = { { id: "submit-button", target: "submit-button", - caretDirection: "left", + 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/types.ts b/apps/code/src/renderer/features/tour/types.ts index 687c06201..0ba139ccb 100644 --- a/apps/code/src/renderer/features/tour/types.ts +++ b/apps/code/src/renderer/features/tour/types.ts @@ -1,11 +1,8 @@ -export type CaretDirection = "top" | "right" | "bottom" | "left"; - export type TourStepAdvance = { type: "action" } | { type: "click" }; export interface TourStep { id: string; target: string; - caretDirection: CaretDirection; hogSrc: string; message: string; advanceOn: TourStepAdvance; From 1dd6335d5462f8a82da7b28345a11a8d3404a854 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 17 Apr 2026 14:37:19 -0700 Subject: [PATCH 08/22] Update tourStore.ts --- .../features/tour/stores/tourStore.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts index d6292d591..ca96e9aa3 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -26,7 +26,9 @@ export const useTourStore = create()( activeStepIndex: 0, startTour: (tourId) => { - if (get().completedTourIds.includes(tourId)) return; + const { completedTourIds, activeTourId } = get(); + if (completedTourIds.includes(tourId) || activeTourId === tourId) + return; set({ activeTourId: tourId, activeStepIndex: 0 }); }, @@ -41,19 +43,21 @@ export const useTourStore = create()( if (!currentStep || currentStep.id !== stepId) return; if (activeStepIndex >= tour.steps.length - 1) { - set((state) => ({ - completedTourIds: [...state.completedTourIds, activeTourId], - activeTourId: null, - activeStepIndex: 0, - })); + set((state) => { + if (!state.activeTourId) return state; + return { + completedTourIds: [...state.completedTourIds, state.activeTourId], + activeTourId: null, + activeStepIndex: 0, + }; + }); } else { set({ activeStepIndex: activeStepIndex + 1 }); } }, completeTour: (tourId) => { - const { activeTourId, completedTourIds } = get(); - if (activeTourId !== tourId) return; + const { completedTourIds } = get(); if (completedTourIds.includes(tourId)) return; set({ completedTourIds: [...completedTourIds, tourId], From e2819da9626ea312e7f7e9de8c3f5ebae05e9bd8 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 20 Apr 2026 10:19:53 -0700 Subject: [PATCH 09/22] Position tour tooltip relative to target element --- .../features/tour/components/TourOverlay.tsx | 1 + .../features/tour/components/TourTooltip.tsx | 276 +++++++++++++----- .../tour/tours/createFirstTaskTour.ts | 3 - apps/code/src/renderer/features/tour/types.ts | 3 + .../tour/utils/calculateTooltipPlacement.ts | 115 ++++++++ 5 files changed, 328 insertions(+), 70 deletions(-) create mode 100644 apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 315211528..900391748 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -141,6 +141,7 @@ export function TourOverlay() { stepNumber={activeStepIndex + 1} totalSteps={tour.steps.length} onDismiss={dismiss} + targetRect={targetRect} /> )} diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx index 29394107e..562ebb3b3 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -3,16 +3,23 @@ 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"; +import type { TooltipPlacement, TourStep } from "../types"; +import { calculateTooltipPlacement } from "../utils/calculateTooltipPlacement"; interface TourTooltipProps { step: TourStep; stepNumber: number; totalSteps: number; onDismiss: () => void; + targetRect: DOMRect; } const HOG_SIZE = 64; +const HOG_GAP = 8; +const BUBBLE_MAX_WIDTH = 280; +const TOOLTIP_WIDTH_ESTIMATE = BUBBLE_MAX_WIDTH + HOG_GAP + HOG_SIZE; +const TOOLTIP_HEIGHT_ESTIMATE = 100; + const CARET_SIZE = 12; const CARET_INNER = 11; @@ -26,22 +33,6 @@ const talkingAnimation = { }, }; -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: { @@ -61,7 +52,53 @@ const hogEntranceVariants = { }, }; -function RightCaret() { +const CARET_SIDE_MAP: Record< + TooltipPlacement, + "left" | "right" | "top" | "bottom" +> = { + right: "left", + left: "right", + bottom: "top", + top: "bottom", +}; + +const TRANSFORM_ORIGIN_MAP: Record = { + right: "left center", + left: "right center", + bottom: "top center", + top: "bottom center", +}; + +function getBubbleVariants(placement: TooltipPlacement) { + const dx = placement === "right" ? 12 : placement === "left" ? -12 : 0; + const dy = placement === "bottom" ? -12 : placement === "top" ? 12 : 0; + + return { + initial: { opacity: 0, scale: 0.92, x: dx, y: dy }, + animate: { + opacity: 1, + scale: 1, + x: 0, + y: 0, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, + exit: { + opacity: 0, + scale: 0.95, + x: dx * 0.5, + y: dy * 0.5, + transition: { duration: 0.15 }, + }, + }; +} + +function Caret({ + side, + offset = 0, +}: { + side: "left" | "right" | "top" | "bottom"; + offset?: number; +}) { const borderColor = "var(--gray-a5)"; const fillColor = "var(--color-panel-solid)"; @@ -71,32 +108,116 @@ function RightCaret() { height: 0, }; - return ( - <> -
-
- - ); + switch (side) { + case "right": + return ( + <> +
+
+ + ); + case "left": + return ( + <> +
+
+ + ); + case "top": + return ( + <> +
+
+ + ); + case "bottom": + return ( + <> +
+
+ + ); + } } export function TourTooltip({ @@ -104,10 +225,22 @@ export function TourTooltip({ stepNumber, totalSteps, onDismiss, + targetRect, }: TourTooltipProps) { const isDarkMode = useThemeStore((s) => s.isDarkMode); const controls = useAnimationControls(); + const { placement, x, y, arrowOffset } = calculateTooltipPlacement( + targetRect, + TOOLTIP_WIDTH_ESTIMATE, + TOOLTIP_HEIGHT_ESTIMATE, + step.preferredPlacement, + ); + + const caretSide = CARET_SIDE_MAP[placement]; + const hogOnRight = true; + const bubbleVariants = getBubbleVariants(placement); + // biome-ignore lint/correctness/useExhaustiveDependencies: restart animation on step change useEffect(() => { controls.stop(); @@ -117,6 +250,30 @@ export function TourTooltip({ return () => clearTimeout(timer); }, [controls, step.id]); + const hogElement = ( + + + + ); + return createPortal( + {!hogOnRight && hogElement} + - + - - - + {hogOnRight && hogElement}
, diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts index aed17c5ad..9305ee45b 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -9,7 +9,6 @@ export const createFirstTaskTour: TourDefinition = { { 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" }, @@ -17,7 +16,6 @@ export const createFirstTaskTour: TourDefinition = { { id: "task-editor", target: "task-input-editor", - hogSrc: builderHog, message: "Describe what you want to build or fix. Be as specific as you like!", @@ -26,7 +24,6 @@ export const createFirstTaskTour: TourDefinition = { { 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/types.ts b/apps/code/src/renderer/features/tour/types.ts index 0ba139ccb..939653ba7 100644 --- a/apps/code/src/renderer/features/tour/types.ts +++ b/apps/code/src/renderer/features/tour/types.ts @@ -1,11 +1,14 @@ export type TourStepAdvance = { type: "action" } | { type: "click" }; +export type TooltipPlacement = "right" | "left" | "top" | "bottom"; + export interface TourStep { id: string; target: string; hogSrc: string; message: string; advanceOn: TourStepAdvance; + preferredPlacement?: TooltipPlacement; } export interface TourDefinition { diff --git a/apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts b/apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts new file mode 100644 index 000000000..b7417f9b7 --- /dev/null +++ b/apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts @@ -0,0 +1,115 @@ +import type { TooltipPlacement } from "../types"; + +const TOOLTIP_MARGIN = 12; +const VIEWPORT_PADDING = 8; + +const DEFAULT_ORDER: TooltipPlacement[] = ["right", "left", "top", "bottom"]; + +export interface PlacedTooltip { + placement: TooltipPlacement; + x: number; + y: number; + arrowOffset: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function calculateTooltipPlacement( + targetRect: DOMRect, + tooltipWidth: number, + tooltipHeight: number, + preferred?: TooltipPlacement, +): PlacedTooltip { + const vw = window.innerWidth; + const vh = window.innerHeight; + + const spaceRight = vw - targetRect.right; + const spaceLeft = targetRect.left; + const spaceAbove = targetRect.top; + + const targetCenterX = targetRect.left + targetRect.width / 2; + const targetCenterY = targetRect.top + targetRect.height / 2; + + const order = preferred + ? [preferred, ...DEFAULT_ORDER.filter((p) => p !== preferred)] + : DEFAULT_ORDER; + + for (const placement of order) { + switch (placement) { + case "right": { + if (spaceRight < tooltipWidth + TOOLTIP_MARGIN) break; + const idealY = targetCenterY - tooltipHeight / 2; + const y = clamp( + idealY, + VIEWPORT_PADDING, + vh - VIEWPORT_PADDING - tooltipHeight, + ); + return { + placement, + x: targetRect.right + TOOLTIP_MARGIN, + y, + arrowOffset: idealY - y, + }; + } + case "left": { + if (spaceLeft < tooltipWidth + TOOLTIP_MARGIN) break; + const idealY = targetCenterY - tooltipHeight / 2; + const y = clamp( + idealY, + VIEWPORT_PADDING, + vh - VIEWPORT_PADDING - tooltipHeight, + ); + return { + placement, + x: targetRect.left - TOOLTIP_MARGIN - tooltipWidth, + y, + arrowOffset: idealY - y, + }; + } + case "top": { + if (spaceAbove < tooltipHeight + TOOLTIP_MARGIN) break; + const idealX = targetCenterX - tooltipWidth / 2; + const x = clamp( + idealX, + VIEWPORT_PADDING, + vw - VIEWPORT_PADDING - tooltipWidth, + ); + return { + placement, + x, + y: targetRect.top - TOOLTIP_MARGIN - tooltipHeight, + arrowOffset: idealX - x, + }; + } + case "bottom": { + const idealX = targetCenterX - tooltipWidth / 2; + const x = clamp( + idealX, + VIEWPORT_PADDING, + vw - VIEWPORT_PADDING - tooltipWidth, + ); + return { + placement, + x, + y: targetRect.bottom + TOOLTIP_MARGIN, + arrowOffset: idealX - x, + }; + } + } + } + + const idealX = targetCenterX - tooltipWidth / 2; + const x = clamp( + idealX, + VIEWPORT_PADDING, + vw - VIEWPORT_PADDING - tooltipWidth, + ); + return { + placement: "bottom", + x, + y: targetRect.bottom + TOOLTIP_MARGIN, + arrowOffset: idealX - x, + }; +} From 48d478efc6a6beba7f448d85bf8f898b9d776f6f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:30:17 -0700 Subject: [PATCH 10/22] Grandfather existing users out of first task tour --- .../src/renderer/features/tour/stores/tourStore.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts index ca96e9aa3..d2ede1089 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -1,5 +1,7 @@ +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { createFirstTaskTour } from "../tours/createFirstTaskTour"; import { TOUR_REGISTRY } from "../tours/tourRegistry"; interface TourStoreState { @@ -85,6 +87,16 @@ export const useTourStore = create()( partialize: (state) => ({ completedTourIds: state.completedTourIds, }), + onRehydrateStorage: () => () => { + const migrationKey = "tour-store-v1-migrated"; + if (localStorage.getItem(migrationKey)) return; + localStorage.setItem(migrationKey, "1"); + + const { hasCompletedOnboarding } = useOnboardingStore.getState(); + if (hasCompletedOnboarding) { + useTourStore.getState().completeTour(createFirstTaskTour.id); + } + }, }, ), ); From f7b95e7643858bc14247495b026cd018bd3ca30c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:31:52 -0700 Subject: [PATCH 11/22] Default clean script to dev data only, add --all flag --- scripts/clean-posthog-code-macos.sh | 202 +++++++++++++++------------- 1 file changed, 109 insertions(+), 93 deletions(-) diff --git a/scripts/clean-posthog-code-macos.sh b/scripts/clean-posthog-code-macos.sh index 814cd6167..092c10577 100755 --- a/scripts/clean-posthog-code-macos.sh +++ b/scripts/clean-posthog-code-macos.sh @@ -3,28 +3,38 @@ # Clean PostHog Code app data from macOS # # Usage: -# ./scripts/clean-posthog-code-macos.sh # Clean data only -# ./scripts/clean-posthog-code-macos.sh --app # Clean data and delete app +# ./scripts/clean-posthog-code-macos.sh # Clean dev data only +# ./scripts/clean-posthog-code-macos.sh --all # Clean all data (dev + production + legacy) +# ./scripts/clean-posthog-code-macos.sh --app # Clean all data and delete app set -e DELETE_APP=false +CLEAN_ALL=false for arg in "$@"; do case $arg in + --all) + CLEAN_ALL=true + shift + ;; --app) DELETE_APP=true + CLEAN_ALL=true shift ;; -h|--help) - echo "Usage: $0 [--app]" + echo "Usage: $0 [--all] [--app]" echo "" echo "Options:" - echo " --app Also delete PostHog Code.app from /Applications" + echo " --all Clean all data (dev + production + legacy). Without this, only dev data is cleaned." + echo " --app Clean all data and delete PostHog Code.app from /Applications" echo "" - echo "This script removes:" - echo " - ~/Library/Application Support/@posthog/posthog-code" + echo "By default (no flags), only dev data is cleaned:" echo " - ~/Library/Application Support/@posthog/posthog-code-dev" + echo "" + echo "With --all, also removes:" + echo " - ~/Library/Application Support/@posthog/posthog-code" echo " - ~/Library/Application Support/@posthog/Array (legacy)" echo " - ~/Library/Application Support/@posthog/Twig (legacy)" echo " - ~/Library/Application Support/@posthog/twig-dev (legacy)" @@ -38,118 +48,124 @@ for arg in "$@"; do esac done -echo "Cleaning PostHog Code data from macOS..." -echo "" - -# Application Support - current electron data locations -if [ -d "$HOME/Library/Application Support/@posthog/posthog-code" ]; then - echo "Removing ~/Library/Application Support/@posthog/posthog-code" - rm -rf "$HOME/Library/Application Support/@posthog/posthog-code" +if [ "$CLEAN_ALL" = true ]; then + echo "Cleaning all PostHog Code data from macOS..." +else + echo "Cleaning PostHog Code dev data from macOS..." fi +echo "" +# Dev data (always cleaned) if [ -d "$HOME/Library/Application Support/@posthog/posthog-code-dev" ]; then echo "Removing ~/Library/Application Support/@posthog/posthog-code-dev" rm -rf "$HOME/Library/Application Support/@posthog/posthog-code-dev" fi -# Application Support - legacy locations -if [ -d "$HOME/Library/Application Support/@posthog/Array" ]; then - echo "Removing ~/Library/Application Support/@posthog/Array" - rm -rf "$HOME/Library/Application Support/@posthog/Array" -fi +if [ "$CLEAN_ALL" = true ]; then + # Application Support - production + if [ -d "$HOME/Library/Application Support/@posthog/posthog-code" ]; then + echo "Removing ~/Library/Application Support/@posthog/posthog-code" + rm -rf "$HOME/Library/Application Support/@posthog/posthog-code" + fi -if [ -d "$HOME/Library/Application Support/@posthog/Twig" ]; then - echo "Removing ~/Library/Application Support/@posthog/Twig" - rm -rf "$HOME/Library/Application Support/@posthog/Twig" -fi + # Application Support - legacy locations + if [ -d "$HOME/Library/Application Support/@posthog/Array" ]; then + echo "Removing ~/Library/Application Support/@posthog/Array" + rm -rf "$HOME/Library/Application Support/@posthog/Array" + fi -if [ -d "$HOME/Library/Application Support/@posthog/twig-dev" ]; then - echo "Removing ~/Library/Application Support/@posthog/twig-dev" - rm -rf "$HOME/Library/Application Support/@posthog/twig-dev" -fi + if [ -d "$HOME/Library/Application Support/@posthog/Twig" ]; then + echo "Removing ~/Library/Application Support/@posthog/Twig" + rm -rf "$HOME/Library/Application Support/@posthog/Twig" + fi -# Clean up empty @posthog parent folder if it exists and is empty -if [ -d "$HOME/Library/Application Support/@posthog" ]; then - rmdir "$HOME/Library/Application Support/@posthog" 2>/dev/null || true -fi + if [ -d "$HOME/Library/Application Support/@posthog/twig-dev" ]; then + echo "Removing ~/Library/Application Support/@posthog/twig-dev" + rm -rf "$HOME/Library/Application Support/@posthog/twig-dev" + fi -# Legacy locations (in case they exist) -if [ -d "$HOME/Library/Application Support/twig" ]; then - echo "Removing ~/Library/Application Support/twig" - rm -rf "$HOME/Library/Application Support/twig" -fi + if [ -d "$HOME/Library/Application Support/twig" ]; then + echo "Removing ~/Library/Application Support/twig" + rm -rf "$HOME/Library/Application Support/twig" + fi -if [ -d "$HOME/Library/Application Support/Twig" ]; then - echo "Removing ~/Library/Application Support/Twig" - rm -rf "$HOME/Library/Application Support/Twig" -fi + if [ -d "$HOME/Library/Application Support/Twig" ]; then + echo "Removing ~/Library/Application Support/Twig" + rm -rf "$HOME/Library/Application Support/Twig" + fi -# Preferences -if [ -f "$HOME/Library/Preferences/com.posthog.array.plist" ]; then - echo "Removing ~/Library/Preferences/com.posthog.array.plist" - rm -f "$HOME/Library/Preferences/com.posthog.array.plist" -fi + # Preferences + if [ -f "$HOME/Library/Preferences/com.posthog.array.plist" ]; then + echo "Removing ~/Library/Preferences/com.posthog.array.plist" + rm -f "$HOME/Library/Preferences/com.posthog.array.plist" + fi -if [ -f "$HOME/Library/Preferences/com.posthog.twig.plist" ]; then - echo "Removing ~/Library/Preferences/com.posthog.twig.plist" - rm -f "$HOME/Library/Preferences/com.posthog.twig.plist" -fi + if [ -f "$HOME/Library/Preferences/com.posthog.twig.plist" ]; then + echo "Removing ~/Library/Preferences/com.posthog.twig.plist" + rm -f "$HOME/Library/Preferences/com.posthog.twig.plist" + fi -# Caches -if [ -d "$HOME/Library/Caches/com.posthog.array" ]; then - echo "Removing ~/Library/Caches/com.posthog.array" - rm -rf "$HOME/Library/Caches/com.posthog.array" -fi + # Caches + if [ -d "$HOME/Library/Caches/com.posthog.array" ]; then + echo "Removing ~/Library/Caches/com.posthog.array" + rm -rf "$HOME/Library/Caches/com.posthog.array" + fi -if [ -d "$HOME/Library/Caches/com.posthog.twig" ]; then - echo "Removing ~/Library/Caches/com.posthog.twig" - rm -rf "$HOME/Library/Caches/com.posthog.twig" -fi + if [ -d "$HOME/Library/Caches/com.posthog.twig" ]; then + echo "Removing ~/Library/Caches/com.posthog.twig" + rm -rf "$HOME/Library/Caches/com.posthog.twig" + fi -if [ -d "$HOME/Library/Caches/twig" ]; then - echo "Removing ~/Library/Caches/twig" - rm -rf "$HOME/Library/Caches/twig" -fi + if [ -d "$HOME/Library/Caches/twig" ]; then + echo "Removing ~/Library/Caches/twig" + rm -rf "$HOME/Library/Caches/twig" + fi -if [ -d "$HOME/Library/Caches/Twig" ]; then - echo "Removing ~/Library/Caches/Twig" - rm -rf "$HOME/Library/Caches/Twig" -fi + if [ -d "$HOME/Library/Caches/Twig" ]; then + echo "Removing ~/Library/Caches/Twig" + rm -rf "$HOME/Library/Caches/Twig" + fi -# Home directory data (logs and cache) -if [ -d "$HOME/.posthog-code" ]; then - echo "Removing ~/.posthog-code" - rm -rf "$HOME/.posthog-code" -fi + # Home directory data (logs and cache) + if [ -d "$HOME/.posthog-code" ]; then + echo "Removing ~/.posthog-code" + rm -rf "$HOME/.posthog-code" + fi -# Logs -if [ -d "$HOME/Library/Logs/PostHog Code" ]; then - echo "Removing ~/Library/Logs/PostHog Code" - rm -rf "$HOME/Library/Logs/PostHog Code" -fi + # Logs + if [ -d "$HOME/Library/Logs/PostHog Code" ]; then + echo "Removing ~/Library/Logs/PostHog Code" + rm -rf "$HOME/Library/Logs/PostHog Code" + fi -if [ -d "$HOME/Library/Logs/twig" ]; then - echo "Removing ~/Library/Logs/twig" - rm -rf "$HOME/Library/Logs/twig" -fi + if [ -d "$HOME/Library/Logs/twig" ]; then + echo "Removing ~/Library/Logs/twig" + rm -rf "$HOME/Library/Logs/twig" + fi -if [ -d "$HOME/Library/Logs/Twig" ]; then - echo "Removing ~/Library/Logs/Twig" - rm -rf "$HOME/Library/Logs/Twig" -fi + if [ -d "$HOME/Library/Logs/Twig" ]; then + echo "Removing ~/Library/Logs/Twig" + rm -rf "$HOME/Library/Logs/Twig" + fi -# Saved Application State -if [ -d "$HOME/Library/Saved Application State/com.posthog.array.savedState" ]; then - echo "Removing ~/Library/Saved Application State/com.posthog.array.savedState" - rm -rf "$HOME/Library/Saved Application State/com.posthog.array.savedState" + # Saved Application State + if [ -d "$HOME/Library/Saved Application State/com.posthog.array.savedState" ]; then + echo "Removing ~/Library/Saved Application State/com.posthog.array.savedState" + rm -rf "$HOME/Library/Saved Application State/com.posthog.array.savedState" + fi + + if [ -d "$HOME/Library/Saved Application State/com.posthog.twig.savedState" ]; then + echo "Removing ~/Library/Saved Application State/com.posthog.twig.savedState" + rm -rf "$HOME/Library/Saved Application State/com.posthog.twig.savedState" + fi fi -if [ -d "$HOME/Library/Saved Application State/com.posthog.twig.savedState" ]; then - echo "Removing ~/Library/Saved Application State/com.posthog.twig.savedState" - rm -rf "$HOME/Library/Saved Application State/com.posthog.twig.savedState" +# Clean up empty @posthog parent folder if it exists and is empty +if [ -d "$HOME/Library/Application Support/@posthog" ]; then + rmdir "$HOME/Library/Application Support/@posthog" 2>/dev/null || true fi -# App (optional) +# App (optional, implies --all) if [ "$DELETE_APP" = true ]; then if [ -d "/Applications/PostHog Code.app" ]; then echo "Removing /Applications/PostHog Code.app" @@ -168,7 +184,7 @@ fi echo "" echo "Done!" -if [ "$DELETE_APP" = false ]; then +if [ "$CLEAN_ALL" = false ]; then echo "" - echo "Note: PostHog Code.app was not deleted. Use --app flag to also remove the app." + echo "Note: Only dev data was cleaned. Use --all to clean everything, or --app to also remove the app." fi From ab10fda0ce0387e043e6354bc1a606eec4e8da66 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:38:14 -0700 Subject: [PATCH 12/22] analytics --- .../features/tour/stores/tourStore.ts | 33 ++++++++++++++++++- apps/code/src/shared/types/analytics.ts | 17 ++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts index d2ede1089..44a7391b0 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -1,4 +1,6 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { createFirstTaskTour } from "../tours/createFirstTaskTour"; @@ -31,7 +33,15 @@ export const useTourStore = create()( const { completedTourIds, activeTourId } = get(); if (completedTourIds.includes(tourId) || activeTourId === tourId) return; + const tour = TOUR_REGISTRY[tourId]; set({ activeTourId: tourId, activeStepIndex: 0 }); + track(ANALYTICS_EVENTS.TOUR_EVENT, { + tour_id: tourId, + action: "started", + step_id: tour?.steps[0]?.id, + step_index: 0, + total_steps: tour?.steps.length, + }); }, advance: (tourId, stepId) => { @@ -44,6 +54,14 @@ export const useTourStore = create()( const currentStep = tour.steps[activeStepIndex]; if (!currentStep || currentStep.id !== stepId) return; + track(ANALYTICS_EVENTS.TOUR_EVENT, { + tour_id: tourId, + action: "step_advanced", + step_id: stepId, + step_index: activeStepIndex, + total_steps: tour.steps.length, + }); + if (activeStepIndex >= tour.steps.length - 1) { set((state) => { if (!state.activeTourId) return state; @@ -53,6 +71,11 @@ export const useTourStore = create()( activeStepIndex: 0, }; }); + track(ANALYTICS_EVENTS.TOUR_EVENT, { + tour_id: tourId, + action: "completed", + total_steps: tour.steps.length, + }); } else { set({ activeStepIndex: activeStepIndex + 1 }); } @@ -69,8 +92,16 @@ export const useTourStore = create()( }, dismiss: () => { - const { activeTourId } = get(); + const { activeTourId, activeStepIndex } = get(); if (!activeTourId) return; + const tour = TOUR_REGISTRY[activeTourId]; + track(ANALYTICS_EVENTS.TOUR_EVENT, { + tour_id: activeTourId, + action: "dismissed", + step_id: tour?.steps[activeStepIndex]?.id, + step_index: activeStepIndex, + total_steps: tour?.steps.length, + }); set((state) => ({ completedTourIds: [...state.completedTourIds, activeTourId], activeTourId: null, diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 90dad0ea0..1c612c4aa 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -198,6 +198,17 @@ export interface SessionConfigChangedProperties { to_value: string; } +// Tour events +type TourAction = "started" | "step_advanced" | "dismissed" | "completed"; + +export interface TourEventProperties { + tour_id: string; + action: TourAction; + step_id?: string; + step_index?: number; + total_steps?: number; +} + // Branch mismatch events type BranchMismatchAction = "switch" | "continue" | "cancel"; @@ -287,6 +298,9 @@ export const ANALYTICS_EVENTS = { BRANCH_MISMATCH_WARNING_SHOWN: "Branch mismatch warning shown", BRANCH_MISMATCH_ACTION: "Branch mismatch action", + // Tour events + TOUR_EVENT: "Tour event", + // Error events TASK_CREATION_FAILED: "Task creation failed", AGENT_SESSION_ERROR: "Agent session error", @@ -347,6 +361,9 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN]: BranchMismatchWarningShownProperties; [ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION]: BranchMismatchActionProperties; + // Tour events + [ANALYTICS_EVENTS.TOUR_EVENT]: TourEventProperties; + // Error events [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; From 19192ec3b60f9a5e16b76f07311c45967bbeda90 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:42:18 -0700 Subject: [PATCH 13/22] Update PromptInput.tsx --- .../features/message-editor/components/PromptInput.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 0e591b7d7..efdbdbee7 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -236,6 +236,7 @@ export const PromptInput = forwardRef( onClick={handleSubmitClick} disabled={submitBlocked} aria-label="Send message" + data-tour="submit-button" > @@ -260,6 +261,8 @@ export const PromptInput = forwardRef(
From 0df7bf2c83ff01df3d386f20b6d4ee5e30b08c84 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:43:41 -0700 Subject: [PATCH 14/22] Update PromptInput.tsx --- .../features/message-editor/components/PromptInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index efdbdbee7..0dcdbad3b 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -249,6 +249,8 @@ export const PromptInput = forwardRef( onClick={handleContainerClick} className={`h-auto bg-card ${isBashMode ? "ring-1 ring-blue-9" : ""}`} style={{ cursor: "text" }} + data-tour="task-input-editor" + data-tour-ready={!isEmpty ? "true" : undefined} > {attachments.length > 0 && ( @@ -261,8 +263,6 @@ export const PromptInput = forwardRef(
From 1f5ef98aafe02899956077ef6c0aed9b31394217 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:47:15 -0700 Subject: [PATCH 15/22] Update TourOverlay.tsx --- apps/code/src/renderer/features/tour/components/TourOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 900391748..4bce14c95 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -72,7 +72,7 @@ export function TourOverlay() { const handler = () => { if (!advancedRef.current) { advancedRef.current = true; - setTimeout(() => advance(tourId, stepId), 0); + advance(tourId, stepId); } }; From fe4a04b542762a2e46ea7e1ab7a4fb371514fea1 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:50:30 -0700 Subject: [PATCH 16/22] Replace RAF polling with event-driven measurement in useElementRect --- .../features/tour/hooks/useElementRect.ts | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/code/src/renderer/features/tour/hooks/useElementRect.ts b/apps/code/src/renderer/features/tour/hooks/useElementRect.ts index 3b0e01a7b..4172bd993 100644 --- a/apps/code/src/renderer/features/tour/hooks/useElementRect.ts +++ b/apps/code/src/renderer/features/tour/hooks/useElementRect.ts @@ -1,5 +1,10 @@ import { useEffect, useState } from "react"; +function getRect(selector: string): DOMRect | null { + const el = document.querySelector(selector); + return el ? el.getBoundingClientRect() : null; +} + function rectsEqual(a: DOMRect | null, b: DOMRect | null): boolean { if (a === b) return true; if (!a || !b) return false; @@ -20,23 +25,40 @@ export function useElementRect(selector: string | null): DOMRect | null { return; } - let frameId: number; - let prevRect: DOMRect | null = null; + let prev: 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); + const measure = () => { + const next = getRect(selector); + if (!rectsEqual(next, prev)) { + prev = next; + setRect(next ? DOMRect.fromRect(next) : null); } - - frameId = requestAnimationFrame(poll); }; - frameId = requestAnimationFrame(poll); - return () => cancelAnimationFrame(frameId); + measure(); + + const onScroll = () => measure(); + window.addEventListener("scroll", onScroll, { + capture: true, + passive: true, + }); + window.addEventListener("resize", measure); + + const observer = new MutationObserver(measure); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-tour", "data-tour-ready", "style", "class"], + }); + + return () => { + window.removeEventListener("scroll", onScroll, { + capture: true, + } as EventListenerOptions); + window.removeEventListener("resize", measure); + observer.disconnect(); + }; }, [selector]); return rect; From 91464014b7dae31d96a13ac7d283f22c98572e01 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 19:54:09 -0700 Subject: [PATCH 17/22] Scope tour data attributes to task creation view only --- .../features/message-editor/components/PromptInput.tsx | 10 +++++++--- .../features/task-detail/components/TaskInput.tsx | 1 + .../features/tour/tours/createFirstTaskTour.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 0dcdbad3b..fd61de063 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -54,6 +54,7 @@ export interface PromptInputProps { // manual submit override (for flows like new-task that submit outside the editor hook) onSubmitClick?: () => void; submitTooltipOverride?: string; + tourTarget?: string; } export const PromptInput = forwardRef( @@ -88,6 +89,7 @@ export const PromptInput = forwardRef( onBlur, onSubmitClick, submitTooltipOverride, + tourTarget, }, ref, ) => { @@ -236,7 +238,7 @@ export const PromptInput = forwardRef( onClick={handleSubmitClick} disabled={submitBlocked} aria-label="Send message" - data-tour="submit-button" + {...(tourTarget && { "data-tour": `${tourTarget}-submit` })} > @@ -249,8 +251,10 @@ export const PromptInput = forwardRef( onClick={handleContainerClick} className={`h-auto bg-card ${isBashMode ? "ring-1 ring-blue-9" : ""}`} style={{ cursor: "text" }} - data-tour="task-input-editor" - data-tour-ready={!isEmpty ? "true" : undefined} + {...(tourTarget && { + "data-tour": `${tourTarget}-editor`, + "data-tour-ready": !isEmpty ? "true" : undefined, + })} > {attachments.length > 0 && ( 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 6fdc1eeda..fda06c82a 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -524,6 +524,7 @@ export function TaskInput({ autoFocus clearOnSubmit={false} submitDisabledExternal={!canSubmit || isCreatingTask || !isOnline} + tourTarget="task-input" repoPath={selectedDirectory} modeOption={modeOption} onModeChange={handleModeChange} diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts index 9305ee45b..bfc8c9c12 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts @@ -23,7 +23,7 @@ export const createFirstTaskTour: TourDefinition = { }, { id: "submit-button", - target: "submit-button", + target: "task-input-submit", hogSrc: happyHog, message: "Hit send or press Enter to launch your first agent!", advanceOn: { type: "click" }, From 89c3ec86a3ec0b0ae7b4debf3f4244a5ae4a8fba Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 20:01:13 -0700 Subject: [PATCH 18/22] Remove dead useTour hook and add analytics to completeTour --- .../src/renderer/features/tour/hooks/useTour.ts | 13 ------------- .../src/renderer/features/tour/stores/tourStore.ts | 6 ++++++ 2 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 apps/code/src/renderer/features/tour/hooks/useTour.ts diff --git a/apps/code/src/renderer/features/tour/hooks/useTour.ts b/apps/code/src/renderer/features/tour/hooks/useTour.ts deleted file mode 100644 index 3f9a7e435..000000000 --- a/apps/code/src/renderer/features/tour/hooks/useTour.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 index 44a7391b0..ee8a2ad7b 100644 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ b/apps/code/src/renderer/features/tour/stores/tourStore.ts @@ -84,11 +84,17 @@ export const useTourStore = create()( completeTour: (tourId) => { const { completedTourIds } = get(); if (completedTourIds.includes(tourId)) return; + const tour = TOUR_REGISTRY[tourId]; set({ completedTourIds: [...completedTourIds, tourId], activeTourId: null, activeStepIndex: 0, }); + track(ANALYTICS_EVENTS.TOUR_EVENT, { + tour_id: tourId, + action: "completed", + total_steps: tour?.steps.length, + }); }, dismiss: () => { From ae1e4e0a2c24a7c0593ffdd37bebeb1c5d4f7dab Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 20:03:06 -0700 Subject: [PATCH 19/22] Simplify Caret component with data-driven triangle styles --- .../features/tour/components/TourTooltip.tsx | 163 +++++------------- 1 file changed, 44 insertions(+), 119 deletions(-) diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx index 562ebb3b3..12f6a74fb 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/apps/code/src/renderer/features/tour/components/TourTooltip.tsx @@ -92,6 +92,37 @@ function getBubbleVariants(placement: TooltipPlacement) { }; } +const COLORED_BORDER: Record = { + left: "borderRight", + right: "borderLeft", + top: "borderBottom", + bottom: "borderTop", +}; + +function caretTriangle( + side: "left" | "right" | "top" | "bottom", + size: number, + offset: number, + color: string, +): React.CSSProperties { + const isHorizontal = side === "left" || side === "right"; + const [t1, t2] = isHorizontal + ? ["borderTop", "borderBottom"] + : ["borderLeft", "borderRight"]; + + return { + position: "absolute", + width: 0, + height: 0, + [isHorizontal ? "top" : "left"]: `calc(50% + ${offset}px)`, + [side]: -size, + [isHorizontal ? "marginTop" : "marginLeft"]: -size, + [t1]: `${size}px solid transparent`, + [t2]: `${size}px solid transparent`, + [COLORED_BORDER[side]]: `${size}px solid ${color}`, + }; +} + function Caret({ side, offset = 0, @@ -99,125 +130,19 @@ function Caret({ side: "left" | "right" | "top" | "bottom"; offset?: number; }) { - const borderColor = "var(--gray-a5)"; - const fillColor = "var(--color-panel-solid)"; - - const base: React.CSSProperties = { - position: "absolute", - width: 0, - height: 0, - }; - - switch (side) { - case "right": - return ( - <> -
-
- - ); - case "left": - return ( - <> -
-
- - ); - case "top": - return ( - <> -
-
- - ); - case "bottom": - return ( - <> -
-
- - ); - } + return ( + <> +
+
+ + ); } export function TourTooltip({ From 0cb1a0a5d9641b2e825fee43714ccac91d09c3ea Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 20:07:07 -0700 Subject: [PATCH 20/22] Revert unrelated scaling change from tour branch --- apps/code/.storybook/preview.tsx | 2 +- apps/code/src/renderer/components/ThemeWrapper.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/code/.storybook/preview.tsx b/apps/code/.storybook/preview.tsx index d53ba4046..2e3787723 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="100%" + scaling="105%" > diff --git a/apps/code/src/renderer/components/ThemeWrapper.tsx b/apps/code/src/renderer/components/ThemeWrapper.tsx index bb3d6f72d..97dd6286d 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="100%" + scaling="105%" > {children}
From f8e043cc093f78f69251a2e2fe3f3c48bfa87a1e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 20:17:12 -0700 Subject: [PATCH 21/22] Update vitest.config.ts --- packages/git/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/git/vitest.config.ts b/packages/git/vitest.config.ts index eef4c3f9c..6011860bb 100644 --- a/packages/git/vitest.config.ts +++ b/packages/git/vitest.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ environment: "node", include: ["src/**/*.test.ts"], exclude: ["**/node_modules/**", "**/.git/**"], + testTimeout: 10_000, }, }); From 686904b51af458fb5820d8c949c8c1707b49d158 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 21 Apr 2026 20:21:25 -0700 Subject: [PATCH 22/22] Bundle @posthog/enricher in agent tsup build --- packages/agent/tsup.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index b11d7453d..a41ee8c5a 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -55,7 +55,7 @@ const sharedOptions = { splitting: false, outDir: "dist", target: "node20", - noExternal: ["@posthog/shared", "@posthog/git"], + noExternal: ["@posthog/shared", "@posthog/git", "@posthog/enricher"], external: [ ...builtinModules, ...builtinModules.map((m) => `node:${m}`),