diff --git a/package.json b/package.json index c73fcce91..f36f43d82 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@reactour/tour": "3.8.0", "@tailwindcss/vite": "^4.3.0", "@tanstack/history": "1.162.0", "@tanstack/react-query": "^5.100.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b770f0ef..cead07e5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@reactour/tour': + specifier: 3.8.0 + version: 3.8.0(react@19.2.6) '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) @@ -1829,6 +1832,26 @@ packages: resolution: {integrity: sha512-1ln28VkVHUbd5qy+ccXG68voWc0mgZMhBnwG0umxfD+wbkXUcvRzVrLjSqao7N8hCrDqp+Pt5j9Tsqef+9yQQQ==} hasBin: true + '@reactour/mask@1.2.0': + resolution: {integrity: sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/popover@1.3.0': + resolution: {integrity: sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/tour@3.8.0': + resolution: {integrity: sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + + '@reactour/utils@0.6.0': + resolution: {integrity: sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==} + peerDependencies: + react: 16.x || 17.x || 18.x || 19.x + '@rolldown/binding-android-arm64@1.0.1': resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2058,6 +2081,11 @@ packages: cpu: [x64] os: [win32] + '@rooks/use-mutation-observer@4.11.2': + resolution: {integrity: sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==} + peerDependencies: + react: '>=16.8.0' + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -4385,6 +4413,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -6541,6 +6572,29 @@ snapshots: smol-toml: 1.6.1 tinyexec: 1.1.2 + '@reactour/mask@1.2.0(react@19.2.6)': + dependencies: + '@reactour/utils': 0.6.0(react@19.2.6) + react: 19.2.6 + + '@reactour/popover@1.3.0(react@19.2.6)': + dependencies: + '@reactour/utils': 0.6.0(react@19.2.6) + react: 19.2.6 + + '@reactour/tour@3.8.0(react@19.2.6)': + dependencies: + '@reactour/mask': 1.2.0(react@19.2.6) + '@reactour/popover': 1.3.0(react@19.2.6) + '@reactour/utils': 0.6.0(react@19.2.6) + react: 19.2.6 + + '@reactour/utils@0.6.0(react@19.2.6)': + dependencies: + '@rooks/use-mutation-observer': 4.11.2(react@19.2.6) + react: 19.2.6 + resize-observer-polyfill: 1.5.1 + '@rolldown/binding-android-arm64@1.0.1': optional: true @@ -6677,6 +6731,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@rooks/use-mutation-observer@4.11.2(react@19.2.6)': + dependencies: + react: 19.2.6 + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -9284,6 +9342,8 @@ snapshots: require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/components/Learn/FeaturedTours.tsx b/src/components/Learn/FeaturedTours.tsx index d6257188d..a64514fb5 100644 --- a/src/components/Learn/FeaturedTours.tsx +++ b/src/components/Learn/FeaturedTours.tsx @@ -9,6 +9,7 @@ import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { tours as tourCards } from "./tours"; +import { getTour } from "./tours/registry"; interface FeaturedTour { id: string; @@ -30,7 +31,13 @@ function buildFeaturedTours(): FeaturedTour[] { const card = tourCards.find((c) => c.id === id); if (!card) return []; return [ - { id, title: card.title, duration: card.duration, tag, available: false }, + { + id, + title: card.title, + duration: card.duration, + tag, + available: getTour(id) !== undefined, + }, ]; }); } @@ -68,7 +75,7 @@ export function FeaturedTours() { key={tour.id} variant="ghost" size="lg" - disabled + disabled={!tour.available} className="w-full" {...tracking("learning_hub.tours.start", { tour_id: tour.id, diff --git a/src/components/Learn/ToursLibrary.tsx b/src/components/Learn/ToursLibrary.tsx index 62c1349b8..b6c1683d3 100644 --- a/src/components/Learn/ToursLibrary.tsx +++ b/src/components/Learn/ToursLibrary.tsx @@ -1,3 +1,5 @@ +import { Link } from "@tanstack/react-router"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -10,6 +12,7 @@ import { import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; import { @@ -21,8 +24,11 @@ import { type TourDifficulty, tours, } from "./tours"; +import { getTour } from "./tours/registry"; function TourCard({ tour }: { tour: Tour }) { + const isAvailable = getTour(tour.id) !== undefined; + return ( @@ -41,14 +47,28 @@ function TourCard({ tour }: { tour: Tour }) { {tour.duration} - + {isAvailable ? ( + + ) : ( + + )} diff --git a/src/components/Learn/tours/registry.ts b/src/components/Learn/tours/registry.ts new file mode 100644 index 000000000..c306b4864 --- /dev/null +++ b/src/components/Learn/tours/registry.ts @@ -0,0 +1,33 @@ +import type { StepType } from "@reactour/tour"; + +import { publicAsset } from "@/utils/publicAsset"; + +export type TourStep = StepType & { + interaction?: "undock-window" | "redock-window" | "select-task"; + targetWindowId?: string; + fallbackContent?: string; +}; + +export interface TourDefinition { + id: string; + displayName?: string; + requiresEditor?: boolean; + starterPipelineUrl?: string; + steps: TourStep[]; +} + +const tourModules = import.meta.glob("./*.tour.json", { + eager: true, + import: "default", +}); + +const tours: TourDefinition[] = Object.values(tourModules).map((tour) => ({ + ...tour, + starterPipelineUrl: tour.starterPipelineUrl + ? publicAsset(tour.starterPipelineUrl) + : undefined, +})); + +export function getTour(id: string): TourDefinition | undefined { + return tours.find((tour) => tour.id === id); +} diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 76acd180d..cea3f0515 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -196,6 +196,10 @@ const AppMenu = () => { return null; } + if (pathname.startsWith(APP_ROUTES.TOUR)) { + return null; + } + return ; }; diff --git a/src/components/layout/RootLayout.tsx b/src/components/layout/RootLayout.tsx index b989f6c64..dc036480d 100644 --- a/src/components/layout/RootLayout.tsx +++ b/src/components/layout/RootLayout.tsx @@ -9,6 +9,7 @@ import { useSessionPipelineStats } from "@/hooks/useSessionPipelineStats"; import { AnalyticsProvider } from "@/providers/AnalyticsProvider"; import { BackendProvider } from "@/providers/BackendProvider"; import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider"; +import { TourProvider } from "@/providers/TourProvider/TourProvider"; import { PipelineStorageProvider } from "@/services/pipelineStorage/PipelineStorageProvider"; import AppMenu from "./AppMenu"; @@ -26,20 +27,22 @@ function RootLayoutContent() { - - - -
- - -
- -
- - {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( - - )} -
+ + + + +
+ + +
+ +
+ + {import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && ( + + )} +
+
diff --git a/src/providers/TourProvider/TourModeContext.tsx b/src/providers/TourProvider/TourModeContext.tsx new file mode 100644 index 000000000..4bb0e5db2 --- /dev/null +++ b/src/providers/TourProvider/TourModeContext.tsx @@ -0,0 +1,30 @@ +import { createContext, type ReactNode, useContext } from "react"; + +import type { TourDefinition } from "@/components/Learn/tours/registry"; + +export interface TourModeValue { + tour: TourDefinition; + tempPipelineName: string; + // Suppresses the route's on-unmount delete after a Save-as promotion. + markPipelinePromoted: () => void; +} + +const TourModeContext = createContext(null); + +export function TourModeProvider({ + value, + children, +}: { + value: TourModeValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useTourMode(): TourModeValue | null { + return useContext(TourModeContext); +} diff --git a/src/providers/TourProvider/TourOrphanCleanup.tsx b/src/providers/TourProvider/TourOrphanCleanup.tsx new file mode 100644 index 000000000..1a80e215e --- /dev/null +++ b/src/providers/TourProvider/TourOrphanCleanup.tsx @@ -0,0 +1,22 @@ +import { useRouterState } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { APP_ROUTES } from "@/routes/router"; +import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider"; + +import { cleanupOrphanTourPipelines } from "./tourPipelineLifecycle"; + +export function TourOrphanCleanup() { + const storage = usePipelineStorage(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + useEffect(() => { + // Skip on tour routes so we don't race the route's own create flow. + if (pathname.startsWith(APP_ROUTES.TOUR)) return; + void cleanupOrphanTourPipelines(storage); + }, [storage, pathname]); + + return null; +} diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/providers/TourProvider/TourProvider.tsx new file mode 100644 index 000000000..a7b07c11c --- /dev/null +++ b/src/providers/TourProvider/TourProvider.tsx @@ -0,0 +1,32 @@ +import { TourProvider as ReactourProvider } from "@reactour/tour"; +import type { ReactNode } from "react"; + +import { TourOrphanCleanup } from "./TourOrphanCleanup"; +import { + computeDefaultPopoverPosition, + POPOVER_STYLES, + PopoverClampBridge, + renderNextButton, +} from "./tourPopover"; + +export function TourProvider({ children }: { children: ReactNode }) { + return ( + undefined} + > + + + {children} + + ); +} diff --git a/src/providers/TourProvider/tourContent.tsx b/src/providers/TourProvider/tourContent.tsx new file mode 100644 index 000000000..bcf8f23c6 --- /dev/null +++ b/src/providers/TourProvider/tourContent.tsx @@ -0,0 +1,31 @@ +import { Fragment, type ReactNode } from "react"; + +const INLINE_TOKEN = /(\*\*[^*]+\*\*|_[^_]+_|`[^`]+`)/g; + +function renderInline(text: string): ReactNode[] { + return text.split(INLINE_TOKEN).map((part, index) => { + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + if (part.startsWith("_") && part.endsWith("_")) { + return {part.slice(1, -1)}; + } + if (part.startsWith("`") && part.endsWith("`")) { + return {part.slice(1, -1)}; + } + return {part}; + }); +} + +export function TourContent({ text }: { text: string }) { + const paragraphs = text.split(/\n{2,}/); + return ( + <> + {paragraphs.map((paragraph, index) => ( +

0 ? "mt-2" : undefined}> + {renderInline(paragraph)} +

+ ))} + + ); +} diff --git a/src/providers/TourProvider/tourPipelineLifecycle.ts b/src/providers/TourProvider/tourPipelineLifecycle.ts new file mode 100644 index 000000000..2bb8f889b --- /dev/null +++ b/src/providers/TourProvider/tourPipelineLifecycle.ts @@ -0,0 +1,64 @@ +import yaml from "js-yaml"; + +import type { TourDefinition } from "@/components/Learn/tours/registry"; +import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; +import { defaultPipelineYamlWithName } from "@/utils/constants"; + +export const TOUR_PIPELINE_PREFIX = "__tour__"; + +export async function deleteTourPipelineByName( + storage: PipelineStorageService, + name: string, +): Promise { + try { + const file = await storage.resolvePipelineByName(name); + if (file) await file.deleteFile(); + } catch (error) { + console.warn(`Failed to delete tour pipeline "${name}":`, error); + } +} + +export async function cleanupOrphanTourPipelines( + storage: PipelineStorageService, + keep?: string | null, +): Promise { + try { + const files = await storage.rootFolder.listPipelines(); + const orphans = files.filter( + (f) => + f.storageKey.startsWith(TOUR_PIPELINE_PREFIX) && f.storageKey !== keep, + ); + await Promise.all(orphans.map((f) => f.deleteFile())); + } catch (error) { + console.warn("Failed to cleanup orphan tour pipelines:", error); + } +} + +export async function buildTourPipelineYaml( + tour: TourDefinition, + fallbackName: string, +): Promise { + const displayName = tour.displayName ?? fallbackName; + + if (!tour.starterPipelineUrl) { + return defaultPipelineYamlWithName(displayName); + } + + try { + const response = await fetch(tour.starterPipelineUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch tour starter pipeline: ${response.status}`, + ); + } + const text = await response.text(); + const parsed = yaml.load(text) as Record | null; + if (!parsed || typeof parsed !== "object") { + throw new Error("Starter pipeline YAML is not an object"); + } + return yaml.dump({ ...parsed, name: displayName }); + } catch (error) { + console.warn("Falling back to empty tour pipeline:", error); + return defaultPipelineYamlWithName(displayName); + } +} diff --git a/src/providers/TourProvider/tourPopover.tsx b/src/providers/TourProvider/tourPopover.tsx new file mode 100644 index 000000000..f99ace054 --- /dev/null +++ b/src/providers/TourProvider/tourPopover.tsx @@ -0,0 +1,204 @@ +import { type ProviderProps, useTour } from "@reactour/tour"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { APP_ROUTES } from "@/routes/router"; + +// Matches the step-number badge's ≈13px outside offset plus a small margin. +const POPOVER_VIEWPORT_MARGIN = 16; + +export const POPOVER_STYLES = { + popover: (base: object) => ({ + ...base, + borderRadius: "0.75rem", + padding: "1.25rem", + boxShadow: "0 10px 30px rgba(0,0,0,0.12)", + maxWidth: "360px", + }), + maskWrapper: (base: object) => ({ + ...base, + color: "rgba(15, 23, 42, 0.5)", + }), + maskArea: (base: object) => ({ + ...base, + rx: 6, + }), + highlightedArea: ( + base: object, + state?: { width?: number; height?: number }, + ) => ({ + ...base, + display: + state?.width && state?.height ? ("block" as const) : ("none" as const), + fill: "transparent", + stroke: "#60a5fa", + strokeWidth: 2, + rx: 6, + pointerEvents: "none" as const, + }), + badge: (base: object) => ({ + ...base, + background: "#0f172a", + color: "white", + fontSize: "0.75rem", + }), +}; + +interface PositionProps { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + windowWidth: number; + windowHeight: number; +} + +type ResolvedPosition = + | "top" + | "right" + | "bottom" + | "left" + | "center" + | [number, number]; + +export function computeDefaultPopoverPosition( + props: PositionProps, +): ResolvedPosition { + const targetHeight = props.bottom - props.top; + + const isFullHeightRightStrip = + props.right >= props.windowWidth - 4 && + targetHeight > props.windowHeight * 0.5; + + if (isFullHeightRightStrip) { + const popoverWidth = props.width || 380; + const margin = 16; + return [ + Math.max(margin, props.left - popoverWidth - margin), + Math.max(props.top + margin, 64), + ]; + } + + return "bottom"; +} + +type NextButtonProps = Parameters>[0]; + +function FinishButton({ setIsOpen }: Pick) { + const navigate = useNavigate(); + return ( + + ); +} + +export function renderNextButton(props: NextButtonProps) { + const { Button, currentStep, stepsLength, setCurrentStep, setIsOpen, steps } = + props; + const isLastStep = currentStep === stepsLength - 1; + if (isLastStep) { + return ; + } + + const step = steps?.[currentStep]; + if (!step) { + return null; + } + + // Interaction steps advance via the prompted action, not a Next click. + if ("interaction" in step && step.interaction) { + return null; + } + + return ( + + {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 {