Feature/workflow history (#3432)

Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
This commit is contained in:
Alex Angin
2025-09-21 02:48:27 -04:00
committed by GitHub
parent 9a9ee01253
commit 0b47482fcb
19 changed files with 1387 additions and 67 deletions

View File

@@ -0,0 +1,359 @@
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Cross1Icon } from "@radix-ui/react-icons";
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
NodeChange,
EdgeChange,
} from "@xyflow/react";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import { WorkflowBlock, WorkflowSettings } from "../types/workflowTypes";
import { FlowRenderer } from "../editor/FlowRenderer";
import { getElements } from "../editor/workflowEditorUtils";
import { ProxyLocation } from "@/api/types";
import { AppNode } from "../editor/nodes";
type BlockComparison = {
leftBlock?: WorkflowBlock;
rightBlock?: WorkflowBlock;
status: "identical" | "modified" | "added" | "removed";
identifier: string;
};
type Props = {
version1: WorkflowVersion;
version2: WorkflowVersion;
isOpen: boolean;
onClose: () => void;
};
function getBlockIdentifier(block: WorkflowBlock): string {
return `${block.block_type}:${block.label}`;
}
function areBlocksIdentical(
block1: WorkflowBlock,
block2: WorkflowBlock,
): boolean {
// Convert blocks to string representation for comparison
// Remove dynamic fields that shouldn't affect equality
const normalize = (block: WorkflowBlock) => {
const normalized = { ...block };
// Remove output_parameter as it might have different IDs
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { output_parameter, ...rest } = normalized;
return JSON.stringify(rest, Object.keys(rest).sort());
};
return normalize(block1) === normalize(block2);
}
function compareWorkflowBlocks(
blocks1: WorkflowBlock[],
blocks2: WorkflowBlock[],
): BlockComparison[] {
const comparisons: BlockComparison[] = [];
const processedBlocks = new Set<string>();
// Create maps for quick lookup
const blocks1Map = new Map<string, WorkflowBlock>();
const blocks2Map = new Map<string, WorkflowBlock>();
blocks1.forEach((block) => {
const identifier = getBlockIdentifier(block);
blocks1Map.set(identifier, block);
});
blocks2.forEach((block) => {
const identifier = getBlockIdentifier(block);
blocks2Map.set(identifier, block);
});
// Compare blocks that exist in the first version
blocks1.forEach((block1) => {
const identifier = getBlockIdentifier(block1);
const block2 = blocks2Map.get(identifier);
processedBlocks.add(identifier);
if (block2) {
// Block exists in both versions
const isIdentical = areBlocksIdentical(block1, block2);
comparisons.push({
leftBlock: block1,
rightBlock: block2,
status: isIdentical ? "identical" : "modified",
identifier,
});
} else {
// Block was removed in version 2
comparisons.push({
leftBlock: block1,
rightBlock: undefined,
status: "removed",
identifier,
});
}
});
// Check for blocks that were added in version 2
blocks2.forEach((block2) => {
const identifier = getBlockIdentifier(block2);
if (!processedBlocks.has(identifier)) {
comparisons.push({
leftBlock: undefined,
rightBlock: block2,
status: "added",
identifier,
});
}
});
return comparisons;
}
function getWorkflowElements(version: WorkflowVersion) {
const settings: WorkflowSettings = {
proxyLocation: version.proxy_location || ProxyLocation.Residential,
webhookCallbackUrl: version.webhook_callback_url || "",
persistBrowserSession: version.persist_browser_session,
model: version.model,
maxScreenshotScrolls: version.max_screenshot_scrolls || 3,
extraHttpHeaders: version.extra_http_headers
? JSON.stringify(version.extra_http_headers)
: null,
useScriptCache: version.generate_script,
scriptCacheKey: version.cache_key,
aiFallback: version.ai_fallback ?? true,
runSequentially: version.run_sequentially ?? false,
};
return getElements(
version.workflow_definition?.blocks || [],
settings,
false, // not editable in comparison view
);
}
function WorkflowComparisonRenderer({
version,
blockColors,
}: {
version: WorkflowVersion;
title: string;
blockColors?: Map<string, string>;
}) {
// Memoize elements creation to prevent unnecessary re-renders
const elements = useMemo(() => getWorkflowElements(version), [version]);
// Memoize the colored nodes to prevent re-computation
const coloredNodes = useMemo(() => {
if (!blockColors || blockColors.size === 0) {
return elements.nodes;
}
// Apply comparison colors to block nodes
return elements.nodes.map((node) => {
// Check if this is a workflow block node (not start/nodeAdder)
if (
node.type !== "nodeAdder" &&
node.type !== "start" &&
node.data &&
node.data.label
) {
// This is a workflow block node - get its identifier and color
const identifier = `${node.type}:${node.data.label}`;
const color = blockColors.get(identifier);
if (color) {
return {
...node,
data: {
...node.data,
comparisonColor: color,
},
style: {
...node.style,
backgroundColor: color,
border: `2px solid ${color}`,
},
};
}
}
return node;
});
}, [elements.nodes, blockColors]);
const [nodes, setNodes, onNodesChange] = useNodesState(
coloredNodes as AppNode[],
);
const [edges, setEdges, onEdgesChange] = useEdgesState(elements.edges);
const handleNodesChange = useCallback(
(changes: NodeChange<AppNode>[]) => {
onNodesChange(changes);
},
[onNodesChange],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes);
},
[onEdgesChange],
);
return (
<div className="h-full w-full">
<div className="mb-4 flex items-center justify-center">
<div className="text-center">
<div className="mb-1 flex items-center justify-center gap-2">
<Badge variant="secondary">
{version.title}, version: {version.version}
</Badge>
<Badge variant="secondary">
{version.workflow_definition?.blocks?.length || 0} block
{(version.workflow_definition?.blocks?.length || 0) !== 1
? "s"
: ""}
</Badge>
</div>
</div>
</div>
<div className="h-[calc(100%-3rem)] rounded-lg border bg-white">
<FlowRenderer
hideBackground={false}
hideControls={true}
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
initialTitle={version.title}
workflow={version}
/>
</div>
</div>
);
}
function WorkflowVisualComparisonDrawer({
version1,
version2,
isOpen,
onClose,
}: Props) {
if (!isOpen) return null;
const blocks1 = version1.workflow_definition?.blocks || [];
const blocks2 = version2.workflow_definition?.blocks || [];
const comparisons = compareWorkflowBlocks(blocks1, blocks2);
// Statistics
const stats = {
identical: comparisons.filter((c) => c.status === "identical").length,
modified: comparisons.filter((c) => c.status === "modified").length,
added: comparisons.filter((c) => c.status === "added").length,
removed: comparisons.filter((c) => c.status === "removed").length,
};
// Create color mapping for block identifiers
const getComparisonColor = (
status: "identical" | "modified" | "added" | "removed",
): string => {
switch (status) {
case "identical":
return "#86efac"; // green-300
case "modified":
return "#facc15"; // yellow-400
case "added":
case "removed":
return "#c2410c"; // orange-700
default:
return "";
}
};
// Create maps for each version's block colors
const version1BlockColors = new Map<string, string>();
const version2BlockColors = new Map<string, string>();
comparisons.forEach((comparison) => {
const color = getComparisonColor(comparison.status);
// For version1 blocks
if (comparison.leftBlock) {
version1BlockColors.set(comparison.identifier, color);
}
// For version2 blocks
if (comparison.rightBlock) {
version2BlockColors.set(comparison.identifier, color);
}
});
return (
<div className="fixed inset-0 z-50 flex bg-black bg-opacity-50">
{/* Main Drawer */}
<div className="bg-navy mx-auto my-4 flex w-full max-w-[95vw] flex-col rounded-lg shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b p-6">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">
Visual Workflow Versions Comparison
</h2>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-3 text-sm">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-green-300"></div>
<span>Identical ({stats.identical})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-yellow-400"></div>
<span>Modified ({stats.modified})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
<span>Added ({stats.added})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
<span>Removed ({stats.removed})</span>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<Cross1Icon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
<ReactFlowProvider>
<div className="grid flex-1 grid-cols-2 gap-4 p-6">
{/* Version 1 Column */}
<WorkflowComparisonRenderer
version={version1}
title={`Version ${version1.version}`}
blockColors={version1BlockColors}
/>
{/* Version 2 Column */}
<WorkflowComparisonRenderer
version={version2}
title={`Version ${version2.version}`}
blockColors={version2BlockColors}
/>
</div>
</ReactFlowProvider>
</div>
</div>
</div>
);
}
export { WorkflowVisualComparisonDrawer };

View File

@@ -1,6 +1,7 @@
import {
ChevronDownIcon,
ChevronUpIcon,
ClockIcon,
CodeIcon,
CopyIcon,
PlayIcon,
@@ -53,6 +54,7 @@ type Props = {
onCacheKeyValuesClick: () => void;
onSave: () => void;
onRun?: () => void;
onHistory?: () => void;
};
function WorkflowHeader({
@@ -72,6 +74,7 @@ function WorkflowHeader({
onCacheKeyValuesClick,
onSave,
onRun,
onHistory,
}: Props) {
const { title, setTitle } = useWorkflowTitleStore();
const workflowChangesStore = useWorkflowHasChangesStore();
@@ -280,6 +283,23 @@ function WorkflowHeader({
<TooltipContent>Save</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="tertiary"
className="size-10 min-w-[2.5rem]"
onClick={() => {
onHistory?.();
}}
>
<ClockIcon className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>History</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="tertiary" size="lg" onClick={onParametersClick}>
<span className="mr-2">Parameters</span>
{parametersPanelOpen ? (

View File

@@ -62,8 +62,13 @@ import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes";
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel";
import { getWorkflowErrors } from "./workflowEditorUtils";
import { WorkflowComparisonPanel } from "./panels/WorkflowComparisonPanel";
import { getWorkflowErrors, getElements } from "./workflowEditorUtils";
import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import { WorkflowSettings } from "../types/workflowTypes";
import { ProxyLocation } from "@/api/types";
import {
nodeAdderNode,
createNode,
@@ -567,9 +572,134 @@ function Workspace({
}
}
// Centralized function to manage comparison and panel states
function clearComparisonViewAndShowFreshIfActive(active: boolean) {
setWorkflowPanelState({
active,
content: "history",
data: {
showComparison: false,
version1: undefined,
version2: undefined,
},
});
}
function toggleHistoryPanel() {
// Capture current state before making changes
const wasInComparisonMode = workflowPanelState.data?.showComparison;
const isHistoryPanelOpen =
workflowPanelState.active && workflowPanelState.content === "history";
// Always reset code view when toggling history
setShowAllCode(false);
if (wasInComparisonMode || isHistoryPanelOpen) {
// If in comparison mode or history panel is open, close it
clearComparisonViewAndShowFreshIfActive(false);
} else {
// Open history panel fresh
clearComparisonViewAndShowFreshIfActive(true);
}
}
function toggleCodeView() {
// Check comparison state BEFORE clearing it
const wasInComparisonMode = workflowPanelState.data?.showComparison;
// Always clear comparison state first
clearComparisonViewAndShowFreshIfActive(false);
if (wasInComparisonMode) {
// If we were in comparison mode, exit it and show code
setShowAllCode(true);
} else {
// Normal toggle when not in comparison mode
setShowAllCode(!showAllCode);
}
}
const orderedBlockLabels = getOrderedBlockLabels(workflow);
const code = getCode(orderedBlockLabels, blockScripts).join("");
const handleCompareVersions = (
version1: WorkflowVersion,
version2: WorkflowVersion,
mode: "visual" | "json" = "visual",
) => {
console.log(
`${mode === "visual" ? "Visual" : "JSON"} comparison between versions:`,
version1.version,
"and",
version2.version,
);
// Implement visual drawer comparison
if (mode === "visual") {
console.log("Opening visual comparison panel...");
// Keep history panel active but add comparison data
setWorkflowPanelState({
active: true,
content: "history", // Keep history panel active
data: {
version1: JSON.parse(JSON.stringify(version1)),
version2: JSON.parse(JSON.stringify(version2)),
showComparison: true, // Add flag to show comparison
},
});
}
// TODO: Implement JSON diff comparison
if (mode === "json") {
// This will open a JSON diff view
console.log("Opening JSON diff view...");
// Future: setJsonDiffOpen(true);
// Future: setJsonDiffVersions({ version1, version2 });
}
};
const handleSelectState = (selectedVersion: WorkflowVersion) => {
console.log("Loading version into main editor:", selectedVersion.version);
// Close panels
setWorkflowPanelState({
active: false,
content: "parameters",
data: {
showComparison: false,
version1: undefined,
version2: undefined,
},
});
// Load the selected version into the main editor
const settings: WorkflowSettings = {
proxyLocation:
selectedVersion.proxy_location || ProxyLocation.Residential,
webhookCallbackUrl: selectedVersion.webhook_callback_url || "",
persistBrowserSession: selectedVersion.persist_browser_session,
model: selectedVersion.model,
maxScreenshotScrolls: selectedVersion.max_screenshot_scrolls || 3,
extraHttpHeaders: selectedVersion.extra_http_headers
? JSON.stringify(selectedVersion.extra_http_headers)
: null,
useScriptCache: selectedVersion.generate_script,
scriptCacheKey: selectedVersion.cache_key,
aiFallback: selectedVersion.ai_fallback ?? true,
runSequentially: selectedVersion.run_sequentially ?? false,
};
const elements = getElements(
selectedVersion.workflow_definition?.blocks || [],
settings,
true, // editable
);
// Update the main editor with the selected version
setNodes(elements.nodes);
setEdges(elements.edges);
};
return (
<div className="relative h-full w-full">
{/* cycle browser dialog */}
@@ -759,26 +889,44 @@ function Workspace({
onRun={() => {
closeWorkflowPanel();
}}
onShowAllCodeClick={() => {
setShowAllCode(!showAllCode);
}}
onShowAllCodeClick={toggleCodeView}
onHistory={toggleHistoryPanel}
/>
</div>
{/* infinite canvas and sub panels when not in debug mode */}
{!showBrowser && (
<div className="relative flex h-full w-full overflow-hidden overflow-x-hidden">
{/* infinite canvas */}
<FlowRenderer
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
initialTitle={initialTitle}
workflow={workflow}
/>
{/* infinite canvas or comparison view */}
{workflowPanelState.data?.showComparison &&
workflowPanelState.data?.version1 &&
workflowPanelState.data?.version2 ? (
<div
className="absolute left-6 top-[6rem]"
style={{
width: "calc(100% - 32rem)",
height: "calc(100vh - 11rem)",
}}
>
<WorkflowComparisonPanel
key={`${workflowPanelState.data.version1.workflow_id}v${workflowPanelState.data.version1.version}-${workflowPanelState.data.version2.workflow_id}v${workflowPanelState.data.version2.version}`}
version1={workflowPanelState.data.version1}
version2={workflowPanelState.data.version2}
onSelectState={handleSelectState}
/>
</div>
) : (
<FlowRenderer
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
initialTitle={initialTitle}
workflow={workflow}
/>
)}
{/* sub panels */}
{workflowPanelState.active && (
@@ -815,6 +963,14 @@ function Workspace({
<WorkflowParametersPanel />
</div>
)}
{workflowPanelState.content === "history" && (
<div className="pointer-events-auto relative right-0 top-[3.5rem] z-30 h-[calc(100vh-14rem)]">
<WorkflowHistoryPanel
workflowPermanentId={workflowPermanentId!}
onCompare={handleCompareVersions}
/>
</div>
)}
{workflowPanelState.content === "nodeLibrary" && (
<div className="z-30 h-full w-[25rem]">
<WorkflowNodeLibraryPanel
@@ -882,68 +1038,138 @@ function Workspace({
split={{ left: workflowWidth }}
onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
>
{/* code and infinite canvas */}
{/* code and infinite canvas or comparison view */}
<div className="relative h-full w-full">
<div
className={cn(
"skyvern-split-left flex h-full w-[200%] translate-x-[-50%] transition-none duration-300",
{
"w-[100%] translate-x-0":
leftSideLayoutMode === "side-by-side",
},
{
"translate-x-0": showAllCode,
},
)}
ref={dom.splitLeft}
>
{/* code */}
{workflowPanelState.data?.showComparison &&
workflowPanelState.data?.version1 &&
workflowPanelState.data?.version2 ? (
<div
className={cn("h-full w-[50%]", {
"w-[0%]":
leftSideLayoutMode === "side-by-side" && !showAllCode,
})}
className="absolute inset-0 top-[8.5rem] p-6"
style={{
height: "calc(100vh - 14.5rem)",
}}
>
<div className="relative mt-[8.5rem] w-full p-6 pr-5 pt-0">
<div className="absolute right-[1.25rem] top-0 z-20">
<CopyText text={code} />
<WorkflowComparisonPanel
key={`${workflowPanelState.data.version1.workflow_id}v${workflowPanelState.data.version1.version}-${workflowPanelState.data.version2.workflow_id}v${workflowPanelState.data.version2.version}`}
version1={workflowPanelState.data.version1}
version2={workflowPanelState.data.version2}
onSelectState={handleSelectState}
/>
</div>
) : (
<div
className={cn(
"skyvern-split-left flex h-full w-[200%] translate-x-[-50%] transition-none duration-300",
{
"w-[100%] translate-x-0":
leftSideLayoutMode === "side-by-side",
},
{
"translate-x-0": showAllCode,
},
)}
ref={dom.splitLeft}
>
{/* code */}
<div
className={cn("h-full w-[50%]", {
"w-[0%]":
leftSideLayoutMode === "side-by-side" && !showAllCode,
})}
>
<div className="relative mt-[8.5rem] w-full p-6 pr-5 pt-0">
<div className="absolute right-[1.25rem] top-0 z-20">
<CopyText text={code} />
</div>
<CodeEditor
className="w-full overflow-y-scroll"
language="python"
value={code}
lineWrap={false}
readOnly
fontSize={10}
/>
</div>
<CodeEditor
className="w-full overflow-y-scroll"
language="python"
value={code}
lineWrap={false}
readOnly
fontSize={10}
</div>
{/* infinite canvas */}
<div
className={cn("h-full w-[50%]", {
"w-[100%]":
leftSideLayoutMode === "side-by-side" && !showAllCode,
})}
>
<FlowRenderer
hideBackground={true}
hideControls={true}
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
initialTitle={initialTitle}
workflow={workflow}
onContainerResize={containerResizeTrigger}
/>
</div>
</div>
{/* infinite canvas */}
<div
className={cn("h-full w-[50%]", {
"w-[100%]":
leftSideLayoutMode === "side-by-side" && !showAllCode,
})}
>
<FlowRenderer
hideBackground={true}
hideControls={true}
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
initialTitle={initialTitle}
workflow={workflow}
onContainerResize={containerResizeTrigger}
/>
</div>
</div>
)}
</div>
{/* browser & timeline */}
<div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6">
{/* sub panels */}
{workflowPanelState.active && (
<div
className={cn("absolute right-6 top-[8.5rem] z-30", {
"left-6": workflowPanelState.content === "nodeLibrary",
})}
style={{
height:
workflowPanelState.content === "nodeLibrary"
? "calc(100vh - 14rem)"
: "unset",
}}
>
{workflowPanelState.content === "cacheKeyValues" && (
<WorkflowCacheKeyValuesPanel
cacheKeyValues={cacheKeyValues}
pending={cacheKeyValuesLoading}
scriptKey={workflow.cache_key ?? "default"}
onDelete={(cacheKeyValue) => {
setToDeleteCacheKeyValue(cacheKeyValue);
setOpenConfirmCacheKeyValueDeleteDialogue(true);
}}
onPaginate={(page) => {
setPage(page);
}}
onSelect={(cacheKeyValue) => {
setCacheKeyValue(cacheKeyValue);
setCacheKeyValueFilter("");
closeWorkflowPanel();
}}
/>
)}
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "history" && (
<WorkflowHistoryPanel
workflowPermanentId={workflowPermanentId!}
onCompare={handleCompareVersions}
/>
)}
{workflowPanelState.content === "nodeLibrary" && (
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
/>
)}
</div>
)}
{/* browser & timeline */}
<div className="flex h-[calc(100%_-_8rem)] w-full gap-6">
{/* VNC browser */}
{!activeDebugSession ||

View File

@@ -118,6 +118,7 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -48,6 +48,7 @@ function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -107,6 +107,7 @@ function ExtractionNode({ id, data, type }: NodeProps<ExtractionNode>) {
"pointer-events-none bg-slate-950": thisBlockIsPlaying,
"outline outline-2 outline-slate-300": thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -112,6 +112,7 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -102,6 +102,7 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -117,6 +117,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -119,6 +119,7 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -102,6 +102,7 @@ function ValidationNode({ id, data, type }: NodeProps<ValidationNode>) {
"bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsTargetted,
},
data.comparisonColor,
)}
>
<NodeHeader

View File

@@ -8,6 +8,7 @@ export type NodeBaseData = {
editable: boolean;
model: WorkflowModel | null;
showCode?: boolean;
comparisonColor?: string;
};
export const errorMappingExampleValue = {

View File

@@ -0,0 +1,402 @@
import { useCallback, useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
NodeChange,
EdgeChange,
} from "@xyflow/react";
import { WorkflowVersion } from "../../hooks/useWorkflowVersionsQuery";
import { WorkflowBlock, WorkflowSettings } from "../../types/workflowTypes";
import { FlowRenderer } from "../FlowRenderer";
import { getElements } from "../workflowEditorUtils";
import { ProxyLocation } from "@/api/types";
import { AppNode } from "../nodes";
type BlockComparison = {
leftBlock?: WorkflowBlock;
rightBlock?: WorkflowBlock;
status: "identical" | "modified" | "added" | "removed";
identifier: string;
};
type Props = {
version1: WorkflowVersion;
version2: WorkflowVersion;
onSelectState?: (version: WorkflowVersion) => void;
};
// Mapping from WorkflowBlock.block_type to ReactFlow node.type
const BLOCK_TYPE_TO_NODE_TYPE: Record<string, string> = {
task: "task",
task_v2: "taskv2",
validation: "validation",
action: "action",
navigation: "navigation",
extraction: "extraction",
login: "login",
wait: "wait",
file_download: "fileDownload",
code: "codeBlock",
send_email: "sendEmail",
text_prompt: "textPrompt",
for_loop: "loop",
file_url_parser: "fileParser",
pdf_parser: "pdfParser",
download_to_s3: "download",
upload_to_s3: "upload",
file_upload: "fileUpload",
goto_url: "url",
http_request: "http_request",
};
function getBlockIdentifier(block: WorkflowBlock): string {
// Convert block_type to node type for consistent comparison
const nodeType =
BLOCK_TYPE_TO_NODE_TYPE[block.block_type] || block.block_type;
return `${nodeType}:${block.label}`;
}
function areBlocksIdentical(
block1: WorkflowBlock,
block2: WorkflowBlock,
): boolean {
// Convert blocks to string representation for comparison
// Remove dynamic fields that shouldn't affect equality
const normalize = (block: WorkflowBlock) => {
const normalized = { ...block };
// Remove output_parameter as it might have different IDs
const { output_parameter, ...rest } = normalized;
console.log(output_parameter);
return JSON.stringify(rest, Object.keys(rest).sort());
};
return normalize(block1) === normalize(block2);
}
function compareWorkflowBlocks(
blocks1: WorkflowBlock[],
blocks2: WorkflowBlock[],
): BlockComparison[] {
const comparisons: BlockComparison[] = [];
const processedBlocks = new Set<string>();
// Create maps for quick lookup
const blocks1Map = new Map<string, WorkflowBlock>();
const blocks2Map = new Map<string, WorkflowBlock>();
blocks1.forEach((block) => {
const identifier = getBlockIdentifier(block);
blocks1Map.set(identifier, block);
});
blocks2.forEach((block) => {
const identifier = getBlockIdentifier(block);
blocks2Map.set(identifier, block);
});
// Compare blocks that exist in the first version
blocks1.forEach((block1) => {
const identifier = getBlockIdentifier(block1);
const block2 = blocks2Map.get(identifier);
processedBlocks.add(identifier);
if (block2) {
// Block exists in both versions
const isIdentical = areBlocksIdentical(block1, block2);
comparisons.push({
leftBlock: block1,
rightBlock: block2,
status: isIdentical ? "identical" : "modified",
identifier,
});
} else {
// Block was removed in version 2
comparisons.push({
leftBlock: block1,
rightBlock: undefined,
status: "removed",
identifier,
});
}
});
// Check for blocks that were added in version 2
blocks2.forEach((block2) => {
const identifier = getBlockIdentifier(block2);
if (!processedBlocks.has(identifier)) {
comparisons.push({
leftBlock: undefined,
rightBlock: block2,
status: "added",
identifier,
});
}
});
return comparisons;
}
function getWorkflowElements(version: WorkflowVersion) {
const settings: WorkflowSettings = {
proxyLocation: version.proxy_location || ProxyLocation.Residential,
webhookCallbackUrl: version.webhook_callback_url || "",
persistBrowserSession: version.persist_browser_session,
model: version.model,
maxScreenshotScrolls: version.max_screenshot_scrolls || 3,
extraHttpHeaders: version.extra_http_headers
? JSON.stringify(version.extra_http_headers)
: null,
useScriptCache: version.generate_script,
scriptCacheKey: version.cache_key,
aiFallback: version.ai_fallback ?? true,
runSequentially: version.run_sequentially ?? false,
};
// Deep clone the blocks to ensure complete isolation from main editor
const blocks = JSON.parse(
JSON.stringify(version.workflow_definition?.blocks || []),
);
return getElements(
blocks,
settings,
false, // not editable in comparison view
);
}
function WorkflowComparisonRenderer({
version,
onSelectState,
blockColors,
}: {
version: WorkflowVersion;
onSelectState?: (version: WorkflowVersion) => void;
blockColors?: Map<string, string>;
}) {
// Memoize elements creation to prevent unnecessary re-renders
const elements = useMemo(() => getWorkflowElements(version), [version]);
// Memoize the colored nodes to prevent re-computation
const coloredNodes = useMemo(() => {
if (!blockColors || blockColors.size === 0) {
return elements.nodes;
}
// Apply comparison colors to block nodes
return elements.nodes.map((node) => {
// Check if this is a workflow block node (not start/nodeAdder)
if (
node.type !== "nodeAdder" &&
node.type !== "start" &&
node.data &&
node.data.label
) {
// This is a workflow block node - get its identifier and color
const identifier = `${node.type}:${node.data.label}`;
const color = blockColors.get(identifier);
if (color) {
return {
...node,
data: {
...node.data,
comparisonColor: color,
},
style: {
...node.style,
backgroundColor: color,
border: `2px solid ${color}`,
},
};
}
}
return node;
});
}, [elements.nodes, blockColors]);
const [nodes, setNodes, onNodesChange] = useNodesState(
coloredNodes as AppNode[],
);
const [edges, setEdges, onEdgesChange] = useEdgesState(elements.edges);
const handleNodesChange = useCallback(
(changes: NodeChange<AppNode>[]) => {
onNodesChange(changes);
},
[onNodesChange],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes);
},
[onEdgesChange],
);
return (
<div className="h-full w-full">
<div className="mb-3 flex flex-col items-center justify-center gap-2">
<div className="text-center">
<div className="mb-1 flex items-center justify-center gap-2">
<Badge variant="secondary">
{version.title}, version: {version.version}
</Badge>
<Badge variant="secondary">
{version.workflow_definition?.blocks?.length || 0} block
{(version.workflow_definition?.blocks?.length || 0) !== 1
? "s"
: ""}
</Badge>
{onSelectState && (
<Button
size="sm"
onClick={() => onSelectState(version)}
className="text-xs"
>
Select this state
</Button>
)}
</div>
</div>
</div>
<div className="h-[calc(100%-4rem)] rounded-lg border bg-white">
<FlowRenderer
hideBackground={false}
hideControls={true}
nodes={nodes}
edges={edges}
setNodes={setNodes}
setEdges={setEdges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
initialTitle={version.title}
workflow={version}
/>
</div>
</div>
);
}
function WorkflowComparisonPanel({ version1, version2, onSelectState }: Props) {
const comparisons = useMemo(() => {
const blocks1 = version1?.workflow_definition?.blocks || [];
const blocks2 = version2?.workflow_definition?.blocks || [];
return compareWorkflowBlocks(blocks1, blocks2);
}, [
version1?.workflow_definition?.blocks,
version2?.workflow_definition?.blocks,
]);
// Statistics
const stats = useMemo(
() => ({
identical: comparisons.filter((c) => c.status === "identical").length,
modified: comparisons.filter((c) => c.status === "modified").length,
added: comparisons.filter((c) => c.status === "added").length,
removed: comparisons.filter((c) => c.status === "removed").length,
}),
[comparisons],
);
// Create color mapping for block identifiers
const getComparisonColor = (
status: "identical" | "modified" | "added" | "removed",
): string => {
switch (status) {
case "identical":
return "#86efac"; // green-300
case "modified":
return "#facc15"; // yellow-400
case "added":
case "removed":
return "#c2410c"; // orange-700
default:
return "";
}
};
// Create memoized maps for each version's block colors
const { version1BlockColors, version2BlockColors } = useMemo(() => {
const v1Colors = new Map<string, string>();
const v2Colors = new Map<string, string>();
comparisons.forEach((comparison) => {
const color = getComparisonColor(comparison.status);
// For version1 blocks
if (comparison.leftBlock) {
v1Colors.set(comparison.identifier, color);
}
// For version2 blocks
if (comparison.rightBlock) {
v2Colors.set(comparison.identifier, color);
}
});
return {
version1BlockColors: v1Colors,
version2BlockColors: v2Colors,
};
}, [comparisons]);
return (
<div className="flex h-full w-full flex-col rounded-lg bg-slate-elevation2">
{/* Header */}
<div className="flex-shrink-0 p-4 pb-3">
<h2 className="mb-2 text-lg font-semibold">Version Comparison</h2>
<div className="flex gap-3 text-sm">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-green-300"></div>
<span>Identical ({stats.identical})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-yellow-400"></div>
<span>Modified ({stats.modified})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
<span>Added ({stats.added})</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded-full bg-orange-700"></div>
<span>Removed ({stats.removed})</span>
</div>
</div>
</div>
<Separator />
{/* Content - Two columns for comparison */}
<div className="flex-1 overflow-hidden p-4">
<div className="grid h-full grid-cols-2 gap-4">
{/* Version 1 Column */}
<ReactFlowProvider>
<WorkflowComparisonRenderer
key={`k1-${version1.workflow_id}v${version1.version}`}
version={version1}
onSelectState={onSelectState}
blockColors={version1BlockColors}
/>
</ReactFlowProvider>
{/* Version 2 Column */}
<ReactFlowProvider>
<WorkflowComparisonRenderer
key={`k2-${version2.workflow_id}v${version2.version}`}
version={version2}
onSelectState={onSelectState}
blockColors={version2BlockColors}
/>
</ReactFlowProvider>
</div>
</div>
</div>
);
}
export { WorkflowComparisonPanel };

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { basicLocalTimeFormat } from "@/util/timeFormat";
import {
useWorkflowVersionsQuery,
WorkflowVersion,
} from "../../hooks/useWorkflowVersionsQuery";
type Props = {
workflowPermanentId: string;
onCompare?: (
version1: WorkflowVersion,
version2: WorkflowVersion,
mode?: "visual" | "json",
) => void;
};
function WorkflowHistoryPanel({ workflowPermanentId, onCompare }: Props) {
const { data: versions, isLoading } = useWorkflowVersionsQuery({
workflowPermanentId,
});
const [selectedVersions, setSelectedVersions] = useState<Set<number>>(
new Set(),
);
// Set default selection: current (latest) and previous version
useEffect(() => {
if (versions && versions.length > 0) {
// Versions are already sorted by version descending from the backend
const defaultSelection = new Set<number>();
// Select the latest version (current)
const firstVersion = versions[0];
if (firstVersion) defaultSelection.add(firstVersion.version);
// Select the previous version if it exists
const secondVersion = versions[1];
if (secondVersion) defaultSelection.add(secondVersion.version);
setSelectedVersions(defaultSelection);
}
}, [versions]);
const handleVersionToggle = (version: number) => {
const newSelected = new Set(selectedVersions);
if (newSelected.has(version)) {
newSelected.delete(version);
} else {
// If already at max 2 selections, remove the oldest selection
if (newSelected.size >= 2) {
const versionsArray = Array.from(newSelected);
// Remove first (oldest) selection
const versionToDelete = versionsArray[0];
if (versionToDelete) newSelected.delete(versionToDelete);
}
newSelected.add(version);
}
setSelectedVersions(newSelected);
};
const handleCompare = (mode: "visual" | "json" = "visual") => {
if (selectedVersions.size === 2 && versions) {
const selectedVersionsArray = Array.from(selectedVersions);
const version1 = versions.find(
(v) => v.version === selectedVersionsArray[0],
);
const version2 = versions.find(
(v) => v.version === selectedVersionsArray[1],
);
if (version1 && version2) {
onCompare?.(version1, version2, mode);
}
}
};
// Versions are already sorted by the backend, no need to sort again
const sortedVersions = versions || [];
const canCompare = selectedVersions.size === 2;
return (
<div className="flex h-full w-[25rem] flex-col rounded-lg bg-slate-elevation2">
{/* Header */}
<div className="flex-shrink-0 p-4 pb-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Workflow History</h2>
<div className="text-sm text-muted-foreground">
{selectedVersions.size}/2 selected
</div>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Select up to 2 versions to compare. Current and previous versions are
selected by default.
</p>
</div>
<Separator />
{/* Version List */}
<ScrollArea className="flex-1">
<div className="p-4">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center space-x-3 rounded-lg border p-3"
>
<Skeleton className="h-4 w-4" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</div>
) : sortedVersions.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No version history found
</div>
) : (
<div className="space-y-2">
{sortedVersions.map((workflow, index) => {
const isSelected = selectedVersions.has(workflow.version);
const isCurrent = index === 0;
return (
<div
key={workflow.version}
className={`flex cursor-pointer items-center space-x-3 rounded-lg border p-3 transition-colors ${
isSelected
? "border-primary/20 bg-primary/5"
: "hover:bg-muted/50"
}`}
onClick={() => handleVersionToggle(workflow.version)}
>
<Checkbox
checked={isSelected}
onChange={() => {}} // Handled by parent click
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">
Version {workflow.version}
</span>
{isCurrent && (
<Badge variant="secondary">Current</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Modified: {basicLocalTimeFormat(workflow.modified_at)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
<Separator />
{/* Footer */}
<div className="flex-shrink-0 p-4 pt-3">
<div className="flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => handleCompare("json")}
disabled={!canCompare || isLoading}
>
JSON Diff
</Button>
<Button
className="flex-1"
onClick={() => handleCompare("visual")}
disabled={!canCompare || isLoading}
>
Visual Compare
</Button>
</div>
</div>
</div>
);
}
export { WorkflowHistoryPanel };

View File

@@ -0,0 +1,27 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { WorkflowApiResponse } from "../types/workflowTypes";
type Props = {
workflowPermanentId?: string;
};
export type WorkflowVersion = WorkflowApiResponse;
function useWorkflowVersionsQuery({ workflowPermanentId }: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<WorkflowVersion[]>({
queryKey: ["workflowVersions", workflowPermanentId],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowPermanentId}/versions`)
.then((response) => response.data);
},
enabled: !!workflowPermanentId,
});
}
export { useWorkflowVersionsQuery };

View File

@@ -1,14 +1,24 @@
import { create } from "zustand";
import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery";
type WorkflowPanelState = {
active: boolean;
content: "cacheKeyValues" | "parameters" | "nodeLibrary";
content:
| "cacheKeyValues"
| "parameters"
| "nodeLibrary"
| "history"
| "comparison";
data?: {
previous?: string | null;
next?: string | null;
parent?: string;
connectingEdgeType?: string;
disableLoop?: boolean;
// For comparison panel
version1?: WorkflowVersion;
version2?: WorkflowVersion;
showComparison?: boolean;
};
};