diff --git a/react-compiler.config.js b/react-compiler.config.js index 1d0447453..e5ea9d9d9 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -65,6 +65,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/shared/TaskDetails/Actions/UnpackSubgraphButton.tsx", "src/components/shared/ReactFlow/FlowSidebar/components/ComponentHoverPopover.tsx", "src/components/shared/ReactFlow/FlowControls/StackingControls.tsx", + "src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx", "src/components/shared/ReactFlow/FlowCanvas/FlexNode", "src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/ZIndexEditor.tsx", "src/components/Editor/IOEditor/IOZIndexEditor.tsx", diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx index 13dde76b8..3e6407a1e 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator.tsx @@ -14,6 +14,34 @@ import { getExecutionStatusLabel, } from "@/utils/executionStatus"; +type StatusTabProps = { + status: string; + label?: string; + className?: string; +}; + +/** @public consumed by the run comparison graph, landed later in this stack. */ +export const StatusTab = ({ status, label, className }: StatusTabProps) => { + const { style, text, icon } = getStatusMetadata(status); + + return ( +
+
+ {label && {label}} + {icon} + {text} +
+
+ ); +}; + type StatusIndicatorProps = { status: string; disabledCache?: boolean; @@ -23,20 +51,12 @@ export const StatusIndicator = ({ status, disabledCache = false, }: StatusIndicatorProps) => { - const { style, text, icon } = getStatusMetadata(status); - return (
-
-
- {icon} - {text} -
-
+ {disabledCache && (
diff --git a/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx index b6b19f3f3..42d8c8e15 100644 --- a/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx +++ b/src/routes/v2/pages/CompareView/components/DiffStatusBadge.tsx @@ -2,29 +2,56 @@ import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; +import type { SpotlightMode } from "@/routes/v2/pages/CompareView/utils/buildMergedGraph"; import { DIFF_STATUS_ICON, type DiffStatus } from "@/utils/diffStatus"; -export const DIFF_STATUS_LABELS: Record = { +const DIFF_STATUS_LABELS: Record = { unchanged: "Unchanged", lost: "Removed", new: "Added", changed: "Changed", }; +const SPOTLIGHT_STATUS_LABELS: Partial> = { + lost: "Only in A", + new: "Only in B", +}; + +/** + * "Added" and "Removed" describe a move from A to B, which only makes sense + * while both runs are on screen. With one run spotlighted the reader is standing + * inside it, so a task being highlighted *and* called removed contradicts + * itself — name the run it belongs to instead. + */ +export function diffStatusLabel( + status: DiffStatus, + spotlight: SpotlightMode = "both", +): string { + const sideLabel = + spotlight === "both" ? undefined : SPOTLIGHT_STATUS_LABELS[status]; + return sideLabel ?? DIFF_STATUS_LABELS[status]; +} + const DIFF_STATUS_TONE: Record = { unchanged: "bg-diff-unchanged text-diff-unchanged-foreground", - lost: "bg-diff-lost text-diff-lost-foreground line-through", + lost: "bg-diff-lost text-diff-lost-foreground", new: "bg-diff-new text-diff-new-foreground", changed: "bg-diff-changed text-diff-changed-foreground", }; interface DiffStatusBadgeProps { status: DiffStatus; + spotlight?: SpotlightMode; className?: string; } -export function DiffStatusBadge({ status, className }: DiffStatusBadgeProps) { +export function DiffStatusBadge({ + status, + spotlight = "both", + className, +}: DiffStatusBadgeProps) { const icon = DIFF_STATUS_ICON[status]; + const label = diffStatusLabel(status, spotlight); return ( {icon && } - {DIFF_STATUS_LABELS[status]} + {label} ); diff --git a/src/routes/v2/pages/CompareView/components/GraphDiffView.tsx b/src/routes/v2/pages/CompareView/components/GraphDiffView.tsx new file mode 100644 index 000000000..c0be3267b --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/GraphDiffView.tsx @@ -0,0 +1,452 @@ +import "@xyflow/react/dist/style.css"; + +import { + Background, + Controls, + Panel, + ReactFlow, + ReactFlowProvider, + useNodesInitialized, + useNodesState, + useReactFlow, + useViewport, +} from "@xyflow/react"; +import equal from "fast-deep-equal"; +import { useEffect, useState } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { autoLayoutNodes } from "@/components/shared/ReactFlow/FlowCanvas/utils/autolayout"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { useAnalytics } from "@/providers/AnalyticsProvider"; +import { + buildMergedGraph, + type MergedNode, + type SpotlightMode, +} from "@/routes/v2/pages/CompareView/utils/buildMergedGraph"; +import type { CompareMode } from "@/routes/v2/pages/CompareView/utils/compareMode"; +import type { + DiffStatus, + PipelineComparison, + TaskDiff, +} from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { RUN_TONE } from "@/routes/v2/pages/CompareView/utils/runTone"; +import { FLOW_CANVAS_DEFAULT_PROPS } from "@/routes/v2/shared/flowCanvasDefaults"; +import { tracking } from "@/utils/tracking"; + +import { diffStatusLabel } from "./DiffStatusBadge"; +import { IoDiffDetail } from "./IoDiffDetail"; +import { MergedIoNode } from "./MergedIoNode"; +import { MergedTaskNode } from "./MergedTaskNode"; +import { TaskDiffDetail } from "./TaskDiffDetail"; + +const taskDisplayName = (diff: TaskDiff) => + diff.a?.componentRef.spec?.name ?? + diff.b?.componentRef.spec?.name ?? + diff.taskId; + +const NODE_TYPES = { mergedTask: MergedTaskNode, mergedIo: MergedIoNode }; + +const EDGE_STROKE: Record = { + unchanged: "var(--diff-unchanged)", + lost: "var(--diff-lost)", + new: "var(--diff-new)", + changed: "var(--diff-changed)", +}; + +const SWATCH: Record = { + unchanged: "bg-diff-unchanged", + lost: "bg-diff-lost", + new: "bg-diff-new", + changed: "bg-diff-changed", +}; + +const LEGEND_ORDER: DiffStatus[] = ["new", "lost", "changed", "unchanged"]; + +const nodeInRun = (status: DiffStatus, run: "a" | "b") => + run === "a" ? status !== "new" : status !== "lost"; + +const edgeInRun = (membership: DiffStatus, run: "a" | "b") => + run === "a" ? membership !== "new" : membership !== "lost"; + +interface GraphDiffViewProps { + comparison: PipelineComparison; + nameA: string; + nameB: string; + labelA: string; + labelB: string; + mode: CompareMode; +} + +export function GraphDiffView(props: GraphDiffViewProps) { + if (!props.comparison.hasComparableGraph) { + return ( + + {props.mode.kind === "empty" + ? "Select two runs to compare their pipeline graphs." + : "Neither run has a graph pipeline, so there are no tasks to lay out. Use the YAML tab to compare the raw specifications."} + + ); + } + + return ( + + + + ); +} + +function MergedGraphCanvas({ + comparison, + nameA, + nameB, + labelA, + labelB, + mode, +}: GraphDiffViewProps) { + const { track } = useAnalytics(); + const singleRun = mode.kind === "single"; + + const { nodes: base, edges: baseEdges } = buildMergedGraph(comparison); + const topology = base.map((node) => node.id).join("|"); + + const [nodes, setNodes, onNodesChange] = useNodesState(base); + const [spotlight, setSpotlight] = useState("both"); + const [laidOut, setLaidOut] = useState(false); + const [seededTopology, setSeededTopology] = useState(topology); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const selectedNode = selectedNodeId + ? (base.find((node) => node.id === selectedNodeId) ?? null) + : null; + + const initialized = useNodesInitialized(); + const { fitView } = useReactFlow(); + + /** + * The comparison is refetched every few seconds while either run is still + * going, so the rebuilt model has to be pushed back into node state — seeding + * `useNodesState` only covers first paint, and the graph would otherwise keep + * showing the statuses it was born with. Diffs are swapped in place so + * positions survive a status-only refresh; a changed node set reseeds and + * re-runs layout instead. + */ + useEffect(() => { + if (topology !== seededTopology) { + setNodes(base); + setSeededTopology(topology); + setLaidOut(false); + return; + } + + setNodes((current) => { + const rebuilt = new Map(base.map((node) => [node.id, node.data.diff])); + let changed = false; + + const next = current.map((node) => { + const diff = rebuilt.get(node.id); + if (!diff || equal(diff, node.data.diff)) return node; + changed = true; + return { ...node, data: { ...node.data, diff } } as MergedNode; + }); + + return changed ? next : current; + }); + }, [base, topology, seededTopology, setNodes]); + + const sizeSignature = nodes + .map( + (node) => `${node.id}@${node.measured?.width}x${node.measured?.height}`, + ) + .join("|"); + const [layoutSignature, setLayoutSignature] = useState(""); + + /** + * Node height is content-driven — spotlighting a run adds its side values, a + * status arriving adds a status tab — so positions computed for the previous + * heights leave nodes overlapping each other. Laying out again whenever a + * measured size changes keeps them apart, and since positions never feed back + * into sizes it settles in one pass. + */ + useEffect(() => { + if (!initialized || sizeSignature === layoutSignature) return; + setNodes((current) => autoLayoutNodes(current, baseEdges) as MergedNode[]); + setLayoutSignature(sizeSignature); + setLaidOut(true); + }, [initialized, sizeSignature, layoutSignature, baseEdges, setNodes]); + + useEffect(() => { + if (laidOut) { + fitView({ padding: 0.2, maxZoom: 1 }); + } + }, [laidOut, fitView]); + + /** + * Nodes outside the spotlighted run fade into the background. Where layout puts + * two of them on top of each other, a faded node winning the stack would hide + * the run the reader asked to see, so the spotlighted ones are lifted a layer. + */ + const displayNodes: MergedNode[] = nodes.map((node) => { + const dimmed = + spotlight !== "both" && !nodeInRun(node.data.diff.status, spotlight); + return { + ...node, + data: { ...node.data, spotlight, singleRun }, + zIndex: dimmed ? 0 : 1, + style: { ...node.style, opacity: dimmed ? 0.35 : 1 }, + } as MergedNode; + }); + + const displayEdges = baseEdges.map((edge) => { + const membership = edge.data?.membership ?? "unchanged"; + const dimmed = spotlight !== "both" && !edgeInRun(membership, spotlight); + return { + ...edge, + style: { + ...edge.style, + stroke: EDGE_STROKE[membership], + strokeWidth: 2, + opacity: dimmed ? 0.15 : 1, + }, + }; + }); + + const spotlightModes: { + value: SpotlightMode; + label: string; + title: string; + }[] = [ + { value: "both", label: "Both", title: "Show both runs" }, + { value: "a", label: "A", title: `Highlight run A · ${nameA}` }, + { value: "b", label: "B", title: `Highlight run B · ${nameB}` }, + ]; + + return ( + + + + {!singleRun && ( + + + Highlight + + {spotlightModes.map(({ value, label, title }) => ( + + ))} + + )} + + {singleRun ? nameA || nameB : `A · ${nameA} vs B · ${nameB}`} + + + + {!singleRun && ( + + {LEGEND_ORDER.map((status) => ( + + + + {diffStatusLabel(status, spotlight)} + + + ))} + + )} + + + + { + setSelectedNodeId(node.id); + track("compare_runs.graph.node.inspect", { + diff_status: base.find((candidate) => candidate.id === node.id) + ?.data.diff.status, + }); + }} + nodesConnectable={false} + nodesDraggable={false} + edgesFocusable={false} + deleteKeyCode={null} + onPaneClick={() => setSelectedNodeId(null)} + > + + + {singleRun && ( + + + + + Select a second run to see what changed. + + + + )} + {spotlight !== "both" && ( + + + + {spotlight === "a" ? labelA : labelB} + + + {spotlight === "a" ? nameA : nameB} + + + + )} + + + + { + if (!open) setSelectedNodeId(null); + }} + > + {selectedNodeId && } + {selectedNode && ( + event.preventDefault()} + className="max-h-96 w-80 overflow-y-auto overscroll-contain" + > + + + + {selectedNode.type === "mergedTask" + ? taskDisplayName(selectedNode.data.diff) + : selectedNode.data.diff.name} + + + + {selectedNode.type === "mergedTask" ? ( + + ) : ( + + )} + + + )} + + + ); +} + +/** + * Mirrors a node's on-screen box as a fixed, invisible popover anchor, so the + * detail panel can be portalled to the body and overlay the page. A + * `NodeToolbar` can't: it portals inside the canvas, whose `overflow-hidden` + * clips it. Kept a separate component so the per-frame viewport subscription + * re-renders only the anchor while panning, not the canvas. + */ +function NodeScreenAnchor({ nodeId }: { nodeId: string }) { + const { zoom } = useViewport(); + const { flowToScreenPosition, getInternalNode } = useReactFlow(); + + const node = getInternalNode(nodeId); + if (!node) return null; + + const { x, y } = flowToScreenPosition(node.internals.positionAbsolute); + + return ( + +
+ + ); +} diff --git a/src/routes/v2/pages/CompareView/components/MergedIoNode.tsx b/src/routes/v2/pages/CompareView/components/MergedIoNode.tsx new file mode 100644 index 000000000..33e57d9fe --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/MergedIoNode.tsx @@ -0,0 +1,163 @@ +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; + +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { MergedIoNodeData } from "@/routes/v2/pages/CompareView/utils/buildMergedGraph"; +import type { KeyedDiffEntry } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { ioDisplayStatus } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { formatDiffValue } from "@/routes/v2/pages/CompareView/utils/formatDiffValue"; +import { summarizeIoChange } from "@/routes/v2/pages/CompareView/utils/summarizeChange"; + +import { DiffStatusBadge } from "./DiffStatusBadge"; +import { MEMBERSHIP_BORDER } from "./mergedNodeStyles"; +import { SideValues } from "./SideValues"; + +/** + * Slight background tint hinting at the node's kind, echoing the blue inputs / + * violet outputs of the editor and run views so they read at a glance. + */ +const KIND_TINT: Record<"input" | "output", string> = { + input: "bg-blue-50 dark:bg-blue-950/40", + output: "bg-violet-50 dark:bg-violet-950/40", +}; + +const MAX_IO_FIELDS = 2; +const LONG_IO_VALUE = 22; + +function ioFieldLabel(key: string): string { + return key === "default" || key === "value" ? "value" : key; +} + +/** + * Compact rendering of what changed on an input/output between the two runs. + * Short scalar changes are shown as a `before → after` transition (before + * struck through); anything too long collapses to a " changed" label. + */ +function IoFieldChanges({ fields }: { fields: KeyedDiffEntry[] }) { + const shown = fields.slice(0, MAX_IO_FIELDS); + const remaining = fields.length - shown.length; + + return ( + + {shown.map((entry) => { + const before = formatDiffValue(entry.a); + const after = formatDiffValue(entry.b); + const label = ioFieldLabel(entry.key); + const fits = before.length + after.length <= LONG_IO_VALUE; + return ( + + {fits ? ( + <> + {before} →{" "} + {after} + + ) : ( + `${label} changed` + )} + + ); + })} + {remaining > 0 && ( + + +{remaining} more + + )} + + ); +} + +type MergedIoNodeType = Node; + +export function MergedIoNode({ data }: NodeProps) { + const { diff, spotlight, singleRun } = data; + const isInput = diff.kind === "input"; + const changedFields = diff.fieldDiffs.filter( + (entry) => entry.status !== "unchanged", + ); + const side = spotlight === "b" ? "b" : "a"; + const displayStatus = ioDisplayStatus(diff); + const showSideValues = spotlight !== "both" && changedFields.length > 0; + const changeSummary = + displayStatus === "changed" ? summarizeIoChange(diff) : ""; + const showFieldChanges = + !showSideValues && + diff.status === "changed" && + changedFields.length > 0 && + changeSummary !== "source rewired"; + + return ( + + + + + + {isInput ? "Input" : "Output"} + + + {!singleRun && ( + + )} + + + + {diff.name} + + {showSideValues ? ( + + ) : showFieldChanges ? ( + + ) : ( + changeSummary && ( + + {changeSummary} + + ) + )} + + {isInput ? ( + + ) : ( + + )} + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/MergedTaskNode.tsx b/src/routes/v2/pages/CompareView/components/MergedTaskNode.tsx new file mode 100644 index 000000000..6ba222b40 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/MergedTaskNode.tsx @@ -0,0 +1,187 @@ +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; +import type { ReactNode } from "react"; + +import { trimDigest } from "@/components/shared/ManageComponent/utils/digest"; +import { StatusTab } from "@/components/shared/ReactFlow/FlowCanvas/TaskNode/StatusIndicator"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { QuickTooltip } from "@/components/ui/tooltip"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { MergedTaskNodeData } from "@/routes/v2/pages/CompareView/utils/buildMergedGraph"; +import { summarizeTaskChange } from "@/routes/v2/pages/CompareView/utils/summarizeChange"; + +import { DiffStatusBadge } from "./DiffStatusBadge"; +import { MEMBERSHIP_BORDER } from "./mergedNodeStyles"; +import { SideValues } from "./SideValues"; + +function StatusTabRow({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +type MergedTaskNodeType = Node; + +export function MergedTaskNode({ data }: NodeProps) { + const { diff, spotlight, singleRun } = data; + + const spotlightSide = spotlight === "b" ? "b" : "a"; + const side = spotlight === "b" ? diff.b : diff.a; + const name = + side?.componentRef.spec?.name ?? + diff.a?.componentRef.spec?.name ?? + diff.b?.componentRef.spec?.name ?? + diff.taskId; + + const changeSummary = + diff.status === "changed" ? summarizeTaskChange(diff) : ""; + + const changedArgs = diff.argumentDiffs.filter( + (entry) => entry.status !== "unchanged", + ); + const showSideValues = spotlight !== "both" && changedArgs.length > 0; + + const { componentChanged } = diff; + const sideDigest = + (spotlight === "b" ? diff.digestB : diff.digestA) ?? + diff.digestA ?? + diff.digestB; + const digestFull = + componentChanged && diff.digestA && diff.digestB + ? `A: ${diff.digestA}\nB: ${diff.digestB}` + : sideDigest; + const digestDisplay = + componentChanged && diff.digestA && diff.digestB + ? `${trimDigest(diff.digestA)} → ${trimDigest(diff.digestB)}` + : sideDigest + ? trimDigest(sideDigest) + : undefined; + + const cacheDisabled = + spotlight === "b" ? diff.cacheDisabledB : diff.cacheDisabledA; + const showCacheIcon = cacheDisabled || diff.cacheChanged; + const cacheTooltip = diff.cacheChanged + ? `Caching ${diff.cacheDisabledA ? "off" : "on"} in A, ${diff.cacheDisabledB ? "off" : "on"} in B` + : "Caching disabled"; + + const statusA = spotlight === "b" ? undefined : diff.statusA; + const statusB = spotlight === "a" ? undefined : diff.statusB; + const singleStatus = diff.statusA ?? diff.statusB; + + return ( + + {singleRun + ? singleStatus && ( + + + + ) + : (statusA || statusB) && ( + + {statusA ? ( + + ) : ( +
+ )} + {statusB ? ( + + ) : ( +
+ )} + + )} + + + + {!singleRun && ( + + )} + {showCacheIcon && ( + + + + )} + + {digestDisplay && ( + + + {digestDisplay} + + + )} + + + + {name} + + + {diff.taskId} + + {showSideValues ? ( + + ) : ( + changeSummary && ( + + {changeSummary} + + ) + )} + + + + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/SideValues.tsx b/src/routes/v2/pages/CompareView/components/SideValues.tsx new file mode 100644 index 000000000..3f4b7039e --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/SideValues.tsx @@ -0,0 +1,46 @@ +import { BlockStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import type { KeyedDiffEntry } from "@/routes/v2/pages/CompareView/utils/comparePipelines"; +import { formatDiffValue } from "@/routes/v2/pages/CompareView/utils/formatDiffValue"; + +const MAX_SIDE_VALUES = 4; + +interface SideValuesProps { + fields: KeyedDiffEntry[]; + side: "a" | "b"; +} + +/** + * Compact, single-line-per-field rendering of one run's values, used when the + * graph is spotlighting run A or B so nodes show that side's actual values + * instead of an aggregate "N fields changed" summary. + */ +export function SideValues({ fields, side }: SideValuesProps) { + const shown = fields.slice(0, MAX_SIDE_VALUES); + const remaining = fields.length - shown.length; + + return ( + + {shown.map((entry) => { + const value = formatDiffValue(side === "b" ? entry.b : entry.a); + return ( + + {entry.key}: {value} + + ); + })} + {remaining > 0 && ( + + +{remaining} more + + )} + + ); +} diff --git a/src/routes/v2/pages/CompareView/components/mergedNodeStyles.ts b/src/routes/v2/pages/CompareView/components/mergedNodeStyles.ts new file mode 100644 index 000000000..f57c60d98 --- /dev/null +++ b/src/routes/v2/pages/CompareView/components/mergedNodeStyles.ts @@ -0,0 +1,8 @@ +import type { DiffStatus } from "@/utils/diffStatus"; + +export const MEMBERSHIP_BORDER: Record = { + unchanged: "border-diff-unchanged", + lost: "border-diff-lost", + new: "border-diff-new", + changed: "border-diff-changed", +}; diff --git a/src/routes/v2/pages/CompareView/utils/buildMergedGraph.ts b/src/routes/v2/pages/CompareView/utils/buildMergedGraph.ts index e35365173..2290687a0 100644 --- a/src/routes/v2/pages/CompareView/utils/buildMergedGraph.ts +++ b/src/routes/v2/pages/CompareView/utils/buildMergedGraph.ts @@ -24,6 +24,7 @@ export interface MergedTaskNodeData extends Record { export interface MergedIoNodeData extends Record { diff: IoDiff; spotlight: SpotlightMode; + singleRun?: boolean; } type MergedTaskNode = Node;