+
> | undefined;
@@ -187,7 +188,11 @@ export function useLibraryDragGate({
const allow = libraryDragAllow.toLowerCase();
const handleDragStart = (event: DragEvent) => {
const target = elementFromEvent(event);
- if (!target?.closest('[data-dock-window-content="component-library"]')) {
+ if (
+ !target?.closest(
+ '[data-dock-window-content="component-library"], [data-dock-window-content="component-search-v2"]',
+ )
+ ) {
return;
}
const item =
@@ -209,6 +214,82 @@ export function useLibraryDragGate({
}, [isOpen, libraryDragAllow]);
}
+export function useTourDragPassthrough({ isOpen }: { isOpen: boolean }): void {
+ useEffect(() => {
+ if (!isOpen) return undefined;
+ const setDragging = () => {
+ document.body.dataset.tourDragging = "true";
+ };
+ const clearDragging = () => {
+ delete document.body.dataset.tourDragging;
+ };
+ document.addEventListener("dragstart", setDragging, true);
+ document.addEventListener("dragend", clearDragging, true);
+ document.addEventListener("drop", clearDragging, true);
+ return () => {
+ document.removeEventListener("dragstart", setDragging, true);
+ document.removeEventListener("dragend", clearDragging, true);
+ document.removeEventListener("drop", clearDragging, true);
+ clearDragging();
+ };
+ }, [isOpen]);
+}
+
+interface AnchorCreatedSubgraphArgs {
+ isOpen: boolean;
+ active: boolean;
+ currentStep: number;
+ navigation: NavigationStore;
+ setSteps: SetSteps;
+}
+
+export function useAnchorCreatedSubgraph({
+ isOpen,
+ active,
+ currentStep,
+ navigation,
+ setSteps,
+}: AnchorCreatedSubgraphArgs): void {
+ const createdNameRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return undefined;
+ const subgraphIds = () =>
+ navigation.activeSpec?.tasks
+ .filter((t) => t.subgraphSpec !== undefined)
+ .map((t) => t.$id) ?? [];
+ const seen = new Set(subgraphIds());
+ const record = () => {
+ const name = recordNewSubgraphName(
+ seen,
+ navigation.activeSpec?.tasks ?? [],
+ );
+ if (name) createdNameRef.current = name;
+ };
+ return reaction(() => subgraphIds().join("|"), record);
+ }, [isOpen, navigation]);
+
+ useEffect(() => {
+ if (!isOpen || !active) return undefined;
+ const name = createdNameRef.current;
+ if (!name) return undefined;
+ const selector = `[data-tour-card="task"][data-tour-card-name="${name}"]`;
+ setSteps?.((prev) =>
+ prev.map((step, index) => {
+ if (index !== currentStep) return step;
+ const anchored: TourStep = {
+ ...step,
+ selector,
+ ringSelectors: [selector],
+ position: "top",
+ };
+ return anchored;
+ }),
+ );
+ return pollForSelectorThenRefreshSteps(selector, selector, setSteps);
+ }, [isOpen, active, currentStep, setSteps]);
+}
+
interface ResetLibrarySearchArgs {
isOpen: boolean;
currentStep: number;
diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts
new file mode 100644
index 000000000..9603925b3
--- /dev/null
+++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import { recordNewSubgraphName } from "./editorTourBridge.utils";
+
+const subgraph = (id: string, name: string) => ({
+ $id: id,
+ name,
+ subgraphSpec: {},
+});
+const leaf = (id: string, name: string) => ({ $id: id, name });
+
+describe("recordNewSubgraphName", () => {
+ it("records every unseen subgraph and returns the last new name", () => {
+ const seen = new Set();
+ const result = recordNewSubgraphName(seen, [
+ subgraph("a", "Training"),
+ subgraph("b", "Evaluation"),
+ ]);
+ expect(result).toBe("Evaluation");
+ expect(seen).toEqual(new Set(["a", "b"]));
+ });
+
+ it("reports the name of a newly created subgraph", () => {
+ const seen = new Set(["a", "b"]);
+ const result = recordNewSubgraphName(seen, [
+ subgraph("a", "Training"),
+ subgraph("b", "Evaluation"),
+ subgraph("c", "My Subgraph"),
+ ]);
+ expect(result).toBe("My Subgraph");
+ expect(seen.has("c")).toBe(true);
+ });
+
+ it("ignores non-subgraph tasks", () => {
+ const seen = new Set();
+ const result = recordNewSubgraphName(seen, [
+ leaf("g", "Generate"),
+ leaf("s", "Split"),
+ ]);
+ expect(result).toBeNull();
+ expect(seen.size).toBe(0);
+ });
+
+ it("does not treat a re-surfaced subgraph as new after navigating away and back", () => {
+ const seen = new Set();
+ const root = [subgraph("a", "Training"), subgraph("b", "Evaluation")];
+
+ recordNewSubgraphName(seen, root);
+ expect(recordNewSubgraphName(seen, [])).toBeNull();
+ expect(recordNewSubgraphName(seen, root)).toBeNull();
+ });
+});
diff --git a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts
index 95dff6b1a..7966a3cb4 100644
--- a/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts
+++ b/src/routes/v2/pages/Editor/components/EditorTourBridge/editorTourBridge.utils.ts
@@ -49,6 +49,20 @@ export function countSubgraphTasks(spec: ComponentSpec | null): number {
return spec.tasks.filter((t) => t.subgraphSpec !== undefined).length;
}
+export function recordNewSubgraphName(
+ seen: Set,
+ tasks: readonly { $id: string; name: string; subgraphSpec?: unknown }[],
+): string | null {
+ let created: string | null = null;
+ for (const task of tasks) {
+ if (task.subgraphSpec !== undefined && !seen.has(task.$id)) {
+ seen.add(task.$id);
+ created = task.name;
+ }
+ }
+ return created;
+}
+
export function elementFromEvent(event: Event): Element | null {
return event.target instanceof Element ? event.target : null;
}
diff --git a/src/routes/v2/shared/windows/windowPersistence.test.ts b/src/routes/v2/shared/windows/windowPersistence.test.ts
new file mode 100644
index 000000000..1826ebd87
--- /dev/null
+++ b/src/routes/v2/shared/windows/windowPersistence.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { clearLayout, TOUR_WINDOW_LAYOUT_ID } from "./windowPersistence";
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("clearLayout", () => {
+ it("removes only the targeted layout key", () => {
+ localStorage.setItem("window-layout-editor", '{"editor":true}');
+ localStorage.setItem("window-layout-tour", '{"tour":true}');
+
+ clearLayout(TOUR_WINDOW_LAYOUT_ID);
+
+ expect(localStorage.getItem("window-layout-tour")).toBeNull();
+ expect(localStorage.getItem("window-layout-editor")).toBe(
+ '{"editor":true}',
+ );
+ });
+
+ it("is a no-op when the layout key is absent", () => {
+ expect(() => clearLayout(TOUR_WINDOW_LAYOUT_ID)).not.toThrow();
+ expect(localStorage.getItem("window-layout-tour")).toBeNull();
+ });
+
+ it("keeps the tour layout id distinct from the editor's", () => {
+ expect(TOUR_WINDOW_LAYOUT_ID).not.toBe("editor");
+ });
+});
diff --git a/src/routes/v2/shared/windows/windowPersistence.ts b/src/routes/v2/shared/windows/windowPersistence.ts
index 82be0a5bd..5fac10193 100644
--- a/src/routes/v2/shared/windows/windowPersistence.ts
+++ b/src/routes/v2/shared/windows/windowPersistence.ts
@@ -22,6 +22,8 @@ import type { WindowStoreImpl } from "./windowStore";
*/
let activeLayoutId: string | null = null;
+export const TOUR_WINDOW_LAYOUT_ID = "tour";
+
function getLayoutStorageKey(layoutId: string | null): string {
if (!layoutId) return "editorV2-window-layout";
return `window-layout-${layoutId}`;
@@ -31,50 +33,11 @@ function getStorageKey(): string {
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 {
+export function clearLayout(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;
+ localStorage.removeItem(getLayoutStorageKey(layoutId));
} catch (error) {
- console.warn(`Failed to restore layout "${layoutId}":`, error);
- return false;
+ console.warn(`Failed to clear layout "${layoutId}":`, error);
}
}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 838afa45c..a913d6b4c 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -15,6 +15,10 @@
position: absolute;
}
+body[data-tour-dragging="true"] .reactour-tour-mask svg rect {
+ pointer-events: none !important;
+}
+
/* Use the proper box layout model by default, but allow elements to override */
html {
box-sizing: border-box;