-
Tour
-
Tour component placeholder
+
+
+
+ Preparing tour…
+
);
}
+
+function TourReactourBridge({
+ tour,
+ urlStep,
+ onUrlStepChange,
+}: {
+ tour: TourDefinition;
+ urlStep: number;
+ onUrlStepChange: (step: number) => void;
+}) {
+ const { setSteps, setCurrentStep, setIsOpen, currentStep } = useTour();
+
+ const lastSyncRef = useRef
(null);
+ const initializedRef = useRef(false);
+
+ // Reactour silently no-ops if step selectors aren't in the DOM at open time.
+ useEffect(() => {
+ if (initializedRef.current) return;
+ let cancelled = false;
+ void waitForSelector('[data-testid="editor-v2"]').then(() => {
+ if (cancelled || initializedRef.current) return;
+ initializedRef.current = true;
+
+ const stepsWithMarkdown = tour.steps.map((step) =>
+ typeof step.content === "string"
+ ? { ...step, content: }
+ : step,
+ );
+ setSteps?.(stepsWithMarkdown);
+
+ const clamped = Math.min(Math.max(0, urlStep), tour.steps.length - 1);
+ setCurrentStep(clamped);
+ lastSyncRef.current = clamped;
+ setIsOpen(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [tour, urlStep, setSteps, setCurrentStep, setIsOpen]);
+
+ useEffect(() => {
+ return () => {
+ setIsOpen(false);
+ };
+ }, [setIsOpen]);
+
+ useEffect(() => {
+ if (!initializedRef.current) return;
+ if (urlStep === lastSyncRef.current) return;
+ lastSyncRef.current = urlStep;
+ const clamped = Math.min(Math.max(0, urlStep), tour.steps.length - 1);
+ setCurrentStep(clamped);
+ }, [urlStep, tour.steps.length, setCurrentStep]);
+
+ useEffect(() => {
+ if (!initializedRef.current) return;
+ if (currentStep === lastSyncRef.current) return;
+ lastSyncRef.current = currentStep;
+ onUrlStepChange(currentStep);
+ }, [currentStep, onUrlStepChange]);
+
+ return null;
+}
+
+export function TourPage() {
+ const params = useParams({ strict: false });
+ const search = useSearch({ strict: false });
+ const navigate = useNavigate();
+ const storage = usePipelineStorage();
+
+ const tourId =
+ "tourId" in params && typeof params.tourId === "string"
+ ? params.tourId
+ : "";
+
+ const tour = getTour(tourId);
+
+ const [resolved, setResolved] = useState(null);
+
+ // Refs (not state) so the unmount cleanup reads the latest values across
+ // strict-mode double-mounts.
+ const tempPipelineRef = useRef(null);
+ const promotedRef = useRef(false);
+
+ useEffect(() => {
+ if (!tour) return;
+ snapshotLayout(EDITOR_LAYOUT_ID);
+ return () => {
+ restoreLayout(EDITOR_LAYOUT_ID);
+ };
+ }, [tour]);
+
+ // Set the target name synchronously so unmount cleanup always has something
+ // to delete, even if the user bails before the async create finishes.
+ useEffect(() => {
+ if (!tour) return undefined;
+ tempPipelineRef.current = tourPipelineName(tour);
+ let cancelled = false;
+ void (async () => {
+ try {
+ const created = await createOrReuseTourPipeline(tour, storage);
+ if (cancelled) return;
+ setResolved(created);
+ } catch (error) {
+ console.warn("Failed to create tour pipeline:", error);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [tour, storage]);
+
+ useEffect(() => {
+ return () => {
+ if (promotedRef.current) return;
+ const name = tempPipelineRef.current;
+ if (!name) return;
+ tempPipelineRef.current = null;
+ void deleteTourPipelineByName(storage, name);
+ };
+ }, [storage]);
+
+ const handleUrlStepChange = (step: number) => {
+ void navigate({
+ to: APP_ROUTES.TOUR_DETAIL,
+ params: { tourId },
+ search: { step },
+ replace: true,
+ });
+ };
+
+ const markPipelinePromoted = () => {
+ promotedRef.current = true;
+ };
+
+ if (!tour) {
+ return ;
+ }
+
+ if (!resolved) {
+ return ;
+ }
+
+ const rawStep = (search as { step?: unknown }).step;
+ const parsedStep =
+ typeof rawStep === "number"
+ ? rawStep
+ : typeof rawStep === "string"
+ ? Number.parseInt(rawStep, 10)
+ : 0;
+ const urlStep = Number.isFinite(parsedStep) ? parsedStep : 0;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx
index 94bac1e71..573840d88 100644
--- a/src/routes/v2/pages/Editor/EditorV2.tsx
+++ b/src/routes/v2/pages/Editor/EditorV2.tsx
@@ -30,6 +30,7 @@ import type { PipelineRef } from "@/services/pipelineStorage/types";
import { useDebugPanelWindow } from "./components/DebugPanel";
import { DriverPermissionGate } from "./components/DriverPermissionGate";
import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar";
+import { EditorTourBridge } from "./components/EditorTourBridge";
import { EmptyEditorState } from "./components/EmptyEditorState";
import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas";
import { useComponentLibraryWindow } from "./hooks/useComponentLibraryWindow";
@@ -132,6 +133,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
+
{pipelineRef ? (
@@ -146,10 +148,13 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
);
}
-/**
- * Shell component for the Editor V2 route.
- */
-export function EditorV2() {
+// Non-editor-v2 routes (e.g. `/tour/$tourId`) pass `pipelineRef` directly.
+// Without a prop, we fall back to reading the route's params/search.
+export function EditorV2({
+ pipelineRef: pipelineRefProp,
+}: {
+ pipelineRef?: PipelineRef | null;
+} = {}) {
const params = useParams({ strict: false });
const search = useSearch({ strict: false });
const fileId =
@@ -162,9 +167,12 @@ export function EditorV2() {
? params.pipelineName
: null;
- const pipelineRef: PipelineRef | null = pipelineName
- ? { name: pipelineName, fileId }
- : null;
+ const pipelineRef: PipelineRef | null =
+ pipelineRefProp !== undefined
+ ? pipelineRefProp
+ : pipelineName
+ ? { name: pipelineName, fileId }
+ : null;
return (
diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx
index a82f3b6b6..a21018784 100644
--- a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx
+++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx
@@ -1,13 +1,18 @@
+import { useTour } from "@reactour/tour";
+import { useNavigate } from "@tanstack/react-router";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import logo from "/Tangle_Icon_White.png";
import { PipelineNameDialog } from "@/components/shared/Dialogs";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Link } from "@/components/ui/link";
import { Text } from "@/components/ui/typography";
+import { useTourMode } from "@/providers/TourProvider/TourModeContext";
+import { APP_ROUTES } from "@/routes/router";
import { usePipelineRename } from "@/routes/v2/pages/Editor/hooks/usePipelineRename";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { AppMenuActions } from "@/routes/v2/shared/components/AppMenuActions";
@@ -28,15 +33,40 @@ export const EditorMenuBar = observer(function EditorMenuBar() {
const { navigation } = useSharedStores();
const { pipelineFile } = useEditorSession();
const handlePipelineRename = usePipelineRename();
+ const tourMode = useTourMode();
+ const { isOpen: tourPopupOpen, setIsOpen: setTourPopupOpen } = useTour();
+ const navigate = useNavigate();
+
const spec = navigation.activeSpec;
- const pipelineName = spec?.name ?? "Untitled pipeline";
+ const pipelineNameFromSpec = spec?.name ?? "Untitled pipeline";
+ const displayName =
+ tourMode?.tour.displayName ?? tourMode?.tour.id ?? pipelineNameFromSpec;
const displayMenu = Boolean(pipelineFile.activePipelineFile);
const [renameOpen, setRenameOpen] = useState(false);
+ const [saveAsOpen, setSaveAsOpen] = useState(false);
+
+ const showResumeAndSave = Boolean(tourMode) && !tourPopupOpen;
+ const isInTour = Boolean(tourMode);
+
+ const handleTourSaveAs = async (name: string) => {
+ if (!tourMode) return;
+ tourMode.markPipelinePromoted();
+ await handlePipelineRename(name);
+ };
+
+ const handleResumeTour = () => {
+ setTourPopupOpen(true);
+ };
+
+ const handleExitTour = () => {
+ setTourPopupOpen(false);
+ void navigate({ to: APP_ROUTES.LEARN_TOURS });
+ };
return (
- {pipelineName}
+ {displayName}
-
+ {tourMode && (
+
+ Tour
+
+ )}
+ {!tourMode && (
+
+ )}
-
name === pipelineName}
- />
+ {!tourMode && (
+ name === pipelineNameFromSpec}
+ />
+ )}
@@ -114,6 +153,20 @@ export const EditorMenuBar = observer(function EditorMenuBar() {
)}
+ {tourMode && (
+
+ )}
+
{displayMenu && (
<>
+ {isInTour && tourMode && (
+ <>
+ {showResumeAndSave && (
+ <>
+
+
+ >
+ )}
+
+
+ >
+ )}
diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx
index 0e27479b4..b2efed891 100644
--- a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx
+++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Icon } from "@/components/ui/icon";
import { useAnalytics } from "@/providers/AnalyticsProvider";
+import { useTourMode } from "@/providers/TourProvider/TourModeContext";
import { APP_ROUTES } from "@/routes/router";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { MenuTriggerButton } from "@/routes/v2/shared/components/MenuTriggerButton";
@@ -55,6 +56,7 @@ export function FileMenu() {
const activePipeline = pipelineFileStore.activePipelineFile;
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
const canMove = activePipeline?.folder.canMoveFilesOut ?? false;
+ const tourMode = useTourMode();
return (
<>
@@ -96,15 +98,17 @@ export function FileMenu() {
Save as
- {
- track("v2.pipeline_editor.file_menu.rename.click");
- setRenameDialogOpen(true);
- }}
- >
-
- Rename
-
+ {!tourMode && (
+ {
+ track("v2.pipeline_editor.file_menu.rename.click");
+ setRenameDialogOpen(true);
+ }}
+ >
+
+ Rename
+
+ )}
{
@@ -147,17 +151,21 @@ export function FileMenu() {
>
)}
-
- {
- track("v2.pipeline_editor.file_menu.delete_pipeline.click");
- setDeleteDialogOpen(true);
- }}
- className="text-destructive focus:text-destructive"
- >
-
- Delete pipeline
-
+ {!tourMode && (
+ <>
+
+ {
+ track("v2.pipeline_editor.file_menu.delete_pipeline.click");
+ setDeleteDialogOpen(true);
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete pipeline
+
+ >
+ )}
diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx
new file mode 100644
index 000000000..72da851be
--- /dev/null
+++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx
@@ -0,0 +1,197 @@
+import { useTour } from "@reactour/tour";
+import { reaction } from "mobx";
+import { useEffect } from "react";
+
+import type { TourStep } from "@/components/Learn/tours/registry";
+import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
+import type { WindowStoreImpl } from "@/routes/v2/shared/windows/windowStore";
+
+function followWindowPosition(
+ windows: WindowStoreImpl,
+ targetWindowId: string | undefined,
+): () => void {
+ if (!targetWindowId) return () => undefined;
+
+ let rafId: number | null = null;
+ const dispose = reaction(
+ () => {
+ const w = windows.getWindowById(targetWindowId);
+ return w ? `${w.position.x},${w.position.y}` : "";
+ },
+ () => {
+ if (rafId !== null) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ window.dispatchEvent(new Event("resize"));
+ });
+ },
+ );
+
+ return () => {
+ dispose();
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };
+}
+
+function trackDockStateTransition(
+ windows: WindowStoreImpl,
+ matchInitial: (w: { dockState: string }) => boolean,
+ matchTransition: (w: { dockState: string }) => boolean,
+ targetWindowId?: string,
+): { didTransition: () => boolean; dispose: () => void } {
+ const baseline = new Set();
+ for (const w of windows.getAllWindows()) {
+ if (targetWindowId ? w.id === targetWindowId : matchInitial(w)) {
+ baseline.add(w.id);
+ }
+ }
+ let fired = false;
+
+ const stateReaction = reaction(
+ () =>
+ windows
+ .getAllWindows()
+ .map((w) => `${w.id}:${w.dockState}`)
+ .join("|"),
+ () => {
+ for (const w of windows.getAllWindows()) {
+ if (targetWindowId) {
+ if (w.id === targetWindowId && matchTransition(w)) {
+ fired = true;
+ }
+ continue;
+ }
+ if (matchInitial(w)) {
+ baseline.add(w.id);
+ } else if (baseline.has(w.id) && matchTransition(w)) {
+ fired = true;
+ }
+ }
+ },
+ );
+
+ return {
+ didTransition: () => fired,
+ dispose: stateReaction,
+ };
+}
+
+export function EditorTourBridge() {
+ const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour();
+ const { windows } = useSharedStores();
+
+ const step = steps[currentStep] as TourStep | undefined;
+ const interaction = step?.interaction;
+ const targetWindowId = step?.targetWindowId;
+
+ useEffect(() => {
+ if (!isOpen) return undefined;
+
+ // Run outside the interaction branch so informational/fallback steps that
+ // target a floating window still track its position.
+ const stopFollow = followWindowPosition(windows, targetWindowId);
+
+ if (!interaction) return stopFollow;
+
+ const advance = () => {
+ setCurrentStep((s: number) =>
+ Math.min(s + 1, Math.max(0, steps.length - 1)),
+ );
+ };
+
+ const skipWithFallback = (currentStepData: TourStep) => {
+ if (currentStepData.fallbackContent) {
+ const replaced: TourStep = {
+ ...currentStepData,
+ content: currentStepData.fallbackContent,
+ interaction: undefined,
+ stepInteraction: false,
+ };
+ const next = steps.map((s, i) => (i === currentStep ? replaced : s));
+ setSteps?.(next);
+ } else {
+ advance();
+ }
+ };
+
+ if (interaction === "undock-window" || interaction === "redock-window") {
+ const isDocked = (w: { dockState: string }) => w.dockState !== "none";
+ const isUndocked = (w: { dockState: string }) => w.dockState === "none";
+ const matchInitial =
+ interaction === "undock-window" ? isDocked : isUndocked;
+ const matchTransition =
+ interaction === "undock-window" ? isUndocked : isDocked;
+
+ if (targetWindowId) {
+ const target = windows.getWindowById(targetWindowId);
+ if (!target || matchTransition(target)) {
+ skipWithFallback(step);
+ return stopFollow;
+ }
+ } else {
+ const hasSourceWindow = windows
+ .getAllWindows()
+ .some((w) => w.state !== "hidden" && matchInitial(w));
+ if (!hasSourceWindow) {
+ if (step) skipWithFallback(step);
+ else advance();
+ return stopFollow;
+ }
+ }
+
+ const tracker = trackDockStateTransition(
+ windows,
+ matchInitial,
+ matchTransition,
+ targetWindowId,
+ );
+
+ // The window's drag handler attaches its own mouseup on mousedown, so it
+ // fires after this one and is what flips dockState (`model.dock(side)`).
+ // Defer with setTimeout(0) so MobX has reacted before we check.
+ let pendingCheck: ReturnType | null = null;
+ const handleMouseUp = () => {
+ if (pendingCheck !== null) clearTimeout(pendingCheck);
+ pendingCheck = setTimeout(() => {
+ pendingCheck = null;
+ if (tracker.didTransition()) advance();
+ }, 0);
+ };
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ stopFollow();
+ tracker.dispose();
+ if (pendingCheck !== null) clearTimeout(pendingCheck);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+
+ if (interaction === "select-task") {
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target as Element | null;
+ if (target?.closest(".react-flow__node")) {
+ advance();
+ }
+ };
+ document.addEventListener("click", handleClick);
+ return () => {
+ stopFollow();
+ document.removeEventListener("click", handleClick);
+ };
+ }
+
+ return stopFollow;
+ }, [
+ isOpen,
+ interaction,
+ targetWindowId,
+ setCurrentStep,
+ setSteps,
+ step,
+ steps,
+ windows,
+ ]);
+
+ return null;
+}
diff --git a/src/routes/v2/shared/windows/windowPersistence.ts b/src/routes/v2/shared/windows/windowPersistence.ts
index 6e40d20d6..82be0a5bd 100644
--- a/src/routes/v2/shared/windows/windowPersistence.ts
+++ b/src/routes/v2/shared/windows/windowPersistence.ts
@@ -22,9 +22,60 @@ import type { WindowStoreImpl } from "./windowStore";
*/
let activeLayoutId: string | null = null;
+function getLayoutStorageKey(layoutId: string | null): string {
+ if (!layoutId) return "editorV2-window-layout";
+ return `window-layout-${layoutId}`;
+}
+
function getStorageKey(): string {
- if (!activeLayoutId) return "editorV2-window-layout";
- return `window-layout-${activeLayoutId}`;
+ return getLayoutStorageKey(activeLayoutId);
+}
+
+function snapshotStorageKey(layoutId: string): string {
+ return `${getLayoutStorageKey(layoutId)}-snapshot`;
+}
+
+function snapshotActiveKey(layoutId: string): string {
+ return `${snapshotStorageKey(layoutId)}-active`;
+}
+
+// Stashes the layout aside so the next mount starts from defaults. Pair with
+// restoreLayout to roll back.
+export function snapshotLayout(layoutId: string): void {
+ try {
+ const key = getLayoutStorageKey(layoutId);
+ const current = localStorage.getItem(key);
+ if (current !== null) {
+ localStorage.setItem(snapshotStorageKey(layoutId), current);
+ } else {
+ localStorage.removeItem(snapshotStorageKey(layoutId));
+ }
+ localStorage.setItem(snapshotActiveKey(layoutId), "1");
+ localStorage.removeItem(key);
+ } catch (error) {
+ console.warn(`Failed to snapshot layout "${layoutId}":`, error);
+ }
+}
+
+export function restoreLayout(layoutId: string): boolean {
+ try {
+ if (localStorage.getItem(snapshotActiveKey(layoutId)) === null) {
+ return false;
+ }
+ const key = getLayoutStorageKey(layoutId);
+ const saved = localStorage.getItem(snapshotStorageKey(layoutId));
+ if (saved !== null) {
+ localStorage.setItem(key, saved);
+ } else {
+ localStorage.removeItem(key);
+ }
+ localStorage.removeItem(snapshotStorageKey(layoutId));
+ localStorage.removeItem(snapshotActiveKey(layoutId));
+ return true;
+ } catch (error) {
+ console.warn(`Failed to restore layout "${layoutId}":`, error);
+ return false;
+ }
}
interface PersistedWindowState {