@@ -137,33 +140,43 @@ const PipelineEditor = withSuspenseWrapper(
function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
const { navigation } = useSharedStores();
+ const tourMode = useTourMode();
useEffect(() => {
navigation.setRequestedPipelineName(pipelineRef?.name ?? null);
}, [navigation, pipelineRef?.name]);
+ let body: ReactNode;
+ if (pipelineRef) {
+ body = (
+
+
+
+ );
+ } else if (tourMode) {
+ body =
;
+ } else {
+ body =
;
+ }
+
return (
-
- {pipelineRef ? (
-
-
-
- ) : (
-
- )}
-
+
+ {body}
);
}
-/**
- * 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 =
@@ -176,9 +189,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..cac8dd206 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,26 @@ export const EditorMenuBar = observer(function EditorMenuBar() {
const { navigation } = useSharedStores();
const { pipelineFile } = useEditorSession();
const handlePipelineRename = usePipelineRename();
+ const tourMode = useTourMode();
+ const { 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 handleExitTour = () => {
+ setTourPopupOpen(false);
+ void navigate({ to: APP_ROUTES.LEARN_TOURS });
+ };
+
return (
- {pipelineName}
+ {displayName}
-
+ {tourMode && (
+
+ Tour
+
+ )}
+ {!tourMode && (
+
+ )}
-
name === pipelineName}
- />
+ {!tourMode && (
+ name === pipelineNameFromSpec}
+ />
+ )}
@@ -114,6 +139,20 @@ export const EditorMenuBar = observer(function EditorMenuBar() {
)}
+ {tourMode && (
+
+ )}
+
@@ -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..ecc49762e
--- /dev/null
+++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx
@@ -0,0 +1,3 @@
+export function EditorTourBridge() {
+ 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 {
diff --git a/src/utils/dispatchResizeOnToggle.ts b/src/utils/dispatchResizeOnToggle.ts
new file mode 100644
index 000000000..6d3236ba3
--- /dev/null
+++ b/src/utils/dispatchResizeOnToggle.ts
@@ -0,0 +1,13 @@
+import { isTourActive } from "@/utils/tourActive";
+
+export function dispatchResizeOnToggle(open: boolean): void {
+ if (!isTourActive()) return;
+ requestAnimationFrame(() => {
+ window.dispatchEvent(new Event("resize"));
+ });
+ if (!open) {
+ setTimeout(() => {
+ window.dispatchEvent(new Event("resize"));
+ }, 250);
+ }
+}
diff --git a/src/utils/tourActive.ts b/src/utils/tourActive.ts
new file mode 100644
index 000000000..c7641d703
--- /dev/null
+++ b/src/utils/tourActive.ts
@@ -0,0 +1,9 @@
+let tourActive = false;
+
+export function setTourActive(active: boolean): void {
+ tourActive = active;
+}
+
+export function isTourActive(): boolean {
+ return tourActive;
+}
diff --git a/src/utils/waitForSelector.ts b/src/utils/waitForSelector.ts
new file mode 100644
index 000000000..81f70ac42
--- /dev/null
+++ b/src/utils/waitForSelector.ts
@@ -0,0 +1,38 @@
+interface WaitForSelectorOptions {
+ timeoutMs?: number;
+ signal?: AbortSignal;
+}
+
+export function waitForSelector(
+ selector: string,
+ { timeoutMs = 5000, signal }: WaitForSelectorOptions = {},
+): Promise {
+ if (document.querySelector(selector)) return Promise.resolve(true);
+ if (signal?.aborted) return Promise.resolve(false);
+
+ return new Promise((resolve) => {
+ const disposers: Array<() => void> = [];
+ let settled = false;
+ const finish = (found: boolean) => {
+ if (settled) return;
+ settled = true;
+ disposers.forEach((dispose) => dispose());
+ resolve(found);
+ };
+
+ const observer = new MutationObserver(() => {
+ if (document.querySelector(selector)) finish(true);
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ disposers.push(() => observer.disconnect());
+
+ const timer = setTimeout(() => finish(false), timeoutMs);
+ disposers.push(() => clearTimeout(timer));
+
+ if (signal) {
+ const onAbort = () => finish(false);
+ signal.addEventListener("abort", onAbort, { once: true });
+ disposers.push(() => signal.removeEventListener("abort", onAbort));
+ }
+ });
+}