From 861d93a4886cc287e8bd6d42713d4ce142dbf128 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Fri, 6 Mar 2026 10:23:48 -0500 Subject: [PATCH] Add sharable node on runs --- .../ReactFlow/FlowCanvas/FlowCanvas.tsx | 20 ++++++-- .../TaskNode/TaskNodeCard/TaskNodeCard.tsx | 14 ++++++ .../NodesOverlay/NodesOverlayProvider.tsx | 4 +- src/components/shared/ReactFlow/constants.ts | 2 + src/hooks/useFocusNodeFromParam.ts | 50 +++++++++++++++++++ src/hooks/useFocusNodeParam.ts | 37 ++++++++++++++ 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 src/components/shared/ReactFlow/constants.ts create mode 100644 src/hooks/useFocusNodeFromParam.ts create mode 100644 src/hooks/useFocusNodeParam.ts diff --git a/src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx b/src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx index 417dbfd17..5289dbe4b 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx @@ -25,6 +25,7 @@ import { BlockStack } from "@/components/ui/layout"; import useComponentSpecToEdges from "@/hooks/useComponentSpecToEdges"; import useConfirmationDialog from "@/hooks/useConfirmationDialog"; import { useCopyPaste } from "@/hooks/useCopyPaste"; +import { useFocusNodeFromParam } from "@/hooks/useFocusNodeFromParam"; import { useGhostNode } from "@/hooks/useGhostNode"; import { useIOSelectionPersistence } from "@/hooks/useIOSelectionPersistence"; import { useNodeCallbacks } from "@/hooks/useNodeCallbacks"; @@ -51,6 +52,7 @@ import { } from "@/utils/subgraphUtils"; import { SuspenseWrapper } from "../../SuspenseWrapper"; +import { FIT_VIEW_ANIMATION_DURATION, FIT_VIEW_MAX_ZOOM } from "../constants"; import { useNodesOverlay } from "../NodesOverlay/NodesOverlayProvider"; import { getBulkUpdateConfirmationDetails } from "./ConfirmationDialogs/BulkUpdateConfirmationDialog"; import { getDeleteConfirmationDetails } from "./ConfirmationDialogs/DeleteConfirmation"; @@ -269,6 +271,11 @@ const FlowCanvasContent = ({ const [reactFlowInstance, setReactFlowInstance] = useState(); + const { clearFocusNode } = useFocusNodeFromParam( + reactFlowInstance, + nodes.length > 0, + ); + const onInit: OnInit = (instance) => { setReactFlowInstance(instance); setReactFlowInstanceForOverlay(instance); @@ -887,8 +894,8 @@ const FlowCanvasContent = ({ useEffect(() => { reactFlowInstance?.fitView({ - maxZoom: 1, - duration: 300, + maxZoom: FIT_VIEW_MAX_ZOOM, + duration: FIT_VIEW_ANIMATION_DURATION, }); }, [currentSubgraphPath, reactFlowInstance]); @@ -899,7 +906,7 @@ const FlowCanvasContent = ({ const fitView = () => { reactFlowInstance?.fitView({ - maxZoom: 1, + maxZoom: FIT_VIEW_MAX_ZOOM, }); }; @@ -991,6 +998,9 @@ const FlowCanvasContent = ({ const onPaneClick = () => { clearContent(); + if (readOnly) { + clearFocusNode(); + } }; const getSelectionMode = () => { @@ -1033,8 +1043,8 @@ const FlowCanvasContent = ({ requestAnimationFrame(() => { reactFlowInstance?.fitView({ - maxZoom: 1, - duration: 300, + maxZoom: FIT_VIEW_MAX_ZOOM, + duration: FIT_VIEW_ANIMATION_DURATION, }); }); }; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx index 2a93df6fe..c6776d0c4 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx @@ -11,6 +11,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout"; import { QuickTooltip } from "@/components/ui/tooltip"; import { Text } from "@/components/ui/typography"; import { useEdgeSelectionHighlight } from "@/hooks/useEdgeSelectionHighlight"; +import { useFocusNodeParam } from "@/hooks/useFocusNodeParam"; import { useIsMultiSelect } from "@/hooks/useIsMultiSelect"; import { buildExecutionUrl } from "@/hooks/useSubgraphBreadcrumbs"; import { cn } from "@/lib/utils"; @@ -65,6 +66,7 @@ const TaskNodeCard = () => { const { dimensions, selected, highlighted, readOnly, isCollapsed } = state; const { isMultiSelect, isMultiSelectRef } = useIsMultiSelect(); + const { setFocusNode, clearFocusNode } = useFocusNodeParam(); const { isConnectedToSelectedEdge } = useEdgeSelectionHighlight(nodeId); @@ -164,11 +166,19 @@ const TaskNodeCard = () => { if (selected && !isMultiSelect) { setContent(taskConfigMarkup); setContextPanelOpen(true); + + if (readOnly && taskId) { + setFocusNode(taskId); + } } return () => { if (selected && !isMultiSelectRef.current) { clearContent(); + + if (readOnly && taskId) { + clearFocusNode(); + } } }; }, [ @@ -178,6 +188,10 @@ const TaskNodeCard = () => { setContent, clearContent, setContextPanelOpen, + readOnly, + taskId, + setFocusNode, + clearFocusNode, ]); if (!taskSpec) { diff --git a/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx b/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx index be0e184da..c097d7651 100644 --- a/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx +++ b/src/components/shared/ReactFlow/NodesOverlay/NodesOverlayProvider.tsx @@ -13,6 +13,8 @@ import type { TaskSpec, } from "@/utils/componentSpec"; +import { FIT_VIEW_MAX_ZOOM } from "../constants"; + type RegisterNodeOptions = { nodeId: string; taskSpec: TaskSpec; @@ -95,7 +97,7 @@ export const NodesOverlayProvider = ({ children }: PropsWithChildren<{}>) => { (await instanceRef.current?.fitView({ nodes: [{ id: nodeId }], duration: 200, - maxZoom: 1, + maxZoom: FIT_VIEW_MAX_ZOOM, })) ?? false ); }, []); diff --git a/src/components/shared/ReactFlow/constants.ts b/src/components/shared/ReactFlow/constants.ts new file mode 100644 index 000000000..0295a22e3 --- /dev/null +++ b/src/components/shared/ReactFlow/constants.ts @@ -0,0 +1,2 @@ +export const FIT_VIEW_MAX_ZOOM = 1; +export const FIT_VIEW_ANIMATION_DURATION = 300; diff --git a/src/hooks/useFocusNodeFromParam.ts b/src/hooks/useFocusNodeFromParam.ts new file mode 100644 index 000000000..e363d68d3 --- /dev/null +++ b/src/hooks/useFocusNodeFromParam.ts @@ -0,0 +1,50 @@ +import type { ReactFlowInstance } from "@xyflow/react"; +import { useEffect, useRef } from "react"; + +import { + FIT_VIEW_ANIMATION_DURATION, + FIT_VIEW_MAX_ZOOM, +} from "@/components/shared/ReactFlow/constants"; +import { taskIdToNodeId } from "@/utils/nodes/nodeIdUtils"; + +import { useFocusNodeParam } from "./useFocusNodeParam"; + +/** + * Focuses a node on initial page load based on the `focus` query param. + * Selects the node and pans the viewport to center it. + * + * @param reactFlowInstance - The ReactFlow instance for node manipulation + * @param hasNodes - Whether the canvas has rendered nodes (use `nodes.length > 0`) + */ +export const useFocusNodeFromParam = ( + reactFlowInstance: ReactFlowInstance | undefined, + hasNodes: boolean, +) => { + const { focusNodeId: focusTaskId, clearFocusNode } = useFocusNodeParam(); + const hasAppliedFocus = useRef(false); + + useEffect(() => { + if (hasAppliedFocus.current) return; + if (!hasNodes || !reactFlowInstance || !focusTaskId) return; + + const targetNodeId = taskIdToNodeId(focusTaskId); + const node = reactFlowInstance.getNode(targetNodeId); + if (!node) return; + + hasAppliedFocus.current = true; + + reactFlowInstance.setNodes((nds) => + nds.map((n) => ({ ...n, selected: n.id === targetNodeId })), + ); + + requestAnimationFrame(() => { + reactFlowInstance.fitView({ + nodes: [node], + maxZoom: FIT_VIEW_MAX_ZOOM, + duration: FIT_VIEW_ANIMATION_DURATION, + }); + }); + }, [focusTaskId, reactFlowInstance, hasNodes]); + + return { clearFocusNode }; +}; diff --git a/src/hooks/useFocusNodeParam.ts b/src/hooks/useFocusNodeParam.ts new file mode 100644 index 000000000..2adff25a6 --- /dev/null +++ b/src/hooks/useFocusNodeParam.ts @@ -0,0 +1,37 @@ +import { useCallback, useSyncExternalStore } from "react"; + +const FOCUS_PARAM = "focus"; + +const getSnapshot = () => { + const params = new URLSearchParams(window.location.search); + return params.get(FOCUS_PARAM) ?? undefined; +}; + +const subscribe = (callback: () => void) => { + window.addEventListener("popstate", callback); + return () => window.removeEventListener("popstate", callback); +}; + +const updateSearchParam = (taskId: string | undefined) => { + const url = new URL(window.location.href); + if (taskId) { + url.searchParams.set(FOCUS_PARAM, taskId); + } else { + url.searchParams.delete(FOCUS_PARAM); + } + window.history.replaceState(window.history.state, "", url.toString()); +}; + +export const useFocusNodeParam = () => { + const focusNodeId = useSyncExternalStore(subscribe, getSnapshot); + + const setFocusNode = useCallback((taskId: string) => { + updateSearchParam(taskId); + }, []); + + const clearFocusNode = useCallback(() => { + updateSearchParam(undefined); + }, []); + + return { focusNodeId, setFocusNode, clearFocusNode }; +};