From 3db5dd4e5b7786e2f2358c5eb1118eb82927ad4a Mon Sep 17 00:00:00 2001 From: Aaron Perez Date: Mon, 13 Apr 2026 23:26:23 -0500 Subject: [PATCH] Add undo/redo to workflow editor (SKY-8869) (#5484) --- skyvern-frontend/package-lock.json | 654 ++++++++++++++++++ skyvern-frontend/package.json | 2 + .../routes/workflows/editor/FlowRenderer.tsx | 8 +- .../workflows/editor/WorkflowHeader.tsx | 57 +- .../src/routes/workflows/editor/Workspace.tsx | 70 ++ .../editor/hooks/useWorkflowHistory.test.ts | 270 ++++++++ .../editor/hooks/useWorkflowHistory.ts | 372 ++++++++++ .../editor/hooks/workflowHistoryState.test.ts | 382 ++++++++++ .../editor/hooks/workflowHistoryState.ts | 259 +++++++ skyvern-frontend/src/util/platform.ts | 22 + 10 files changed, 2094 insertions(+), 2 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/hooks/useWorkflowHistory.test.ts create mode 100644 skyvern-frontend/src/routes/workflows/editor/hooks/useWorkflowHistory.ts create mode 100644 skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.test.ts create mode 100644 skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.ts create mode 100644 skyvern-frontend/src/util/platform.ts diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index 317c9c6a9..338649999 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -71,6 +71,7 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@testing-library/react": "^16.1.0", "@types/node": "^20.11.30", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", @@ -83,6 +84,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.26", + "jsdom": "^25.0.1", "lint-staged": "^15.5.2", "postcss": "^8.4.37", "prettier": "^3.8.1", @@ -113,6 +115,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.27.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", @@ -284,6 +327,121 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dagrejs/dagre": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", @@ -3871,6 +4029,63 @@ "react": "^18.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -4438,6 +4653,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -4531,6 +4756,17 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -5193,6 +5429,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5295,6 +5552,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5312,6 +5583,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -5382,6 +5660,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5430,6 +5719,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5499,6 +5796,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -6412,6 +6722,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -6427,6 +6750,34 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -6614,6 +6965,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -6686,6 +7044,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6940,6 +7339,17 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -7180,6 +7590,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7325,6 +7742,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7742,6 +8172,44 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8168,6 +8636,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -8226,6 +8701,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -8801,6 +9289,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", @@ -8990,6 +9485,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9009,6 +9524,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -9953,6 +10494,80 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10093,6 +10708,45 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index 011565ad5..d213bc9a2 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -82,6 +82,7 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@testing-library/react": "^16.1.0", "@types/node": "^20.11.30", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", @@ -94,6 +95,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.26", + "jsdom": "^25.0.1", "lint-staged": "^15.5.2", "postcss": "^8.4.37", "prettier": "^3.8.1", diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 986aba4c4..11d21faa2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -1053,7 +1053,13 @@ function FlowRenderer({ return ( change.type === "add" || change.type === "remove" || - change.type === "replace" + change.type === "replace" || + // User drag-drop. `dragging === false` fires once at the + // end of a drag gesture. Programmatic position updates + // (mount-time layout, setNodes from node components) + // leave `dragging` undefined, so this filter doesn't + // falsely trip for them. + (change.type === "position" && change.dragging === false) ); }) ) { diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index 9adb00db9..af7bcce36 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -8,8 +8,9 @@ import { CopyIcon, PlayIcon, ReloadIcon, + ResetIcon, } from "@radix-ui/react-icons"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { SaveIcon } from "@/components/icons/SaveIcon"; import { BrowserIcon } from "@/components/icons/BrowserIcon"; @@ -37,6 +38,7 @@ import { useDebugStore } from "@/store/useDebugStore"; import { useRecordingStore } from "@/store/useRecordingStore"; import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import { isMacPlatform } from "@/util/platform"; import { cn } from "@/util/utils"; import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes"; @@ -48,6 +50,8 @@ type Props = { cacheKeyValue: string | null; cacheKeyValues: CacheKeyValuesResponse | undefined; cacheKeyValuesPanelOpen: boolean; + canUndo: boolean; + canRedo: boolean; isGeneratingCode?: boolean; isTemplate?: boolean; parametersPanelOpen: boolean; @@ -63,6 +67,8 @@ type Props = { onShowAllCodeClick?: () => void; onCacheKeyValuesClick: () => void; onSave: () => void; + onUndo: () => void; + onRedo: () => void; onRun?: () => void; onHistory?: () => void; }; @@ -71,6 +77,8 @@ function WorkflowHeader({ cacheKeyValue, cacheKeyValues, cacheKeyValuesPanelOpen, + canUndo, + canRedo, isGeneratingCode, isTemplate, parametersPanelOpen, @@ -86,6 +94,8 @@ function WorkflowHeader({ onShowAllCodeClick, onCacheKeyValuesClick, onSave, + onUndo, + onRedo, onRun, onHistory, }: Readonly) { @@ -106,6 +116,17 @@ function WorkflowHeader({ const credentialGetter = useCredentialGetter(); const queryClient = useQueryClient(); + // Keyboard shortcut labels shown in tooltips - keep them platform-aware + // so Windows/Linux users don't see the Mac Command glyph. Memoized so + // we don't re-sniff the platform on every render (it's stable for the + // session). + const { undoShortcutLabel, redoShortcutLabel } = useMemo(() => { + const mac = isMacPlatform(); + return { + undoShortcutLabel: mac ? "⌘Z" : "Ctrl+Z", + redoShortcutLabel: mac ? "⌘⇧Z" : "Ctrl+Shift+Z", + }; + }, []); const templateMutation = useMutation({ mutationFn: async (newIsTemplate: boolean) => { @@ -323,6 +344,40 @@ function WorkflowHeader({ + + + + + + Undo ({undoShortcutLabel}) + + + + + + + + Redo ({redoShortcutLabel}) + + diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index ad59cc996..919839312 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -80,9 +80,11 @@ import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore"; import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils"; import { DebuggerBlockRuns } from "@/routes/workflows/debugger/DebuggerBlockRuns"; import { copyText } from "@/util/copyText"; +import { isMacPlatform } from "@/util/platform"; import { cn } from "@/util/utils"; import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer"; +import { useWorkflowHistory } from "./hooks/useWorkflowHistory"; import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes"; import { ConditionalNodeData } from "./nodes/ConditionalNode/types"; import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel"; @@ -246,6 +248,12 @@ function Workspace({ const postHog = usePostHog(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { + undo: undoWorkflowEdit, + redo: redoWorkflowEdit, + canUndo: canUndoWorkflowEdit, + canRedo: canRedoWorkflowEdit, + } = useWorkflowHistory({ nodes, edges, setNodes, setEdges }); const { getNodes, getEdges } = useReactFlow(); const saveWorkflow = useWorkflowSave({ status: "published" }); const { data: workflowRun } = useWorkflowRunQuery(); @@ -395,6 +403,64 @@ function Workspace({ }; }, []); + // Undo/redo keyboard shortcuts. Skip when the user is typing inside an + // editable element so the browser's native per-input undo keeps working. + const isRecording = recordingStore.isRecording; + // macOS users expect Cmd+Y to be browser "History Forward" (some apps + // bind it to "Redo Typing"), so we only honour Ctrl+Y on non-Mac. + // Memoized so the platform sniff runs exactly once per mount. + const isMac = useMemo(() => isMacPlatform(), []); + useEffect(() => { + const isEditableTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { + return true; + } + if (target.isContentEditable) return true; + // Monaco wraps its editor surface in a div with role="textbox"; let + // it keep native undo as well. + if (target.getAttribute("role") === "textbox") return true; + return false; + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Recording owns the editor - don't let hotkeys mutate state behind + // the disabled toolbar buttons. + if (isRecording) return; + // IME composition (CJK, accents) fires keydown events we must not + // intercept - those belong to the composition flow. + if (event.isComposing) return; + const mod = event.metaKey || event.ctrlKey; + if (!mod) return; + // Match the typed character via event.key rather than event.code. + // On QWERTZ / Dvorak / AZERTY the labeled Z key is at a different + // physical position than US QWERTY, so matching event.code would + // either miss the user's Cmd+Z entirely or fire undo when they + // press a different key. event.key honors the keycap label. + const key = event.key.toLowerCase(); + const isZ = key === "z"; + const isY = key === "y"; + if (!isZ && !isY) return; + if (isY && isMac) return; + if (isEditableTarget(event.target)) return; + + if (isZ && !event.shiftKey) { + event.preventDefault(); + undoWorkflowEdit(); + } else if ((isZ && event.shiftKey) || isY) { + // Cmd/Ctrl+Shift+Z is the universal redo; Ctrl+Y is the + // Windows/Linux alternate redo binding (not accepted on Mac). + event.preventDefault(); + redoWorkflowEdit(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [undoWorkflowEdit, redoWorkflowEdit, isRecording, isMac]); + useEffect(() => { const currentUrlValue = searchParams.get("cache-key-value"); @@ -1289,6 +1355,10 @@ function Workspace({ ({ getClient: vi.fn() })); +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => null, +})); + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { Edge } from "@xyflow/react"; + +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import type { AppNode } from "../nodes"; +import { useWorkflowHistory } from "./useWorkflowHistory"; + +function makeNode(id: string, label = id, extra: Record = {}) { + return { + id, + type: "task", + position: { x: 0, y: 0 }, + data: { label, ...extra }, + } as unknown as AppNode; +} + +function resetStore() { + const s = useWorkflowHasChangesStore.getState(); + s.setHasChanges(false); + // Drain any leftover internal-update count. + while (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) { + s.endInternalUpdate(); + } +} + +describe("useWorkflowHistory hook", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetStore(); + }); + + function setup(initialNodes: AppNode[] = [], initialEdges: Edge[] = []) { + let nodes = initialNodes; + let edges = initialEdges; + + const setNodes = vi.fn( + (update: AppNode[] | ((prev: AppNode[]) => AppNode[])) => { + nodes = typeof update === "function" ? update(nodes) : update; + }, + ); + const setEdges = vi.fn((update: Edge[] | ((prev: Edge[]) => Edge[])) => { + edges = typeof update === "function" ? update(edges) : update; + }); + + const hook = renderHook( + ({ n, e }: { n: AppNode[]; e: Edge[] }) => + useWorkflowHistory({ nodes: n, edges: e, setNodes, setEdges }), + { initialProps: { n: nodes, e: edges } }, + ); + + return { + hook, + getNodes: () => nodes, + getEdges: () => edges, + setNodes, + setEdges, + rerender: (n: AppNode[], e: Edge[]) => { + nodes = n; + edges = e; + hook.rerender({ n, e }); + }, + }; + } + + it("captures a baseline and reports canUndo=false initially", () => { + const { hook } = setup([makeNode("a")]); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(false); + }); + + it("capture -> undo -> redo round-trip", () => { + const n1 = [makeNode("a", "v1")]; + const e1: Edge[] = []; + const { hook, rerender, setNodes } = setup(n1, e1); + + // Mark dirty so pushes land as user edits (not baseline drift). + act(() => { + useWorkflowHasChangesStore.getState().setHasChanges(true); + }); + + // Edit 1: change the label. + const n2 = [makeNode("a", "v2")]; + act(() => { + rerender(n2, e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(hook.result.current.canUndo).toBe(true); + + // Undo back to v1. + act(() => { + hook.result.current.undo(); + }); + expect(setNodes).toHaveBeenCalled(); + expect(hook.result.current.canRedo).toBe(true); + + // Redo forward to v2. + act(() => { + hook.result.current.redo(); + }); + expect(hook.result.current.canUndo).toBe(true); + expect(hook.result.current.canRedo).toBe(false); + }); + + it("capture after undo clears the redo stack", () => { + const n1 = [makeNode("a", "v1")]; + const e1: Edge[] = []; + const { hook, rerender } = setup(n1, e1); + + act(() => { + useWorkflowHasChangesStore.getState().setHasChanges(true); + }); + + // v1 -> v2 + act(() => { + rerender([makeNode("a", "v2")], e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + + // Undo to v1 + act(() => { + hook.result.current.undo(); + }); + expect(hook.result.current.canRedo).toBe(true); + + // Simulate React re-rendering with the undo'd state (v1) before + // the user makes a new edit. In the real app setNodes triggers this + // automatically; in the test we drive it manually. + act(() => { + rerender(n1, e1); + }); + + // New edit v3 (diverge from v2) + act(() => { + rerender([makeNode("a", "v3")], e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + + // Redo should be gone since we diverged. + expect(hook.result.current.canRedo).toBe(false); + }); + + it("debounce tail does not fire after undo (C2 regression)", () => { + const n1 = [makeNode("a", "v1")]; + const e1: Edge[] = []; + const { hook, rerender } = setup(n1, e1); + + act(() => { + useWorkflowHasChangesStore.getState().setHasChanges(true); + }); + + // Push v2 so we have something to undo to. + act(() => { + rerender([makeNode("a", "v2")], e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + + // Start typing v3 (debounce timer starts but hasn't fired). + act(() => { + rerender([makeNode("a", "v3")], e1); + }); + // Don't advance timers — the debounce is still pending. + + // Undo back to v1 (flushes the pending v3 first, then undoes). + act(() => { + hook.result.current.undo(); + }); + + // Now advance past the original debounce window. The guarded + // timer should be a no-op because undo cleared it. + act(() => { + vi.advanceTimersByTime(500); + }); + + // We should be able to redo (the undo worked and no stale + // capture overwrote it). + expect(hook.result.current.canRedo).toBe(true); + }); + + it("undo is blocked during internal updates", () => { + const n1 = [makeNode("a", "v1")]; + const e1: Edge[] = []; + const { hook, rerender } = setup(n1, e1); + + act(() => { + useWorkflowHasChangesStore.getState().setHasChanges(true); + }); + + act(() => { + rerender([makeNode("a", "v2")], e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(hook.result.current.canUndo).toBe(true); + + // Begin an internal update. + act(() => { + useWorkflowHasChangesStore.getState().beginInternalUpdate(); + }); + + // Undo should be a no-op while internal update is active. + act(() => { + hook.result.current.undo(); + }); + // canUndo should still be true (undo didn't fire). + expect(hook.result.current.canUndo).toBe(true); + + act(() => { + useWorkflowHasChangesStore.getState().endInternalUpdate(); + }); + }); + + it("undo is blocked while a node is mid-drag (R5)", () => { + const draggingNode = { + ...makeNode("a", "v1"), + dragging: true, + } as unknown as AppNode; + const e1: Edge[] = []; + const { hook, rerender } = setup([makeNode("a", "v1")], e1); + + act(() => { + useWorkflowHasChangesStore.getState().setHasChanges(true); + }); + + act(() => { + rerender([makeNode("a", "v2")], e1); + }); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(hook.result.current.canUndo).toBe(true); + + // Simulate mid-drag: rerender with dragging=true on the node. + act(() => { + rerender([draggingNode], e1); + }); + + // Undo should bail because a node is dragging. + act(() => { + hook.result.current.undo(); + }); + expect(hook.result.current.canUndo).toBe(true); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/editor/hooks/useWorkflowHistory.ts b/skyvern-frontend/src/routes/workflows/editor/hooks/useWorkflowHistory.ts new file mode 100644 index 000000000..4398d4628 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/hooks/useWorkflowHistory.ts @@ -0,0 +1,372 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { Edge } from "@xyflow/react"; +import { usePostHog } from "posthog-js/react"; + +import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; +import type { AppNode } from "../nodes"; +import { + canRedo as historyCanRedo, + canUndo as historyCanUndo, + cloneSnapshot, + createInitialHistoryState, + MAX_HISTORY_ENTRIES, + pushSnapshot, + redo as historyRedo, + replacePresent, + snapshotsEqual, + undo as historyUndo, + type WorkflowHistoryState, + type WorkflowSnapshot, +} from "./workflowHistoryState"; + +// Debounced so typing into a text field doesn't flood the stack with +// one entry per keystroke. +const CAPTURE_DEBOUNCE_MS = 300; + +type UseWorkflowHistoryParams = { + nodes: AppNode[]; + edges: Edge[]; + // Match React Flow's useNodesState / useEdgesState signatures so + // callers can pass the setter dispatcher straight through (and use + // the updater-function form elsewhere if they want). + setNodes: Dispatch>; + setEdges: Dispatch>; +}; + +type UseWorkflowHistoryResult = { + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +}; + +/** + * In-memory undo/redo for the workflow editor canvas. + * + * Watches `nodes`/`edges`, captures debounced snapshots onto a bounded + * history stack, and exposes undo/redo callbacks that restore snapshots + * via `setNodes`/`setEdges`. Not persisted - a browser refresh clears + * the stack. See SKY-8869. + */ +export function useWorkflowHistory({ + nodes, + edges, + setNodes, + setEdges, +}: UseWorkflowHistoryParams): UseWorkflowHistoryResult { + const posthog = usePostHog(); + const historyRef = useRef(createInitialHistoryState()); + // One-shot: applySnapshot sets, capture effect consumes on next fire. + const isApplyingRef = useRef(false); + // Raised while internalUpdateCount > 0; consumed by the sync flush path. + const wasInternalUpdateRef = useRef(false); + // Set via the zustand subscribe below on any 0→1+ internalUpdateCount + // transition. Lets us detect internal updates even when begin/end are + // batched into a single React commit (so the capture effect wouldn't + // otherwise observe count > 0 in isolation). + const internalUpdateObservedRef = useRef(false); + // Eagerly-cloned snapshot of the pre-internal-update state. Populated + // by the subscribe callback *before* React commits the internal + // update's nodes/edges changes, so a pending user edit that predates + // the internal update can be pushed as its own history entry instead + // of being merged with the internal-update state. + const pendingSnapshotRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + const latestNodesRef = useRef(nodes); + const latestEdgesRef = useRef(edges); + + const [flags, setFlags] = useState<{ canUndo: boolean; canRedo: boolean }>({ + canUndo: false, + canRedo: false, + }); + + const internalUpdateCount = useWorkflowHasChangesStore( + (state) => state.internalUpdateCount, + ); + + // useLayoutEffect with no dep array: runs synchronously after every + // commit so a user-triggered flush (undo keydown) always sees the + // latest values, not a stale snapshot between commit and the next + // regular useEffect fire. + useLayoutEffect(() => { + latestNodesRef.current = nodes; + latestEdgesRef.current = edges; + }); + + const refreshFlags = useCallback(() => { + const state = historyRef.current; + const nextCanUndo = historyCanUndo(state); + const nextCanRedo = historyCanRedo(state); + setFlags((prev) => + prev.canUndo === nextCanUndo && prev.canRedo === nextCanRedo + ? prev + : { canUndo: nextCanUndo, canRedo: nextCanRedo }, + ); + }, []); + + // Commit a specific snapshot into history. Routing logic: + // - No baseline yet → seed. + // - Snapshot equal to present → no-op (clear internal-update flag). + // - `hasChanges === false` → drift baseline (pre-first-edit gate). + // - Internal update in progress → drift baseline (caller contract). + // - Otherwise → push as an undoable entry. + // + // `forceAsUserEdit` bypasses the internal-update drift gate. It's used + // when flushing a pending snapshot we know predates the internal update + // (because it was cloned before the begin/end subscribe fired). + const commitSnapshot = useCallback( + (snapshot: WorkflowSnapshot, options?: { forceAsUserEdit?: boolean }) => { + const current = historyRef.current; + + if (current.present === null) { + historyRef.current = { past: [], present: snapshot, future: [] }; + refreshFlags(); + return; + } + + if (snapshotsEqual(current.present, snapshot)) { + wasInternalUpdateRef.current = false; + return; + } + + const storeState = useWorkflowHasChangesStore.getState(); + + if (!storeState.hasChanges) { + historyRef.current = replacePresent(current, snapshot); + wasInternalUpdateRef.current = false; + refreshFlags(); + return; + } + + if ( + !options?.forceAsUserEdit && + (wasInternalUpdateRef.current || storeState.internalUpdateCount > 0) + ) { + historyRef.current = replacePresent(current, snapshot); + wasInternalUpdateRef.current = false; + refreshFlags(); + return; + } + + historyRef.current = pushSnapshot(current, snapshot); + refreshFlags(); + }, + [refreshFlags], + ); + + // Convenience wrapper: commit the CURRENT nodes/edges (via + // latestNodesRef). Used by the normal debounce path and the + // internal-update-exit sync flush. + const captureIfChanged = useCallback( + (options?: { forceAsUserEdit?: boolean }) => { + const snapshot = cloneSnapshot( + latestNodesRef.current, + latestEdgesRef.current, + ); + commitSnapshot(snapshot, options); + }, + [commitSnapshot], + ); + + // Seed the baseline synchronously so a user who edits within the + // 300ms debounce window has a present to undo back to. + useEffect(() => { + if (historyRef.current.present === null) { + historyRef.current = { + past: [], + present: cloneSnapshot(latestNodesRef.current, latestEdgesRef.current), + future: [], + }; + refreshFlags(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Detect internal-update transitions via a direct zustand subscribe, + // independent of React's render/batch cycle. Fires synchronously + // inside `beginInternalUpdate()`'s setState stack - critically, BEFORE + // the handler goes on to modify nodes/edges. + // + // INVARIANT: latestNodesRef must reflect the latest user-committed + // state when this callback fires. It is maintained by a bare + // useLayoutEffect (no dep array) which runs synchronously after every + // React commit. This means every user edit that changes nodes/edges + // MUST have been committed (rendered + layout-effected) before any + // code path calls beginInternalUpdate(). If React 18 concurrent + // features (startTransition, useDeferredValue) defer a user-edit + // commit past a synchronous beginInternalUpdate call, the ref will + // hold a stale value and the cloned pending snapshot will predate the + // user's edit. All current callers of beginInternalUpdate run in + // useEffect or event handlers (synchronous commit boundaries), so + // this invariant holds today. + useEffect(() => { + return useWorkflowHasChangesStore.subscribe((state, prevState) => { + if (state.internalUpdateCount > prevState.internalUpdateCount) { + internalUpdateObservedRef.current = true; + if (pendingSnapshotRef.current === null) { + pendingSnapshotRef.current = cloneSnapshot( + latestNodesRef.current, + latestEdgesRef.current, + ); + } + } + }); + }, []); + + const flushPendingCapture = useCallback(() => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + if (isApplyingRef.current) return; + if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return; + captureIfChanged(); + }, [captureIfChanged]); + + useEffect(() => { + if (isApplyingRef.current) { + isApplyingRef.current = false; + return; + } + + // An internal update was seen since the last capture-effect fire - + // possibly merged into this same commit via React batching. Push + // the pre-internal-update pending snapshot (captured eagerly by + // the subscribe) as a proper user-edit entry before handling the + // current state. + if (internalUpdateObservedRef.current) { + internalUpdateObservedRef.current = false; + const pending = pendingSnapshotRef.current; + pendingSnapshotRef.current = null; + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + if (pending !== null) { + commitSnapshot(pending, { forceAsUserEdit: true }); + } + wasInternalUpdateRef.current = true; + // Fall through: if count is now 0, the internal-update exit + // branch below will sync-flush the drift. + } + + if (internalUpdateCount > 0) { + wasInternalUpdateRef.current = true; + return; + } + + // Exiting an internal update: flush synchronously so the drift + // lands before a subsequent user edit can coalesce into it. + if (wasInternalUpdateRef.current) { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + captureIfChanged(); + return; + } + + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + if (isApplyingRef.current) return; + captureIfChanged(); + }, CAPTURE_DEBOUNCE_MS); + + return () => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, [nodes, edges, internalUpdateCount, captureIfChanged, commitSnapshot]); + + const applySnapshot = useCallback( + (snapshotNodes: AppNode[], snapshotEdges: Edge[]) => { + // Fresh copies - downstream mutates node.data in place. + const cloned = cloneSnapshot(snapshotNodes, snapshotEdges); + isApplyingRef.current = true; + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setNodes(cloned.nodes); + setEdges(cloned.edges); + // Undo/redo across a save boundary diverges the canvas from the + // persisted state - mark dirty so the leave-page warning fires + // and the save button activates. Worst case is a false positive + // when the user undoes back exactly to the last-saved state, but + // silently losing divergence is the bigger failure. + // + // TODO(SKY-8869): once a "last saved snapshot" is tracked in the + // hook, compare against it here and only flip the flag when the + // applied state actually diverges. + useWorkflowHasChangesStore.getState().setHasChanges(true); + }, + [setNodes, setEdges], + ); + + const undo = useCallback(() => { + // History navigation is unsafe while an internal update is in + // flight: the subscribe path has already queued a pending + // pre-internal snapshot and an observed flag, and consuming them + // after an undo would rewrite history behind the user's back + // (clearing redo, pushing a stale entry onto past). Bail and let + // the internal update finish first. + if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return; + // Bail if any node is mid-drag. Cmd/Ctrl+Z during a drag gesture + // would pop a snapshot while React Flow's drag controller still + // holds a reference to the dragging node, causing a desync between + // RF's internal drag state and the restored snapshot. + if (latestNodesRef.current.some((n) => n.dragging)) return; + // Flush first so an edit still pending in the debounce window isn't dropped. + flushPendingCapture(); + const result = historyUndo(historyRef.current); + if (result === null) return; + historyRef.current = result.state; + applySnapshot(result.applied.nodes, result.applied.edges); + refreshFlags(); + posthog.capture("builder.undo_redo.used", { + action: "undo", + node_count: result.applied.nodes.length, + edge_count: result.applied.edges.length, + history_depth: result.state.past.length, + cap_reached: result.state.past.length >= MAX_HISTORY_ENTRIES, + }); + }, [applySnapshot, flushPendingCapture, refreshFlags, posthog]); + + const redo = useCallback(() => { + if (useWorkflowHasChangesStore.getState().internalUpdateCount > 0) return; + if (latestNodesRef.current.some((n) => n.dragging)) return; + flushPendingCapture(); + const result = historyRedo(historyRef.current); + if (result === null) return; + historyRef.current = result.state; + applySnapshot(result.applied.nodes, result.applied.edges); + refreshFlags(); + posthog.capture("builder.undo_redo.used", { + action: "redo", + node_count: result.applied.nodes.length, + edge_count: result.applied.edges.length, + history_depth: result.state.past.length, + cap_reached: result.state.past.length >= MAX_HISTORY_ENTRIES, + }); + }, [applySnapshot, flushPendingCapture, refreshFlags, posthog]); + + return { + undo, + redo, + canUndo: flags.canUndo, + canRedo: flags.canRedo, + }; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.test.ts b/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.test.ts new file mode 100644 index 000000000..75d3616d5 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import type { Edge } from "@xyflow/react"; + +import type { AppNode } from "../nodes"; +import { + canRedo, + canUndo, + cloneSnapshot, + createInitialHistoryState, + MAX_HISTORY_ENTRIES, + pushSnapshot, + redo, + replacePresent, + snapshotsEqual, + undo, + type WorkflowHistoryState, + type WorkflowSnapshot, +} from "./workflowHistoryState"; + +// Shape of a node we build for tests. The production type is a large +// discriminated union; casting keeps the tests focused on history logic +// without dragging in every node type. +function makeNode(id: string, label = id, extra: Record = {}) { + return { + id, + type: "task", + position: { x: 0, y: 0 }, + data: { label, ...extra }, + } as unknown as AppNode; +} + +function makeEdge(id: string, source: string, target: string): Edge { + return { id, source, target }; +} + +function snapshot(nodes: AppNode[], edges: Edge[] = []): WorkflowSnapshot { + return { nodes, edges }; +} + +function snapshotWithLabel(id: string, label: string): WorkflowSnapshot { + return snapshot([makeNode(id, label)]); +} + +describe("workflowHistoryState / cloneSnapshot", () => { + it("returns a deep copy so mutations don't leak into history", () => { + const node = makeNode("a", "first"); + const edge = makeEdge("e1", "a", "b"); + const clone = cloneSnapshot([node], [edge]); + + // Mutate the source and ensure the clone is unaffected. + (node.data as { label: string }).label = "mutated"; + edge.source = "z"; + + expect((clone.nodes[0]!.data as { label: string }).label).toBe("first"); + expect(clone.edges[0]!.source).toBe("a"); + }); + + it("strips React Flow runtime `selected` from cloned edges", () => { + const edge = { + id: "e1", + source: "a", + target: "b", + selected: true, + } as unknown as Edge; + const clone = cloneSnapshot([], [edge]); + expect(clone.edges[0] as Record).not.toHaveProperty( + "selected", + ); + expect(clone.edges[0]!.id).toBe("e1"); + }); + + it("strips React Flow runtime-maintained fields from cloned nodes", () => { + // React Flow mutates these on the fly from the rendered DOM; keeping + // them in a snapshot would reapply stale dimensions on undo/redo. + const node = { + id: "a", + type: "task", + position: { x: 0, y: 0 }, + data: { label: "a" }, + measured: { width: 200, height: 80 }, + width: 200, + height: 80, + selected: true, + dragging: true, + positionAbsolute: { x: 100, y: 100 }, + } as unknown as AppNode; + + const clone = cloneSnapshot([node], []); + const cloned = clone.nodes[0] as Record; + + expect(cloned).not.toHaveProperty("measured"); + expect(cloned).not.toHaveProperty("width"); + expect(cloned).not.toHaveProperty("height"); + expect(cloned).not.toHaveProperty("selected"); + expect(cloned).not.toHaveProperty("dragging"); + expect(cloned).not.toHaveProperty("positionAbsolute"); + // Core fields must still round-trip. + expect(cloned.id).toBe("a"); + expect(cloned.type).toBe("task"); + }); + + it("clones nested data objects", () => { + const node = makeNode("a", "a", { + parameters: { inputs: ["x"] }, + }); + const clone = cloneSnapshot([node], []); + const clonedData = clone.nodes[0]!.data as unknown as { + parameters: { inputs: string[] }; + }; + clonedData.parameters.inputs.push("y"); + + const sourceData = node.data as unknown as { + parameters: { inputs: string[] }; + }; + expect(sourceData.parameters.inputs).toEqual(["x"]); + }); +}); + +describe("workflowHistoryState / snapshotsEqual", () => { + it("returns true for structurally identical snapshots", () => { + const a = snapshot([makeNode("n1")], [makeEdge("e1", "n1", "n2")]); + const b = snapshot([makeNode("n1")], [makeEdge("e1", "n1", "n2")]); + expect(snapshotsEqual(a, b)).toBe(true); + }); + + it("returns false when node counts differ", () => { + const a = snapshot([makeNode("n1")]); + const b = snapshot([makeNode("n1"), makeNode("n2")]); + expect(snapshotsEqual(a, b)).toBe(false); + }); + + it("returns false when nested data differs", () => { + expect( + snapshotsEqual( + snapshotWithLabel("n1", "old"), + snapshotWithLabel("n1", "new"), + ), + ).toBe(false); + }); + + it("is independent of node array ordering", () => { + // React Flow may reorder nodes between renders (reconciliation, + // copy-paste, etc.). The history comparator must not treat that as + // a structural change. + const a = snapshot([makeNode("n1", "v1"), makeNode("n2", "v2")]); + const b = snapshot([makeNode("n2", "v2"), makeNode("n1", "v1")]); + expect(snapshotsEqual(a, b)).toBe(true); + }); + + it("is independent of edge array ordering", () => { + const nodes = [makeNode("n1"), makeNode("n2"), makeNode("n3")]; + const a = snapshot(nodes, [ + makeEdge("e1", "n1", "n2"), + makeEdge("e2", "n2", "n3"), + ]); + const b = snapshot(nodes, [ + makeEdge("e2", "n2", "n3"), + makeEdge("e1", "n1", "n2"), + ]); + expect(snapshotsEqual(a, b)).toBe(true); + }); + + it("is key-order independent", () => { + // React Flow sometimes reshuffles key order between renders (e.g. when + // `measured` shows up); a JSON.stringify comparison would flag these + // as different and create spurious history entries. + const a = snapshot([ + { + id: "n1", + type: "task", + position: { x: 0, y: 0 }, + data: { label: "same" }, + } as unknown as AppNode, + ]); + const b = snapshot([ + { + data: { label: "same" }, + position: { y: 0, x: 0 }, + type: "task", + id: "n1", + } as unknown as AppNode, + ]); + expect(snapshotsEqual(a, b)).toBe(true); + }); +}); + +describe("workflowHistoryState / pushSnapshot", () => { + it("seeds the present on the first push", () => { + const state = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "first"), + ); + expect(state.present).not.toBeNull(); + expect(state.past).toHaveLength(0); + expect(state.future).toHaveLength(0); + }); + + it("moves the old present into past and clears future on a new push", () => { + let state = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "v1"), + ); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + // Simulate an outstanding redo stack that should be cleared by a new edit. + state = { ...state, future: [snapshotWithLabel("n1", "stale")] }; + + state = pushSnapshot(state, snapshotWithLabel("n1", "v3")); + + expect(state.past).toHaveLength(2); + expect(state.future).toHaveLength(0); + expect((state.present!.nodes[0]!.data as { label: string }).label).toBe( + "v3", + ); + }); + + it("is a no-op when the snapshot equals the current present", () => { + const seeded = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "same"), + ); + const after = pushSnapshot(seeded, snapshotWithLabel("n1", "same")); + expect(after).toBe(seeded); + }); + + it("caps the past stack at MAX_HISTORY_ENTRIES", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + // Seed once so the first real push lands in `past`. + state = pushSnapshot(state, snapshotWithLabel("n1", "v0")); + for (let i = 1; i <= MAX_HISTORY_ENTRIES + 5; i++) { + state = pushSnapshot(state, snapshotWithLabel("n1", `v${i}`)); + } + expect(state.past.length).toBe(MAX_HISTORY_ENTRIES); + // Oldest surviving entry should be v5 - we overflowed by 5. + const oldest = state.past[0]!; + expect((oldest.nodes[0]!.data as { label: string }).label).toBe("v5"); + }); +}); + +describe("workflowHistoryState / undo", () => { + it("returns null when there is nothing to undo", () => { + expect(undo(createInitialHistoryState())).toBeNull(); + const seeded = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "only"), + ); + expect(undo(seeded)).toBeNull(); + }); + + it("restores the previous snapshot and pushes present into future", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v3")); + + const result = undo(state)!; + expect(result).not.toBeNull(); + expect((result.applied.nodes[0]!.data as { label: string }).label).toBe( + "v2", + ); + expect(result.state.past).toHaveLength(1); + expect(result.state.future).toHaveLength(1); + expect( + (result.state.future[0]!.nodes[0]!.data as { label: string }).label, + ).toBe("v3"); + }); +}); + +describe("workflowHistoryState / redo", () => { + it("returns null when the future stack is empty", () => { + const state = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "v1"), + ); + expect(redo(state)).toBeNull(); + }); + + it("moves the next future snapshot back into present", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + + const undone = undo(state)!; + const redone = redo(undone.state)!; + + expect((redone.applied.nodes[0]!.data as { label: string }).label).toBe( + "v2", + ); + expect(redone.state.future).toHaveLength(0); + expect(redone.state.past).toHaveLength(1); + }); + + it("is cleared when a new push happens after an undo", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v3")); + + state = undo(state)!.state; + expect(state.future).toHaveLength(1); + + state = pushSnapshot(state, snapshotWithLabel("n1", "v2b")); + expect(state.future).toHaveLength(0); + expect(redo(state)).toBeNull(); + }); +}); + +describe("workflowHistoryState / replacePresent", () => { + it("updates the baseline without touching the past stack", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + const pastBefore = state.past; + + const replaced = replacePresent(state, snapshotWithLabel("n1", "v2-int")); + expect(replaced.past).toBe(pastBefore); + expect((replaced.present!.nodes[0]!.data as { label: string }).label).toBe( + "v2-int", + ); + }); + + it("clears the redo stack so stale snapshots aren't reachable", () => { + // User edits v1→v2→v3, undoes twice, then an internal update (branch + // switch, layout pass) replaces the present. The existing redo stack + // is from the pre-replace timeline and must not be reachable. + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v3")); + state = undo(state)!.state; + state = undo(state)!.state; + expect(state.future).toHaveLength(2); + + const replaced = replacePresent(state, snapshotWithLabel("n1", "branchX")); + expect(replaced.future).toHaveLength(0); + expect(redo(replaced)).toBeNull(); + }); + + it("returns the original state when the snapshot equals the present and future is empty", () => { + const seeded = pushSnapshot( + createInitialHistoryState(), + snapshotWithLabel("n1", "same"), + ); + const replaced = replacePresent(seeded, snapshotWithLabel("n1", "same")); + expect(replaced).toBe(seeded); + }); + + it("still clears future even when the snapshot matches the present", () => { + // If there's a stale redo stack sitting around, we must not return + // the unchanged state - the redo entries are still invalid. + let state: WorkflowHistoryState = createInitialHistoryState(); + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + state = undo(state)!.state; + expect(state.future).toHaveLength(1); + + const replaced = replacePresent(state, snapshotWithLabel("n1", "v1")); + expect(replaced.future).toHaveLength(0); + }); +}); + +describe("workflowHistoryState / canUndo & canRedo flags", () => { + it("tracks availability of undo/redo through a full edit cycle", () => { + let state: WorkflowHistoryState = createInitialHistoryState(); + expect(canUndo(state)).toBe(false); + expect(canRedo(state)).toBe(false); + + state = pushSnapshot(state, snapshotWithLabel("n1", "v1")); + expect(canUndo(state)).toBe(false); + + state = pushSnapshot(state, snapshotWithLabel("n1", "v2")); + expect(canUndo(state)).toBe(true); + expect(canRedo(state)).toBe(false); + + state = undo(state)!.state; + expect(canUndo(state)).toBe(false); + expect(canRedo(state)).toBe(true); + + state = redo(state)!.state; + expect(canRedo(state)).toBe(false); + expect(canUndo(state)).toBe(true); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.ts b/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.ts new file mode 100644 index 000000000..db6988086 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/hooks/workflowHistoryState.ts @@ -0,0 +1,259 @@ +import type { Edge } from "@xyflow/react"; +import type { AppNode } from "../nodes"; + +// Snapshot of the workflow editor canvas at a point in time. We store the +// full nodes/edges arrays (deep-cloned at capture time) so that rolling back +// is just a matter of calling setNodes/setEdges with the previous snapshot. +export type WorkflowSnapshot = { + nodes: AppNode[]; + edges: Edge[]; +}; + +export type WorkflowHistoryState = { + past: WorkflowSnapshot[]; + present: WorkflowSnapshot | null; + future: WorkflowSnapshot[]; +}; + +// Hard cap on how many undo entries we keep. When a new entry would +// push the stack past this limit, the oldest entry is dropped - so +// from the user's perspective Undo walks back at most this many steps +// and then bottoms out silently on whatever baseline is left. 50 +// mirrors typical editor behaviour (VSCode-ish) and bounds memory +// growth for large workflows. +export const MAX_HISTORY_ENTRIES = 50; + +export function createInitialHistoryState(): WorkflowHistoryState { + return { past: [], present: null, future: [] }; +} + +// Deep clone a snapshot. Node/edge data objects are mutable in the editor +// (see updateNodeData() callers in the node components) so a shallow copy +// would let later edits bleed into history entries. +export function cloneSnapshot( + nodes: readonly AppNode[], + edges: readonly Edge[], +): WorkflowSnapshot { + return { + nodes: nodes.map((node) => cloneNode(node)), + edges: edges.map((edge) => cloneEdge(edge)), + }; +} + +// Fields React Flow maintains at runtime from the rendered DOM. They must +// NOT be part of a history snapshot: reapplying a stale `measured` / width +// / height / selected / dragging causes a flicker because RF thinks the +// node is already measured at outdated dimensions before re-measuring. +const REACT_FLOW_RUNTIME_FIELDS = [ + "measured", + "width", + "height", + "selected", + "dragging", + "positionAbsolute", +] as const; + +function cloneNode(node: AppNode): AppNode { + // Strip runtime fields first, then deep-clone the whole filtered + // object. This protects against in-place mutation of any field + // (data, style, className, position, etc.) leaking into snapshots + // already pushed onto history. + const filtered: Record = {}; + for (const [key, value] of Object.entries(node)) { + if ((REACT_FLOW_RUNTIME_FIELDS as readonly string[]).includes(key)) { + continue; + } + filtered[key] = value; + } + return deepClone(filtered) as AppNode; +} + +// React Flow sets `selected` on edges from user interaction; treat it the +// same as its node-level runtime counterparts so undo/redo doesn't briefly +// re-highlight an edge before RF clears it. Extend this list if additional +// edge flicker is observed on snapshot restore, mirroring the node-side +// REACT_FLOW_RUNTIME_FIELDS pattern above. +const REACT_FLOW_EDGE_RUNTIME_FIELDS = ["selected"] as const; + +function cloneEdge(edge: Edge): Edge { + // Same pattern as cloneNode: strip runtime fields, then deep-clone + // the whole object so mutations to edge.style / edge.markerEnd / + // edge.label etc. can't bleed into snapshots on the history stack. + const filtered: Record = {}; + for (const [key, value] of Object.entries(edge)) { + if ((REACT_FLOW_EDGE_RUNTIME_FIELDS as readonly string[]).includes(key)) { + continue; + } + filtered[key] = value; + } + return deepClone(filtered) as Edge; +} + +function deepClone(value: T): T { + // structuredClone is universally available in our target environments + // (all supported browsers and Node >= 17). Snapshot data is plain + // JSON-ish - nodes/edges and their `data` objects - so the clone + // never encounters functions or other unstructured values. + return structuredClone(value); +} + +// Order-independent structural equality. We don't use JSON.stringify because +// React Flow occasionally reorders keys between renders (e.g. when `measured` +// is added), which would falsely report two semantically-identical snapshots +// as different and generate spurious history entries. +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if ( + a === null || + b === null || + typeof a !== "object" || + typeof b !== "object" + ) { + return false; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + const bArr = b as unknown[]; + if (a.length !== bArr.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], bArr[i])) return false; + } + return true; + } + const aRec = a as Record; + const bRec = b as Record; + const aKeys = Object.keys(aRec); + const bKeys = Object.keys(bRec); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bRec, key)) return false; + if (!deepEqual(aRec[key], bRec[key])) return false; + } + return true; +} + +// Compare two node arrays by id, independent of array order. React Flow +// occasionally reorders nodes between renders (reconciliation passes, +// copy-paste insertions, etc.), which would make a positional compare +// flag a semantically identical state as different. +function nodesEqualById(a: AppNode[], b: AppNode[]): boolean { + if (a.length !== b.length) return false; + const bMap = new Map(); + for (const node of b) bMap.set(node.id, node); + for (const an of a) { + const bn = bMap.get(an.id); + if (!bn) return false; + if (!deepEqual(an, bn)) return false; + } + return true; +} + +// Compare two edge arrays by id, independent of array order. Same +// rationale as nodesEqualById. +function edgesEqualById(a: Edge[], b: Edge[]): boolean { + if (a.length !== b.length) return false; + const bMap = new Map(); + for (const edge of b) bMap.set(edge.id, edge); + for (const ae of a) { + const be = bMap.get(ae.id); + if (!be) return false; + if (!deepEqual(ae, be)) return false; + } + return true; +} + +/** Both snapshots must be pre-cloned (via {@link cloneSnapshot}) before + * comparison; this function does not strip React Flow runtime fields. */ +export function snapshotsEqual( + a: WorkflowSnapshot, + b: WorkflowSnapshot, +): boolean { + if (a === b) return true; + if (a.nodes.length !== b.nodes.length) return false; + if (a.edges.length !== b.edges.length) return false; + return nodesEqualById(a.nodes, b.nodes) && edgesEqualById(a.edges, b.edges); +} + +// Push a new snapshot as the present state, moving the previous present into +// the past stack and clearing any redo stack. Returns the original state +// unchanged when the snapshot is identical to the current present (no-op). +export function pushSnapshot( + state: WorkflowHistoryState, + snapshot: WorkflowSnapshot, +): WorkflowHistoryState { + if (state.present === null) { + return { past: [], present: snapshot, future: [] }; + } + if (snapshotsEqual(state.present, snapshot)) { + return state; + } + const nextPast = [...state.past, state.present]; + if (nextPast.length > MAX_HISTORY_ENTRIES) { + // Drop the oldest entry so the stack stays bounded. + nextPast.shift(); + } + return { past: nextPast, present: snapshot, future: [] }; +} + +// Replace the present without growing the past stack. Used when we want the +// history baseline to catch up after an internal (non-user) update like a +// branch switch, or during the mount-time settle window, without creating +// an undo step the user didn't ask for. +// +// The redo (`future`) stack is cleared because any entries it contained are +// snapshots from the pre-replace timeline and would be invalid to jump back +// to. `past` is preserved so ordinary layout-only updates don't nuke the +// user's edit history. +export function replacePresent( + state: WorkflowHistoryState, + snapshot: WorkflowSnapshot, +): WorkflowHistoryState { + if ( + state.present !== null && + state.future.length === 0 && + snapshotsEqual(state.present, snapshot) + ) { + return state; + } + return { past: state.past, present: snapshot, future: [] }; +} + +export function canUndo(state: WorkflowHistoryState): boolean { + return state.past.length > 0 && state.present !== null; +} + +export function canRedo(state: WorkflowHistoryState): boolean { + return state.future.length > 0 && state.present !== null; +} + +// Walk one step back. Returns the new state and the snapshot to apply to the +// editor, or null when there is nothing to undo. +export function undo(state: WorkflowHistoryState): { + state: WorkflowHistoryState; + applied: WorkflowSnapshot; +} | null { + if (!canUndo(state)) return null; + const nextPast = state.past.slice(0, -1); + const applied = state.past[state.past.length - 1]!; + const nextFuture = [state.present!, ...state.future]; + return { + state: { past: nextPast, present: applied, future: nextFuture }, + applied, + }; +} + +// Walk one step forward. Returns the new state and the snapshot to apply, or +// null when there is nothing to redo. +export function redo(state: WorkflowHistoryState): { + state: WorkflowHistoryState; + applied: WorkflowSnapshot; +} | null { + if (!canRedo(state)) return null; + const applied = state.future[0]!; + const nextFuture = state.future.slice(1); + const nextPast = [...state.past, state.present!]; + return { + state: { past: nextPast, present: applied, future: nextFuture }, + applied, + }; +} diff --git a/skyvern-frontend/src/util/platform.ts b/skyvern-frontend/src/util/platform.ts new file mode 100644 index 000000000..1e1da2558 --- /dev/null +++ b/skyvern-frontend/src/util/platform.ts @@ -0,0 +1,22 @@ +// True when running in a Mac/iOS user agent. Prefers the newer +// `navigator.userAgentData.platform` (User-Agent Client Hints) when the +// browser exposes it, and falls back to `navigator.userAgent` string +// sniffing. `navigator.platform` is deliberately avoided because it's +// spec-deprecated and produces console warnings in recent Chrome. +// +// Safe to call in SSR contexts: returns `false` when `navigator` is +// undefined. +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + + const uaData = ( + navigator as Navigator & { + userAgentData?: { platform?: string }; + } + ).userAgentData; + if (uaData && typeof uaData.platform === "string") { + return /mac/i.test(uaData.platform); + } + + return /mac|iphone|ipad|ipod/i.test(navigator.userAgent || ""); +}