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[];
+}