From 0bf07b81ee01261862fe108e7a2838c34153f2cb Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Wed, 3 Jun 2026 10:42:22 -0700 Subject: [PATCH] feat: Guided Tours Framework (Subgraphs) --- src/components/Learn/tours/registry.ts | 8 +- .../TourProvider/tourActionLabels.ts | 10 + .../Editor/components/EditorTourBridge.tsx | 179 +++++++++++++++++- 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts index e8155f756..b60413c40 100644 --- a/src/components/Learn/tours/registry.ts +++ b/src/components/Learn/tours/registry.ts @@ -13,13 +13,19 @@ export type TourStep = StepType & { | "connect-edge" | "expand-folder" | "library-search" - | "set-argument"; + | "set-argument" + | "navigate-into-subgraph" + | "navigate-to-root" + | "unpack-subgraph" + | "multi-select-tasks" + | "create-subgraph"; targetWindowId?: string; targetFolderName?: string; targetArgumentName?: string; targetSearchTerm?: string; targetTaskName?: string; targetComponentName?: string; + targetMinCount?: number; targetEdge?: { sourceTaskName: string; sourcePortName: string; diff --git a/src/providers/TourProvider/tourActionLabels.ts b/src/providers/TourProvider/tourActionLabels.ts index 106d9b65c..4e8af464c 100644 --- a/src/providers/TourProvider/tourActionLabels.ts +++ b/src/providers/TourProvider/tourActionLabels.ts @@ -26,6 +26,16 @@ export function tourActionLabel({ interaction }: TourActionLabelInput): string { return "Connect the highlighted ports"; case "set-argument": return "Set the highlighted value"; + case "navigate-into-subgraph": + return "Open the highlighted subgraph"; + case "navigate-to-root": + return "Return to the top level"; + case "unpack-subgraph": + return "Unpack the subgraph"; + case "multi-select-tasks": + return "Select the highlighted tasks"; + case "create-subgraph": + return "Create a subgraph from the selected tasks"; default: return GENERIC_LABEL; } diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx index b90e8693d..89efa4ffb 100644 --- a/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx +++ b/src/routes/v2/pages/Editor/components/EditorTourBridge.tsx @@ -1,4 +1,5 @@ import { useTour } from "@reactour/tour"; +import { useViewport } from "@xyflow/react"; import { reaction } from "mobx"; import { useEffect } from "react"; @@ -116,6 +117,15 @@ export function EditorTourBridge() { const { steps, currentStep, setCurrentStep, setSteps, isOpen } = useTour(); const { windows, navigation, editor } = useSharedStores(); const { markStepComplete } = useTourProgress(); + const { x: viewportX, y: viewportY, zoom: viewportZoom } = useViewport(); + + useEffect(() => { + if (!isOpen) return; + const rafId = requestAnimationFrame(() => { + window.dispatchEvent(new Event("resize")); + }); + return () => cancelAnimationFrame(rafId); + }, [isOpen, viewportX, viewportY, viewportZoom]); const step = steps[currentStep] as TourStep | undefined; const interaction = step?.interaction; @@ -125,15 +135,47 @@ export function EditorTourBridge() { const ensureWindowRestoredId = step?.ensureWindowRestored; const requiresTaskSelected = step?.requiresTaskSelected; const libraryDragAllow = step?.targetComponentName ?? step?.targetTaskName; + const stepSelector = step?.selector; useEffect(() => { if (!isOpen) return; if (!ensureWindowRestoredId) return; const w = windows.getWindowById(ensureWindowRestoredId); - if (w && (w.state === "hidden" || w.isMinimized)) { + const wasHidden = !!w && (w.state === "hidden" || w.isMinimized); + if (wasHidden) { w.restore(); } - }, [isOpen, ensureWindowRestoredId, currentStep, windows]); + if (!w) return; + + let cancelled = false; + const start = Date.now(); + const wantSelector = typeof stepSelector === "string" ? stepSelector : null; + const waitForDom = () => { + if (cancelled) return; + const found = wantSelector + ? document.querySelector(wantSelector) + : document.querySelector( + `[data-dock-window="${ensureWindowRestoredId}"]`, + ); + if (found || Date.now() - start > 1500) { + setSteps?.((prev) => [...prev]); + return; + } + window.setTimeout(waitForDom, 50); + }; + window.setTimeout(waitForDom, 50); + + return () => { + cancelled = true; + }; + }, [ + isOpen, + ensureWindowRestoredId, + currentStep, + windows, + stepSelector, + setSteps, + ]); useEffect(() => { if (!isOpen) return undefined; @@ -209,7 +251,6 @@ export function EditorTourBridge() { }; }, [isOpen, libraryDragAllow]); - const stepSelector = step?.selector; useEffect(() => { if (!isOpen) return; if (!resetLibrarySearchFlag) return; @@ -552,6 +593,138 @@ export function EditorTourBridge() { }; } + if (interaction === "navigate-into-subgraph") { + const targetName = step?.targetTaskName?.toLowerCase(); + const baselineDepth = navigation.navigationDepth; + + const matches = () => { + if (navigation.navigationDepth <= baselineDepth) return false; + if (!targetName) return true; + const last = + navigation.navigationPath[navigation.navigationPath.length - 1]; + return last?.displayName?.toLowerCase() === targetName; + }; + + if (matches()) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => matches(), + (m) => { + if (m) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "navigate-to-root") { + const isAtRoot = () => navigation.navigationDepth === 0; + + if (isAtRoot()) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => isAtRoot(), + (m) => { + if (m) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "unpack-subgraph") { + const countSubgraphTasks = () => { + const spec = navigation.activeSpec; + if (!spec) return 0; + return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; + }; + const baseline = countSubgraphTasks(); + + const dispose = reaction( + () => countSubgraphTasks(), + (current) => { + if (current < baseline) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "multi-select-tasks") { + const minCount = step?.targetMinCount ?? 2; + + const taskSelectionCount = () => + editor.multiSelection.filter((n) => n.type === "task").length; + + if (taskSelectionCount() >= minCount) { + skip(); + return stopFollow; + } + + const dispose = reaction( + () => taskSelectionCount(), + (current) => { + if (current >= minCount) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + + if (interaction === "create-subgraph") { + const countSubgraphTasks = () => { + const spec = navigation.activeSpec; + if (!spec) return 0; + return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length; + }; + const baseline = countSubgraphTasks(); + + const dispose = reaction( + () => countSubgraphTasks(), + (current) => { + if (current > baseline) { + dispose(); + advance(); + } + }, + ); + + return () => { + stopFollow(); + dispose(); + }; + } + if (isCountInteraction(interaction)) { const baseline = countForInteraction(navigation.activeSpec, interaction);