diff --git a/src/components/Compare/ComparePage.tsx b/src/components/Compare/ComparePage.tsx
new file mode 100644
index 000000000..8036ca41f
--- /dev/null
+++ b/src/components/Compare/ComparePage.tsx
@@ -0,0 +1,138 @@
+import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
+import { useMemo } from "react";
+
+import { LoadingScreen } from "@/components/shared/LoadingScreen";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { useRunsById } from "@/hooks/useRunsById";
+import type { CompareSearchParams } from "@/routes/router";
+import type { PipelineRun } from "@/types/pipelineRun";
+
+import { ComparisonView } from "./Diff";
+import { RunSelector } from "./RunSelector";
+
+/**
+ * Parse run IDs from URL search param
+ */
+function parseRunIds(runsParam: string | undefined): string[] {
+ if (!runsParam) return [];
+ return runsParam
+ .split(",")
+ .map((id) => id.trim())
+ .filter((id) => id.length > 0);
+}
+
+/**
+ * Sort runs chronologically (earliest first).
+ * This ensures consistent diff semantics: newer runs show additions relative to older runs.
+ */
+function sortRunsChronologically(runs: PipelineRun[]): PipelineRun[] {
+ return [...runs].sort((a, b) => {
+ const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return dateA - dateB;
+ });
+}
+
+/**
+ * Main page for comparing pipeline runs.
+ * State is persisted in URL for shareability.
+ * URL format: /compare?runs=123,456,789&pipeline=MyPipeline
+ */
+export const ComparePage = () => {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const search = useSearch({ strict: false }) as CompareSearchParams;
+
+ // Parse run IDs from URL
+ const runIdsFromUrl = useMemo(() => parseRunIds(search.runs), [search.runs]);
+
+ // Fetch runs by IDs if present in URL
+ const {
+ runs: fetchedRuns,
+ isLoading,
+ notFoundIds,
+ isReady,
+ } = useRunsById(runIdsFromUrl);
+
+ // Determine view state based on URL
+ const showComparison = runIdsFromUrl.length >= 2;
+
+ const handleCompare = (runs: PipelineRun[]) => {
+ // Sort runs chronologically before storing in URL
+ const sortedRuns = sortRunsChronologically(runs);
+ const runIds = sortedRuns.map((r) => String(r.id)).join(",");
+ const pipelineName = sortedRuns[0]?.pipeline_name;
+
+ navigate({
+ to: pathname,
+ search: {
+ runs: runIds,
+ pipeline: pipelineName,
+ } as CompareSearchParams,
+ });
+ };
+
+ const handleBack = () => {
+ // Clear runs from URL but keep pipeline for convenience
+ navigate({
+ to: pathname,
+ search: {
+ pipeline: search.pipeline,
+ } as CompareSearchParams,
+ });
+ };
+
+ // Loading state when fetching runs from URL
+ if (showComparison && isLoading) {
+ return ;
+ }
+
+ // Error state if some runs weren't found
+ const hasNotFoundError = showComparison && notFoundIds.length > 0;
+
+ return (
+
+
+
+
+ Compare Runs
+
+ {!showComparison && (
+
+ Select a pipeline and compare up to 3 runs to see differences in
+ arguments and tasks.
+
+ )}
+
+
+ {/* Warning if some runs weren't found */}
+ {hasNotFoundError && (
+
+
+
+
+ Could not find {notFoundIds.length === 1 ? "run" : "runs"}:{" "}
+ {notFoundIds.join(", ")}
+
+
+
+ )}
+
+ {/* Show selector if not enough runs, or comparison if ready */}
+ {!showComparison || !isReady ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/src/components/Compare/Diff/ArgumentsDiff.tsx b/src/components/Compare/Diff/ArgumentsDiff.tsx
new file mode 100644
index 000000000..607762d85
--- /dev/null
+++ b/src/components/Compare/Diff/ArgumentsDiff.tsx
@@ -0,0 +1,74 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import type { ValueDiff } from "@/utils/diff/types";
+
+import { ArgumentRow, ArgumentsTable } from "./ArgumentsTable";
+
+interface ArgumentsDiffProps {
+ arguments: ValueDiff[];
+ runLabels: string[];
+}
+
+export const ArgumentsDiff = ({
+ arguments: args,
+ runLabels,
+}: ArgumentsDiffProps) => {
+ const changedArgs = args.filter((a) => a.changeType !== "unchanged");
+ const unchangedArgs = args.filter((a) => a.changeType === "unchanged");
+
+ if (args.length === 0) {
+ return (
+
+ No pipeline arguments to compare.
+
+ );
+ }
+
+ return (
+
+
+
+ Pipeline Arguments
+
+
+ {changedArgs.length} changed, {unchangedArgs.length} unchanged
+
+
+
+ {/* Changed arguments */}
+ {changedArgs.length > 0 && (
+
+ {changedArgs.map((diff) => (
+
+ ))}
+
+ )}
+
+ {/* Unchanged arguments (collapsible) */}
+ {unchangedArgs.length > 0 && (
+
+
+
+
+ {unchangedArgs.length} unchanged arguments
+
+
+
+
+ {unchangedArgs.map((diff) => (
+
+ ))}
+
+
+
+
+ )}
+
+ );
+};
diff --git a/src/components/Compare/Diff/ArgumentsTable.tsx b/src/components/Compare/Diff/ArgumentsTable.tsx
new file mode 100644
index 000000000..35c1262ba
--- /dev/null
+++ b/src/components/Compare/Diff/ArgumentsTable.tsx
@@ -0,0 +1,154 @@
+import type { PropsWithChildren } from "react";
+
+import { Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type { ChangeType, ValueDiff } from "@/utils/diff/types";
+
+const changeTypeStyles: Record = {
+ added: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
+ removed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
+ modified:
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
+ unchanged: "",
+};
+
+const changeTypeIcons: Record = {
+ added: "+",
+ removed: "-",
+ modified: "~",
+ unchanged: "",
+};
+
+interface ArgumentsTableProps extends PropsWithChildren {
+ runLabels: string[];
+ compact?: boolean;
+}
+
+/**
+ * Table container for arguments comparison with proper column alignment
+ */
+export const ArgumentsTable = ({
+ runLabels,
+ compact = false,
+ children,
+}: ArgumentsTableProps) => {
+ const columnCount = runLabels.length;
+
+ return (
+
+
+ {/* Header row */}
+ {!compact && (
+ <>
+
+
+ Argument
+
+
+
{/* Spacer for change indicator */}
+ {runLabels.map((label) => (
+
+
+ {label}
+
+
+ ))}
+ >
+ )}
+
+ {children}
+
+
+ );
+};
+
+interface ArgumentRowProps {
+ diff: ValueDiff;
+ compact?: boolean;
+}
+
+/**
+ * Single row in the arguments table
+ */
+export const ArgumentRow = ({ diff, compact = false }: ArgumentRowProps) => {
+ const { key, values, changeType } = diff;
+ const showChangeIndicator = changeType !== "unchanged";
+
+ return (
+ <>
+ {/* Key column */}
+
+
+ {key}
+
+
+
+ {/* Change indicator column */}
+
+ {showChangeIndicator && (
+
+ {changeTypeIcons[changeType]}
+
+ )}
+
+
+ {/* Value columns */}
+ {values.map((value, index) => {
+ const isEmpty = value === undefined || value === "";
+ const isFirst = index === 0;
+ const showHighlight = changeType !== "unchanged" && !isFirst;
+
+ return (
+
+
+ {isEmpty ? "(empty)" : value}
+
+
+ );
+ })}
+ >
+ );
+};
+
diff --git a/src/components/Compare/Diff/ComparisonView.tsx b/src/components/Compare/Diff/ComparisonView.tsx
new file mode 100644
index 000000000..ce1bf575b
--- /dev/null
+++ b/src/components/Compare/Diff/ComparisonView.tsx
@@ -0,0 +1,175 @@
+import { useMemo } from "react";
+
+import { LoadingScreen } from "@/components/shared/LoadingScreen";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import { Separator } from "@/components/ui/separator";
+import { Text } from "@/components/ui/typography";
+import { useRunComparisonData } from "@/hooks/useRunComparisonData";
+import { useBackend } from "@/providers/BackendProvider";
+import { APP_ROUTES } from "@/routes/router";
+import type { PipelineRun } from "@/types/pipelineRun";
+import { formatDate } from "@/utils/date";
+import { compareRuns } from "@/utils/diff/compareRuns";
+
+import { ArgumentsDiff } from "./ArgumentsDiff";
+import { TasksVisualDiff } from "./TasksVisualDiff";
+
+interface ComparisonViewProps {
+ runs: PipelineRun[];
+ onBack: () => void;
+}
+
+/**
+ * Generate run labels for display (e.g., "Run #123 (Jan 1)")
+ */
+function generateRunLabels(runs: PipelineRun[]): string[] {
+ return runs.map((run) => {
+ const date = run.created_at ? formatDate(run.created_at) : "Unknown date";
+ return `Run #${run.id} (${date})`;
+ });
+}
+
+export const ComparisonView = ({ runs, onBack }: ComparisonViewProps) => {
+ const { ready } = useBackend();
+ const { comparisonData, isLoading, error, isReady } =
+ useRunComparisonData(runs);
+
+ const runLabels = useMemo(() => generateRunLabels(runs), [runs]);
+
+ const diffResult = useMemo(() => {
+ if (!isReady || comparisonData.length < 2) {
+ return null;
+ }
+ try {
+ return compareRuns(comparisonData);
+ } catch (e) {
+ console.error("Failed to compute diff:", e);
+ return null;
+ }
+ }, [comparisonData, isReady]);
+
+ if (!ready) {
+ return (
+
+
+
+ Back to selection
+
+
+
+
+
+ Backend is not configured. Full comparison requires a connected
+ backend to fetch execution details.
+
+
+
+
+ Basic metadata comparison is shown below based on locally available
+ data.
+
+
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
+
+ Back to selection
+
+
+
+
+ Failed to load run details: {String(error)}
+
+
+
+ );
+ }
+
+ if (!diffResult) {
+ return (
+
+
+
+ Back to selection
+
+
+ Unable to compute comparison.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Back to selection
+
+
+ Comparing {runs.length} runs
+
+
+
+ {/* Run info cards */}
+
+ {runs.map((run, index) => (
+
+ ))}
+
+
+
+
+ {/* Arguments Diff */}
+
+
+
+
+ {/* Tasks Visual Diff */}
+
+
+ );
+};
diff --git a/src/components/Compare/Diff/DiffSummary.tsx b/src/components/Compare/Diff/DiffSummary.tsx
new file mode 100644
index 000000000..103ec4ac4
--- /dev/null
+++ b/src/components/Compare/Diff/DiffSummary.tsx
@@ -0,0 +1,112 @@
+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 { RunDiffResult } from "@/utils/diff/types";
+import { pluralize } from "@/utils/string";
+
+interface DiffSummaryProps {
+ diff: RunDiffResult;
+}
+
+interface SummaryCardProps {
+ title: string;
+ count: number;
+ icon: string;
+ variant: "default" | "success" | "warning" | "error";
+}
+
+const SummaryCard = ({ title, count, icon, variant }: SummaryCardProps) => {
+ const variantStyles = {
+ default: "border-border bg-card",
+ success: "border-green-500 bg-green-50 dark:bg-green-900/10",
+ warning: "border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10",
+ error: "border-red-500 bg-red-50 dark:bg-red-900/10",
+ };
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+ {count}
+
+
+
+ );
+};
+
+export const DiffSummary = ({ diff }: DiffSummaryProps) => {
+ const { tasks, summary } = diff;
+
+ const taskChanges = tasks.added.length + tasks.removed.length + tasks.modified.length;
+
+ if (!summary.hasChanges) {
+ return (
+
+
+
+ No differences found between runs
+
+
+ );
+ }
+
+ return (
+
+
+ Summary
+
+
+
+ 0 ? "warning" : "default"}
+ />
+
+ 0 ? "warning" : "default"}
+ />
+
+ 0 ? "success" : "default"}
+ />
+
+ 0 ? "error" : "default"}
+ />
+
+
+ {summary.hasChanges && (
+
+ Found {summary.totalArgumentChanges}{" "}
+ {pluralize(summary.totalArgumentChanges, "argument change")} and{" "}
+ {taskChanges} {pluralize(taskChanges, "task change")} across{" "}
+ {diff.runIds.length} runs.
+
+ )}
+
+ );
+};
+
diff --git a/src/components/Compare/Diff/TasksDiff.tsx b/src/components/Compare/Diff/TasksDiff.tsx
new file mode 100644
index 000000000..4f910fe7b
--- /dev/null
+++ b/src/components/Compare/Diff/TasksDiff.tsx
@@ -0,0 +1,188 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+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 { RunDiffResult, TaskDiff as TaskDiffType } from "@/utils/diff/types";
+
+import { ValueDiffInline } from "./ValueDiffDisplay";
+
+interface TasksDiffProps {
+ tasks: RunDiffResult["tasks"];
+}
+
+const TaskBadge = ({
+ taskId,
+ type,
+}: {
+ taskId: string;
+ type: "added" | "removed" | "modified" | "unchanged";
+}) => {
+ const styles = {
+ added: "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700",
+ removed: "bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-700",
+ modified: "bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-700",
+ unchanged: "bg-muted text-muted-foreground border-border",
+ };
+
+ const icons = {
+ added: "Plus",
+ removed: "Minus",
+ modified: "Pencil",
+ unchanged: "Check",
+ };
+
+ return (
+
+
+ {taskId}
+
+ );
+};
+
+const TaskDetailDiff = ({
+ task,
+}: {
+ task: TaskDiffType;
+}) => {
+ const hasDetails =
+ task.digestDiff || task.componentNameDiff || task.argumentsDiff.length > 0;
+
+ if (!hasDetails) {
+ return null;
+ }
+
+ return (
+
+ {task.componentNameDiff && (
+
+ )}
+ {task.digestDiff && }
+ {task.argumentsDiff.length > 0 && (
+
+
+ Changed Arguments:
+
+ {task.argumentsDiff.map((argDiff) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const TasksDiff = ({ tasks }: TasksDiffProps) => {
+ const { added, removed, modified, unchanged } = tasks;
+ const hasChanges = added.length > 0 || removed.length > 0 || modified.length > 0;
+
+ if (!hasChanges && unchanged.length === 0) {
+ return (
+
+ No tasks to compare.
+
+ );
+ }
+
+ return (
+
+
+
+ Tasks
+
+
+ {added.length + removed.length + modified.length} changed,{" "}
+ {unchanged.length} unchanged
+
+
+
+ {/* Added tasks */}
+ {added.length > 0 && (
+
+
+
+
+ Added Tasks ({added.length})
+
+
+
+ {added.map((taskId) => (
+
+ ))}
+
+
+ )}
+
+ {/* Removed tasks */}
+ {removed.length > 0 && (
+
+
+
+
+ Removed Tasks ({removed.length})
+
+
+
+ {removed.map((taskId) => (
+
+ ))}
+
+
+ )}
+
+ {/* Modified tasks */}
+ {modified.length > 0 && (
+
+
+
+
+ Modified Tasks ({modified.length})
+
+
+
+ {modified.map((task) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Unchanged tasks (collapsible) */}
+ {unchanged.length > 0 && (
+
+
+
+
+ {unchanged.length} unchanged tasks
+
+
+
+
+ {unchanged.map((taskId) => (
+
+ ))}
+
+
+
+
+ )}
+
+ );
+};
+
diff --git a/src/components/Compare/Diff/TasksVisualDiff.tsx b/src/components/Compare/Diff/TasksVisualDiff.tsx
new file mode 100644
index 000000000..e284e5882
--- /dev/null
+++ b/src/components/Compare/Diff/TasksVisualDiff.tsx
@@ -0,0 +1,596 @@
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+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 { ComponentSpec, TaskSpec } from "@/utils/componentSpec";
+import type {
+ ChangeType,
+ RunComparisonData,
+ TaskDiff,
+} from "@/utils/diff/types";
+
+/**
+ * Extracted task data for visual display
+ */
+interface VisualTask {
+ taskId: string;
+ /** Full path for nested tasks (e.g., "parent/child/grandchild") */
+ fullPath: string;
+ changeType: ChangeType;
+ componentName?: string;
+ componentDigest?: string;
+ /** Arguments per run: [run1Args, run2Args, ...] */
+ argumentsByRun: (Record | undefined)[];
+ /** Diff details if modified */
+ diff?: TaskDiff;
+ /** Nested tasks if this task has a graph implementation */
+ children: VisualTask[];
+ /** Nesting depth for indentation */
+ depth: number;
+ /** Whether this task is a subgraph */
+ isSubgraph: boolean;
+}
+
+interface TasksVisualDiffProps {
+ comparisonData: RunComparisonData[];
+ tasksDiff: {
+ added: string[];
+ removed: string[];
+ modified: TaskDiff[];
+ unchanged: string[];
+ };
+ runLabels: string[];
+}
+
+const changeColors: Record = {
+ added: "border-l-green-500",
+ removed: "border-l-red-500",
+ modified: "border-l-yellow-500",
+ unchanged: "border-l-transparent",
+};
+
+const changeDotColors: Record = {
+ added: "bg-green-500",
+ removed: "bg-red-500",
+ modified: "bg-yellow-500",
+ unchanged: "bg-muted-foreground/30",
+};
+
+/**
+ * Check if a component spec has a graph implementation
+ */
+function hasGraphImplementation(spec?: ComponentSpec): boolean {
+ return !!(spec?.implementation && "graph" in spec.implementation);
+}
+
+/**
+ * Get nested component spec from a task's componentRef
+ */
+function getNestedSpec(taskSpec?: TaskSpec): ComponentSpec | undefined {
+ return taskSpec?.componentRef?.spec as ComponentSpec | undefined;
+}
+
+/**
+ * Extract tasks from a graph implementation, handling nested subgraphs recursively
+ */
+function extractTasksFromGraph(
+ taskSpecs: (TaskSpec | undefined)[],
+ taskId: string,
+ parentPath: string,
+ depth: number,
+ changeTypeMap: Map,
+ modifiedMap: Map,
+): VisualTask {
+ const fullPath = parentPath ? `${parentPath}/${taskId}` : taskId;
+ const changeType =
+ changeTypeMap.get(fullPath) ?? changeTypeMap.get(taskId) ?? "unchanged";
+ const diff = modifiedMap.get(fullPath) ?? modifiedMap.get(taskId);
+
+ // Extract arguments from each run
+ const argumentsByRun = taskSpecs.map((taskSpec) => {
+ if (!taskSpec?.arguments) return undefined;
+ const args: Record = {};
+ for (const [key, value] of Object.entries(taskSpec.arguments)) {
+ args[key] = stringifyArgValue(value);
+ }
+ return args;
+ });
+
+ // Get component info from first available task spec
+ const firstTaskSpec = taskSpecs.find((ts) => ts !== undefined);
+ const componentName =
+ diff?.componentNameDiff?.values[0] ?? firstTaskSpec?.componentRef?.name;
+ const componentDigest =
+ diff?.digestDiff?.values[0] ?? firstTaskSpec?.componentRef?.digest;
+
+ // Check if any run has a nested graph implementation
+ const nestedSpecs = taskSpecs.map((ts) => getNestedSpec(ts));
+ const hasNestedGraph = nestedSpecs.some((spec) =>
+ hasGraphImplementation(spec),
+ );
+
+ // Extract children recursively if this is a subgraph
+ const children: VisualTask[] = [];
+ if (hasNestedGraph) {
+ // Collect all nested task IDs across runs
+ const nestedTaskIds: string[] = [];
+ const seenNestedIds = new Set();
+
+ for (const spec of nestedSpecs) {
+ if (hasGraphImplementation(spec)) {
+ const nestedTasks = (
+ spec!.implementation as { graph: { tasks: Record } }
+ ).graph.tasks;
+ for (const nestedTaskId of Object.keys(nestedTasks)) {
+ if (!seenNestedIds.has(nestedTaskId)) {
+ seenNestedIds.add(nestedTaskId);
+ nestedTaskIds.push(nestedTaskId);
+ }
+ }
+ }
+ }
+
+ // Recursively extract each nested task
+ for (const nestedTaskId of nestedTaskIds) {
+ const nestedTaskSpecs = nestedSpecs.map((spec) => {
+ if (!hasGraphImplementation(spec)) return undefined;
+ return (
+ spec!.implementation as { graph: { tasks: Record } }
+ ).graph.tasks[nestedTaskId];
+ });
+
+ children.push(
+ extractTasksFromGraph(
+ nestedTaskSpecs,
+ nestedTaskId,
+ fullPath,
+ depth + 1,
+ changeTypeMap,
+ modifiedMap,
+ ),
+ );
+ }
+ }
+
+ return {
+ taskId,
+ fullPath,
+ changeType,
+ componentName,
+ componentDigest,
+ argumentsByRun,
+ diff,
+ children,
+ depth,
+ isSubgraph: hasNestedGraph,
+ };
+}
+
+/**
+ * Extract all tasks from comparison data for visual display,
+ * preserving the order as they appear in the YAML (first run's order as base,
+ * with new tasks from other runs appended at the end).
+ * Handles nested graph implementations recursively.
+ */
+function extractVisualTasks(
+ comparisonData: RunComparisonData[],
+ tasksDiff: TasksVisualDiffProps["tasksDiff"],
+): VisualTask[] {
+ // Build a map of change types for quick lookup
+ const changeTypeMap = new Map();
+ const modifiedMap = new Map();
+
+ for (const taskId of tasksDiff.added) {
+ changeTypeMap.set(taskId, "added");
+ }
+ for (const taskId of tasksDiff.removed) {
+ changeTypeMap.set(taskId, "removed");
+ }
+ for (const diff of tasksDiff.modified) {
+ changeTypeMap.set(diff.taskId, "modified");
+ modifiedMap.set(diff.taskId, diff);
+ }
+ for (const taskId of tasksDiff.unchanged) {
+ changeTypeMap.set(taskId, "unchanged");
+ }
+
+ // Collect task IDs in YAML order from all runs, preserving first occurrence order
+ const orderedTaskIds: string[] = [];
+ const seenTaskIds = new Set();
+
+ for (const run of comparisonData) {
+ const impl = run.componentSpec?.implementation;
+ if (impl && "graph" in impl && impl.graph.tasks) {
+ for (const taskId of Object.keys(impl.graph.tasks)) {
+ if (!seenTaskIds.has(taskId)) {
+ seenTaskIds.add(taskId);
+ orderedTaskIds.push(taskId);
+ }
+ }
+ }
+ }
+
+ // Build visual tasks in the preserved order with recursive subgraph extraction
+ const tasks: VisualTask[] = [];
+
+ for (const taskId of orderedTaskIds) {
+ // Get task specs from all runs
+ const taskSpecs = comparisonData.map((run) => {
+ const impl = run.componentSpec?.implementation;
+ if (impl && "graph" in impl) {
+ return impl.graph.tasks?.[taskId] as TaskSpec | undefined;
+ }
+ return undefined;
+ });
+
+ tasks.push(
+ extractTasksFromGraph(
+ taskSpecs,
+ taskId,
+ "",
+ 0,
+ changeTypeMap,
+ modifiedMap,
+ ),
+ );
+ }
+
+ return tasks;
+}
+
+/**
+ * Convert argument value to display string
+ */
+function stringifyArgValue(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ if (typeof value === "string") return value;
+ if (typeof value === "object") {
+ // Handle taskOutput references
+ if ("taskOutput" in value) {
+ const ref = value as {
+ taskOutput: { taskId: string; outputName: string };
+ };
+ return `{{${ref.taskOutput.taskId}.${ref.taskOutput.outputName}}}`;
+ }
+ // Handle graphInput references
+ if ("graphInput" in value) {
+ const ref = value as { graphInput: { inputName: string } };
+ return `{{inputs.${ref.graphInput.inputName}}}`;
+ }
+ return JSON.stringify(value);
+ }
+ return String(value);
+}
+
+/**
+ * Single task node card - compact design with support for nested subgraphs
+ */
+const TaskNodeCard = ({
+ task,
+ runLabels,
+ expandedTasks,
+ onToggle,
+}: {
+ task: VisualTask;
+ runLabels: string[];
+ expandedTasks: Set;
+ onToggle: (fullPath: string) => void;
+}) => {
+ // Collect all unique argument keys across runs
+ const allArgKeys = new Set();
+ task.argumentsByRun.forEach((args) => {
+ if (args) Object.keys(args).forEach((k) => allArgKeys.add(k));
+ });
+
+ const hasArgs = allArgKeys.size > 0;
+ const changedCount = task.diff?.argumentsDiff?.length ?? 0;
+ const isExpanded = expandedTasks.has(task.fullPath);
+ const hasChildren = task.children.length > 0;
+
+ return (
+ 0 ? `${task.depth * 16}px` : undefined,
+ }}
+ >
+
+ {/* Header - compact */}
+
onToggle(task.fullPath)}
+ className="w-full px-2 py-1.5 flex items-center gap-2 hover:bg-muted/30 transition-colors text-left"
+ >
+ {/* Status dot */}
+
+
+ {/* Subgraph indicator */}
+ {task.isSubgraph && (
+
+ )}
+
+ {/* Task ID */}
+
+ {task.taskId}
+
+
+ {/* Component name - subdued */}
+ {task.componentName && (
+
+ · {task.componentName}
+
+ )}
+
+ {/* Spacer */}
+
+
+ {/* Children count for subgraphs */}
+ {hasChildren && (
+
+ {task.children.length} tasks
+
+ )}
+
+ {/* Changed count badge */}
+ {changedCount > 0 && (
+
+ {changedCount}Δ
+
+ )}
+
+ {/* Expand icon */}
+
+
+
+ {/* Expanded content - arguments */}
+ {isExpanded && hasArgs && (
+
+
+
+
+
+ arg
+
+ {runLabels.map((label, i) => (
+
+ {label.replace(/^Run #/, "#")}
+
+ ))}
+
+
+
+ {Array.from(allArgKeys)
+ .sort()
+ .map((argKey) => {
+ const values = task.argumentsByRun.map(
+ (args) => args?.[argKey],
+ );
+ const isChanged =
+ task.diff?.argumentsDiff?.some((d) => d.key === argKey) ??
+ false;
+
+ return (
+
+
+ {argKey}
+
+ {values.map((val, i) => (
+ 0 &&
+ val !== values[0] &&
+ "text-yellow-700 dark:text-yellow-400",
+ )}
+ title={val || undefined}
+ >
+ {val || (
+ —
+ )}
+
+ ))}
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {/* Nested children (subgraph tasks) */}
+ {isExpanded && hasChildren && (
+
+ {task.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+/**
+ * Collect all task paths recursively from visual tasks
+ */
+function collectAllPaths(tasks: VisualTask[]): string[] {
+ const paths: string[] = [];
+ for (const task of tasks) {
+ paths.push(task.fullPath);
+ if (task.children.length > 0) {
+ paths.push(...collectAllPaths(task.children));
+ }
+ }
+ return paths;
+}
+
+/**
+ * Collect paths of changed tasks (including nested)
+ */
+function collectChangedPaths(tasks: VisualTask[]): string[] {
+ const paths: string[] = [];
+ for (const task of tasks) {
+ if (task.changeType !== "unchanged") {
+ paths.push(task.fullPath);
+ }
+ if (task.children.length > 0) {
+ paths.push(...collectChangedPaths(task.children));
+ }
+ }
+ return paths;
+}
+
+/**
+ * Count total tasks including nested
+ */
+function countAllTasks(tasks: VisualTask[]): number {
+ let count = tasks.length;
+ for (const task of tasks) {
+ count += countAllTasks(task.children);
+ }
+ return count;
+}
+
+/**
+ * Visual DAG comparison showing tasks as node cards
+ */
+export const TasksVisualDiff = ({
+ comparisonData,
+ tasksDiff,
+ runLabels,
+}: TasksVisualDiffProps) => {
+ const visualTasks = extractVisualTasks(comparisonData, tasksDiff);
+
+ const [expandedTasks, setExpandedTasks] = useState>(() => {
+ // Auto-expand all changed tasks (including nested)
+ return new Set(collectChangedPaths(visualTasks));
+ });
+
+ const toggleTask = (fullPath: string) => {
+ setExpandedTasks((prev) => {
+ const next = new Set(prev);
+ if (next.has(fullPath)) {
+ next.delete(fullPath);
+ } else {
+ next.add(fullPath);
+ }
+ return next;
+ });
+ };
+
+ const expandAll = () => {
+ setExpandedTasks(new Set(collectAllPaths(visualTasks)));
+ };
+
+ const collapseAll = () => {
+ setExpandedTasks(new Set());
+ };
+
+ const totalTasks = countAllTasks(visualTasks);
+
+ if (visualTasks.length === 0) {
+ return (
+
+ No tasks to compare.
+
+ );
+ }
+
+ return (
+
+ {/* Header - compact */}
+
+
+
+ Tasks ({totalTasks})
+
+ {/* Inline legend */}
+
+
+
+ {tasksDiff.added.length}
+
+
+
+ {tasksDiff.removed.length}
+
+
+
+ {tasksDiff.modified.length}
+
+
+
+ {tasksDiff.unchanged.length}
+
+
+
+
+
+ Expand
+
+
+ Collapse
+
+
+
+
+ {/* All tasks - tight spacing */}
+
+ {visualTasks.map((task) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Compare/Diff/ValueDiffDisplay.tsx b/src/components/Compare/Diff/ValueDiffDisplay.tsx
new file mode 100644
index 000000000..a941b238e
--- /dev/null
+++ b/src/components/Compare/Diff/ValueDiffDisplay.tsx
@@ -0,0 +1,75 @@
+import { InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type { ChangeType, ValueDiff } from "@/utils/diff/types";
+
+const changeTypeStyles: Record = {
+ added: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
+ removed: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
+ modified:
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
+ unchanged: "",
+};
+
+const changeTypeIcons: Record = {
+ added: "+",
+ removed: "-",
+ modified: "~",
+ unchanged: "",
+};
+
+/**
+ * Compact inline display for value diffs (used in task details)
+ */
+export const ValueDiffInline = ({ diff }: { diff: ValueDiff }) => {
+ const { key, values, changeType } = diff;
+ const showIndicator = changeType !== "unchanged";
+
+ return (
+
+ {/* Key */}
+
+ {key}
+
+
+ {/* Change indicator */}
+
+ {showIndicator && (
+
+ {changeTypeIcons[changeType]}
+
+ )}
+
+
+ {/* Values */}
+
+ {values.map((value, index) => {
+ const isEmpty = value === undefined || value === "";
+ const isFirst = index === 0;
+ const showHighlight = changeType !== "unchanged" && !isFirst;
+
+ return (
+
+ {isEmpty ? "(empty)" : value}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/Compare/Diff/index.ts b/src/components/Compare/Diff/index.ts
new file mode 100644
index 000000000..3f35f39fe
--- /dev/null
+++ b/src/components/Compare/Diff/index.ts
@@ -0,0 +1,2 @@
+export { ComparisonView } from "./ComparisonView";
+
diff --git a/src/components/Compare/RunSelector/RunSelectionList.tsx b/src/components/Compare/RunSelector/RunSelectionList.tsx
new file mode 100644
index 000000000..b1961ff62
--- /dev/null
+++ b/src/components/Compare/RunSelector/RunSelectionList.tsx
@@ -0,0 +1,108 @@
+import { usePipelineRuns } from "@/components/shared/PipelineRunDisplay/usePipelineRuns";
+import { Checkbox } from "@/components/ui/checkbox";
+import { InlineStack } from "@/components/ui/layout";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import type { PipelineRun } from "@/types/pipelineRun";
+import { formatDate } from "@/utils/date";
+
+import { PipelineRunStatus } from "../../shared/PipelineRunDisplay/components/PipelineRunStatus";
+
+interface RunSelectionListProps {
+ pipelineName: string;
+ selectedRuns: PipelineRun[];
+ onRunSelect: (run: PipelineRun, selected: boolean) => void;
+ maxSelections: number;
+}
+
+export const RunSelectionList = ({
+ pipelineName,
+ selectedRuns,
+ onRunSelect,
+ maxSelections,
+}: RunSelectionListProps) => {
+ const { data: runs } = usePipelineRuns(pipelineName);
+
+ if (!runs || runs.length === 0) {
+ return (
+
+ No runs found for this pipeline.
+
+ );
+ }
+
+ const isRunSelected = (run: PipelineRun) =>
+ selectedRuns.some((r) => r.id === run.id);
+
+ const isMaxSelected = selectedRuns.length >= maxSelections;
+
+ return (
+
+
+
+
+ Run ID
+ Status
+ Created At
+ Created By
+
+
+
+ {runs.map((run) => {
+ const selected = isRunSelected(run);
+ const disabled = !selected && isMaxSelected;
+
+ return (
+ !disabled && onRunSelect(run, !selected)}
+ >
+ e.stopPropagation()}>
+
+ onRunSelect(run, checked === true)
+ }
+ />
+
+
+ #{run.id}
+
+
+
+
+
+
+ {run.created_at ? formatDate(run.created_at) : "N/A"}
+
+
+
+
+ {run.created_by || "Unknown"}
+
+
+
+ );
+ })}
+
+
+ );
+};
+
diff --git a/src/components/Compare/RunSelector/RunSelector.tsx b/src/components/Compare/RunSelector/RunSelector.tsx
new file mode 100644
index 000000000..2ee170bcf
--- /dev/null
+++ b/src/components/Compare/RunSelector/RunSelector.tsx
@@ -0,0 +1,165 @@
+import { Suspense, useEffect, useState } from "react";
+
+import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper";
+import { Label } from "@/components/ui/label";
+import { BlockStack, InlineStack } from "@/components/ui/layout";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Text } from "@/components/ui/typography";
+import type { PipelineRun } from "@/types/pipelineRun";
+import {
+ type ComponentFileEntry,
+ getAllComponentFilesFromList,
+} from "@/utils/componentStore";
+import { USER_PIPELINES_LIST_NAME } from "@/utils/constants";
+
+import { RunSelectionList } from "./RunSelectionList";
+import { SelectionBar } from "./SelectionBar";
+
+const MAX_SELECTIONS = 3;
+
+interface RunSelectorProps {
+ onCompare: (runs: PipelineRun[]) => void;
+ /** Pre-select a pipeline from URL */
+ initialPipelineName?: string;
+}
+
+type Pipelines = Map;
+
+const PipelineSelectorSkeleton = () => (
+
+
+
+
+);
+
+export const RunSelector = withSuspenseWrapper(
+ ({ onCompare, initialPipelineName }: RunSelectorProps) => {
+ const [pipelines, setPipelines] = useState(new Map());
+ const [selectedPipeline, setSelectedPipeline] = useState(
+ initialPipelineName ?? "",
+ );
+ const [selectedRuns, setSelectedRuns] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ const fetchPipelines = async () => {
+ setIsLoading(true);
+ try {
+ const result = await getAllComponentFilesFromList(
+ USER_PIPELINES_LIST_NAME,
+ );
+ const sortedPipelines = new Map(
+ [...result.entries()].sort((a, b) => {
+ return (
+ new Date(b[1].modificationTime).getTime() -
+ new Date(a[1].modificationTime).getTime()
+ );
+ }),
+ );
+ setPipelines(sortedPipelines);
+
+ // If initialPipelineName was provided but not valid, clear selection
+ if (initialPipelineName && !result.has(initialPipelineName)) {
+ setSelectedPipeline("");
+ }
+ } catch (error) {
+ console.error("Failed to load pipelines:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchPipelines();
+ }, [initialPipelineName]);
+
+ const handlePipelineSelect = (value: string) => {
+ setSelectedPipeline(value);
+ setSelectedRuns([]);
+ };
+
+ const handleRunSelect = (run: PipelineRun, selected: boolean) => {
+ if (selected) {
+ if (selectedRuns.length < MAX_SELECTIONS) {
+ setSelectedRuns([...selectedRuns, run]);
+ }
+ } else {
+ setSelectedRuns(selectedRuns.filter((r) => r.id !== run.id));
+ }
+ };
+
+ const handleCompare = () => {
+ if (selectedRuns.length >= 2) {
+ onCompare(selectedRuns);
+ }
+ };
+
+ const handleClearSelection = () => {
+ setSelectedRuns([]);
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ const pipelineNames = Array.from(pipelines.keys());
+
+ return (
+
+
+ Select Pipeline
+
+
+
+
+
+ {pipelineNames.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+
+ {selectedPipeline && (
+
+
+
+ Select Runs to Compare
+
+
+ Select 2-{MAX_SELECTIONS} runs
+
+
+
+ }>
+
+
+
+ )}
+
+ {selectedRuns.length > 0 && (
+
+ )}
+
+ );
+ },
+ PipelineSelectorSkeleton,
+);
+
diff --git a/src/components/Compare/RunSelector/SelectionBar.tsx b/src/components/Compare/RunSelector/SelectionBar.tsx
new file mode 100644
index 000000000..457514da3
--- /dev/null
+++ b/src/components/Compare/RunSelector/SelectionBar.tsx
@@ -0,0 +1,48 @@
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { InlineStack } from "@/components/ui/layout";
+import { Text } from "@/components/ui/typography";
+import type { PipelineRun } from "@/types/pipelineRun";
+import { pluralize } from "@/utils/string";
+
+interface SelectionBarProps {
+ selectedRuns: PipelineRun[];
+ onCompare: () => void;
+ onClearSelection: () => void;
+}
+
+export const SelectionBar = ({
+ selectedRuns,
+ onCompare,
+ onClearSelection,
+}: SelectionBarProps) => {
+ const canCompare = selectedRuns.length >= 2;
+
+ return (
+
+
+
+ {selectedRuns.length} {pluralize(selectedRuns.length, "run")} selected
+
+
+
+
+
+ Compare {selectedRuns.length}{" "}
+ {pluralize(selectedRuns.length, "run")}
+
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/src/components/Compare/RunSelector/index.ts b/src/components/Compare/RunSelector/index.ts
new file mode 100644
index 000000000..83acac27a
--- /dev/null
+++ b/src/components/Compare/RunSelector/index.ts
@@ -0,0 +1,2 @@
+export { RunSelector } from "./RunSelector";
+
diff --git a/src/components/Home/RunSection/RunSection.tsx b/src/components/Home/RunSection/RunSection.tsx
index 2ec03c0ec..c66acd516 100644
--- a/src/components/Home/RunSection/RunSection.tsx
+++ b/src/components/Home/RunSection/RunSection.tsx
@@ -20,6 +20,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useBackend } from "@/providers/BackendProvider";
+import type { HomeSearchParams } from "@/routes/router";
import { getBackendStatusString } from "@/utils/backend";
import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling";
@@ -32,13 +33,11 @@ const CREATED_BY_ME_FILTER = "created_by:me";
const INCLUDE_PIPELINE_NAME_QUERY_KEY = "include_pipeline_names";
const INCLUDE_EXECUTION_STATS_QUERY_KEY = "include_execution_stats";
-type RunSectionSearch = { page_token?: string; filter?: string };
-
export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
const { backendUrl, configured, available, ready } = useBackend();
const navigate = useNavigate();
const { pathname } = useLocation();
- const search = useSearch({ strict: false }) as RunSectionSearch;
+ const search = useSearch({ strict: false }) as HomeSearchParams;
const isCreatedByMeDefault = useBetaFlagValue("created-by-me-default");
const dataVersion = useRef(0);
@@ -103,7 +102,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
}, [isCreatedByMeDefault]);
const handleFilterChange = (value: boolean) => {
- const nextSearch: RunSectionSearch = { ...search };
+ const nextSearch: HomeSearchParams = { ...search };
delete nextSearch.page_token;
if (value) {
@@ -140,7 +139,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
const handleUserSearch = () => {
if (!searchUser.trim()) return;
- const nextSearch: RunSectionSearch = { ...search };
+ const nextSearch: HomeSearchParams = { ...search };
delete nextSearch.page_token;
// Create or update the created_by filter
@@ -171,7 +170,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
const handlePreviousPage = () => {
const previousToken = previousPageTokens[previousPageTokens.length - 1];
setPreviousPageTokens(previousPageTokens.slice(0, -1));
- const nextSearch: RunSectionSearch = { ...search };
+ const nextSearch: HomeSearchParams = { ...search };
if (previousToken) {
nextSearch.page_token = previousToken;
} else {
@@ -182,7 +181,7 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
const handleFirstPage = () => {
setPreviousPageTokens([]);
- const nextSearch: RunSectionSearch = { ...search };
+ const nextSearch: HomeSearchParams = { ...search };
delete nextSearch.page_token;
navigate({ to: pathname, search: nextSearch });
};
diff --git a/src/hooks/useRunComparisonData.ts b/src/hooks/useRunComparisonData.ts
new file mode 100644
index 000000000..dfdf3f7fd
--- /dev/null
+++ b/src/hooks/useRunComparisonData.ts
@@ -0,0 +1,165 @@
+import { useQueries } from "@tanstack/react-query";
+import yaml from "js-yaml";
+
+import type {
+ GetExecutionInfoResponse,
+ PipelineRunResponse,
+} from "@/api/types.gen";
+import { useBackend } from "@/providers/BackendProvider";
+import {
+ fetchExecutionDetails,
+ fetchPipelineRun,
+} from "@/services/executionService";
+import type { PipelineRun } from "@/types/pipelineRun";
+import { type ComponentSpec, isValidComponentSpec } from "@/utils/componentSpec";
+import type { RunComparisonData } from "@/utils/diff/types";
+import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus";
+
+/**
+ * Extract ComponentSpec from execution details
+ */
+function extractComponentSpec(
+ details: GetExecutionInfoResponse | undefined,
+): ComponentSpec | undefined {
+ if (!details?.task_spec?.componentRef?.spec) {
+ return undefined;
+ }
+
+ const spec = details.task_spec.componentRef.spec;
+ if (isValidComponentSpec(spec)) {
+ return spec as ComponentSpec;
+ }
+
+ // Try parsing from text if spec is stored as string
+ const text = details.task_spec.componentRef.text;
+ if (text) {
+ try {
+ const parsed = yaml.load(text);
+ if (isValidComponentSpec(parsed)) {
+ return parsed as ComponentSpec;
+ }
+ } catch {
+ // Failed to parse
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Transform fetched data into RunComparisonData format
+ */
+function transformToComparisonData(
+ run: PipelineRun,
+ metadata: PipelineRunResponse | undefined,
+ details: GetExecutionInfoResponse | undefined,
+): RunComparisonData {
+ const componentSpec = extractComponentSpec(details);
+
+ // Get status from execution stats
+ let status: string | undefined;
+ if (metadata?.execution_status_stats) {
+ status = getOverallExecutionStatusFromStats(metadata.execution_status_stats);
+ } else if (details) {
+ // Try to derive from other data
+ status = run.status;
+ }
+
+ return {
+ runId: String(run.id),
+ pipelineName: run.pipeline_name,
+ createdAt: run.created_at,
+ createdBy: metadata?.created_by ?? run.created_by,
+ status,
+ arguments: details?.task_spec?.arguments as
+ | Record
+ | undefined,
+ componentSpec,
+ };
+}
+
+/**
+ * Hook to fetch comparison data for multiple pipeline runs
+ */
+export function useRunComparisonData(runs: PipelineRun[]) {
+ const { backendUrl, ready } = useBackend();
+
+ // Fetch metadata for all runs
+ const metadataQueries = useQueries({
+ queries: runs.map((run) => ({
+ queryKey: ["comparison-metadata", run.id],
+ queryFn: () => fetchPipelineRun(String(run.id), backendUrl),
+ enabled: ready && runs.length > 0,
+ staleTime: Infinity,
+ })),
+ });
+
+ // Extract root execution IDs from metadata
+ const rootExecutionIds = metadataQueries.map((query) => ({
+ rootExecutionId: query.data?.root_execution_id,
+ }));
+
+ // Fetch execution details for all runs
+ const detailsQueries = useQueries({
+ queries: rootExecutionIds.map(({ rootExecutionId }) => ({
+ queryKey: ["comparison-details", rootExecutionId],
+ queryFn: () =>
+ rootExecutionId
+ ? fetchExecutionDetails(rootExecutionId, backendUrl)
+ : Promise.resolve(undefined),
+ enabled: ready && !!rootExecutionId,
+ staleTime: Infinity,
+ })),
+ });
+
+ // Combine all data
+ const isLoading =
+ metadataQueries.some((q) => q.isLoading) ||
+ detailsQueries.some((q) => q.isLoading);
+
+ const error =
+ metadataQueries.find((q) => q.error)?.error ||
+ detailsQueries.find((q) => q.error)?.error;
+
+ const comparisonData: RunComparisonData[] = runs.map((run, index) => {
+ const metadata = metadataQueries[index]?.data;
+ const details = detailsQueries[index]?.data;
+ return transformToComparisonData(run, metadata, details);
+ });
+
+ const isReady =
+ !isLoading &&
+ !error &&
+ comparisonData.every((data) => data.runId);
+
+ return {
+ comparisonData,
+ isLoading,
+ error,
+ isReady,
+ };
+}
+
+/**
+ * Simpler hook for when backend is not available - uses only local data
+ */
+export function useLocalRunComparisonData(runs: PipelineRun[]) {
+ // For local runs, we only have basic metadata
+ const comparisonData: RunComparisonData[] = runs.map((run) => ({
+ runId: String(run.id),
+ pipelineName: run.pipeline_name,
+ createdAt: run.created_at,
+ createdBy: run.created_by,
+ status: run.status,
+ arguments: undefined,
+ componentSpec: undefined,
+ }));
+
+ return {
+ comparisonData,
+ isLoading: false,
+ error: null,
+ isReady: runs.length > 0,
+ };
+}
+
diff --git a/src/hooks/useRunsById.ts b/src/hooks/useRunsById.ts
new file mode 100644
index 000000000..b2473e434
--- /dev/null
+++ b/src/hooks/useRunsById.ts
@@ -0,0 +1,90 @@
+import { useQueries } from "@tanstack/react-query";
+import localForage from "localforage";
+
+import type { PipelineRunResponse } from "@/api/types.gen";
+import { useBackend } from "@/providers/BackendProvider";
+import { fetchPipelineRun } from "@/services/executionService";
+import type { PipelineRun } from "@/types/pipelineRun";
+import { DB_NAME, PIPELINE_RUNS_STORE_NAME } from "@/utils/constants";
+import { getOverallExecutionStatusFromStats } from "@/utils/executionStatus";
+
+/**
+ * Fetch a run from local storage by ID
+ */
+async function fetchLocalRun(runId: string): Promise {
+ const pipelineRunsDb = localForage.createInstance({
+ name: DB_NAME,
+ storeName: PIPELINE_RUNS_STORE_NAME,
+ });
+
+ return pipelineRunsDb.getItem(runId);
+}
+
+/**
+ * Transform backend response to PipelineRun type
+ */
+function transformBackendResponse(response: PipelineRunResponse): PipelineRun {
+ // execution_status_stats in PipelineRunResponse is already flat { status: count }
+ const status = response.execution_status_stats
+ ? getOverallExecutionStatusFromStats(response.execution_status_stats)
+ : undefined;
+
+ return {
+ id: Number(response.id),
+ root_execution_id: Number(response.root_execution_id),
+ created_at: response.created_at ?? "",
+ created_by: response.created_by ?? "",
+ pipeline_name: response.pipeline_name ?? "Unknown Pipeline",
+ status,
+ };
+}
+
+/**
+ * Hook to fetch multiple pipeline runs by their IDs.
+ * First tries to fetch from backend, falls back to local storage.
+ */
+export function useRunsById(runIds: string[]) {
+ const { backendUrl, ready } = useBackend();
+
+ const queries = useQueries({
+ queries: runIds.map((runId) => ({
+ queryKey: ["run-by-id", runId, backendUrl],
+ queryFn: async (): Promise => {
+ // Try backend first
+ if (ready && backendUrl) {
+ try {
+ const response = await fetchPipelineRun(runId, backendUrl);
+ return transformBackendResponse(response);
+ } catch {
+ // Backend failed, try local storage
+ }
+ }
+
+ // Fallback to local storage
+ return fetchLocalRun(runId);
+ },
+ enabled: runIds.length > 0,
+ staleTime: Infinity,
+ retry: false,
+ })),
+ });
+
+ const isLoading = queries.some((q) => q.isLoading);
+ const error = queries.find((q) => q.error)?.error;
+
+ // Filter out null results and maintain order
+ const runs: PipelineRun[] = queries
+ .map((q) => q.data)
+ .filter((run): run is PipelineRun => run !== null && run !== undefined);
+
+ // Track which IDs were not found
+ const notFoundIds = runIds.filter((_, index) => !queries[index]?.data);
+
+ return {
+ runs,
+ isLoading,
+ error,
+ notFoundIds,
+ isReady: !isLoading && runs.length > 0,
+ };
+}
diff --git a/src/routes/Compare/Compare.tsx b/src/routes/Compare/Compare.tsx
new file mode 100644
index 000000000..66e43bd4b
--- /dev/null
+++ b/src/routes/Compare/Compare.tsx
@@ -0,0 +1,8 @@
+import { ComparePage } from "@/components/Compare/ComparePage";
+
+const Compare = () => {
+ return ;
+};
+
+export default Compare;
+
diff --git a/src/routes/Compare/index.ts b/src/routes/Compare/index.ts
new file mode 100644
index 000000000..188e05ed7
--- /dev/null
+++ b/src/routes/Compare/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./Compare";
+
diff --git a/src/routes/Home/Home.tsx b/src/routes/Home/Home.tsx
index abcce081f..7025b6206 100644
--- a/src/routes/Home/Home.tsx
+++ b/src/routes/Home/Home.tsx
@@ -1,7 +1,12 @@
import { useRef, useState } from "react";
import { PipelineSection, RunSection } from "@/components/Home";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { InlineStack } from "@/components/ui/layout";
+import { Link } from "@/components/ui/link";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { APP_ROUTES } from "@/routes/router";
const Home = () => {
const [activeTab, setActiveTab] = useState("runs");
@@ -19,9 +24,15 @@ const Home = () => {
return (
-
+
Pipelines
-
+
+
+
+ Compare Runs
+
+
+
mainLayout,
path: APP_ROUTES.HOME,
component: Home,
+ validateSearch: (search: Record): HomeSearchParams => ({
+ page_token:
+ typeof search.page_token === "string" ? search.page_token : undefined,
+ filter: typeof search.filter === "string" ? search.filter : undefined,
+ }),
});
const quickStartRoute = createRoute({
@@ -96,12 +114,33 @@ const runDetailWithSubgraphRoute = createRoute({
component: PipelineRun,
});
+/**
+ * Search params for the compare route.
+ * - runs: Comma-separated list of run IDs to compare
+ * - pipeline: Optional pipeline name to pre-select in the selector
+ */
+export type CompareSearchParams = {
+ runs?: string;
+ pipeline?: string;
+};
+
+const compareRoute = createRoute({
+ getParentRoute: () => mainLayout,
+ path: APP_ROUTES.COMPARE,
+ component: Compare,
+ validateSearch: (search: Record): CompareSearchParams => ({
+ runs: typeof search.runs === "string" ? search.runs : undefined,
+ pipeline: typeof search.pipeline === "string" ? search.pipeline : undefined,
+ }),
+});
+
const appRouteTree = mainLayout.addChildren([
indexRoute,
quickStartRoute,
editorRoute,
runDetailRoute,
runDetailWithSubgraphRoute,
+ compareRoute,
]);
const rootRouteTree = rootRoute.addChildren([
diff --git a/src/utils/diff/compareRuns.ts b/src/utils/diff/compareRuns.ts
new file mode 100644
index 000000000..5fd32d395
--- /dev/null
+++ b/src/utils/diff/compareRuns.ts
@@ -0,0 +1,278 @@
+import type { ArgumentType } from "@/utils/componentSpec";
+import { isGraphImplementation } from "@/utils/componentSpec";
+
+import type {
+ ChangeType,
+ MetadataDiff,
+ RunComparisonData,
+ RunDiffResult,
+ TaskDiff,
+ TaskForComparison,
+ ValueDiff,
+} from "./types";
+
+/**
+ * Stringify an argument value for display and comparison
+ */
+export function stringifyArgumentValue(value: ArgumentType | undefined): string {
+ if (value === undefined || value === null) {
+ return "";
+ }
+ if (typeof value === "string") {
+ return value;
+ }
+ return JSON.stringify(value);
+}
+
+/**
+ * Determine the change type for a set of values across runs
+ */
+function getChangeType(values: (string | undefined)[]): ChangeType {
+ const definedValues = values.filter((v) => v !== undefined);
+
+ if (definedValues.length === 0) {
+ return "unchanged";
+ }
+
+ if (definedValues.length < values.length) {
+ // Some runs have the value, some don't
+ if (definedValues.length === values.length - 1) {
+ // Check if first run doesn't have it (added) or last run doesn't have it (removed)
+ if (values[0] === undefined) {
+ return "added";
+ }
+ if (values[values.length - 1] === undefined) {
+ return "removed";
+ }
+ }
+ return "modified";
+ }
+
+ // All runs have values - check if they're the same
+ const firstValue = definedValues[0];
+ const allSame = definedValues.every((v) => v === firstValue);
+
+ return allSame ? "unchanged" : "modified";
+}
+
+/**
+ * Create a ValueDiff from values across runs
+ */
+function createValueDiff(key: string, values: (string | undefined)[]): ValueDiff {
+ return {
+ key,
+ values,
+ changeType: getChangeType(values),
+ };
+}
+
+/**
+ * Extract tasks from a ComponentSpec for comparison
+ */
+function extractTasksForComparison(
+ run: RunComparisonData,
+): Map {
+ const tasks = new Map();
+
+ if (!run.componentSpec?.implementation) {
+ return tasks;
+ }
+
+ if (!isGraphImplementation(run.componentSpec.implementation)) {
+ return tasks;
+ }
+
+ const graphTasks = run.componentSpec.implementation.graph.tasks;
+
+ for (const [taskId, taskSpec] of Object.entries(graphTasks)) {
+ tasks.set(taskId, {
+ taskId,
+ componentName: taskSpec.componentRef.name,
+ componentDigest: taskSpec.componentRef.digest,
+ arguments: taskSpec.arguments ?? {},
+ });
+ }
+
+ return tasks;
+}
+
+/**
+ * Compare metadata between runs
+ */
+function compareMetadata(runs: RunComparisonData[]): MetadataDiff {
+ return {
+ createdAt: createValueDiff(
+ "Created At",
+ runs.map((r) => r.createdAt),
+ ),
+ createdBy: createValueDiff(
+ "Created By",
+ runs.map((r) => r.createdBy),
+ ),
+ status: createValueDiff(
+ "Status",
+ runs.map((r) => r.status),
+ ),
+ };
+}
+
+/**
+ * Compare pipeline arguments between runs
+ */
+function compareArguments(runs: RunComparisonData[]): ValueDiff[] {
+ // Collect all unique argument keys across all runs
+ const allKeys = new Set();
+
+ for (const run of runs) {
+ if (run.arguments) {
+ Object.keys(run.arguments).forEach((key) => allKeys.add(key));
+ }
+ }
+
+ const diffs: ValueDiff[] = [];
+
+ for (const key of allKeys) {
+ const values = runs.map((run) =>
+ stringifyArgumentValue(run.arguments?.[key]),
+ );
+
+ // Only return non-empty or if at least one run has this argument
+ const hasAnyValue = values.some((v) => v !== "");
+ if (hasAnyValue) {
+ const diff = createValueDiff(key, values.map((v) => v || undefined));
+ diffs.push(diff);
+ }
+ }
+
+ // Sort by key name
+ return diffs.sort((a, b) => a.key.localeCompare(b.key));
+}
+
+/**
+ * Compare tasks between runs
+ */
+function compareTasks(runs: RunComparisonData[]): RunDiffResult["tasks"] {
+ const taskMaps = runs.map((run) => extractTasksForComparison(run));
+
+ // Collect all unique task IDs across all runs
+ const allTaskIds = new Set();
+ taskMaps.forEach((map) => map.forEach((_, id) => allTaskIds.add(id)));
+
+ const added: string[] = [];
+ const removed: string[] = [];
+ const modified: TaskDiff[] = [];
+ const unchanged: string[] = [];
+
+ for (const taskId of allTaskIds) {
+ const tasksInRuns = taskMaps.map((map) => map.get(taskId));
+ const presentCount = tasksInRuns.filter(Boolean).length;
+
+ // Determine if task was added/removed
+ if (presentCount < runs.length) {
+ // Task doesn't exist in all runs
+ if (tasksInRuns[0] === undefined && tasksInRuns.some(Boolean)) {
+ added.push(taskId);
+ } else if (tasksInRuns[0] !== undefined && tasksInRuns.some((t) => !t)) {
+ removed.push(taskId);
+ }
+ continue;
+ }
+
+ // Task exists in all runs - compare details
+ const taskDiff = compareTask(taskId, tasksInRuns as TaskForComparison[]);
+
+ if (taskDiff.changeType === "unchanged") {
+ unchanged.push(taskId);
+ } else {
+ modified.push(taskDiff);
+ }
+ }
+
+ return { added, removed, modified, unchanged };
+}
+
+/**
+ * Compare a single task across runs
+ */
+function compareTask(
+ taskId: string,
+ tasks: TaskForComparison[],
+): TaskDiff {
+ const digestDiff = createValueDiff(
+ "Component Digest",
+ tasks.map((t) => t.componentDigest),
+ );
+
+ const componentNameDiff = createValueDiff(
+ "Component Name",
+ tasks.map((t) => t.componentName),
+ );
+
+ // Compare arguments
+ const allArgKeys = new Set();
+ tasks.forEach((task) =>
+ Object.keys(task.arguments).forEach((key) => allArgKeys.add(key)),
+ );
+
+ const argumentsDiff: ValueDiff[] = [];
+
+ for (const key of allArgKeys) {
+ const values = tasks.map((task) =>
+ stringifyArgumentValue(task.arguments[key]),
+ );
+ const diff = createValueDiff(key, values.map((v) => v || undefined));
+
+ if (diff.changeType !== "unchanged") {
+ argumentsDiff.push(diff);
+ }
+ }
+
+ // Determine overall change type
+ const hasDigestChange = digestDiff.changeType !== "unchanged";
+ const hasArgumentChanges = argumentsDiff.length > 0;
+ const changeType: ChangeType =
+ hasDigestChange || hasArgumentChanges ? "modified" : "unchanged";
+
+ return {
+ taskId,
+ changeType,
+ digestDiff: hasDigestChange ? digestDiff : undefined,
+ componentNameDiff:
+ componentNameDiff.changeType !== "unchanged"
+ ? componentNameDiff
+ : undefined,
+ argumentsDiff,
+ };
+}
+
+/**
+ * Compare multiple pipeline runs and return a structured diff
+ */
+export function compareRuns(runs: RunComparisonData[]): RunDiffResult {
+ if (runs.length < 2) {
+ throw new Error("At least 2 runs are required for comparison");
+ }
+
+ const metadata = compareMetadata(runs);
+ const arguments_ = compareArguments(runs);
+ const tasks = compareTasks(runs);
+
+ const argumentChanges = arguments_.filter(
+ (a) => a.changeType !== "unchanged",
+ ).length;
+ const taskChanges =
+ tasks.added.length + tasks.removed.length + tasks.modified.length;
+
+ return {
+ runIds: runs.map((r) => r.runId),
+ metadata,
+ arguments: arguments_,
+ tasks,
+ summary: {
+ totalArgumentChanges: argumentChanges,
+ totalTaskChanges: taskChanges,
+ hasChanges: argumentChanges > 0 || taskChanges > 0,
+ },
+ };
+}
+
diff --git a/src/utils/diff/types.ts b/src/utils/diff/types.ts
new file mode 100644
index 000000000..169f5e515
--- /dev/null
+++ b/src/utils/diff/types.ts
@@ -0,0 +1,79 @@
+import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec";
+
+/**
+ * Represents the data needed to compare runs
+ */
+export interface RunComparisonData {
+ runId: string;
+ pipelineName: string;
+ createdAt: string;
+ createdBy?: string;
+ status?: string;
+ arguments?: Record;
+ componentSpec?: ComponentSpec;
+}
+
+/**
+ * Change type for diff items
+ */
+export type ChangeType = "added" | "removed" | "modified" | "unchanged";
+
+/**
+ * Represents a single value difference between runs
+ */
+export interface ValueDiff {
+ key: string;
+ values: (string | undefined)[];
+ changeType: ChangeType;
+}
+
+/**
+ * Represents metadata differences between runs
+ */
+export interface MetadataDiff {
+ createdAt: ValueDiff;
+ createdBy: ValueDiff;
+ status: ValueDiff;
+}
+
+/**
+ * Represents differences in a task between runs
+ */
+export interface TaskDiff {
+ taskId: string;
+ changeType: ChangeType;
+ digestDiff?: ValueDiff;
+ componentNameDiff?: ValueDiff;
+ argumentsDiff: ValueDiff[];
+}
+
+/**
+ * Complete diff result between multiple runs
+ */
+export interface RunDiffResult {
+ runIds: string[];
+ metadata: MetadataDiff;
+ arguments: ValueDiff[];
+ tasks: {
+ added: string[];
+ removed: string[];
+ modified: TaskDiff[];
+ unchanged: string[];
+ };
+ summary: {
+ totalArgumentChanges: number;
+ totalTaskChanges: number;
+ hasChanges: boolean;
+ };
+}
+
+/**
+ * A simplified task representation for comparison
+ */
+export interface TaskForComparison {
+ taskId: string;
+ componentName?: string;
+ componentDigest?: string;
+ arguments: Record;
+}
+