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 (
-
+
{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;