From 163130eea475e86d2eb013a5b8517d46293b7c46 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 8 Mar 2026 00:08:57 +0800 Subject: [PATCH 1/8] feat(tools): implement whiteboard tool with canvas management and webview integration This commit adds comprehensive whiteboard functionality: Core Features: - Implement openWhiteboard tool with seeded canvas support - Add Fabric.js integration for canvas rendering - Implement scene summary generation for text-based model compatibility Components Added: - WhiteboardPanel webview with HTML/CSS/TS integration - Canvas state management (canvasState.ts) - Fabric registry for shape management (fabricRegistry.ts) - Seeded canvas support with coordinate-first contract (seededCanvas.ts) - Circle path implementation for shape rendering (circlePath.ts) Testing: - Comprehensive test coverage for all new components - Unit tests for canvas, scene summary, and webview functionality - Integration tests for whiteboard tool workflow Infrastructure: - Update MCP server integration for tool support - Enhance storage with chat history improvements - Update package dependencies and localization files - Modify webview provider for whiteboard support Files changed: 41 files, 12026 insertions(+), 97 deletions(-) --- esbuild.js | 16 +- media/webview.html | 22 +- media/whiteboard.css | 228 ++ media/whiteboard.html | 89 + package-lock.json | 1261 ++++++++++- package.json | 385 ++++ package.nls.json | 34 +- package.nls.pt-br.json | 44 +- package.nls.pt.json | 44 +- src/localization.ts | 34 + src/mcp/mcpServer.test.ts | 411 ++++ src/mcp/mcpServer.ts | 35 +- src/storage/chatHistoryStorage.test.ts | 393 ++++ src/storage/chatHistoryStorage.ts | 248 ++- src/tools/index.ts | 38 + src/tools/openWhiteboard.test.ts | 1016 +++++++++ src/tools/openWhiteboard.ts | 309 +++ src/tools/packageMetadata.test.ts | 39 + src/tools/schemas.ts | 243 ++- src/tools/whiteboard.test.ts | 355 ++++ src/tools/whiteboardToolResult.test.ts | 124 ++ src/tools/whiteboardToolResult.ts | 18 + src/webview/interactionListModels.test.ts | 145 ++ src/webview/interactionListModels.ts | 175 ++ src/webview/main.ts | 234 +- src/webview/types.test.ts | 341 +++ src/webview/types.ts | 248 ++- src/webview/uiIntegration.test.ts | 82 + src/webview/utils/mockToolCall.ts | 131 +- src/webview/webviewProvider.ts | 124 +- src/webview/whiteboard.test.ts | 735 +++++++ src/webview/whiteboard.ts | 2360 +++++++++++++++++++++ src/webview/whiteboardPanel.test.ts | 614 ++++++ src/webview/whiteboardPanel.ts | 426 ++++ src/whiteboard/canvasState.ts | 29 + src/whiteboard/circlePath.ts | 50 + src/whiteboard/fabricRegistry.test.ts | 64 + src/whiteboard/fabricRegistry.ts | 110 + src/whiteboard/sceneSummary.test.ts | 297 +++ src/whiteboard/sceneSummary.ts | 361 ++++ src/whiteboard/seededCanvas.ts | 211 ++ 41 files changed, 12026 insertions(+), 97 deletions(-) create mode 100644 media/whiteboard.css create mode 100644 media/whiteboard.html create mode 100644 src/mcp/mcpServer.test.ts create mode 100644 src/storage/chatHistoryStorage.test.ts create mode 100644 src/tools/openWhiteboard.test.ts create mode 100644 src/tools/openWhiteboard.ts create mode 100644 src/tools/packageMetadata.test.ts create mode 100644 src/tools/whiteboard.test.ts create mode 100644 src/tools/whiteboardToolResult.test.ts create mode 100644 src/tools/whiteboardToolResult.ts create mode 100644 src/webview/interactionListModels.test.ts create mode 100644 src/webview/interactionListModels.ts create mode 100644 src/webview/types.test.ts create mode 100644 src/webview/uiIntegration.test.ts create mode 100644 src/webview/whiteboard.test.ts create mode 100644 src/webview/whiteboard.ts create mode 100644 src/webview/whiteboardPanel.test.ts create mode 100644 src/webview/whiteboardPanel.ts create mode 100644 src/whiteboard/canvasState.ts create mode 100644 src/whiteboard/circlePath.ts create mode 100644 src/whiteboard/fabricRegistry.test.ts create mode 100644 src/whiteboard/fabricRegistry.ts create mode 100644 src/whiteboard/sceneSummary.test.ts create mode 100644 src/whiteboard/sceneSummary.ts create mode 100644 src/whiteboard/seededCanvas.ts diff --git a/esbuild.js b/esbuild.js index 60cd826..69159b1 100644 --- a/esbuild.js +++ b/esbuild.js @@ -98,7 +98,21 @@ async function main() { plugins: [esbuildProblemMatcherPlugin], }); - const contexts = [extensionCtx, webviewCtx, planReviewCtx]; + // Whiteboard webview bundle (browser) + const whiteboardCtx = await esbuild.context({ + entryPoints: ['src/webview/whiteboard.ts'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/whiteboard.js', + logLevel: 'info', + plugins: [esbuildProblemMatcherPlugin], + }); + + const contexts = [extensionCtx, webviewCtx, planReviewCtx, whiteboardCtx]; // CLI bundle (Node.js standalone) - Only for Antigravity if (antigravity) { diff --git a/media/webview.html b/media/webview.html index 44c9009..7d6ee19 100644 --- a/media/webview.html +++ b/media/webview.html @@ -51,7 +51,7 @@

- +

@@ -78,6 +78,10 @@

aria-label="{{historyFilterPlanReview}}"> +
+ + + + + + + + + +
+ +
+ + + + + + + + +
+ + +
+
+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ + + +
+
+ + + + diff --git a/package-lock.json b/package-lock.json index 75c0ffb..f65370f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@vscode/codicons": "^0.0.43", + "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "zod": "^4.1.13" @@ -482,6 +483,40 @@ "hono": "^4" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", @@ -521,6 +556,16 @@ } } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -569,6 +614,21 @@ "integrity": "sha512-8sf8WOBoZkyUi8ogCm5ycHJJGhwOEG3E9b64+JIx+m6bCExdkc30VwCwr94cXUU1opmRD0CTCWLcN46I8WLJIg==", "license": "CC-BY-4.0" }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -582,6 +642,56 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -615,6 +725,16 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -628,6 +748,28 @@ "node": ">=4" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -683,6 +825,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -703,7 +852,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/body-parser": { @@ -734,7 +883,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -798,6 +947,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -813,6 +978,16 @@ "node": ">=4" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -830,13 +1005,43 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -904,6 +1109,48 @@ "node": ">= 8" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -975,6 +1222,26 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT", + "optional": true + }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1011,6 +1278,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1020,6 +1304,30 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1040,6 +1348,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1174,7 +1489,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1262,6 +1577,62 @@ "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1350,6 +1721,19 @@ "express": ">= 4.11" } }, + "node_modules/fabric": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/fabric/-/fabric-6.9.1.tgz", + "integrity": "sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1409,6 +1793,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1427,6 +1851,39 @@ "node": ">= 0.8" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1482,6 +1939,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -1560,6 +2039,28 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -1664,7 +2165,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1676,6 +2177,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1714,6 +2222,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1734,6 +2255,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -1750,6 +2300,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1938,6 +2500,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -2001,6 +2573,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2174,6 +2753,52 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -2218,6 +2843,32 @@ "node": ">=4" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -2305,11 +2956,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2318,12 +2982,69 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2340,6 +3061,68 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -2442,6 +3225,27 @@ "which": "bin/which" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT", + "optional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2547,6 +3351,32 @@ "node": ">=4" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2556,6 +3386,16 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2650,6 +3490,29 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -2674,6 +3537,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "optional": true + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2713,6 +3583,21 @@ "node": ">=4" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -2766,6 +3651,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2797,6 +3689,23 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2833,6 +3742,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -2874,6 +3804,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "optional": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -2921,6 +3864,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -3082,6 +4032,56 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -3141,6 +4141,31 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -3219,6 +4244,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -3255,6 +4293,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT", + "optional": true + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3264,6 +4327,35 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -3422,6 +4514,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3431,6 +4533,24 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -3451,6 +4571,79 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3555,12 +4748,68 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "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": "4.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT", + "optional": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/zod": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", diff --git a/package.json b/package.json index 33c96d5..4db56b5 100644 --- a/package.json +++ b/package.json @@ -346,6 +346,390 @@ ] } }, + { + "name": "open_whiteboard", + "tags": [ + "whiteboard", + "diagramming", + "visual-context", + "user-interaction", + "seamless-agent" + ], + "toolReferenceName": "openWhiteboard", + "displayName": "Open Whiteboard", + "modelDescription": "Open a standalone whiteboard so the user can sketch, annotate, or submit visual context back to the agent. Treat this as a coordinate scene graph tool, not an image generation tool: seed content with initialCanvases[].seedElements using explicit positions/sizes, and rely on the returned sceneSummary for text-based reasoning over coordinates and labels. The result includes an explicit action: `approved` means use the submitted sketch as confirmed user input, while `recreateWithChanges` means the user requested revisions and you MUST address the annotated feedback and call open_whiteboard again with an updated sketch before concluding. For agent-authored starter content, prefer initialCanvases[].seedElements because Seamless Agent converts simple shapes/text into reliable whiteboard drawings. Reserve initialCanvases[].fabricState for advanced/reopen flows. If you intentionally want an empty board, set blankCanvas to true. Otherwise, provide initialCanvases so the extension does not silently open a blank board when starter content was expected.", + "canBeReferencedInPrompt": true, + "icon": "$(symbol-color)", + "inputSchema": { + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "Optional context or instructions for the whiteboard session" + }, + "title": { + "type": "string", + "description": "Optional title for the whiteboard panel" + }, + "blankCanvas": { + "type": "boolean", + "description": "Set to true only when you intentionally open an empty whiteboard. If omitted or false, provide initialCanvases with starter content." + }, + "initialCanvases": { + "type": "array", + "description": "Provide starter canvases for seeded or reopened sessions. Required unless blankCanvas is true.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name for the pre-populated canvas" + }, + "fabricState": { + "type": "string", + "description": "Advanced path for reopening sessions: serialized Fabric.js JSON. It must be valid JSON with an objects array. Prefer seedElements for new agent-authored starter sketches." + }, + "seedElements": { + "type": "array", + "description": "Preferred agent-friendly path for coordinate-first starter sketches. Provide basic shapes/text with explicit positions and Seamless Agent will convert them into Fabric.js canvas content.", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "rectangle" + ] + }, + "id": { + "type": "string", + "description": "Optional stable object id" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "strokeColor": { + "type": "string", + "description": "Optional outline color such as '#2563eb'" + }, + "fillColor": { + "type": "string", + "description": "Optional fill color such as 'rgba(37,99,235,0.18)'" + }, + "strokeWidth": { + "type": "number" + }, + "zIndex": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Optional stacking order hint. Lower values render behind higher values." + }, + "rotation": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Optional clockwise rotation in degrees." + }, + "opacity": { + "type": "number" + } + }, + "required": [ + "type", + "x", + "y", + "width", + "height" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "circle" + ] + }, + "id": { + "type": "string", + "description": "Optional stable object id" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "radius": { + "type": "number" + }, + "strokeColor": { + "type": "string" + }, + "fillColor": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "zIndex": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Optional stacking order hint. Lower values render behind higher values." + }, + "rotation": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Optional clockwise rotation in degrees." + }, + "opacity": { + "type": "number" + } + }, + "required": [ + "type", + "x", + "y", + "radius" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "triangle" + ] + }, + "id": { + "type": "string", + "description": "Optional stable object id" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "strokeColor": { + "type": "string" + }, + "fillColor": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "zIndex": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Optional stacking order hint. Lower values render behind higher values." + }, + "rotation": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Optional clockwise rotation in degrees." + }, + "opacity": { + "type": "number" + } + }, + "required": [ + "type", + "x", + "y", + "width", + "height" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "line" + ] + }, + "id": { + "type": "string", + "description": "Optional stable object id" + }, + "start": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "end": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "strokeColor": { + "type": "string" + }, + "strokeWidth": { + "type": "number" + }, + "zIndex": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Optional stacking order hint. Lower values render behind higher values." + }, + "rotation": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Optional clockwise rotation in degrees." + }, + "opacity": { + "type": "number" + } + }, + "required": [ + "type", + "start", + "end" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "id": { + "type": "string", + "description": "Optional stable object id" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "text": { + "type": "string", + "description": "Text to place on the canvas" + }, + "color": { + "type": "string", + "description": "Optional text color such as '#111827'" + }, + "fontSize": { + "type": "number" + }, + "fontWeight": { + "type": "integer", + "minimum": 100, + "maximum": 900, + "description": "Optional text weight from 100 to 900." + }, + "fontStyle": { + "type": "string", + "enum": [ + "normal", + "italic", + "oblique" + ], + "description": "Optional text style." + }, + "textAlign": { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ], + "description": "Optional text alignment." + }, + "fontFamily": { + "type": "string" + }, + "zIndex": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Optional stacking order hint. Lower values render behind higher values." + }, + "rotation": { + "type": "number", + "minimum": -360, + "maximum": 360, + "description": "Optional clockwise rotation in degrees." + }, + "opacity": { + "type": "number" + } + }, + "required": [ + "type", + "x", + "y", + "text" + ] + } + ] + } + } + }, + "required": [ + "name" + ] + } + } + } + } + }, { "name": "walkthrough_review", "tags": [ @@ -434,6 +818,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@vscode/codicons": "^0.0.43", + "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", "zod": "^4.1.13" diff --git a/package.nls.json b/package.nls.json index a28c127..81446d6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -106,9 +106,17 @@ "detail.response": "Response", "detail.noResponse": "No response", "detail.options": "Options", + "detail.whiteboard": "Whiteboard", + "detail.whiteboardContext": "Context", + "detail.whiteboardCanvases": "Canvases", + "detail.whiteboardSubmittedCanvases": "Submitted canvases", + "detail.whiteboardNoCanvases": "No canvases stored", + "detail.whiteboardSession": "Whiteboard session", + "detail.whiteboardStatus": "Status", "history.filter.all": "All", "history.filter.askUser": "Ask User", "history.filter.planReview": "Plan Review", + "history.filter.whiteboard": "Whiteboard", "history.viewDetail": "View Detail", "command.cancelPendingPlans.title": "Cancel Pending Plans", "command.showPending.title": "Show Pending Requests", @@ -116,6 +124,7 @@ "command.clearHistory.title": "Clear History", "status.closed": "Closed", "status.active": "Active", + "status.submitted": "Submitted", "errors.noSuchInteraction": "Interaction not found.", "batch.selectMode": "Select", "batch.exitSelectMode": "Cancel", @@ -131,10 +140,33 @@ "debug.sectionAskUser": "Ask User", "debug.sectionPlanReview": "Plan Review", "debug.sectionWalkthroughReview": "Walkthrough Review", + "debug.sectionWhiteboard": "Whiteboard", "debug.mockAskUser": "Plain Question", "debug.mockAskUserOptions": "Options Question", "debug.mockAskUserMultiStep": "Multi-Step Question", "debug.mockAskUserMultiStepLongText": "Multi-Step Long Text Options", "debug.mockPlanReview": "Plan Review", - "debug.mockWalkthroughReview": "Walkthrough Review" + "debug.mockWalkthroughReview": "Walkthrough Review", + "debug.mockWhiteboard": "Open Whiteboard", + "whiteboard": "Whiteboard", + "openWhiteboard": "Open Whiteboard", + "whiteboardContext": "Context/instructions for this whiteboard", + "whiteboardTitle": "Whiteboard", + "whiteboardSubmitted": "Whiteboard submitted", + "whiteboardCancelled": "Whiteboard cancelled", + "whiteboardNewCanvas": "New Canvas", + "whiteboardDeleteCanvas": "Delete Canvas", + "whiteboardSubmit": "Submit", + "whiteboardCancel": "Cancel", + "whiteboardUndo": "Undo", + "whiteboardRedo": "Redo", + "whiteboardClear": "Clear Canvas", + "whiteboardToolPen": "Pen", + "whiteboardToolHighlighter": "Highlighter", + "whiteboardToolRectangle": "Rectangle", + "whiteboardToolCircle": "Circle", + "whiteboardToolLine": "Line", + "whiteboardToolArrow": "Arrow", + "whiteboardToolText": "Text", + "whiteboardToolEraser": "Eraser" } diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index e06e79f..20180b5 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -98,9 +98,17 @@ "detail.response": "Resposta", "detail.noResponse": "Sem resposta", "detail.options": "Opções", + "detail.whiteboard": "Quadro branco", + "detail.whiteboardContext": "Contexto", + "detail.whiteboardCanvases": "Telas", + "detail.whiteboardSubmittedCanvases": "Telas enviadas", + "detail.whiteboardNoCanvases": "Nenhuma tela armazenada", + "detail.whiteboardSession": "Sessão de quadro branco", + "detail.whiteboardStatus": "Status", "history.filter.all": "Tudo", "history.filter.askUser": "Perguntar ao usuário", "history.filter.planReview": "Revisão de Plano", + "history.filter.whiteboard": "Quadro branco", "history.viewDetail": "Visualizar Detalhes", "session.recentSessions": "Sessões Recentes", "session.interactions": "{0} interações", @@ -116,6 +124,7 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", + "status.submitted": "Enviado", "errors.noSuchInteraction": "Interação não encontrada.", "batch.selectMode": "Selecionar", "batch.exitSelectMode": "Cancelar", @@ -126,5 +135,38 @@ "confirm.deleteSelected": "Tem certeza que deseja excluir {0} itens selecionados?", "console.orTypeYourOwn": "Ou digite sua própria resposta abaixo", "response.selectedOptions": "Opções selecionadas:", - "response.additionalResponse": "Resposta adicional:" + "response.additionalResponse": "Resposta adicional:", + "console.debugTools": "Depuração", + "debug.sectionAskUser": "Perguntar ao usuário", + "debug.sectionPlanReview": "Revisão de Plano", + "debug.sectionWalkthroughReview": "Revisão guiada", + "debug.sectionWhiteboard": "Quadro branco", + "debug.mockAskUser": "Pergunta simples", + "debug.mockAskUserOptions": "Pergunta com opções", + "debug.mockAskUserMultiStep": "Pergunta em várias etapas", + "debug.mockAskUserMultiStepLongText": "Pergunta em várias etapas com texto longo", + "debug.mockPlanReview": "Revisão de Plano", + "debug.mockWalkthroughReview": "Revisão guiada", + "debug.mockWhiteboard": "Abrir quadro branco", + "whiteboard": "Quadro branco", + "openWhiteboard": "Abrir quadro branco", + "whiteboardContext": "Contexto/instruções para este quadro branco", + "whiteboardTitle": "Quadro branco", + "whiteboardSubmitted": "Quadro branco enviado", + "whiteboardCancelled": "Quadro branco cancelado", + "whiteboardNewCanvas": "Nova tela", + "whiteboardDeleteCanvas": "Excluir tela", + "whiteboardSubmit": "Enviar", + "whiteboardCancel": "Cancelar", + "whiteboardUndo": "Desfazer", + "whiteboardRedo": "Refazer", + "whiteboardClear": "Limpar tela", + "whiteboardToolPen": "Caneta", + "whiteboardToolHighlighter": "Marca-texto", + "whiteboardToolRectangle": "Retângulo", + "whiteboardToolCircle": "Círculo", + "whiteboardToolLine": "Linha", + "whiteboardToolArrow": "Seta", + "whiteboardToolText": "Texto", + "whiteboardToolEraser": "Borracha" } diff --git a/package.nls.pt.json b/package.nls.pt.json index 9c2f946..5072eb1 100644 --- a/package.nls.pt.json +++ b/package.nls.pt.json @@ -106,9 +106,17 @@ "detail.response": "Resposta", "detail.noResponse": "Sem resposta", "detail.options": "Opções", + "detail.whiteboard": "Quadro branco", + "detail.whiteboardContext": "Contexto", + "detail.whiteboardCanvases": "Telas", + "detail.whiteboardSubmittedCanvases": "Telas submetidas", + "detail.whiteboardNoCanvases": "Nenhuma tela armazenada", + "detail.whiteboardSession": "Sessão de quadro branco", + "detail.whiteboardStatus": "Estado", "history.filter.all": "Tudo", "history.filter.askUser": "Perguntar ao utilizador", "history.filter.planReview": "Revisão de Plano", + "history.filter.whiteboard": "Quadro branco", "history.viewDetail": "Ver Detalhes", "command.cancelPendingPlans.title": "Cancelar Planos Pendentes", "command.showPending.title": "Ver Pendentes", @@ -116,6 +124,7 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", + "status.submitted": "Submetido", "errors.noSuchInteraction": "Interação não encontrada.", "batch.selectMode": "Selecionar", "batch.exitSelectMode": "Cancelar", @@ -126,5 +135,38 @@ "confirm.deleteSelected": "Tem a certeza que deseja eliminar {0} itens selecionados?", "console.orTypeYourOwn": "Ou escreva a sua própria resposta abaixo", "response.selectedOptions": "Opções selecionadas:", - "response.additionalResponse": "Resposta adicional:" + "response.additionalResponse": "Resposta adicional:", + "console.debugTools": "Depuração", + "debug.sectionAskUser": "Perguntar ao utilizador", + "debug.sectionPlanReview": "Revisão de Plano", + "debug.sectionWalkthroughReview": "Revisão guiada", + "debug.sectionWhiteboard": "Quadro branco", + "debug.mockAskUser": "Pergunta simples", + "debug.mockAskUserOptions": "Pergunta com opções", + "debug.mockAskUserMultiStep": "Pergunta em várias etapas", + "debug.mockAskUserMultiStepLongText": "Pergunta em várias etapas com texto longo", + "debug.mockPlanReview": "Revisão de Plano", + "debug.mockWalkthroughReview": "Revisão guiada", + "debug.mockWhiteboard": "Abrir quadro branco", + "whiteboard": "Quadro branco", + "openWhiteboard": "Abrir quadro branco", + "whiteboardContext": "Contexto/instruções para este quadro branco", + "whiteboardTitle": "Quadro branco", + "whiteboardSubmitted": "Quadro branco submetido", + "whiteboardCancelled": "Quadro branco cancelado", + "whiteboardNewCanvas": "Nova tela", + "whiteboardDeleteCanvas": "Eliminar tela", + "whiteboardSubmit": "Submeter", + "whiteboardCancel": "Cancelar", + "whiteboardUndo": "Desfazer", + "whiteboardRedo": "Refazer", + "whiteboardClear": "Limpar tela", + "whiteboardToolPen": "Caneta", + "whiteboardToolHighlighter": "Marcador", + "whiteboardToolRectangle": "Retângulo", + "whiteboardToolCircle": "Círculo", + "whiteboardToolLine": "Linha", + "whiteboardToolArrow": "Seta", + "whiteboardToolText": "Texto", + "whiteboardToolEraser": "Borracha" } diff --git a/src/localization.ts b/src/localization.ts index dead4e5..beb422a 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -139,11 +139,19 @@ export const strings = { get response() { return localize('detail.response'); }, get noResponse() { return localize('detail.noResponse'); }, get options() { return localize('detail.options'); }, + get detailWhiteboard() { return localize('detail.whiteboard'); }, + get detailWhiteboardContext() { return localize('detail.whiteboardContext'); }, + get detailWhiteboardCanvases() { return localize('detail.whiteboardCanvases'); }, + get detailWhiteboardSubmittedCanvases() { return localize('detail.whiteboardSubmittedCanvases'); }, + get detailWhiteboardNoCanvases() { return localize('detail.whiteboardNoCanvases'); }, + get detailWhiteboardSession() { return localize('detail.whiteboardSession'); }, + get detailWhiteboardStatus() { return localize('detail.whiteboardStatus'); }, // History filters get historyFilterAll() { return localize('history.filter.all'); }, get historyFilterAskUser() { return localize('history.filter.askUser'); }, get historyFilterPlanReview() { return localize('history.filter.planReview'); }, + get historyFilterWhiteboard() { return localize('history.filter.whiteboard'); }, // Attachments / images get attachmentNoFilesFound() { return localize('attachment.noFilesFound'); }, @@ -178,12 +186,38 @@ export const strings = { get debugSectionAskUser() { return localize('debug.sectionAskUser'); }, get debugSectionPlanReview() { return localize('debug.sectionPlanReview'); }, get debugSectionWalkthroughReview() { return localize('debug.sectionWalkthroughReview'); }, + get debugSectionWhiteboard() { return localize('debug.sectionWhiteboard'); }, get debugMockAskUser() { return localize('debug.mockAskUser'); }, get debugMockAskUserOptions() { return localize('debug.mockAskUserOptions'); }, get debugMockAskUserMultiStep() { return localize('debug.mockAskUserMultiStep'); }, get debugMockAskUserMultiStepLongText() { return localize('debug.mockAskUserMultiStepLongText'); }, get debugMockPlanReview() { return localize('debug.mockPlanReview'); }, get debugMockWalkthroughReview() { return localize('debug.mockWalkthroughReview'); }, + get debugMockWhiteboard() { return localize('debug.mockWhiteboard'); }, + get submitted() { return localize('status.submitted'); }, + + // Whiteboard + get whiteboard() { return localize('whiteboard'); }, + get openWhiteboard() { return localize('openWhiteboard'); }, + get whiteboardContext() { return localize('whiteboardContext'); }, + get whiteboardTitle() { return localize('whiteboardTitle'); }, + get whiteboardSubmitted() { return localize('whiteboardSubmitted'); }, + get whiteboardCancelled() { return localize('whiteboardCancelled'); }, + get whiteboardNewCanvas() { return localize('whiteboardNewCanvas'); }, + get whiteboardDeleteCanvas() { return localize('whiteboardDeleteCanvas'); }, + get whiteboardSubmit() { return localize('whiteboardSubmit'); }, + get whiteboardCancel() { return localize('whiteboardCancel'); }, + get whiteboardUndo() { return localize('whiteboardUndo'); }, + get whiteboardRedo() { return localize('whiteboardRedo'); }, + get whiteboardClear() { return localize('whiteboardClear'); }, + get whiteboardToolPen() { return localize('whiteboardToolPen'); }, + get whiteboardToolHighlighter() { return localize('whiteboardToolHighlighter'); }, + get whiteboardToolRectangle() { return localize('whiteboardToolRectangle'); }, + get whiteboardToolCircle() { return localize('whiteboardToolCircle'); }, + get whiteboardToolLine() { return localize('whiteboardToolLine'); }, + get whiteboardToolArrow() { return localize('whiteboardToolArrow'); }, + get whiteboardToolText() { return localize('whiteboardToolText'); }, + get whiteboardToolEraser() { return localize('whiteboardToolEraser'); }, // Errors get noSuchInteraction() { return localize('error.noSuchInteraction'); }, diff --git a/src/mcp/mcpServer.test.ts b/src/mcp/mcpServer.test.ts new file mode 100644 index 0000000..58165d6 --- /dev/null +++ b/src/mcp/mcpServer.test.ts @@ -0,0 +1,411 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { z } from 'zod'; + +import { WhiteboardInputSchema } from '../tools/schemas'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +type RegisteredTool = { + name: string; + config: { + inputSchema: z.ZodTypeAny; + }; + handler: (args: unknown, context: { signal?: AbortSignal }) => Promise; +}; + +function summarizeSchemaResult(schema: z.ZodTypeAny, input: unknown) { + const result = schema.safeParse(input); + if (result.success) { + return { + success: true as const, + data: result.data, + }; + } + + return { + success: false as const, + error: result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), + }; +} + +describe('McpServerManager open_whiteboard registration', () => { + const modulePath = require.resolve('./mcpServer.ts'); + let originalLoad: typeof Module._load; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + async function loadHarness(options: { + openWhiteboard?: (params: unknown) => Promise; + } = {}) { + const registeredTools: RegisteredTool[] = []; + + class MockMcpServer { + registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) { + registeredTools.push({ name, config, handler }); + } + + async connect() { + return undefined; + } + + async close() { + return undefined; + } + } + + class MockStreamableHTTPServerTransport { + constructor(_options: unknown) { } + + async handleRequest() { + return undefined; + } + } + + const httpMock = { + createServer() { + const server = { + listen(_port: number, _host: string, callback?: () => void) { + callback?.(); + }, + address() { + return { port: 43123 }; + }, + close(callback?: () => void) { + callback?.(); + }, + on() { + return server; + } + }; + + return server; + } + }; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + CancellationTokenSource: class { + token = { isCancellationRequested: false }; + cancel() { + this.token.isCancellationRequested = true; + } + }, + window: { + showErrorMessage() { }, + showInformationMessage() { }, + } + }; + } + + if (request === 'http') { + return httpMock; + } + + if (request === 'fs') { + return { + existsSync() { + return false; + }, + mkdirSync() { }, + readFileSync() { + throw new Error('not implemented'); + }, + writeFileSync() { }, + }; + } + + if (request === 'os') { + return { + homedir() { + return '/tmp'; + } + }; + } + + if (request === 'crypto') { + return { + randomUUID() { + return 'uuid'; + } + }; + } + + if (request === '@modelcontextprotocol/sdk/server/mcp.js') { + return { + McpServer: MockMcpServer, + }; + } + + if (request === '@modelcontextprotocol/sdk/server/streamableHttp.js') { + return { + StreamableHTTPServerTransport: MockStreamableHTTPServerTransport, + }; + } + + if (request === '../tools') { + return { + askUser: async () => ({ responded: true, response: 'ok', attachments: [] }), + openWhiteboard: options.openWhiteboard ?? (async () => ({ + submitted: false, + canvases: [], + interactionId: 'wb_test', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + })), + planReviewApproval: async () => ({ status: 'approved', requiredRevisions: [], reviewId: 'review_1' }), + walkthroughReview: async () => ({ status: 'acknowledged', requiredRevisions: [], reviewId: 'review_2' }), + }; + } + + if (request === '../logging') { + return { + Logger: { + log() { }, + warn() { }, + error() { }, + } + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { McpServerManager } = require('./mcpServer.ts') as typeof import('./mcpServer'); + const manager = new McpServerManager({} as any, {} as any); + await manager.start(); + + const openWhiteboardTool = registeredTools.find((tool) => tool.name === 'open_whiteboard'); + assert.ok(openWhiteboardTool, 'Expected open_whiteboard MCP tool to be registered'); + + return { + openWhiteboardTool, + }; + } + + it('accepts seedElements in the MCP schema and forwards parsed seeded canvases unchanged', async () => { + const receivedCalls: unknown[] = []; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard(params) { + receivedCalls.push(params); + return { + submitted: false, + canvases: [], + interactionId: 'wb_seeded', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }; + } + }); + + const seededInput = { + title: 'Seeded whiteboard', + context: 'Sketch a basic flow.', + initialCanvases: [ + { + name: 'Sketch 1', + seedElements: [ + { + type: 'rectangle', + x: 40, + y: 60, + width: 220, + height: 120, + strokeColor: '#2563eb', + }, + { + type: 'text', + x: 72, + y: 96, + text: 'Start', + } + ] + } + ] + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, seededInput), + summarizeSchemaResult(WhiteboardInputSchema, seededInput), + ); + + await openWhiteboardTool.handler(seededInput, {}); + + assert.deepStrictEqual(receivedCalls, [seededInput]); + }); + + it('accepts explicit blankCanvas requests and forwards them unchanged', async () => { + const receivedCalls: unknown[] = []; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard(params) { + receivedCalls.push(params); + return { + submitted: false, + canvases: [], + interactionId: 'wb_blank', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }; + } + }); + + const blankInput = { + title: 'Blank whiteboard', + context: 'Start from scratch.', + blankCanvas: true, + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, blankInput), + summarizeSchemaResult(WhiteboardInputSchema, blankInput), + ); + + await openWhiteboardTool.handler(blankInput, {}); + + assert.deepStrictEqual(receivedCalls, [blankInput]); + }); + + it('rejects invalid seeded input before calling openWhiteboard', async () => { + let openWhiteboardCalls = 0; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard() { + openWhiteboardCalls += 1; + return { + submitted: false, + canvases: [], + interactionId: 'wb_invalid_seed', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }; + } + }); + + const invalidSeededInput = { + title: 'Broken seed', + initialCanvases: [ + { + name: 'Broken canvas', + seedElements: [ + { + type: 'text', + x: 10, + y: 20, + text: '', + } + ] + } + ] + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidSeededInput), + summarizeSchemaResult(WhiteboardInputSchema, invalidSeededInput), + ); + + await assert.rejects( + () => openWhiteboardTool.handler(invalidSeededInput, {}), + /Seed text cannot be empty/, + ); + assert.strictEqual(openWhiteboardCalls, 0); + }); + + it('rejects empty fabricState strings before runtime and never calls openWhiteboard', async () => { + let openWhiteboardCalls = 0; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard() { + openWhiteboardCalls += 1; + return { + submitted: false, + canvases: [], + interactionId: 'wb_invalid_fabric', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }; + } + }); + + const invalidFabricStateInput = { + title: 'Broken fabric seed', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: '', + } + ] + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidFabricStateInput), + summarizeSchemaResult(WhiteboardInputSchema, invalidFabricStateInput), + ); + + await assert.rejects( + () => openWhiteboardTool.handler(invalidFabricStateInput, {}), + /Canvas fabricState cannot be empty/, + ); + assert.strictEqual(openWhiteboardCalls, 0); + }); + + it('rejects implicit blank requests before calling openWhiteboard', async () => { + let openWhiteboardCalls = 0; + const { openWhiteboardTool } = await loadHarness({ + async openWhiteboard() { + openWhiteboardCalls += 1; + return { + submitted: false, + canvases: [], + interactionId: 'wb_implicit_blank', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }; + } + }); + + const implicitBlankInput = { + title: 'Implicit blank whiteboard', + context: 'Start from scratch.', + }; + + assert.deepStrictEqual( + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, implicitBlankInput), + summarizeSchemaResult(WhiteboardInputSchema, implicitBlankInput), + ); + + await assert.rejects( + () => openWhiteboardTool.handler(implicitBlankInput, {}), + /Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard/, + ); + assert.strictEqual(openWhiteboardCalls, 0); + }); +}); diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index 9e6ce0c..201be8a 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -8,7 +8,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { AgentInteractionProvider } from '../webview/webviewProvider'; -import { askUser, planReviewApproval, walkthroughReview } from '../tools'; +import { askUser, openWhiteboard, planReviewApproval, walkthroughReview } from '../tools'; +import { parseWhiteboardInput, WhiteboardInputSchema } from '../tools/schemas'; import { Logger } from '../logging'; export class McpServerManager { @@ -155,6 +156,38 @@ export class McpServerManager { } ); + // Register open_whiteboard tool + this.mcpServer.registerTool( + "open_whiteboard", + { + inputSchema: WhiteboardInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + const tokenSource = new vscode.CancellationTokenSource(); + if (signal) { + signal.onabort = () => tokenSource.cancel(); + } + + const params = parseWhiteboardInput(args); + + const result = await openWhiteboard( + params, + this.context, + this.provider, + tokenSource.token + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ] + }; + } + ); + // Register walkthrough_review tool (explicit: walkthrough review mode) this.mcpServer.registerTool( "walkthrough_review", diff --git a/src/storage/chatHistoryStorage.test.ts b/src/storage/chatHistoryStorage.test.ts new file mode 100644 index 0000000..083a347 --- /dev/null +++ b/src/storage/chatHistoryStorage.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +type MementoStore = { + get(key: string, defaultValue?: T): T; + update(key: string, value: unknown): void; +}; + +function createMemento(initialState: Record = {}): MementoStore { + const state = new Map(Object.entries(initialState)); + return { + get(key: string, defaultValue?: T): T { + return (state.has(key) ? state.get(key) : defaultValue) as T; + }, + update(key: string, value: unknown): void { + state.set(key, value); + }, + }; +} + +function createExtensionContext(initialState: Record = {}) { + return { + workspaceState: createMemento(initialState), + globalState: createMemento(), + }; +} + +describe('ChatHistoryStorage whiteboard helpers', () => { + const modulePath = require.resolve('./chatHistoryStorage.ts'); + let originalLoad: typeof Module._load; + let storageContext: 'workspace' | 'global' = 'workspace'; + let warnLog: string[]; + let errorLog: string[]; + + beforeEach(() => { + originalLoad = Module._load; + storageContext = 'workspace'; + warnLog = []; + errorLog = []; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + function loadChatHistoryStorage() { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + workspace: { + getConfiguration() { + return { + get(_key: string, defaultValue?: T) { + return (storageContext as T) ?? defaultValue; + }, + }; + }, + onDidChangeConfiguration() { + return { + dispose() { }, + }; + }, + fs: { + async writeFile() { }, + }, + workspaceFolders: undefined, + }, + window: { + showErrorMessage() { }, + showInformationMessage() { }, + }, + Uri: { + file(fsPath: string) { + return { fsPath }; + }, + joinPath(base: { fsPath: string }, ...parts: string[]) { + return { fsPath: [base.fsPath, ...parts].join('/') }; + }, + }, + }; + } + + if (request === '../config/storage') { + return { + getStorageContext() { + return storageContext; + }, + }; + } + + if (request === '../logging') { + return { + Logger: { + warn(message: string) { + warnLog.push(message); + }, + error(message: string) { + errorLog.push(message); + }, + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + return require('./chatHistoryStorage.ts') as typeof import('./chatHistoryStorage'); + } + + function createCanvas(id: string, fabricState = '{"version":"6.0.0","objects":[]}') { + return { + id, + name: `Canvas ${id}`, + fabricState, + createdAt: 100, + updatedAt: 100, + }; + } + + it('saves and reads whiteboard sessions through dedicated helpers', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = storage.saveWhiteboardInteraction({ + title: 'Architecture sketch', + context: 'Map the service boundaries', + canvases: [createCanvas('canvas_1')], + activeCanvasId: 'canvas_1', + status: 'pending', + }); + + const session = storage.getWhiteboardSession(interactionId); + + assert.equal(session?.interactionId, interactionId); + assert.equal(session?.title, 'Architecture sketch'); + assert.equal(session?.context, 'Map the service boundaries'); + assert.equal(session?.activeCanvasId, 'canvas_1'); + assert.deepEqual(session?.canvases, [createCanvas('canvas_1')]); + assert.equal(session?.status, 'pending'); + }); + + it('updates a whiteboard session without clobbering stored canvases', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = storage.saveWhiteboardInteraction({ + title: 'Architecture sketch', + canvases: [createCanvas('canvas_1')], + activeCanvasId: 'canvas_1', + status: 'pending', + }); + + storage.updateWhiteboardSession(interactionId, { + status: 'approved', + submittedAt: 1234, + submittedCanvases: [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + ], + }); + + const session = storage.getWhiteboardSession(interactionId); + + assert.equal(session?.status, 'approved'); + assert.equal(session?.submittedAt, 1234); + assert.deepEqual(session?.submittedCanvases, [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + ]); + assert.deepEqual(session?.canvases, [createCanvas('canvas_1')]); + assert.equal(session?.activeCanvasId, 'canvas_1'); + }); + + it('cleans up stale abandoned whiteboard sessions but preserves approved history', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { now: () => now }) as InstanceType; + + const staleInteractionId = storage.saveWhiteboardInteraction({ + title: 'Abandoned board', + canvases: [createCanvas('stale_canvas')], + activeCanvasId: 'stale_canvas', + status: 'pending', + }); + storage.updateInteraction(staleInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const submittedInteractionId = storage.saveWhiteboardInteraction({ + title: 'Approved board', + canvases: [createCanvas('submitted_canvas')], + activeCanvasId: 'submitted_canvas', + status: 'approved', + submittedAt: now - 1000, + submittedCanvases: [ + { + id: 'submitted_canvas', + name: 'Canvas submitted_canvas', + imageUri: 'file:///tmp/submitted.png', + }, + ], + }); + storage.updateInteraction(submittedInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const recentInteractionId = storage.saveWhiteboardInteraction({ + title: 'Recent board', + canvases: [createCanvas('recent_canvas')], + activeCanvasId: 'recent_canvas', + status: 'pending', + }); + + storage.cleanupOldWhiteboardSessions(); + + assert.equal(storage.getInteraction(staleInteractionId), undefined); + assert.ok(storage.getInteraction(submittedInteractionId)); + assert.ok(storage.getInteraction(recentInteractionId)); + }); + + it('triggers stale-session cleanup when whiteboard storage nears the quota threshold', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { + now: () => now, + maxStorageBytes: 2400, + quotaCleanupThreshold: 0.5, + }) as InstanceType; + + const oversizedState = JSON.stringify({ + version: '6.0.0', + objects: [ + { + type: 'textbox', + text: 'x'.repeat(900), + }, + ], + }); + + const staleInteractionId = storage.saveWhiteboardInteraction({ + title: 'Old large board', + canvases: [createCanvas('stale_canvas', oversizedState)], + activeCanvasId: 'stale_canvas', + status: 'pending', + }); + storage.updateInteraction(staleInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + const freshInteractionId = storage.saveWhiteboardInteraction({ + title: 'Fresh board', + canvases: [createCanvas('fresh_canvas')], + activeCanvasId: 'fresh_canvas', + status: 'pending', + }); + + assert.equal(storage.getInteraction(staleInteractionId), undefined); + assert.ok(storage.getInteraction(freshInteractionId)); + assert.match(warnLog.join('\n'), /quota/i); + }); + + it('refuses to persist oversized whiteboard payloads when cleanup cannot get below the storage quota', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { + maxStorageBytes: 1200, + quotaCleanupThreshold: 0.5, + }) as InstanceType; + + const baselineInteractionId = storage.saveWhiteboardInteraction({ + title: 'Baseline board', + canvases: [createCanvas('baseline_canvas')], + activeCanvasId: 'baseline_canvas', + status: 'pending', + }); + + const oversizedState = JSON.stringify({ + version: '6.0.0', + objects: [ + { + type: 'textbox', + text: 'y'.repeat(1500), + }, + ], + }); + + assert.throws(() => { + storage.saveWhiteboardInteraction({ + title: 'Oversized board', + canvases: [createCanvas('oversized_canvas', oversizedState)], + activeCanvasId: 'oversized_canvas', + status: 'pending', + }); + }, /quota exceeded/i); + + assert.ok(storage.getInteraction(baselineInteractionId)); + assert.equal(storage.getInteractionsByType('whiteboard').length, 1); + assert.match(warnLog.join('\n'), /quota threshold reached/i); + assert.match(errorLog.join('\n'), /quota exceeded/i); + }); + + it('round-trips multi-canvas submissions through storage helper updates', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const context = createExtensionContext(); + const storage = new ChatHistoryStorage(context as any); + + const interactionId = storage.saveWhiteboardInteraction({ + title: 'Architecture board', + canvases: [createCanvas('canvas_1'), createCanvas('canvas_2')], + activeCanvasId: 'canvas_2', + status: 'pending', + }); + + storage.updateWhiteboardInteraction(interactionId, { + title: 'Architecture board v2', + whiteboardSession: { + status: 'approved', + submittedAt: 5678, + submittedCanvases: [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + { + id: 'canvas_2', + name: 'Canvas canvas_2', + imageUri: 'file:///tmp/canvas-2.png', + }, + ], + }, + }); + + const interaction = storage.getInteraction(interactionId); + assert.equal(interaction?.title, 'Architecture board v2'); + assert.equal(interaction?.whiteboardSession?.status, 'approved'); + assert.equal(interaction?.whiteboardSession?.submittedAt, 5678); + assert.deepEqual(interaction?.whiteboardSession?.canvases, [createCanvas('canvas_1'), createCanvas('canvas_2')]); + assert.equal(interaction?.whiteboardSession?.activeCanvasId, 'canvas_2'); + assert.deepEqual(interaction?.whiteboardSession?.submittedCanvases, [ + { + id: 'canvas_1', + name: 'Canvas canvas_1', + imageUri: 'file:///tmp/canvas-1.png', + }, + { + id: 'canvas_2', + name: 'Canvas canvas_2', + imageUri: 'file:///tmp/canvas-2.png', + }, + ]); + assert.deepEqual(storage.getCompletedInteractions().map((entry) => entry.id), [interactionId]); + }); + + it('preserves cancelled whiteboard history during stale-session cleanup', () => { + const { ChatHistoryStorage } = loadChatHistoryStorage(); + const now = Date.UTC(2026, 2, 7, 12, 0, 0); + const context = createExtensionContext(); + const storage = new (ChatHistoryStorage as any)(context, { now: () => now }) as InstanceType; + + const cancelledInteractionId = storage.saveWhiteboardInteraction({ + title: 'Cancelled board', + canvases: [createCanvas('cancelled_canvas')], + activeCanvasId: 'cancelled_canvas', + status: 'cancelled', + }); + storage.updateInteraction(cancelledInteractionId, { + timestamp: now - (8 * 24 * 60 * 60 * 1000), + }); + + storage.cleanupOldWhiteboardSessions(); + + assert.ok(storage.getInteraction(cancelledInteractionId)); + }); +}); diff --git a/src/storage/chatHistoryStorage.ts b/src/storage/chatHistoryStorage.ts index 8ea0d89..4fe7c5c 100644 --- a/src/storage/chatHistoryStorage.ts +++ b/src/storage/chatHistoryStorage.ts @@ -1,6 +1,16 @@ import * as vscode from 'vscode'; -import type { RequiredPlanRevisions, StoredInteraction } from '../webview/types'; +import { + isCompletedStoredInteraction, + isPendingStoredInteraction, + type RequiredPlanRevisions, + type StoredInteraction, + type WhiteboardCanvas, + type WhiteboardSession, + type WhiteboardSessionStatus, + type WhiteboardSubmittedCanvas, +} from '../webview/types'; import { getStorageContext } from '../config/storage'; +import { Logger } from '../logging'; /** * Storage keys for global state @@ -9,6 +19,32 @@ const STORAGE_KEYS = { INTERACTIONS: 'seamless-agent.interactions', }; +const DEFAULT_WHITEBOARD_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_STORAGE_QUOTA_BYTES = 100 * 1024 * 1024; +const DEFAULT_STORAGE_QUOTA_THRESHOLD = 0.9; + +export interface ChatHistoryStorageOptions { + now?: () => number; + whiteboardSessionMaxAgeMs?: number; + maxStorageBytes?: number; + quotaCleanupThreshold?: number; +} + +export class StorageQuotaExceededError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorageQuotaExceededError'; + } +} + + +/** + * Whiteboard sessions should be stored on StoredInteraction.whiteboardSession so + * session-scoped canvases follow the same workspace/global storage lifecycle as + * ask_user and plan_review interactions. Dedicated whiteboard save/update helpers + * will build on this shared interaction record in a later task. + */ + /** * Manages persistence of interactions * Uses VS Code's globalState for cross-session persistence @@ -17,10 +53,17 @@ const STORAGE_KEYS = { export class ChatHistoryStorage { private context: vscode.ExtensionContext; private config: vscode.WorkspaceConfiguration; + private readonly options: Required; - constructor(context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext, options: ChatHistoryStorageOptions = {}) { this.context = context; this.config = vscode.workspace.getConfiguration('seamless-agent'); + this.options = { + now: options.now ?? (() => Date.now()), + whiteboardSessionMaxAgeMs: options.whiteboardSessionMaxAgeMs ?? DEFAULT_WHITEBOARD_SESSION_MAX_AGE_MS, + maxStorageBytes: options.maxStorageBytes ?? DEFAULT_STORAGE_QUOTA_BYTES, + quotaCleanupThreshold: options.quotaCleanupThreshold ?? DEFAULT_STORAGE_QUOTA_THRESHOLD, + }; } // ======================== @@ -39,7 +82,7 @@ export class ChatHistoryStorage { * Get all interactions, sorted by timestamp (most recent first) */ getAllInteractions(): StoredInteraction[] { - const interactions = this.storage.get(STORAGE_KEYS.INTERACTIONS, []); + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; return interactions.sort((a, b) => b.timestamp - a.timestamp); } @@ -57,7 +100,7 @@ export class ChatHistoryStorage { **/ getPendingInteraction(interactionId: string): StoredInteraction | undefined { const interaction = this.getInteraction(interactionId); - if (interaction?.status === 'pending') { + if (interaction && isPendingStoredInteraction(interaction)) { return interaction; } } @@ -122,11 +165,48 @@ export class ChatHistoryStorage { return interactionId; } + /** + * Save a new whiteboard interaction + */ + saveWhiteboardInteraction(data: { + title?: string; + context?: string; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + isDebug?: boolean; + }): string { + const interactionId = this.generateId('wb'); + const interaction: StoredInteraction = { + id: interactionId, + type: 'whiteboard', + timestamp: Date.now(), + title: data.title, + isDebug: data.isDebug, + whiteboardSession: { + id: interactionId, + interactionId, + context: data.context, + title: data.title, + canvases: data.canvases || [], + activeCanvasId: data.activeCanvasId, + status: data.status || 'pending', + submittedAt: data.submittedAt, + submittedCanvases: data.submittedCanvases, + }, + }; + + this.saveInteraction(interaction); + return interactionId; + } + /** * Save an interaction to storage */ private saveInteraction(interaction: StoredInteraction): void { - const interactions = this.storage.get(STORAGE_KEYS.INTERACTIONS, []); + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; const existingIndex = interactions.findIndex(i => i.id === interaction.id); if (existingIndex >= 0) { @@ -135,7 +215,7 @@ export class ChatHistoryStorage { interactions.push(interaction); } - this.storage.update(STORAGE_KEYS.INTERACTIONS, interactions); + this.storage.update(STORAGE_KEYS.INTERACTIONS, this.prepareInteractionsForStorage(interactions)); } /** @@ -149,6 +229,76 @@ export class ChatHistoryStorage { } } + /** + * Update an existing whiteboard interaction by merging whiteboard session fields. + */ + updateWhiteboardInteraction(interactionId: string, updates: { + title?: string; + whiteboardSession?: Partial>; + }): void { + const interaction = this.getInteraction(interactionId); + if (!interaction) { + Logger.warn(`Cannot update missing whiteboard interaction: ${interactionId}`); + return; + } + + if (interaction.type !== 'whiteboard') { + Logger.warn(`Cannot update non-whiteboard interaction as whiteboard: ${interactionId}`); + return; + } + + const updatedSession = updates.whiteboardSession + ? { + ...(interaction.whiteboardSession || { + id: interactionId, + interactionId, + canvases: [], + status: 'pending' as const, + }), + ...updates.whiteboardSession, + } + : interaction.whiteboardSession; + + this.saveInteraction({ + ...interaction, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updatedSession ? { whiteboardSession: updatedSession } : {}), + }); + } + + /** + * Get a whiteboard session by interaction ID. + */ + getWhiteboardSession(interactionId: string): WhiteboardSession | undefined { + const interaction = this.getInteraction(interactionId); + if (!interaction || interaction.type !== 'whiteboard') { + return undefined; + } + + return interaction.whiteboardSession; + } + + /** + * Update the stored whiteboard session for a whiteboard interaction. + */ + updateWhiteboardSession(interactionId: string, updates: Partial): void { + this.updateWhiteboardInteraction(interactionId, { + whiteboardSession: updates, + }); + } + + /** + * Remove stale whiteboard sessions that were abandoned and never submitted. + */ + cleanupOldWhiteboardSessions(): void { + const interactions = [...this.storage.get(STORAGE_KEYS.INTERACTIONS, [])]; + const cleanedInteractions = this.pruneOldWhiteboardSessions(interactions); + + if (cleanedInteractions.length !== interactions.length) { + this.storage.update(STORAGE_KEYS.INTERACTIONS, cleanedInteractions); + } + } + /** * Delete an interaction */ @@ -173,7 +323,7 @@ export class ChatHistoryStorage { clearAll(): void { const allInteractions = this.getAllInteractions(); // Keep only pending interactions - they should only be cancelled via command - const pendingInteractions = allInteractions.filter(i => i.status === 'pending'); + const pendingInteractions = allInteractions.filter(isPendingStoredInteraction); this.storage.update(STORAGE_KEYS.INTERACTIONS, pendingInteractions); } @@ -189,18 +339,26 @@ export class ChatHistoryStorage { .filter(i => i.type === 'plan_review' && i.status === 'pending'); } + /** + * Get all pending whiteboard interactions. + */ + getPendingWhiteboards(): StoredInteraction[] { + return this.getAllInteractions() + .filter(i => i.type === 'whiteboard' && isPendingStoredInteraction(i)); + } + /** * Get all completed interactions (not pending) */ getCompletedInteractions(): StoredInteraction[] { return this.getAllInteractions() - .filter(i => i.type === 'ask_user' || (i.type === 'plan_review' && i.status !== 'pending')); + .filter(isCompletedStoredInteraction); } /** * Get interactions by type */ - getInteractionsByType(type: 'ask_user' | 'plan_review'): StoredInteraction[] { + getInteractionsByType(type: 'ask_user' | 'plan_review' | 'whiteboard'): StoredInteraction[] { return this.getAllInteractions().filter(i => i.type === type); } @@ -283,15 +441,80 @@ export class ChatHistoryStorage { pendingReviews: this.getPendingPlanReviews().length, }; } + + private prepareInteractionsForStorage(interactions: StoredInteraction[]): StoredInteraction[] { + const totalSize = this.getSerializedSize(interactions); + if (totalSize <= this.getQuotaCleanupThresholdBytes()) { + return interactions; + } + + const cleanedInteractions = this.pruneOldWhiteboardSessions(interactions); + const removedCount = interactions.length - cleanedInteractions.length; + + if (removedCount > 0) { + Logger.warn( + `Whiteboard storage quota threshold reached (${totalSize} bytes); cleaned ${removedCount} stale session(s).` + ); + } else { + Logger.warn( + `Whiteboard storage quota threshold reached (${totalSize} bytes); no stale whiteboard sessions were eligible for cleanup.` + ); + } + + const cleanedSize = this.getSerializedSize(cleanedInteractions); + if (cleanedSize > this.options.maxStorageBytes) { + const message = `Whiteboard storage quota exceeded (${cleanedSize} bytes after cleanup; limit ${this.options.maxStorageBytes}); refusing to persist oversized payload.`; + Logger.error(message); + throw new StorageQuotaExceededError(message); + } + + return cleanedInteractions; + } + + private pruneOldWhiteboardSessions(interactions: StoredInteraction[]): StoredInteraction[] { + const staleBefore = this.options.now() - this.options.whiteboardSessionMaxAgeMs; + return interactions.filter((interaction) => !this.shouldCleanupWhiteboardInteraction(interaction, staleBefore)); + } + + private shouldCleanupWhiteboardInteraction(interaction: StoredInteraction, staleBefore: number): boolean { + if (interaction.type !== 'whiteboard') { + return false; + } + + if (interaction.timestamp >= staleBefore) { + return false; + } + + const session = interaction.whiteboardSession; + if (!session) { + return true; + } + + if (session.status === 'approved' || session.status === 'recreateWithChanges' || session.status === 'cancelled') { + return false; + } + + return (session.submittedCanvases?.length ?? 0) === 0; + } + + private getQuotaCleanupThresholdBytes(): number { + return Math.floor(this.options.maxStorageBytes * this.options.quotaCleanupThreshold); + } + + private getSerializedSize(interactions: StoredInteraction[]): number { + return JSON.stringify(interactions).length; + } } // Singleton instance let storageInstance: ChatHistoryStorage | undefined; +let extensionContextInstance: vscode.ExtensionContext | undefined; /** * Initialize the storage with extension context */ export function initializeChatHistoryStorage(context: vscode.ExtensionContext): ChatHistoryStorage { + extensionContextInstance = context; storageInstance = new ChatHistoryStorage(context); return storageInstance; } @@ -305,3 +528,10 @@ export function getChatHistoryStorage(): ChatHistoryStorage { } return storageInstance; } + +export function getExtensionContext(): vscode.ExtensionContext { + if (!extensionContextInstance) { + throw new Error('Extension context not initialized. Call initializeChatHistoryStorage first.'); + } + return extensionContextInstance; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 730273e..cc01f9c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ export * from './schemas'; // Re-export tool functions export { askUser } from './askUser'; export { planReview, planReviewApproval, walkthroughReview } from './planReview'; +export { openWhiteboard } from './openWhiteboard'; // Re-export utils export * from './utils'; @@ -16,16 +17,20 @@ export * from './utils'; // Import for internal use import { askUser } from './askUser'; import { planReviewApproval, walkthroughReview } from './planReview'; +import { openWhiteboard } from './openWhiteboard'; import { readFileAsBuffer, getImageMimeType, validateImageMagicNumber } from './utils'; +import { createWhiteboardLanguageModelResultParts } from './whiteboardToolResult'; import { AskUserInput, ApprovePlanInput, PlanReviewInput, WalkthroughReviewInput, + WhiteboardInput, parseAskUserInput, parseApprovePlanInput, parsePlanReviewInput, parseWalkthroughReviewInput, + parseWhiteboardInput, } from './schemas'; import { Logger } from '../logging'; @@ -209,6 +214,38 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: } }); + // Register the open_whiteboard tool (standalone whiteboard) + const openWhiteboardTool = vscode.lm.registerTool('open_whiteboard', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: WhiteboardInput; + try { + params = parseWhiteboardInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + submitted: false, + canvases: [], + interactionId: '', + error: `Validation error: ${errorMessage}` + })) + ]); + } + + const result = await openWhiteboard(params, context, provider, token); + const resultParts = await createWhiteboardLanguageModelResultParts(result); + + return new vscode.LanguageModelToolResult(resultParts.map((part) => + new vscode.LanguageModelTextPart(part.value) + )); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || 'Open whiteboard' + }; + }, + }); + // Register the walkthrough_review tool (explicit: walkthrough review mode) const walkthroughReviewTool = vscode.lm.registerTool('walkthrough_review', { async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { @@ -248,6 +285,7 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: confirmationTool, approvePlanTool, planReviewTool, + openWhiteboardTool, walkthroughReviewTool ); diff --git a/src/tools/openWhiteboard.test.ts b/src/tools/openWhiteboard.test.ts new file mode 100644 index 0000000..4b7b00a --- /dev/null +++ b/src/tools/openWhiteboard.test.ts @@ -0,0 +1,1016 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { WhiteboardCanvasSubmission, WhiteboardSubmittedCanvas } from '../webview/types'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +function createTokenController(initiallyCancelled = false) { + let isCancellationRequested = initiallyCancelled; + let handler: (() => void) | undefined; + + const token = { + get isCancellationRequested() { + return isCancellationRequested; + }, + onCancellationRequested(callback: () => void) { + handler = callback; + return { + dispose() { + if (handler === callback) { + handler = undefined; + } + } + }; + } + }; + + return { + token, + cancel() { + isCancellationRequested = true; + handler?.(); + } + }; +} + +describe('openWhiteboard', () => { + const modulePath = require.resolve('./openWhiteboard.ts'); + let originalLoad: typeof Module._load; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + function loadOpenWhiteboard(mockLogger?: { error?: (...args: unknown[]) => void }) { + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === '../logging') { + return { + Logger: { + error: mockLogger?.error ?? (() => { }), + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + return (require('./openWhiteboard.ts') as typeof import('./openWhiteboard')).openWhiteboard; + } + + it('returns a cancelled result without touching dependencies when already cancelled', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(true); + let saveCalls = 0; + let showCalls = 0; + let refreshCalls = 0; + + const result = await openWhiteboard( + {}, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome: () => { refreshCalls += 1; } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + saveCalls += 1; + return 'wb_never'; + }, + updateWhiteboardInteraction() { + throw new Error('should not update storage'); + }, + }, + panel: { + async showWithOptions() { + showCalls += 1; + return { submitted: true, action: 'approved', canvases: [] }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1000, + } + } + ); + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + canvases: [], + interactionId: '', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }); + assert.strictEqual(saveCalls, 0); + assert.strictEqual(showCalls, 0); + assert.strictEqual(refreshCalls, 0); + }); + + it('saves a pending interaction, refreshes home, and persists submitted canvases', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + const refreshLog: string[] = []; + const storageCalls: Array<{ type: 'save' | 'update'; payload: any }> = []; + + let submittedCanvases: WhiteboardSubmittedCanvas[] = []; + + const result = await openWhiteboard( + { + title: 'Design Whiteboard', + context: 'Sketch the service boundaries.', + initialCanvases: [ + { + name: 'Architecture sketch', + fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"seed_rect","whiteboardObjectType":"rectangle","left":40,"top":50,"width":220,"height":120,"stroke":"#2563eb","fill":"rgba(37,99,235,0.18)","strokeWidth":2}]}' + } + ] + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome: () => { refreshLog.push('refresh'); } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction(payload) { + storageCalls.push({ type: 'save', payload }); + return 'wb_123'; + }, + updateWhiteboardInteraction(interactionId, payload) { + storageCalls.push({ type: 'update', payload: { interactionId, ...payload } }); + }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + assert.strictEqual(options.interactionId, 'wb_123'); + assert.strictEqual(options.session.canvases.length, 1); + submittedCanvases = [ + { + id: options.session.canvases[0].id, + name: options.session.canvases[0].name, + imageUri: 'data:image/png;base64,abc123' + } + ]; + return { + submitted: true, + action: 'approved', + canvases: submittedCanvases.map(({ id, imageUri }) => ({ id, imageUri })) + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000000, + } + } + ); + + assert.deepStrictEqual(result, { + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', + canvases: submittedCanvases, + interactionId: 'wb_123', + sceneSummary: { + totalCanvases: 1, + totalElements: 1, + canvases: [ + { + id: 'canvas_1700000000000_1', + name: 'Architecture sketch', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 1, + elements: [ + { + id: 'seed_rect', + objectType: 'rectangle', + bounds: { + x: 40, + y: 50, + width: 220, + height: 120, + }, + center: { + x: 150, + y: 110, + }, + zIndex: 0, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + strokeWidth: 2, + opacity: 1, + } + ], + } + ], + }, + }); + assert.strictEqual(refreshLog.length, 2); + assert.strictEqual(storageCalls.length, 2); + assert.strictEqual(storageCalls[0]?.type, 'save'); + assert.strictEqual(storageCalls[1]?.type, 'update'); + assert.deepStrictEqual(storageCalls[1]?.payload, { + interactionId: 'wb_123', + whiteboardSession: { + status: 'approved', + submittedAt: 1700000000000, + canvases: storageCalls[0]!.payload.canvases, + activeCanvasId: 'canvas_1700000000000_1', + submittedCanvases, + } + }); + }); + + it('returns recreateWithChanges when the user requests another whiteboard pass', async () => { + const openWhiteboard = loadOpenWhiteboard(); + + const result = await openWhiteboard( + { + title: 'Edit and resubmit', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_changes'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + return { + submitted: true, + action: 'recreateWithChanges', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'data:image/png;base64,updated', + }, + ], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000001, + } + } + ); + + assert.equal(result.submitted, true); + assert.equal(result.action, 'recreateWithChanges'); + }); + + it('uses the submitted fabricState as the authoritative final scene summary', async () => { + const openWhiteboard = loadOpenWhiteboard(); + + const result = await openWhiteboard( + { + title: 'Edit and submit', + initialCanvases: [ + { + name: 'Canvas One', + fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"seed_rect","whiteboardObjectType":"rectangle","left":40,"top":50,"width":220,"height":120,"stroke":"#2563eb","fill":"rgba(37,99,235,0.18)","strokeWidth":2}]}' + } + ] + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + createTokenController().token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_final_state'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + return { + submitted: true, + action: 'approved', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'data:image/png;base64,updated', + fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"updated_rect","whiteboardObjectType":"rectangle","left":300,"top":180,"width":180,"height":90,"stroke":"#059669","fill":"rgba(5,150,105,0.18)","strokeWidth":3}]}' + }, + ], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000001000, + } + } + ); + + assert.equal(result.sceneSummary.totalElements, 1); + assert.equal(result.sceneSummary.canvases[0]?.elements[0]?.id, 'updated_rect'); + assert.deepStrictEqual(result.sceneSummary.canvases[0]?.elements[0]?.center, { + x: 390, + y: 225, + }); + }); + + it('rejects implicit blank whiteboards before saving or opening the panel', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + let saveCalls = 0; + let panelCalls = 0; + + await assert.rejects( + () => openWhiteboard( + { + title: 'Implicit blank whiteboard', + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + saveCalls += 1; + return 'wb_implicit_blank'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions() { + panelCalls += 1; + return { + submitted: false, + action: 'cancelled', + canvases: [], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000004, + } + } + ), + /Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard/, + ); + + assert.equal(saveCalls, 0); + assert.equal(panelCalls, 0); + }); + + it('opens an explicit blank canvas when blankCanvas is true', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + const storageCalls: Array<{ type: 'save' | 'update'; payload: any }> = []; + + const result = await openWhiteboard( + { + title: 'Blank Whiteboard', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction(payload) { + storageCalls.push({ type: 'save', payload }); + return 'wb_blank'; + }, + updateWhiteboardInteraction(interactionId, payload) { + storageCalls.push({ type: 'update', payload: { interactionId, ...payload } }); + }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + assert.equal(options.session.canvases.length, 1); + assert.equal(options.session.activeCanvasId, options.session.canvases[0]?.id); + assert.equal(options.session.canvases[0]?.name, 'Canvas 1'); + assert.match(options.session.canvases[0]?.fabricState ?? '', /"objects":\[\]/); + + return { + submitted: true, + action: 'approved', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'data:image/png;base64,blank-canvas', + } + ] + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000005, + } + } + ); + + assert.strictEqual(storageCalls.length, 2); + assert.deepStrictEqual(storageCalls[0], { + type: 'save', + payload: { + title: 'Blank Whiteboard', + context: undefined, + canvases: [ + { + id: 'canvas_1700000000005_1', + name: 'Canvas 1', + fabricState: storageCalls[0]?.payload.canvases[0].fabricState, + createdAt: 1700000000005, + updatedAt: 1700000000005, + } + ], + activeCanvasId: 'canvas_1700000000005_1', + status: 'pending', + isDebug: undefined, + } + }); + assert.deepStrictEqual(JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState), { + version: JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState).version, + width: 1600, + height: 900, + backgroundColor: '#ffffff', + objects: [], + }); + assert.match(JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState).version, /\S+/); + assert.deepStrictEqual(storageCalls[1], { + type: 'update', + payload: { + interactionId: 'wb_blank', + whiteboardSession: { + status: 'approved', + submittedAt: 1700000000005, + canvases: storageCalls[0]!.payload.canvases, + activeCanvasId: 'canvas_1700000000005_1', + submittedCanvases: [ + { + id: 'canvas_1700000000005_1', + name: 'Canvas 1', + imageUri: 'data:image/png;base64,blank-canvas', + } + ], + } + } + }); + assert.deepStrictEqual(result, { + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', + canvases: [ + { + id: 'canvas_1700000000005_1', + name: 'Canvas 1', + imageUri: 'data:image/png;base64,blank-canvas', + } + ], + interactionId: 'wb_blank', + sceneSummary: { + totalCanvases: 1, + totalElements: 0, + canvases: [ + { + id: 'canvas_1700000000005_1', + name: 'Canvas 1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 0, + elements: [], + } + ], + }, + }); + }); + + it('converts agent-friendly seed elements into fabric state before opening the panel', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + let panelValidated = false; + + const result = await openWhiteboard( + { + title: 'Seeded demo', + initialCanvases: [ + { + name: 'Demo canvas', + seedElements: [ + { + type: 'rectangle', + x: 40, + y: 50, + width: 220, + height: 120, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + }, + { + type: 'circle', + x: 360, + y: 140, + radius: 60, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.18)', + }, + { + type: 'triangle', + x: 520, + y: 60, + width: 180, + height: 150, + strokeColor: '#16a34a', + fillColor: 'rgba(22,163,74,0.18)', + }, + { + type: 'line', + start: { x: 780, y: 80 }, + end: { x: 1040, y: 220 }, + strokeColor: '#f97316', + strokeWidth: 6, + }, + { + type: 'text', + x: 60, + y: 260, + text: 'Whiteboard Demo', + color: '#111827', + fontSize: 32, + }, + ], + } + ] + } as any, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_seeded_demo'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + panelValidated = true; + assert.equal(options.session.canvases.length, 1); + const seededState = JSON.parse(options.session.canvases[0]!.fabricState); + assert.equal(seededState.width, 1600); + assert.equal(seededState.height, 900); + assert.equal(seededState.backgroundColor, '#ffffff'); + assert.deepStrictEqual( + seededState.objects.map((object: any) => [object.type, object.whiteboardObjectType]), + [ + ['rect', 'rectangle'], + ['path', 'circle'], + ['triangle', 'triangle'], + ['line', 'line'], + ['i-text', 'text'], + ], + ); + assert.equal(seededState.objects[0].stroke, '#2563eb'); + assert.equal(seededState.objects[1].stroke, '#dc2626'); + assert.equal(seededState.objects[2].stroke, '#16a34a'); + assert.equal(seededState.objects[3].stroke, '#f97316'); + assert.equal(seededState.objects[4].text, 'Whiteboard Demo'); + + return { + submitted: false, + action: 'cancelled', + canvases: [], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000010, + } + } + ); + + assert.equal(panelValidated, true); + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + canvases: [], + interactionId: 'wb_seeded_demo', + sceneSummary: { + totalCanvases: 1, + totalElements: 5, + canvases: [ + { + id: 'canvas_1700000000010_1', + name: 'Demo canvas', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 5, + elements: [ + { + id: 'seed_1', + objectType: 'rectangle', + bounds: { + x: 40, + y: 50, + width: 220, + height: 120, + }, + center: { + x: 150, + y: 110, + }, + zIndex: 0, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + strokeWidth: 2, + opacity: 1, + }, + { + id: 'seed_2', + objectType: 'circle', + bounds: { + x: 300, + y: 80, + width: 120, + height: 120, + }, + center: { + x: 360, + y: 140, + }, + zIndex: 1, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.18)', + strokeWidth: 2, + opacity: 1, + }, + { + id: 'seed_3', + objectType: 'triangle', + bounds: { + x: 520, + y: 60, + width: 180, + height: 150, + }, + center: { + x: 610, + y: 135, + }, + zIndex: 2, + strokeColor: '#16a34a', + fillColor: 'rgba(22,163,74,0.18)', + strokeWidth: 2, + opacity: 1, + }, + { + id: 'seed_4', + objectType: 'line', + bounds: { + x: 780, + y: 80, + width: 260, + height: 140, + }, + center: { + x: 910, + y: 150, + }, + zIndex: 3, + strokeColor: '#f97316', + fillColor: '', + strokeWidth: 6, + opacity: 1, + }, + { + id: 'seed_5', + objectType: 'text', + label: 'Whiteboard Demo', + zIndex: 4, + fontSize: 32, + fontFamily: 'sans-serif', + strokeColor: '#111827', + fillColor: '#111827', + strokeWidth: 1, + opacity: 1, + }, + ], + } + ], + }, + }); + }); + + it('preserves the attached Android UI sample payload with rounded rectangles and centered seeded text', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + const sampleInput = JSON.parse(readFileSync(path.join(process.cwd(), 'whiteboard_input test 1.json'), 'utf8')); + let panelValidated = false; + + const result = await openWhiteboard( + sampleInput, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_android_ui_1'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + panelValidated = true; + const seededState = JSON.parse(options.session.canvases[0]!.fabricState); + assert.equal(seededState.objects.length, 41); + assert.equal(seededState.objects[1].originX, 'center'); + assert.equal(seededState.objects[12].rx, 8); + assert.equal(seededState.objects[24].rx, 25); + return { + submitted: false, + action: 'cancelled', + canvases: [], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000011, + } + } + ); + + assert.equal(panelValidated, true); + assert.equal(result.sceneSummary.totalCanvases, 1); + assert.equal(result.sceneSummary.totalElements, 41); + assert.equal(result.sceneSummary.canvases[0]?.elements[1]?.label, 'Android App Title Bar'); + }); + + it('preserves the second attached Android UI sample payload with full element count', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + const sampleInput = JSON.parse(readFileSync(path.join(process.cwd(), 'whiteboard_input test 2.json'), 'utf8')); + let panelValidated = false; + + const result = await openWhiteboard( + sampleInput, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_android_ui_2'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions(_extensionUri, options) { + panelValidated = true; + const seededState = JSON.parse(options.session.canvases[0]!.fabricState); + assert.equal(seededState.objects.length, 45); + return { + submitted: false, + action: 'cancelled', + canvases: [], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000012, + } + } + ); + + assert.equal(panelValidated, true); + assert.equal(result.sceneSummary.totalCanvases, 1); + assert.equal(result.sceneSummary.totalElements, 45); + assert.equal(result.sceneSummary.canvases[0]?.elements[44]?.label?.trim(), '📐 Android UI Mockup - All coordinates & sizes marked for reference'); + }); + + it('rejects JSON-valid but Fabric-invalid raw fabricState before opening the panel', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + let saveCalls = 0; + let panelCalls = 0; + + await assert.rejects( + () => openWhiteboard( + { + title: 'Broken raw fabricState', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: '{"version":"6.9.1","objects":[{"type":"rectangle","left":40,"top":50,"width":220,"height":120}]}' + } + ] + } as any, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + saveCalls += 1; + return 'wb_invalid_raw'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions() { + panelCalls += 1; + return { + submitted: false, + action: 'cancelled', + canvases: [], + }; + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000011, + } + } + ), + /Canvas fabricState contains unsupported Fabric object type "rectangle"/, + ); + + assert.equal(saveCalls, 0); + assert.equal(panelCalls, 0); + }); + + it('marks the interaction as cancelled once and does not persist submitted data when the agent cancels mid-flight', async () => { + const openWhiteboard = loadOpenWhiteboard(); + const tokenController = createTokenController(); + const refreshLog: string[] = []; + const updateCalls: any[] = []; + let resolvePanel: ((value: { submitted: boolean; action: 'approved' | 'recreateWithChanges' | 'cancelled'; canvases: WhiteboardCanvasSubmission[] }) => void) | undefined; + const closeCalls: string[] = []; + + const resultPromise = openWhiteboard( + { + title: 'Cancelled Whiteboard', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome: () => { refreshLog.push('refresh'); } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_cancel'; + }, + updateWhiteboardInteraction(interactionId, payload) { + updateCalls.push({ interactionId, ...payload }); + }, + }, + panel: { + showWithOptions: async () => new Promise((resolve) => { + resolvePanel = resolve; + }), + closeIfOpen(interactionId) { + closeCalls.push(interactionId); + resolvePanel?.({ + submitted: true, + action: 'approved', + canvases: [ + { + id: 'canvas_1700000000001_1', + imageUri: 'data:image/png;base64,late-submit' + } + ] + }); + return true; + } + }, + now: () => 1700000000001, + } + } + ); + + tokenController.cancel(); + const result = await resultPromise; + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + canvases: [], + interactionId: 'wb_cancel', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }); + assert.deepStrictEqual(closeCalls, ['wb_cancel']); + assert.deepStrictEqual(updateCalls, [ + { + interactionId: 'wb_cancel', + whiteboardSession: { + status: 'cancelled' + } + } + ]); + assert.strictEqual(refreshLog.length, 2); + }); + + it('logs and persists cancellation when the whiteboard panel throws', async () => { + const loggerCalls: unknown[][] = []; + const openWhiteboard = loadOpenWhiteboard({ + error: (...args) => { + loggerCalls.push(args); + }, + }); + const tokenController = createTokenController(); + const result = await openWhiteboard( + { + title: 'Broken Whiteboard', + blankCanvas: true, + }, + { extensionUri: { fsPath: '/extension' } } as any, + { refreshHome() { } } as any, + tokenController.token as any, + { + dependencies: { + storage: { + saveWhiteboardInteraction() { + return 'wb_error'; + }, + updateWhiteboardInteraction() { }, + }, + panel: { + async showWithOptions() { + throw new Error('panel failed'); + }, + closeIfOpen() { + return false; + } + }, + now: () => 1700000000002, + } + } + ); + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + canvases: [], + interactionId: 'wb_error', + sceneSummary: { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }, + }); + assert.strictEqual(loggerCalls.length, 1); + assert.strictEqual(loggerCalls[0]?.[0], 'Error showing whiteboard panel:'); + assert.match(String(loggerCalls[0]?.[1]), /panel failed/); + }); +}); diff --git a/src/tools/openWhiteboard.ts b/src/tools/openWhiteboard.ts new file mode 100644 index 0000000..3187680 --- /dev/null +++ b/src/tools/openWhiteboard.ts @@ -0,0 +1,309 @@ +import type * as vscode from 'vscode'; +import { + DEFAULT_WHITEBOARD_CANVAS_NAME, + serializeBlankFabricCanvasState, +} from '../whiteboard/canvasState'; +import { + createEmptyWhiteboardSceneSummary, + summarizeWhiteboardScene, +} from '../whiteboard/sceneSummary'; +import { + normalizeAndValidateLoadableFabricState, + serializeSeedElementsAsFabricState, +} from '../whiteboard/seededCanvas'; + +import { + mergeSubmittedWhiteboardCanvases, + resolveWhiteboardSubmittedCanvases, +} from '../webview/types'; +import type { + WhiteboardCanvas, + WhiteboardPanelOptions, + WhiteboardPanelResult, + WhiteboardReviewAction, + WhiteboardSession, + WhiteboardSessionStatus, + WhiteboardSubmittedCanvas, +} from '../webview/types'; +import type { AgentInteractionProvider } from '../webview/webviewProvider'; +import { + WHITEBOARD_EXPLICIT_BLANK_MESSAGE, +} from './schemas'; +import type { WhiteboardInput, WhiteboardToolResult } from './schemas'; +import { Logger } from '../logging'; + +export interface OpenWhiteboardDependencies { + storage: { + saveWhiteboardInteraction(data: { + title?: string; + context?: string; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + isDebug?: boolean; + }): string; + updateWhiteboardInteraction(interactionId: string, updates: { + title?: string; + whiteboardSession?: { + status?: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; + canvases?: WhiteboardCanvas[]; + activeCanvasId?: string; + }; + }): void; + getWhiteboardSession?(interactionId: string): WhiteboardSession | undefined; + }; + panel: { + showWithOptions(extensionUri: vscode.Uri, options: WhiteboardPanelOptions): Promise; + closeIfOpen(interactionId: string): boolean | Promise; + }; + now(): number; +} + +export interface OpenWhiteboardExecutionOptions { + isDebug?: boolean; + dependencies?: Partial; +} + +function toWhiteboardSessionStatus(result: WhiteboardPanelResult): WhiteboardSessionStatus { + if (!result.submitted) { + return 'cancelled'; + } + + return result.action; +} + +function toWhiteboardToolAction(result: WhiteboardPanelResult): WhiteboardReviewAction { + return result.submitted ? result.action : 'cancelled'; +} + +function createWhiteboardInstruction(action: WhiteboardReviewAction): string { + switch (action) { + case 'approved': + return 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.'; + case 'recreateWithChanges': + return 'The user requested changes to the submitted whiteboard. Address the annotated feedback and call open_whiteboard again with an updated sketch before concluding.'; + case 'cancelled': + default: + return 'The whiteboard was cancelled. Do not treat this submission as approved user input.'; + } +} + +function createCanvas( + initialCanvas: NonNullable[number], + index: number, + now: number, +): WhiteboardCanvas { + const fabricState = initialCanvas.seedElements + ? serializeSeedElementsAsFabricState(initialCanvas.seedElements) + : normalizeAndValidateLoadableFabricState(initialCanvas.fabricState ?? ''); + + return { + id: `canvas_${now}_${index + 1}`, + name: initialCanvas.name, + fabricState, + createdAt: now, + updatedAt: now, + }; +} + +function createInitialCanvasSeed(params: WhiteboardInput): NonNullable { + if (params.initialCanvases?.length) { + return params.initialCanvases; + } + + if (params.blankCanvas !== true) { + throw new Error(WHITEBOARD_EXPLICIT_BLANK_MESSAGE); + } + + return [ + { + name: DEFAULT_WHITEBOARD_CANVAS_NAME, + fabricState: serializeBlankFabricCanvasState(), + } + ]; +} + +async function createDefaultDependencies(): Promise { + const [{ getChatHistoryStorage }, { WhiteboardPanel }] = await Promise.all([ + import('../storage/chatHistoryStorage'), + import('../webview/whiteboardPanel'), + ]); + + const storage = getChatHistoryStorage(); + return { + storage: { + saveWhiteboardInteraction: (data) => storage.saveWhiteboardInteraction(data), + updateWhiteboardInteraction: (interactionId, updates) => storage.updateWhiteboardInteraction(interactionId, updates), + getWhiteboardSession: (interactionId) => storage.getWhiteboardSession(interactionId), + }, + panel: { + showWithOptions: (extensionUri, options) => WhiteboardPanel.showWithOptions(extensionUri, options), + closeIfOpen: (interactionId) => WhiteboardPanel.closeIfOpen(interactionId), + }, + now: () => Date.now(), + }; +} + +export async function openWhiteboard( + params: WhiteboardInput, + context: vscode.ExtensionContext, + provider: AgentInteractionProvider, + token: vscode.CancellationToken, + options: OpenWhiteboardExecutionOptions = {}, +): Promise { + if (token.isCancellationRequested) { + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + canvases: [], + interactionId: '', + sceneSummary: createEmptyWhiteboardSceneSummary(), + }; + } + + const hasAllDependencies = Boolean( + options.dependencies?.storage + && options.dependencies?.panel + && options.dependencies?.now + ); + const defaultDependencies = hasAllDependencies + ? undefined + : await createDefaultDependencies(); + const dependencies: OpenWhiteboardDependencies = { + ...(defaultDependencies || {}), + ...options.dependencies, + storage: { + ...(defaultDependencies?.storage || {}), + ...options.dependencies?.storage, + } as OpenWhiteboardDependencies['storage'], + panel: { + ...(defaultDependencies?.panel || {}), + ...options.dependencies?.panel, + } as OpenWhiteboardDependencies['panel'], + now: options.dependencies?.now ?? defaultDependencies?.now ?? Date.now, + }; + + const now = dependencies.now(); + const title = params.title || 'Whiteboard'; + const canvases = createInitialCanvasSeed(params).map((canvas, index) => createCanvas(canvas, index, now)); + const activeCanvasId = canvases[0]?.id; + + const interactionId = dependencies.storage.saveWhiteboardInteraction({ + title, + context: params.context, + canvases, + activeCanvasId, + status: 'pending', + isDebug: options.isDebug, + }); + + provider.refreshHome(); + + const session = { + id: interactionId, + interactionId, + context: params.context, + title, + canvases, + activeCanvasId, + status: 'pending' as const, + }; + + let cancelledByAgent = false; + const cancellationDisposable = token.onCancellationRequested(() => { + cancelledByAgent = true; + dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + void dependencies.panel.closeIfOpen(interactionId); + provider.refreshHome(); + }); + + try { + const result = await dependencies.panel.showWithOptions(context.extensionUri, { + interactionId, + title, + session, + }); + + if (cancelledByAgent) { + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + canvases: [], + interactionId, + sceneSummary: createEmptyWhiteboardSceneSummary(), + }; + } + + const latestSession = dependencies.storage.getWhiteboardSession?.(interactionId); + const resolvedCanvases = result.submitted + ? mergeSubmittedWhiteboardCanvases(result.canvases, latestSession?.canvases ?? session.canvases) + : latestSession?.canvases ?? session.canvases; + const submittedCanvases = result.submitted + ? resolveWhiteboardSubmittedCanvases(result.canvases, resolvedCanvases) + : []; + + const status = toWhiteboardSessionStatus(result); + const action = toWhiteboardToolAction(result); + const submittedAt = result.submitted ? dependencies.now() : undefined; + + dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status, + submittedAt, + submittedCanvases, + ...(result.submitted + ? { + canvases: resolvedCanvases, + activeCanvasId: latestSession?.activeCanvasId ?? session.activeCanvasId, + } + : {}), + }, + }); + provider.refreshHome(); + + const sceneSummary = summarizeWhiteboardScene(resolvedCanvases.map((canvas) => ({ + id: canvas.id, + name: canvas.name, + fabricState: canvas.fabricState, + }))); + + return { + submitted: result.submitted, + action, + instruction: createWhiteboardInstruction(action), + canvases: submittedCanvases, + interactionId, + sceneSummary, + }; + } catch (error) { + Logger.error('Error showing whiteboard panel:', error); + if (!cancelledByAgent) { + dependencies.storage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + provider.refreshHome(); + } + return { + submitted: false, + action: 'cancelled', + instruction: createWhiteboardInstruction('cancelled'), + canvases: [], + interactionId, + sceneSummary: createEmptyWhiteboardSceneSummary(), + }; + } finally { + cancellationDisposable.dispose(); + } +} diff --git a/src/tools/packageMetadata.test.ts b/src/tools/packageMetadata.test.ts new file mode 100644 index 0000000..8f8a796 --- /dev/null +++ b/src/tools/packageMetadata.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const packageJson = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf8') +) as { + contributes?: { + languageModelTools?: Array<{ + name: string; + tags?: string[]; + icon?: string; + inputSchema?: { + properties?: Record; + }; + }>; + }; +}; + +describe('package metadata', () => { + it('registers open_whiteboard as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'open_whiteboard'); + + assert.ok(tool, 'Expected open_whiteboard to be declared in package.json'); + assert.deepStrictEqual(tool.tags, [ + 'whiteboard', + 'diagramming', + 'visual-context', + 'user-interaction', + 'seamless-agent' + ]); + assert.strictEqual(tool.icon, '$(symbol-color)'); + assert.ok(tool.inputSchema?.properties?.context, 'Expected context input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.ok(tool.inputSchema?.properties?.blankCanvas, 'Expected blankCanvas input schema'); + assert.ok(tool.inputSchema?.properties?.initialCanvases, 'Expected initialCanvases input schema'); + }); +}); diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 61ae8c1..8a67354 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; -import type { RequiredPlanRevisions, PlanReviewMode } from '../webview/types'; +import type { RequiredPlanRevisions, WhiteboardReviewAction, WhiteboardSubmittedCanvas } from '../webview/types'; +import { normalizeAndValidateLoadableFabricState } from '../whiteboard/seededCanvas'; +import type { WhiteboardSceneSummary } from '../whiteboard/sceneSummary'; +import { + DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + DEFAULT_WHITEBOARD_CANVAS_WIDTH, +} from '../whiteboard/canvasState'; // ================================ // Input Schemas with Zod Validation @@ -99,6 +105,219 @@ export const WalkthroughReviewInputSchema = z.object({ .describe('Optional chat session ID for grouping reviews. Auto-generated if not provided.') }); + +const WHITEBOARD_X_RANGE_MESSAGE = `Seed coordinate x must be within the whiteboard width (0-${DEFAULT_WHITEBOARD_CANVAS_WIDTH})`; +const WHITEBOARD_Y_RANGE_MESSAGE = `Seed coordinate y must be within the whiteboard height (0-${DEFAULT_WHITEBOARD_CANVAS_HEIGHT})`; +const WHITEBOARD_STROKE_WIDTH_MESSAGE = 'Seed element strokeWidth must be between 1 and 64'; +const WHITEBOARD_Z_INDEX_MESSAGE = 'Seed element zIndex must be between 0 and 10000'; +const WHITEBOARD_ROTATION_MESSAGE = 'Seed element rotation must be between -360 and 360 degrees'; + +const WhiteboardSeedXSchema = z.number() + .finite() + .min(0, WHITEBOARD_X_RANGE_MESSAGE) + .max(DEFAULT_WHITEBOARD_CANVAS_WIDTH, WHITEBOARD_X_RANGE_MESSAGE) + .describe('Horizontal position in the default 1600px-wide whiteboard canvas.'); + +const WhiteboardSeedYSchema = z.number() + .finite() + .min(0, WHITEBOARD_Y_RANGE_MESSAGE) + .max(DEFAULT_WHITEBOARD_CANVAS_HEIGHT, WHITEBOARD_Y_RANGE_MESSAGE) + .describe('Vertical position in the default 900px-tall whiteboard canvas.'); + +const WhiteboardSeedPointSchema = z.object({ + x: WhiteboardSeedXSchema, + y: WhiteboardSeedYSchema, +}); + +const WhiteboardSeedElementBaseSchema = z.object({ + id: z.string() + .optional() + .describe('Optional stable object id. Omit to let Seamless Agent generate one.'), + strokeColor: z.string() + .optional() + .describe('Optional stroke/outline color such as "#2563eb".'), + fillColor: z.string() + .optional() + .describe('Optional fill color such as "rgba(37,99,235,0.18)".'), + strokeWidth: z.number() + .min(1, WHITEBOARD_STROKE_WIDTH_MESSAGE) + .max(64, WHITEBOARD_STROKE_WIDTH_MESSAGE) + .optional() + .describe('Optional stroke width. Defaults to 2.'), + zIndex: z.number() + .int(WHITEBOARD_Z_INDEX_MESSAGE) + .min(0, WHITEBOARD_Z_INDEX_MESSAGE) + .max(10000, WHITEBOARD_Z_INDEX_MESSAGE) + .optional() + .describe('Optional stacking order hint. Lower values render behind higher values.'), + rotation: z.number() + .min(-360, WHITEBOARD_ROTATION_MESSAGE) + .max(360, WHITEBOARD_ROTATION_MESSAGE) + .optional() + .describe('Optional clockwise rotation in degrees.'), + opacity: z.number() + .min(0, 'Seed element opacity must be at least 0') + .max(1, 'Seed element opacity must be at most 1') + .optional() + .describe('Optional opacity between 0 and 1. Defaults to 1.'), +}); + +const WhiteboardSeedRectangleSchema = WhiteboardSeedElementBaseSchema.extend({ + type: z.literal('rectangle'), + x: WhiteboardSeedXSchema, + y: WhiteboardSeedYSchema, + width: z.number().positive('Rectangle width must be greater than zero'), + height: z.number().positive('Rectangle height must be greater than zero'), + rx: z.number().min(0, 'Rectangle rx must be at least 0').optional(), + ry: z.number().min(0, 'Rectangle ry must be at least 0').optional(), +}); + +const WhiteboardSeedCircleSchema = WhiteboardSeedElementBaseSchema.extend({ + type: z.literal('circle'), + x: WhiteboardSeedXSchema, + y: WhiteboardSeedYSchema, + radius: z.number().positive('Circle radius must be greater than zero'), +}); + +const WhiteboardSeedTriangleSchema = WhiteboardSeedElementBaseSchema.extend({ + type: z.literal('triangle'), + x: WhiteboardSeedXSchema, + y: WhiteboardSeedYSchema, + width: z.number().positive('Triangle width must be greater than zero'), + height: z.number().positive('Triangle height must be greater than zero'), +}); + +const WhiteboardSeedLineSchema = WhiteboardSeedElementBaseSchema.extend({ + type: z.literal('line'), + start: WhiteboardSeedPointSchema, + end: WhiteboardSeedPointSchema, +}); + +const WhiteboardSeedTextSchema = WhiteboardSeedElementBaseSchema.extend({ + type: z.literal('text'), + x: WhiteboardSeedXSchema, + y: WhiteboardSeedYSchema, + text: z.string().min(1, 'Seed text cannot be empty'), + color: z.string() + .optional() + .describe('Optional text color such as "#111827". Defaults to a dark neutral.'), + fontSize: z.number() + .positive('Text fontSize must be greater than zero') + .optional() + .describe('Optional text size. Defaults to 24.'), + fontWeight: z.number() + .int('Seed text fontWeight must be between 100 and 900') + .min(100, 'Seed text fontWeight must be between 100 and 900') + .max(900, 'Seed text fontWeight must be between 100 and 900') + .optional() + .describe('Optional text weight from 100 to 900.'), + fontStyle: z.enum(['normal', 'italic', 'oblique']) + .optional() + .describe('Optional text style.'), + textAlign: z.enum(['left', 'center', 'right', 'justify']) + .optional() + .describe('Optional text alignment.'), + fontFamily: z.string() + .optional() + .describe('Optional font family. Defaults to "sans-serif".'), +}); + +const WhiteboardSeedElementSchema = z.discriminatedUnion('type', [ + WhiteboardSeedRectangleSchema, + WhiteboardSeedCircleSchema, + WhiteboardSeedTriangleSchema, + WhiteboardSeedLineSchema, + WhiteboardSeedTextSchema, +]); + +const WhiteboardInitialCanvasSchema = z.object({ + name: z.string() + .min(1, 'Canvas name cannot be empty') + .describe('Display name for the pre-populated canvas.'), + fabricState: z.string() + .min(1, 'Canvas fabricState cannot be empty') + .optional() + .describe('Advanced path for reopening sessions: serialized Fabric.js JSON. If provided, it must be valid JSON with an objects array. Prefer seedElements for new agent-authored starter sketches.'), + seedElements: z.array(WhiteboardSeedElementSchema) + .min(1, 'Canvas seedElements cannot be empty') + .optional() + .describe('Preferred agent-friendly path for simple starter sketches. Provide basic shapes/text and Seamless Agent will convert them into Fabric.js canvas content.') +}).superRefine((canvas, ctx) => { + const hasFabricState = typeof canvas.fabricState === 'string'; + const hasSeedElements = Array.isArray(canvas.seedElements); + + if (!hasFabricState && !hasSeedElements) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['seedElements'], + message: 'Canvas must include either fabricState or seedElements', + }); + return; + } + + if (hasFabricState && hasSeedElements) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['seedElements'], + message: 'Canvas cannot include both fabricState and seedElements', + }); + return; + } + + const fabricState = hasFabricState ? canvas.fabricState : undefined; + if (fabricState && fabricState.length > 0) { + try { + normalizeAndValidateLoadableFabricState(fabricState); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['fabricState'], + message: error instanceof Error ? error.message : 'Canvas fabricState must be valid JSON with an objects array', + }); + } + } +}); + +export const WHITEBOARD_EXPLICIT_BLANK_MESSAGE = 'Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard'; + +export const WHITEBOARD_AMBIGUOUS_BLANK_MESSAGE = 'blankCanvas cannot be true when initialCanvases are provided'; + +/** + * Schema for open_whiteboard tool input + */ +export const WhiteboardInputSchema = z.object({ + context: z.string() + .optional() + .describe('Optional context/instructions for the whiteboard session.'), + title: z.string() + .optional() + .describe('Optional title for the whiteboard panel.'), + blankCanvas: z.boolean() + .optional() + .describe('Set to true only when you intentionally open an empty whiteboard. If omitted or false, provide initialCanvases with starter content.'), + initialCanvases: z.array(WhiteboardInitialCanvasSchema) + .optional() + .describe('Optional pre-populated canvases. Prefer seedElements for agent-authored starter sketches; use fabricState for reopening/advanced callers.') +}).superRefine((input, ctx) => { + const hasInitialCanvases = Array.isArray(input.initialCanvases) && input.initialCanvases.length > 0; + + if (!hasInitialCanvases && input.blankCanvas !== true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['blankCanvas'], + message: WHITEBOARD_EXPLICIT_BLANK_MESSAGE, + }); + } + + if (hasInitialCanvases && input.blankCanvas === true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['blankCanvas'], + message: WHITEBOARD_AMBIGUOUS_BLANK_MESSAGE, + }); + } +}); + // ================================ // TypeScript Types (derived from schemas) // ================================ @@ -107,6 +326,7 @@ export type AskUserInput = z.infer; export type ApprovePlanInput = z.infer; export type PlanReviewInput = z.infer; export type WalkthroughReviewInput = z.infer; +export type WhiteboardInput = z.infer; // ================================ // Result Interfaces @@ -138,6 +358,19 @@ export interface PlanReviewToolResult { reviewId: string; } + +/** + * Result structure for open_whiteboard tool + */ +export interface WhiteboardToolResult { + submitted: boolean; + action: WhiteboardReviewAction; + instruction: string; + canvases: WhiteboardSubmittedCanvas[]; + interactionId: string; + sceneSummary: WhiteboardSceneSummary; +} + // ================================ // Validation Helpers // ================================ @@ -170,6 +403,14 @@ export function parseWalkthroughReviewInput(input: unknown): WalkthroughReviewIn return WalkthroughReviewInputSchema.parse(input); } + +/** + * Validates and parses open_whiteboard input, throwing on validation errors + */ +export function parseWhiteboardInput(input: unknown): WhiteboardInput { + return WhiteboardInputSchema.parse(input); +} + /** * Safely validates input and returns result or error message */ diff --git a/src/tools/whiteboard.test.ts b/src/tools/whiteboard.test.ts new file mode 100644 index 0000000..d148b8e --- /dev/null +++ b/src/tools/whiteboard.test.ts @@ -0,0 +1,355 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { WhiteboardInputSchema, parseWhiteboardInput, safeParseInput } from './schemas'; + +describe('whiteboard tool contracts', () => { + it('parses standalone whiteboard inputs with seeded canvases', () => { + assert.deepStrictEqual(parseWhiteboardInput({ + title: 'Architecture whiteboard', + context: 'Map service boundaries and data flow.', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: '{"version":"6.9.1","objects":[]}' + }, + { + name: 'Canvas 2', + fabricState: '{"version":"6.9.1","objects":[{"type":"rect"}]}' + } + ] + }), { + title: 'Architecture whiteboard', + context: 'Map service boundaries and data flow.', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: '{"version":"6.9.1","objects":[]}' + }, + { + name: 'Canvas 2', + fabricState: '{"version":"6.9.1","objects":[{"type":"rect"}]}' + } + ] + }); + }); + + it('rejects implicit blank whiteboards when neither initialCanvases nor blankCanvas is supplied', () => { + assert.deepStrictEqual( + safeParseInput(WhiteboardInputSchema, { + title: 'Blank whiteboard', + context: 'Start from scratch.' + }), + { + success: false, + error: 'blankCanvas: Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard' + } + ); + }); + + it('allows opening an explicit blank whiteboard when blankCanvas is true', () => { + assert.deepStrictEqual(parseWhiteboardInput({ + title: 'Blank whiteboard', + context: 'Start from scratch.', + blankCanvas: true, + }), { + title: 'Blank whiteboard', + context: 'Start from scratch.', + blankCanvas: true, + }); + }); + + it('parses agent-friendly seeded drawings for starter canvases', () => { + assert.deepStrictEqual(parseWhiteboardInput({ + title: 'Demo whiteboard', + initialCanvases: [ + { + name: 'Demo canvas', + seedElements: [ + { + type: 'rectangle', + id: 'rect_1', + x: 40, + y: 50, + width: 220, + height: 120, + zIndex: 2, + rotation: 30, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + }, + { + type: 'circle', + x: 360, + y: 140, + radius: 60, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.18)', + }, + { + type: 'triangle', + x: 520, + y: 60, + width: 180, + height: 150, + strokeColor: '#16a34a', + fillColor: 'rgba(22,163,74,0.18)', + }, + { + type: 'line', + start: { x: 780, y: 80 }, + end: { x: 1040, y: 220 }, + strokeColor: '#f97316', + strokeWidth: 6, + }, + { + type: 'text', + x: 60, + y: 260, + text: 'Whiteboard Demo', + color: '#111827', + fontSize: 32, + fontWeight: 700, + fontStyle: 'italic', + textAlign: 'center', + fontFamily: 'sans-serif', + }, + ], + }, + ], + }), { + title: 'Demo whiteboard', + initialCanvases: [ + { + name: 'Demo canvas', + seedElements: [ + { + type: 'rectangle', + id: 'rect_1', + x: 40, + y: 50, + width: 220, + height: 120, + zIndex: 2, + rotation: 30, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + }, + { + type: 'circle', + x: 360, + y: 140, + radius: 60, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.18)', + }, + { + type: 'triangle', + x: 520, + y: 60, + width: 180, + height: 150, + strokeColor: '#16a34a', + fillColor: 'rgba(22,163,74,0.18)', + }, + { + type: 'line', + start: { x: 780, y: 80 }, + end: { x: 1040, y: 220 }, + strokeColor: '#f97316', + strokeWidth: 6, + }, + { + type: 'text', + x: 60, + y: 260, + text: 'Whiteboard Demo', + color: '#111827', + fontSize: 32, + fontWeight: 700, + fontStyle: 'italic', + textAlign: 'center', + fontFamily: 'sans-serif', + }, + ], + }, + ], + }); + }); + + it('reports field-specific validation errors for invalid seeded canvases', () => { + assert.deepStrictEqual( + safeParseInput(WhiteboardInputSchema, { + title: 'Broken seed', + initialCanvases: [ + { + name: '', + fabricState: '' + } + ] + }), + { + success: false, + error: 'initialCanvases.0.name: Canvas name cannot be empty; initialCanvases.0.fabricState: Canvas fabricState cannot be empty' + } + ); + }); + + it('rejects seeded coordinates and style values outside the supported whiteboard range', () => { + const result = safeParseInput(WhiteboardInputSchema, { + title: 'Out of bounds seed', + initialCanvases: [ + { + name: 'Canvas 1', + seedElements: [ + { + type: 'rectangle', + x: -10, + y: 2000, + width: 220, + height: 120, + strokeWidth: 200, + }, + ], + }, + ], + }); + + assert.equal(result.success, false); + if (result.success) { + return; + } + + assert.match(result.error, /initialCanvases\.0\.seedElements\.0\.x: Seed coordinate x must be within the whiteboard width \(0-1600\)/); + assert.match(result.error, /initialCanvases\.0\.seedElements\.0\.y: Seed coordinate y must be within the whiteboard height \(0-900\)/); + assert.match(result.error, /initialCanvases\.0\.seedElements\.0\.strokeWidth: Seed element strokeWidth must be between 1 and 64/); + }); + + it('rejects invalid zIndex, rotation, and text style seed metadata', () => { + const invalidMetadata = safeParseInput(WhiteboardInputSchema, { + title: 'Invalid metadata seed', + initialCanvases: [ + { + name: 'Canvas 1', + seedElements: [ + { + type: 'rectangle', + x: 40, + y: 50, + width: 120, + height: 80, + zIndex: -1, + rotation: 500, + }, + { + type: 'text', + x: 80, + y: 140, + text: 'Label', + fontWeight: 2000, + fontStyle: 'slanted', + textAlign: 'edge', + }, + ], + }, + ], + }); + + assert.equal(invalidMetadata.success, false); + if (invalidMetadata.success) { + return; + } + + assert.match(invalidMetadata.error, /initialCanvases\.0\.seedElements\.0\.zIndex: Seed element zIndex must be between 0 and 10000/); + assert.match(invalidMetadata.error, /initialCanvases\.0\.seedElements\.0\.rotation: Seed element rotation must be between -360 and 360 degrees/); + assert.match(invalidMetadata.error, /initialCanvases\.0\.seedElements\.1\.fontWeight: Seed text fontWeight must be between 100 and 900/); + assert.match(invalidMetadata.error, /initialCanvases\.0\.seedElements\.1\.fontStyle: Invalid option: expected one of "normal"\|"italic"\|"oblique"/); + assert.match(invalidMetadata.error, /initialCanvases\.0\.seedElements\.1\.textAlign: Invalid option: expected one of "left"\|"center"\|"right"\|"justify"/); + }); + + it('rejects invalid raw fabricState seed content instead of accepting a blank canvas fallback', () => { + assert.deepStrictEqual( + safeParseInput(WhiteboardInputSchema, { + title: 'Broken seed', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: 'not valid json' + } + ] + }), + { + success: false, + error: 'initialCanvases.0.fabricState: Canvas fabricState must be valid JSON with an objects array' + } + ); + }); + + it('rejects JSON-valid but Fabric-invalid raw fabricState input', () => { + assert.deepStrictEqual( + safeParseInput(WhiteboardInputSchema, { + title: 'Broken fabric object type', + initialCanvases: [ + { + name: 'Canvas 1', + fabricState: '{"version":"6.9.1","objects":[{"type":"rectangle","left":40,"top":50,"width":220,"height":120}]}' + } + ] + }), + { + success: false, + error: 'initialCanvases.0.fabricState: Canvas fabricState contains unsupported Fabric object type "rectangle"' + } + ); + }); + + it('documents the agent-friendly seedElements path in tool metadata', () => { + const packageJson = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) as { + contributes?: { + languageModelTools?: Array<{ + name?: string; + modelDescription?: string; + inputSchema?: { + properties?: { + blankCanvas?: { + description?: string; + }; + initialCanvases?: { + items?: { + properties?: Record; + }; + }; + }; + }; + }>; + }; + }; + const openWhiteboardTool = packageJson.contributes?.languageModelTools?.find((tool) => tool.name === 'open_whiteboard'); + + assert.match(openWhiteboardTool?.modelDescription ?? '', /seedElements/); + assert.match(openWhiteboardTool?.modelDescription ?? '', /blankCanvas/); + assert.match(openWhiteboardTool?.modelDescription ?? '', /coordinate/i); + assert.match(openWhiteboardTool?.modelDescription ?? '', /scene summary|sceneSummary/i); + assert.match( + openWhiteboardTool?.inputSchema?.properties?.blankCanvas?.description ?? '', + /intentionally open an empty whiteboard/i, + ); + assert.match( + openWhiteboardTool?.inputSchema?.properties?.initialCanvases?.items?.properties?.seedElements?.description ?? '', + /simple starter sketches|coordinate/i, + ); + const seedVariants = (openWhiteboardTool as any)?.inputSchema?.properties?.initialCanvases?.items?.properties?.seedElements?.items?.oneOf as + | Array<{ properties?: Record }> + | undefined; + const rectangleSeed = seedVariants?.find((variant) => (variant.properties?.type as { enum?: string[] } | undefined)?.enum?.[0] === 'rectangle'); + const textSeed = seedVariants?.find((variant) => (variant.properties?.type as { enum?: string[] } | undefined)?.enum?.[0] === 'text'); + + assert.ok(rectangleSeed?.properties?.zIndex, 'Expected rectangle seed metadata to include zIndex'); + assert.ok(rectangleSeed?.properties?.rotation, 'Expected rectangle seed metadata to include rotation'); + assert.ok(textSeed?.properties?.fontWeight, 'Expected text seed metadata to include fontWeight'); + assert.ok(textSeed?.properties?.fontStyle, 'Expected text seed metadata to include fontStyle'); + assert.ok(textSeed?.properties?.textAlign, 'Expected text seed metadata to include textAlign'); + }); +}); diff --git a/src/tools/whiteboardToolResult.test.ts b/src/tools/whiteboardToolResult.test.ts new file mode 100644 index 0000000..3e20eeb --- /dev/null +++ b/src/tools/whiteboardToolResult.test.ts @@ -0,0 +1,124 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { createWhiteboardLanguageModelResultParts } from './whiteboardToolResult'; + +describe('whiteboard language model result parts', () => { + it('returns text-only result parts so non-vision models can consume open_whiteboard safely', async () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'whiteboard-tool-result-')); + const imagePath = path.join(tempDirectory, 'canvas.png'); + fs.writeFileSync(imagePath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Zz6kAAAAASUVORK5CYII=', 'base64')); + + try { + const result = await createWhiteboardLanguageModelResultParts({ + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', + interactionId: 'wb_test', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + imageUri: `file://${imagePath}`, + }, + ], + sceneSummary: { + totalCanvases: 1, + totalElements: 1, + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 1, + elements: [ + { + id: 'seed_rect', + objectType: 'rectangle', + strokeColor: '#2563eb', + }, + ], + }, + ], + }, + }); + + assert.equal(result.length, 1); + assert.equal(result[0]?.type, 'text'); + assert.match(result[0]?.value ?? '', /\"interactionId\":\"wb_test\"/); + assert.match(result[0]?.value ?? '', /\"sceneSummary\"/); + } finally { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } + }); + + it('strips fabricState and thumbnail from canvases to prevent chat session crash', async () => { + // Reproduce the annotation bug: result canvases may carry extra heavy fields at runtime + // (fabricState = full Fabric.js JSON, thumbnail = base64 PNG ~100KB+). + const heavyThumbnail = 'data:image/png;base64,' + 'A'.repeat(50000); + const heavyFabricState = JSON.stringify({ version: '6.9.1', objects: [], background: '#ffffff', width: 1600, height: 900 }); + + const result = await createWhiteboardLanguageModelResultParts({ + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', + interactionId: 'wb_heavy_test', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + imageUri: 'file:///tmp/canvas.png', + // Attach extra runtime fields that leaked before the fix + ...{ fabricState: heavyFabricState, thumbnail: heavyThumbnail }, + } as any, + ], + sceneSummary: { totalCanvases: 1, totalElements: 0, canvases: [] }, + }); + + assert.equal(result.length, 1); + assert.equal(result[0]?.type, 'text'); + + const payload = result[0]?.value ?? ''; + const parsed = JSON.parse(payload); + const canvas = parsed.canvases?.[0] ?? {}; + + // Heavy fields must NOT be in the serialized output + assert.ok(!('fabricState' in canvas), 'fabricState must be stripped from LLM result'); + assert.ok(!('thumbnail' in canvas), 'thumbnail must be stripped from LLM result'); + + // Canonical fields must still be present + assert.equal(canvas.id, 'canvas_1'); + assert.equal(canvas.name, 'Canvas 1'); + assert.equal(canvas.imageUri, 'file:///tmp/canvas.png'); + + // Payload must stay well under 50KB + assert.ok(payload.length < 50_000, `Result too large: ${payload.length} bytes`); + }); + + it('preserves the explicit whiteboard action so the model can distinguish approval from change requests', async () => { + const result = await createWhiteboardLanguageModelResultParts({ + submitted: true, + action: 'recreateWithChanges', + instruction: 'The user requested changes to the submitted whiteboard. Address the annotated feedback and call open_whiteboard again with an updated sketch before concluding.', + interactionId: 'wb_review_action', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + imageUri: 'file:///tmp/canvas.png', + }, + ], + sceneSummary: { totalCanvases: 1, totalElements: 0, canvases: [] }, + }); + + const payload = JSON.parse(result[0]?.value ?? '{}'); + assert.equal(payload.action, 'recreateWithChanges'); + assert.equal(payload.submitted, true); + assert.match(payload.instruction, /call open_whiteboard again/i); + }); +}); diff --git a/src/tools/whiteboardToolResult.ts b/src/tools/whiteboardToolResult.ts new file mode 100644 index 0000000..3e6d607 --- /dev/null +++ b/src/tools/whiteboardToolResult.ts @@ -0,0 +1,18 @@ +import type { WhiteboardToolResult } from './schemas'; + +export type WhiteboardLanguageModelResultPart = { type: 'text'; value: string }; + +export async function createWhiteboardLanguageModelResultParts( + result: WhiteboardToolResult, +): Promise { + // Strip heavy fields (fabricState, thumbnail, shapes, images) from canvas entries + // before sending to the LLM — these can be hundreds of KB and crash the chat session. + // The sceneSummary already provides all structural information the model needs. + const lightResult = { + ...result, + canvases: result.canvases.map(({ id, imageUri, name }) => ({ id, imageUri, name })), + }; + return [ + { type: 'text', value: JSON.stringify(lightResult) }, + ]; +} diff --git a/src/webview/interactionListModels.test.ts b/src/webview/interactionListModels.test.ts new file mode 100644 index 0000000..0a89d55 --- /dev/null +++ b/src/webview/interactionListModels.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + buildPendingStoredInteractionEntries, + buildUnifiedHistoryEntries, +} from './interactionListModels'; +import type { StoredInteraction } from './types'; + +describe('interactionListModels', () => { + it('includes pending whiteboards alongside pending plan reviews', () => { + const entries = buildPendingStoredInteractionEntries([ + { + id: 'review_1', + type: 'plan_review', + timestamp: 10, + title: 'Review API plan', + plan: 'Review the rollout plan', + status: 'pending', + }, + { + id: 'wb_1', + type: 'whiteboard', + timestamp: 20, + title: 'Architecture Whiteboard', + whiteboardSession: { + id: 'wb_1', + interactionId: 'wb_1', + context: 'Sketch the service boundaries.', + canvases: [ + { + id: 'canvas_1', + name: 'System diagram', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 1, + }, + ], + activeCanvasId: 'canvas_1', + status: 'pending', + }, + }, + ] as StoredInteraction[]); + + assert.deepStrictEqual(entries.map((entry) => entry.type), ['whiteboard', 'plan_review']); + assert.match(entries[0]?.preview || '', /Sketch the service boundaries/); + }); + + it('includes completed whiteboards in unified history', () => { + const entries = buildUnifiedHistoryEntries([ + { + id: 'ask_1', + type: 'ask_user', + timestamp: 10, + question: 'Ship it?', + agentName: 'Main Orchestrator', + response: 'Yes', + }, + { + id: 'wb_done', + type: 'whiteboard', + timestamp: 30, + title: 'Final Architecture', + whiteboardSession: { + id: 'wb_done', + interactionId: 'wb_done', + context: 'Final sketch', + canvases: [ + { + id: 'canvas_1', + name: 'Overview', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 2, + }, + ], + activeCanvasId: 'canvas_1', + status: 'approved', + submittedAt: 123, + submittedCanvases: [ + { + id: 'canvas_1', + name: 'Overview', + imageUri: 'data:image/png;base64,abc', + }, + ], + }, + }, + ] as StoredInteraction[]); + + assert.deepStrictEqual(entries.map((entry) => entry.id), ['wb_done', 'ask_1']); + assert.strictEqual(entries[0]?.type, 'whiteboard'); + assert.match(entries[0]?.preview || '', /Overview/); + }); + + it('includes whiteboards marked recreateWithChanges in unified history', () => { + const entries = buildUnifiedHistoryEntries([ + { + id: 'wb_changes', + type: 'whiteboard', + timestamp: 40, + title: 'Requested revisions', + whiteboardSession: { + id: 'wb_changes', + interactionId: 'wb_changes', + context: 'Adjust the footer spacing', + canvases: [], + activeCanvasId: undefined, + status: 'recreateWithChanges', + submittedCanvases: [], + }, + }, + ] as StoredInteraction[]); + + assert.equal(entries[0]?.id, 'wb_changes'); + assert.equal(entries[0]?.status, 'recreateWithChanges'); + }); + + it('supports localized whiteboard fallback labels', () => { + const interactions = [ + { + id: 'wb_localized', + type: 'whiteboard', + timestamp: 50, + whiteboardSession: { + id: 'wb_localized', + interactionId: 'wb_localized', + canvases: [], + activeCanvasId: 'canvas_1', + status: 'cancelled', + }, + }, + ] as StoredInteraction[]; + + const entries = buildUnifiedHistoryEntries(interactions, { + defaultTitle: 'Quadro branco', + historyPreview: 'Quadro branco', + pendingPreview: 'Quadro branco pendente', + submittedPreview: 'Quadro branco enviado', + }); + + assert.strictEqual(entries[0]?.title, 'Quadro branco'); + assert.strictEqual(entries[0]?.preview, 'Quadro branco'); + }); +}); diff --git a/src/webview/interactionListModels.ts b/src/webview/interactionListModels.ts new file mode 100644 index 0000000..de3e7bd --- /dev/null +++ b/src/webview/interactionListModels.ts @@ -0,0 +1,175 @@ +import type { StoredInteraction } from './types'; + +export interface PendingStoredInteractionEntry { + id: string; + type: 'plan_review' | 'whiteboard'; + timestamp: number; + title: string; + preview: string; + status: string; + isDebug?: boolean; +} + +export interface UnifiedHistoryEntry { + id: string; + type: 'ask_user' | 'plan_review' | 'whiteboard'; + timestamp: number; + title: string; + preview: string; + status?: string; + isDebug?: boolean; +} + +export interface WhiteboardInteractionLabels { + defaultTitle?: string; + pendingPreview?: string; + historyPreview?: string; + submittedPreview?: string; +} + +const defaultWhiteboardLabels: Required = { + defaultTitle: 'Whiteboard', + pendingPreview: 'Pending whiteboard', + historyPreview: 'Whiteboard', + submittedPreview: 'Submitted whiteboard', +}; + +export function buildPendingStoredInteractionEntries( + interactions: StoredInteraction[], + labels: WhiteboardInteractionLabels = {}, +): PendingStoredInteractionEntry[] { + const whiteboardLabels = { ...defaultWhiteboardLabels, ...labels }; + + return interactions + .filter((interaction): interaction is StoredInteraction & { type: 'plan_review' | 'whiteboard' } => { + if (interaction.type === 'plan_review') { + return interaction.status === 'pending'; + } + + return interaction.type === 'whiteboard' && interaction.whiteboardSession?.status === 'pending'; + }) + .map((interaction) => ({ + id: interaction.id, + type: interaction.type, + timestamp: interaction.timestamp, + title: getInteractionTitle(interaction, whiteboardLabels), + preview: getPendingInteractionPreview(interaction, whiteboardLabels), + status: getInteractionStatus(interaction) || 'pending', + isDebug: interaction.isDebug, + })) + .sort((left, right) => right.timestamp - left.timestamp); +} + +export function buildUnifiedHistoryEntries( + interactions: StoredInteraction[], + labels: WhiteboardInteractionLabels = {}, +): UnifiedHistoryEntry[] { + const whiteboardLabels = { ...defaultWhiteboardLabels, ...labels }; + + return interactions + .filter((interaction) => interaction.type !== 'whiteboard' || isCompletedWhiteboard(interaction)) + .map((interaction) => ({ + id: interaction.id, + type: interaction.type, + timestamp: interaction.timestamp, + title: getInteractionTitle(interaction, whiteboardLabels), + preview: getHistoryPreview(interaction, whiteboardLabels), + status: getInteractionStatus(interaction), + isDebug: interaction.isDebug, + })) + .sort((left, right) => right.timestamp - left.timestamp) as UnifiedHistoryEntry[]; +} + +function getInteractionTitle( + interaction: StoredInteraction, + labels: Required, +): string { + if (interaction.type === 'plan_review') { + return interaction.title || 'Plan Review'; + } + + if (interaction.type === 'whiteboard') { + return interaction.title || interaction.whiteboardSession?.title || labels.defaultTitle; + } + + return interaction.agentName ?? interaction.question ?? 'Ask User'; +} + +function getPendingInteractionPreview( + interaction: StoredInteraction, + labels: Required, +): string { + if (interaction.type === 'plan_review') { + return interaction.plan || ''; + } + + const session = interaction.whiteboardSession; + if (!session) { + return labels.pendingPreview; + } + + return buildWhiteboardPreview( + session.context, + session.canvases.map((canvas) => canvas.name), + labels.pendingPreview, + ); +} + +function getHistoryPreview( + interaction: StoredInteraction, + labels: Required, +): string { + if (interaction.type === 'plan_review') { + return interaction.plan || ''; + } + + if (interaction.type === 'whiteboard') { + const session = interaction.whiteboardSession; + if (!session) { + return labels.historyPreview; + } + + const submittedCanvasNames = session.submittedCanvases?.map((canvas) => canvas.name) || []; + if (submittedCanvasNames.length > 0) { + return buildWhiteboardPreview(session.context, submittedCanvasNames, labels.submittedPreview); + } + + return buildWhiteboardPreview( + session.context, + session.canvases.map((canvas) => canvas.name), + labels.historyPreview, + ); + } + + return interaction.question || ''; +} + +function getInteractionStatus(interaction: StoredInteraction): string | undefined { + if (interaction.type === 'whiteboard') { + return interaction.whiteboardSession?.status || 'pending'; + } + + return interaction.status; +} + +function isCompletedWhiteboard(interaction: StoredInteraction): boolean { + const status = interaction.whiteboardSession?.status; + return interaction.type === 'whiteboard' && ( + status === 'approved' + || status === 'recreateWithChanges' + || status === 'cancelled' + ); +} + +function buildWhiteboardPreview(context: string | undefined, canvasNames: string[], fallback: string): string { + const parts: string[] = []; + if (context) { + parts.push(context); + } + + if (canvasNames.length > 0) { + parts.push(canvasNames.join(', ')); + } + + return parts.join(' • ') || fallback; +} diff --git a/src/webview/main.ts b/src/webview/main.ts index c9d331c..be6c631 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -143,6 +143,17 @@ declare global { historyFilterAll: string; historyFilterAskUser: string; historyFilterPlanReview: string; + historyFilterWhiteboard: string; + whiteboard: string; + openWhiteboard: string; + whiteboardSubmitted: string; + detailWhiteboard: string; + detailWhiteboardContext: string; + detailWhiteboardCanvases: string; + detailWhiteboardSubmittedCanvases: string; + detailWhiteboardNoCanvases: string; + detailWhiteboardSession: string; + detailWhiteboardStatus: string; // Batch selection batchSelectMode: string; batchExitSelectMode: string; @@ -163,12 +174,15 @@ declare global { debugSectionAskUser: string; debugSectionPlanReview: string; debugSectionWalkthroughReview: string; + debugSectionWhiteboard: string; debugMockAskUser: string; debugMockAskUserOptions: string; debugMockAskUserMultiStep: string; debugMockAskUserMultiStepLongText: string; debugMockPlanReview: string; debugMockWalkthroughReview: string; + debugMockWhiteboard: string; + submitted: string; }; __CONFIG__: { historyTimeDisplay: 'relative' | 'absolute' | 'hybrid'; @@ -190,6 +204,10 @@ import type { StoredInteraction, VSCodeAPI, } from './types'; +import { + buildPendingStoredInteractionEntries, + buildUnifiedHistoryEntries, +} from './interactionListModels'; import { truncate, getLogger } from './utils'; type AskUserOptionsLayout = 'expanded' | 'compact'; @@ -410,6 +428,10 @@ function applyAskUserOptionsTooltipMode(): void { show = type === 'plan_review'; } + else if (filter === 'whiteboard') { + show = type === 'whiteboard'; + } + (item as HTMLElement).style.display = show ? '' : 'none'; }); } @@ -730,6 +752,10 @@ function applyAskUserOptionsTooltipMode(): void { vscode.postMessage({ type: 'openPlanReviewPanel', interactionId: id }); + } else if (type === 'whiteboard') { + vscode.postMessage({ + type: 'openWhiteboardPanel', interactionId: id + }); } else { vscode.postMessage({ type: 'selectInteraction', interactionId: id @@ -783,9 +809,11 @@ function applyAskUserOptionsTooltipMode(): void { vscode.postMessage({ type: 'openPlanReviewPanel', interactionId: id }); - } - - else { + } else if (type === 'whiteboard') { + vscode.postMessage({ + type: 'openWhiteboardPanel', interactionId: id + }); + } else { vscode.postMessage({ type: 'selectInteraction', interactionId: id }); @@ -1763,12 +1791,19 @@ function applyAskUserOptionsTooltipMode(): void { } /** - * Render pending plan reviews in the home view - */ - function renderPendingReviews(reviews: StoredInteraction[]): void { + * Render pending stored interactions in the home view. + */ + function renderPendingStoredInteractions(interactions: StoredInteraction[]): void { if (!pendingReviewsList) return; - if (reviews.length === 0) { + const entries = buildPendingStoredInteractionEntries(interactions || [], { + defaultTitle: window.__STRINGS__?.whiteboard || 'Whiteboard', + pendingPreview: window.__STRINGS__?.detailWhiteboardSession || 'Pending whiteboard', + historyPreview: window.__STRINGS__?.whiteboard || 'Whiteboard', + submittedPreview: window.__STRINGS__?.whiteboardSubmitted || 'Whiteboard submitted', + }); + + if (entries.length === 0) { clearChildren(pendingReviewsList); updatePendingPlaceholder(); updateHomeToolbarBadgesFromDom(); @@ -1777,36 +1812,36 @@ function applyAskUserOptionsTooltipMode(): void { clearChildren(pendingReviewsList); - for (const review of reviews) { + for (const entry of entries) { + const isPlanReview = entry.type === 'plan_review'; const item = el('div', { className: 'list-item', - attrs: { 'data-id': review.id, tabindex: '0' } + attrs: { 'data-id': entry.id, 'data-type': entry.type, tabindex: '0' } }); - const typeIcon = codicon('file-text'); + const typeIcon = codicon(isPlanReview ? 'file-text' : 'symbol-color'); // Header: title + meta const header = el('div', { className: 'list-item-header' }); const titleWrapper = el('div', { className: 'list-item-title-wrapper' }); - const title = el('div', { className: 'list-item-title', text: review.title || 'Plan Review' }); + const title = el('div', { className: 'list-item-title', text: entry.title }); titleWrapper.appendChild(title); const meta = el('div', { className: 'list-item-meta' }); - const status = review.status || 'pending'; - const statusBadge = el('span', { className: `status-badge status-${status}`, text: getStatusLabel(review.status) }); - const time = el('span', { className: 'list-item-time', text: formatTime(review.timestamp) }); + const statusBadge = el('span', { className: `status-badge status-${entry.status}`, text: getStatusLabel(entry.status) }); + const time = el('span', { className: 'list-item-time', text: formatTime(entry.timestamp) }); appendChildren(meta, statusBadge, time); const deleteBtn = el('button', { className: 'list-item-delete', title: window.__STRINGS__.close || 'Close', - attrs: { type: 'button', 'data-id': review.id } + attrs: { type: 'button', 'data-id': entry.id } }, codicon('circle-slash')); appendChildren(header, titleWrapper, meta, deleteBtn); // Preview - const preview = el('div', { className: 'list-item-preview', text: truncate(review.plan || '', 100) }); + const preview = el('div', { className: 'list-item-preview', text: truncate(entry.preview || '', 100) }); // Content wrapper const contentWrapper = el('div', { className: 'list-item-content' }); @@ -1815,14 +1850,20 @@ function applyAskUserOptionsTooltipMode(): void { appendChildren(item, typeIcon, contentWrapper); item.addEventListener('click', () => { - vscode.postMessage({ type: 'openPlanReviewPanel', interactionId: review.id }); + vscode.postMessage({ + type: isPlanReview ? 'openPlanReviewPanel' : 'openWhiteboardPanel', + interactionId: entry.id + }); }); item.addEventListener('keydown', (e: Event) => { const keyEvent = e as KeyboardEvent; if (keyEvent.key !== 'Enter' && keyEvent.key !== ' ') return; e.preventDefault(); - vscode.postMessage({ type: 'openPlanReviewPanel', interactionId: review.id }); + vscode.postMessage({ + type: isPlanReview ? 'openPlanReviewPanel' : 'openWhiteboardPanel', + interactionId: entry.id + }); }); pendingReviewsList.appendChild(item); @@ -1834,42 +1875,16 @@ function applyAskUserOptionsTooltipMode(): void { } /** - * Render unified history (ask_user + plan_review), sorted by timestamp desc. - */ + * Render unified history (ask_user + plan_review + whiteboard), sorted by timestamp desc. + */ function renderUnifiedHistory(interactions: StoredInteraction[]): void { if (!historyList) return; - - type UnifiedEntry = { - id: string; - type: 'ask_user' | 'plan_review'; - timestamp: number; - title: string; - preview: string; - status?: string; - isDebug?: boolean; - }; - - const entries: UnifiedEntry[] = []; - - for (const interaction of interactions || []) { - const isPlanReview = interaction.type === 'plan_review'; - - const title = isPlanReview ? (interaction.title || 'Plan Review') : (interaction.agentName ?? interaction.question ?? 'Ask User'); - const preview = isPlanReview ? truncate(interaction.plan || '', 80) : truncate(interaction.question || '', 80); - - entries.push({ - id: interaction.id, - type: interaction.type, - timestamp: interaction.timestamp, - title, - preview, - status: interaction.status, - isDebug: interaction.isDebug, - }); - } - - // Sort newest first, regardless of type - entries.sort((a, b) => b.timestamp - a.timestamp); + const entries = buildUnifiedHistoryEntries(interactions || [], { + defaultTitle: window.__STRINGS__?.whiteboard || 'Whiteboard', + pendingPreview: window.__STRINGS__?.detailWhiteboardSession || 'Pending whiteboard', + historyPreview: window.__STRINGS__?.whiteboard || 'Whiteboard', + submittedPreview: window.__STRINGS__?.whiteboardSubmitted || 'Whiteboard submitted', + }); clearChildren(historyList); @@ -1886,7 +1901,8 @@ function applyAskUserOptionsTooltipMode(): void { for (const entry of entries) { const isPlanReview = entry.type === 'plan_review'; - const icon = isPlanReview ? 'file-text' : 'comment'; + const isWhiteboard = entry.type === 'whiteboard'; + const icon = isPlanReview ? 'file-text' : (isWhiteboard ? 'symbol-color' : 'comment'); const typeIcon = codicon(icon); const statusClass = entry.status || 'pending'; @@ -1910,7 +1926,7 @@ function applyAskUserOptionsTooltipMode(): void { // Meta: time + status badge (inline on first line) const meta = el('div', { className: 'list-item-meta' }); - if (isPlanReview) { + if (isPlanReview || isWhiteboard) { const statusBadge = el('span', { className: `status-badge status-${statusClass}`, text: getStatusLabel(entry.status) @@ -1937,7 +1953,7 @@ function applyAskUserOptionsTooltipMode(): void { appendChildren(header, titleWrapper, meta, viewBtn, deleteBtn); // Second line: preview text - const preview = el('div', { className: 'list-item-preview', text: entry.preview }); + const preview = el('div', { className: 'list-item-preview', text: truncate(entry.preview, 80) }); // Wrapper for content rows const contentWrapper = el('div', { className: 'list-item-content' }); @@ -2012,7 +2028,9 @@ function applyAskUserOptionsTooltipMode(): void { if (headerTitle) { const title = interaction.type === 'plan_review' ? (interaction.title || 'Plan Review') - : (interaction.agentName ? `${interaction.agentName}: Ask User` : 'Ask User'); + : interaction.type === 'whiteboard' + ? (interaction.title || interaction.whiteboardSession?.title || window.__STRINGS__?.whiteboard || 'Whiteboard') + : (interaction.agentName ? `${interaction.agentName}: Ask User` : 'Ask User'); headerTitle.textContent = title; } @@ -2141,12 +2159,85 @@ function applyAskUserOptionsTooltipMode(): void { ); } - else { - + else if (interaction.type === 'plan_review') { // For plan_review, redirect to panel vscode.postMessage({ type: 'openPlanReviewPanel', interactionId: interaction.id }); + } else { + const whiteboardLabel = window.__STRINGS__?.detailWhiteboard || window.__STRINGS__?.whiteboard || 'Whiteboard'; + const contextLabel = window.__STRINGS__?.detailWhiteboardContext || 'Context'; + const canvasesLabel = window.__STRINGS__?.detailWhiteboardCanvases || 'Canvases'; + const submittedLabel = window.__STRINGS__?.detailWhiteboardSubmittedCanvases || 'Submitted canvases'; + const statusLabel = window.__STRINGS__?.detailWhiteboardStatus || 'Status'; + const noCanvasesLabel = window.__STRINGS__?.detailWhiteboardNoCanvases + || `${window.__STRINGS__?.detailWhiteboardCanvases || window.__STRINGS__?.whiteboard || 'Whiteboard'} unavailable`; + const whiteboardSessionLabel = window.__STRINGS__?.detailWhiteboardSession || 'Whiteboard session'; + const session = interaction.whiteboardSession; + const canvasNames = session?.canvases.map((canvas) => canvas.name) || []; + const submittedCanvases = session?.submittedCanvases || []; + + const statusBlock = el('div', { className: 'detail-section detail-section-plain' }, + el('div', { className: 'detail-label' }, + codicon('pulse'), + statusLabel + ), + el('div', { className: 'detail-content' }, getStatusLabel(session?.status)) + ); + + const contextBlock = session?.context + ? el('div', { className: 'detail-section detail-section-plain' }, + el('div', { className: 'detail-label' }, + codicon('note'), + contextLabel + ), + el('div', { className: 'detail-content markdown-content', html: renderMarkdown(session.context) }) + ) + : tn(''); + + const canvasesBlock = el('div', { className: 'detail-section detail-section-plain' }, + el('div', { className: 'detail-label' }, + codicon('symbol-color'), + canvasesLabel + ), + canvasNames.length > 0 + ? el('ul', { className: 'detail-list' }, + ...canvasNames.map((canvasName) => el('li', { text: canvasName })) + ) + : el('div', { className: 'detail-content' }, noCanvasesLabel) + ); + + const submittedBlock = submittedCanvases.length > 0 + ? el('div', { className: 'detail-section detail-section-plain' }, + el('div', { className: 'detail-label' }, + codicon('check'), + submittedLabel + ), + el('ul', { className: 'detail-list' }, + ...submittedCanvases.map((canvas) => el('li', { text: canvas.name })) + ) + ) + : tn(''); + + detailContent.replaceChildren( + el('div', { className: 'detail-section detail-section-plain' }, + el('div', { className: 'detail-label' }, + codicon('symbol-color'), + whiteboardLabel + ), + el('div', { + className: 'detail-content', + text: interaction.title || session?.title || whiteboardSessionLabel + }) + ), + statusBlock, + contextBlock, + canvasesBlock, + submittedBlock, + el('div', { className: 'detail-meta' }, + el('span', { text: formatTime(interaction.timestamp) }) + ) + ); } } } @@ -2157,7 +2248,7 @@ function applyAskUserOptionsTooltipMode(): void { function getStatusLabel(status?: string): string { switch (status) { case 'approved': return window.__STRINGS__?.approved || 'Approved'; - case 'recreateWithChanges': return window.__STRINGS__?.rejected || 'Rejected'; + case 'recreateWithChanges': return 'Request changes'; case 'acknowledged': return window.__STRINGS__?.acknowledged || 'Acknowledged'; case 'pending': return window.__STRINGS__?.pending || 'Pending'; case 'cancelled': return window.__STRINGS__?.cancelled || 'Cancelled'; @@ -3258,7 +3349,7 @@ function applyAskUserOptionsTooltipMode(): void { const debugList = document.getElementById('debug-tools-list'); if (!debugList) return; - type MockDef = { mockType: 'askUser' | 'askUserOptions' | 'askUserMultiStep' | 'askUserMultiStepLongText' | 'planReview' | 'walkthroughReview'; label: string; icon: string }; + type MockDef = { mockType: 'askUser' | 'askUserOptions' | 'askUserMultiStep' | 'askUserMultiStepLongText' | 'planReview' | 'walkthroughReview' | 'whiteboard' | 'whiteboardTest1' | 'whiteboardTest2'; label: string; icon: string }; type SectionDef = { title: string; items: MockDef[] }; const S = window.__STRINGS__; @@ -3284,6 +3375,15 @@ function applyAskUserOptionsTooltipMode(): void { { mockType: 'walkthroughReview', label: S?.debugMockWalkthroughReview || 'Walkthrough Review', icon: 'book' }, ] } +, + { + title: S?.debugSectionWhiteboard || 'Whiteboard', + items: [ + { mockType: 'whiteboard', label: S?.debugMockWhiteboard || S?.openWhiteboard || S?.whiteboard || 'Whiteboard', icon: 'symbol-color' }, + { mockType: 'whiteboardTest1', label: 'Whiteboard Test 1', icon: 'beaker' }, + { mockType: 'whiteboardTest2', label: 'Whiteboard Test 2', icon: 'beaker-stop' }, + ] + } ]; clearChildren(debugList); @@ -3336,15 +3436,17 @@ function applyAskUserOptionsTooltipMode(): void { showList(message.pendingRequests, message.selectedRequestId); } - // Update pending plan reviews if provided - if (message.pendingPlanReviews) { - renderPendingReviews(message.pendingPlanReviews); - } + renderPendingStoredInteractions([ + ...(message.pendingPlanReviews || []), + ...(message.pendingWhiteboards || []), + ]); // Update history interactions if provided renderUnifiedHistory(message.historyInteractions || []); - // Auto-switch to pending tab if there are pending requests/reviews - const totalPending = (message.pendingRequests?.length || 0) + (message.pendingPlanReviews?.length || 0); + // Auto-switch to pending tab if there are pending requests/stored interactions + const totalPending = (message.pendingRequests?.length || 0) + + (message.pendingPlanReviews?.length || 0) + + (message.pendingWhiteboards?.length || 0); if (totalPending > 0) { switchTab('pending'); diff --git a/src/webview/types.test.ts b/src/webview/types.test.ts new file mode 100644 index 0000000..320cd71 --- /dev/null +++ b/src/webview/types.test.ts @@ -0,0 +1,341 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import { + isCompletedStoredInteraction, + isLegacyWhiteboardSubmittedCanvas, + isPendingStoredInteraction, + mergeSubmittedWhiteboardCanvases, + normalizeWhiteboardSubmittedCanvas, + normalizeWhiteboardSubmittedCanvases, + resolveWhiteboardSubmittedCanvas, + resolveWhiteboardSubmittedCanvases, +} from './types'; + +describe('whiteboard submission normalization', () => { + it('detects legacy canvas submissions', () => { + assert.strictEqual(isLegacyWhiteboardSubmittedCanvas({ + canvasId: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy', + }), true); + + assert.strictEqual(isLegacyWhiteboardSubmittedCanvas({ + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }), false); + }); + + it('does not treat mixed id/canvasId payloads as legacy submissions', () => { + assert.strictEqual(isLegacyWhiteboardSubmittedCanvas({ + id: 'canonical-canvas', + canvasId: 'legacy-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }), false); + }); + + it('normalizes legacy submit payloads to canonical ids', () => { + assert.deepStrictEqual( + normalizeWhiteboardSubmittedCanvas({ + canvasId: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy', + }), + { + id: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy', + } + ); + }); + + it('leaves canonical submit payloads unchanged', () => { + const canonical = { + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }; + + assert.deepStrictEqual(normalizeWhiteboardSubmittedCanvas(canonical), canonical); + }); + + it('rejects submit payloads that include both id and canvasId', () => { + assert.throws( + () => normalizeWhiteboardSubmittedCanvas({ + id: 'canonical-canvas', + canvasId: 'legacy-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }), + /cannot include both id and canvasId/ + ); + }); + + it('normalizes mixed submission arrays to canonical canvas ids', () => { + assert.deepStrictEqual( + normalizeWhiteboardSubmittedCanvases([ + { + canvasId: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy', + }, + { + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }, + ]), + [ + { + id: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy', + }, + { + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Canonical', + }, + ] + ); + }); + + it('resolves stored submission names from the current session canvases', () => { + assert.deepStrictEqual( + resolveWhiteboardSubmittedCanvas( + { + id: 'canvas-1', + imageUri: 'file:///resolved.png', + }, + [ + { + id: 'canvas-1', + name: 'Resolved name', + }, + ] + ), + { + id: 'canvas-1', + imageUri: 'file:///resolved.png', + name: 'Resolved name', + } + ); + }); + + it('prefers explicit submit payload names when resolving stored submissions', () => { + assert.deepStrictEqual( + resolveWhiteboardSubmittedCanvas( + { + id: 'canvas-1', + imageUri: 'file:///explicit.png', + name: 'Explicit name', + }, + [ + { + id: 'canvas-1', + name: 'Stored name', + }, + ] + ), + { + id: 'canvas-1', + imageUri: 'file:///explicit.png', + name: 'Explicit name', + } + ); + }); + + it('resolves legacy and canonical submission arrays into stored canvas records', () => { + assert.deepStrictEqual( + resolveWhiteboardSubmittedCanvases( + [ + { + canvasId: 'legacy-canvas', + imageUri: 'file:///legacy.png', + }, + { + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Explicit canonical', + }, + ], + [ + { + id: 'legacy-canvas', + name: 'Legacy canvas', + }, + { + id: 'canonical-canvas', + name: 'Stored canonical', + }, + ] + ), + [ + { + id: 'legacy-canvas', + imageUri: 'file:///legacy.png', + name: 'Legacy canvas', + }, + { + id: 'canonical-canvas', + imageUri: 'file:///canonical.png', + name: 'Explicit canonical', + }, + ] + ); + }); + + it('throws when a stored/result submission cannot be resolved to a canvas name', () => { + assert.throws( + () => resolveWhiteboardSubmittedCanvas( + { + id: 'missing-canvas', + imageUri: 'file:///missing.png', + }, + [] + ), + /missing a name/ + ); + }); + + it('merges authoritative submitted canvas state back into stored canvases', () => { + assert.deepStrictEqual( + mergeSubmittedWhiteboardCanvases( + [ + { + id: 'canvas-1', + imageUri: 'file:///submitted.png', + fabricState: '{"objects":[{"type":"rect"}]}', + thumbnail: 'data:image/png;base64,AAAA', + shapes: [{ id: 'rect_1', objectType: 'rectangle' }], + images: [], + }, + ], + [ + { + id: 'canvas-1', + name: 'Canvas One', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 1, + }, + ] + ), + [ + { + id: 'canvas-1', + name: 'Canvas One', + fabricState: '{"objects":[{"type":"rect"}]}', + thumbnail: 'data:image/png;base64,AAAA', + shapes: [{ id: 'rect_1', objectType: 'rectangle' }], + images: [], + createdAt: 1, + updatedAt: 1, + }, + ] + ); + }); +}); + +describe('stored interaction status helpers', () => { + it('treats pending whiteboard sessions as pending interactions', () => { + assert.strictEqual(isPendingStoredInteraction({ + id: 'wb-pending', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-1', + interactionId: 'wb-pending', + canvases: [], + status: 'pending', + }, + }), true); + }); + + it('does not treat approved whiteboard sessions as pending interactions', () => { + assert.strictEqual(isPendingStoredInteraction({ + id: 'wb-approved', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-1', + interactionId: 'wb-approved', + canvases: [], + status: 'approved', + submittedCanvases: [], + }, + }), false); + }); + + it('treats whiteboards without a stored session as pending interactions', () => { + assert.strictEqual(isPendingStoredInteraction({ + id: 'wb-missing-session', + type: 'whiteboard', + timestamp: 1, + }), true); + }); + + it('treats approved, recreateWithChanges, and cancelled whiteboards as completed interactions', () => { + assert.strictEqual(isCompletedStoredInteraction({ + id: 'wb-approved', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-1', + interactionId: 'wb-approved', + canvases: [], + status: 'approved', + submittedCanvases: [], + }, + }), true); + + assert.strictEqual(isCompletedStoredInteraction({ + id: 'wb-recreate', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-3', + interactionId: 'wb-recreate', + canvases: [], + status: 'recreateWithChanges', + submittedCanvases: [], + }, + }), true); + + assert.strictEqual(isCompletedStoredInteraction({ + id: 'wb-cancelled', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-2', + interactionId: 'wb-cancelled', + canvases: [], + status: 'cancelled', + }, + }), true); + }); + + it('does not treat pending whiteboards as completed interactions', () => { + assert.strictEqual(isCompletedStoredInteraction({ + id: 'wb-pending', + type: 'whiteboard', + timestamp: 1, + whiteboardSession: { + id: 'session-1', + interactionId: 'wb-pending', + canvases: [], + status: 'pending', + }, + }), false); + }); + + it('does not treat whiteboards without a stored session as completed interactions', () => { + assert.strictEqual(isCompletedStoredInteraction({ + id: 'wb-missing-session', + type: 'whiteboard', + timestamp: 1, + }), false); + }); +}); diff --git a/src/webview/types.ts b/src/webview/types.ts index f639422..227ba26 100644 --- a/src/webview/types.ts +++ b/src/webview/types.ts @@ -4,10 +4,181 @@ export interface RequiredPlanRevisions { revisorInstructions: string; } -// Represents a stored interaction (either ask_user or plan_review) +export interface WhiteboardShapeSummary { + id: string; + objectType: string; + label?: string; +} + +export interface WhiteboardImageReference { + id: string; + sourceUri?: string; + mimeType?: string; + width?: number; + height?: number; +} + +export interface WhiteboardCanvas { + id: string; + name: string; + fabricState: string; + thumbnail?: string; + createdAt: number; + updatedAt: number; + shapes?: WhiteboardShapeSummary[]; + images?: WhiteboardImageReference[]; +} + +/** + * Canonical submit payload after the extension normalizes the message boundary. + * `name` remains optional here because legacy/current submit messages may omit it. + */ +export interface NormalizedWhiteboardCanvasSubmission { + id: string; + imageUri: string; + name?: string; + fabricState?: string; + thumbnail?: string; + shapes?: WhiteboardShapeSummary[]; + images?: WhiteboardImageReference[]; +} + +/** + * Stored/result whiteboard record. Names are required once a submission is + * persisted or returned from the tool contract. + */ +export interface WhiteboardSubmittedCanvas { + id: string; + imageUri: string; + name: string; +} + +export interface LegacyWhiteboardSubmittedCanvas { + /** @deprecated Use `id` for submit payloads. */ + canvasId: string; + imageUri: string; + name?: string; + fabricState?: string; + thumbnail?: string; + shapes?: WhiteboardShapeSummary[]; + images?: WhiteboardImageReference[]; +} + +export type WhiteboardCanvasSubmission = NormalizedWhiteboardCanvasSubmission | LegacyWhiteboardSubmittedCanvas; + +export type WhiteboardReviewAction = 'approved' | 'recreateWithChanges' | 'cancelled'; + +export type WhiteboardSessionStatus = 'pending' | WhiteboardReviewAction; + +export function isLegacyWhiteboardSubmittedCanvas( + canvas: WhiteboardCanvasSubmission +): canvas is LegacyWhiteboardSubmittedCanvas { + return 'canvasId' in canvas && !('id' in canvas); +} + +/** + * Normalizes whiteboard submit payloads at the webview message boundary so the + * stored session/result contracts can stay canonical on `id`. + */ +export function normalizeWhiteboardSubmittedCanvas( + canvas: WhiteboardCanvasSubmission +): NormalizedWhiteboardCanvasSubmission { + if ('id' in canvas && 'canvasId' in canvas) { + throw new Error('Whiteboard canvas submission cannot include both id and canvasId'); + } + + if (isLegacyWhiteboardSubmittedCanvas(canvas)) { + return { + id: canvas.canvasId, + imageUri: canvas.imageUri, + ...(typeof canvas.name === 'string' ? { name: canvas.name } : {}), + ...(typeof canvas.fabricState === 'string' ? { fabricState: canvas.fabricState } : {}), + ...(typeof canvas.thumbnail === 'string' ? { thumbnail: canvas.thumbnail } : {}), + ...(Array.isArray(canvas.shapes) ? { shapes: canvas.shapes } : {}), + ...(Array.isArray(canvas.images) ? { images: canvas.images } : {}), + }; + } + + return canvas; +} + +export function normalizeWhiteboardSubmittedCanvases( + canvases: WhiteboardCanvasSubmission[] +): NormalizedWhiteboardCanvasSubmission[] { + return canvases.map(normalizeWhiteboardSubmittedCanvas); +} + +type WhiteboardCanvasNameLookup = Pick; + +export function resolveWhiteboardSubmittedCanvas( + canvas: WhiteboardCanvasSubmission, + canvases: WhiteboardCanvasNameLookup[] +): WhiteboardSubmittedCanvas { + const normalizedCanvas = normalizeWhiteboardSubmittedCanvas(canvas); + const storedCanvas = canvases.find((candidate) => candidate.id === normalizedCanvas.id); + const name = normalizedCanvas.name ?? storedCanvas?.name; + + if (!name) { + throw new Error(`Whiteboard canvas submission '${normalizedCanvas.id}' is missing a name`); + } + + // Return only the canonical fields: extra heavy fields (fabricState, thumbnail, shapes, images) + // must NOT be forwarded to the stored/result contract to avoid bloating tool results. + return { + id: normalizedCanvas.id, + imageUri: normalizedCanvas.imageUri, + name, + }; +} + +export function resolveWhiteboardSubmittedCanvases( + submittedCanvases: WhiteboardCanvasSubmission[], + canvases: WhiteboardCanvasNameLookup[] +): WhiteboardSubmittedCanvas[] { + return submittedCanvases.map((canvas) => resolveWhiteboardSubmittedCanvas(canvas, canvases)); +} + +export function mergeSubmittedWhiteboardCanvases( + submittedCanvases: WhiteboardCanvasSubmission[], + canvases: WhiteboardCanvas[] +): WhiteboardCanvas[] { + const normalizedSubmissions = new Map( + normalizeWhiteboardSubmittedCanvases(submittedCanvases).map((canvas) => [canvas.id, canvas]) + ); + + return canvases.map((canvas) => { + const submittedCanvas = normalizedSubmissions.get(canvas.id); + if (!submittedCanvas) { + return canvas; + } + + return { + ...canvas, + ...(typeof submittedCanvas.name === 'string' ? { name: submittedCanvas.name } : {}), + ...(typeof submittedCanvas.fabricState === 'string' ? { fabricState: submittedCanvas.fabricState } : {}), + ...(typeof submittedCanvas.thumbnail === 'string' ? { thumbnail: submittedCanvas.thumbnail } : {}), + ...(Array.isArray(submittedCanvas.shapes) ? { shapes: submittedCanvas.shapes } : {}), + ...(Array.isArray(submittedCanvas.images) ? { images: submittedCanvas.images } : {}), + }; + }); +} + +export interface WhiteboardSession { + id: string; + interactionId: string; + context?: string; + title?: string; + canvases: WhiteboardCanvas[]; + activeCanvasId?: string; + status: WhiteboardSessionStatus; + submittedAt?: number; + submittedCanvases?: WhiteboardSubmittedCanvas[]; +} + +// Represents a stored interaction (ask_user, plan_review, or whiteboard) export interface StoredInteraction { id: string; - type: 'ask_user' | 'plan_review'; + type: 'ask_user' | 'plan_review' | 'whiteboard'; timestamp: number; isDebug?: boolean; @@ -25,6 +196,37 @@ export interface StoredInteraction { mode?: 'review' | 'walkthrough'; requiredRevisions?: RequiredPlanRevisions[]; status?: 'pending' | 'approved' | 'recreateWithChanges' | 'acknowledged' | 'closed' | 'cancelled'; + + // For whiteboard + whiteboardSession?: WhiteboardSession; +} + +export function isPendingStoredInteraction(interaction: StoredInteraction): boolean { + if (interaction.type === 'plan_review') { + return interaction.status === 'pending'; + } + + if (interaction.type === 'whiteboard') { + const whiteboardStatus = interaction.whiteboardSession?.status; + return whiteboardStatus !== 'approved' + && whiteboardStatus !== 'recreateWithChanges' + && whiteboardStatus !== 'cancelled'; + } + + return false; +} + +export function isCompletedStoredInteraction(interaction: StoredInteraction): boolean { + if (interaction.type === 'ask_user') { + return true; + } + + if (interaction.type === 'whiteboard') { + const whiteboardStatus = interaction.whiteboardSession?.status; + return whiteboardStatus === 'approved' || whiteboardStatus === 'recreateWithChanges' || whiteboardStatus === 'cancelled'; + } + + return interaction.status !== 'pending'; } // Attachment info @@ -130,6 +332,7 @@ export type ToWebviewMessage = | { type: 'showHome'; pendingRequests: RequestItem[]; pendingPlanReviews: StoredInteraction[]; + pendingWhiteboards: StoredInteraction[]; historyInteractions: StoredInteraction[]; recentInteractions: ToolCallInteraction[]; selectedRequestId?: string; @@ -238,6 +441,10 @@ export type FromWebviewMessage = | { type: 'openPlanReviewPanel'; interactionId: string } + | { + type: 'openWhiteboardPanel'; + interactionId: string + } | { type: 'deleteInteraction'; interactionId: string @@ -296,7 +503,7 @@ export type FromWebviewMessage = | { | { type: 'ready' } | { type: 'debugMockToolCall'; - mockType: 'askUser' | 'askUserOptions' | 'askUserMultiStep' | 'askUserMultiStepLongText' | 'planReview' | 'walkthroughReview'; + mockType: 'askUser' | 'askUserOptions' | 'askUserMultiStep' | 'askUserMultiStepLongText' | 'planReview' | 'walkthroughReview' | 'whiteboard' | 'whiteboardTest1' | 'whiteboardTest2'; } | { type: 'openSettings' @@ -336,6 +543,41 @@ export type PlanReviewPanelFromWebviewMessage = | { type: 'editComment'; index: number; revisorInstructions: string } | { type: 'removeComment'; index: number } | { type: 'exportPlan' }; + + +export type WhiteboardToExtensionMessage = + | { type: 'ready' } + | { type: 'submit'; action: Exclude; canvases: WhiteboardCanvasSubmission[] } + | { type: 'cancel' } + | { + type: 'saveCanvas'; + canvasId: string; + name?: string; + fabricState: string; + thumbnail?: string; + shapes?: WhiteboardShapeSummary[]; + images?: WhiteboardImageReference[]; + } + | { type: 'deleteCanvas'; canvasId: string } + | { type: 'createCanvas'; name: string; canvasId?: string; fabricState?: string } + | { type: 'switchCanvas'; canvasId: string }; + +export type ExtensionToWhiteboardMessage = + | { type: 'initialize'; session: WhiteboardSession; title: string } + | { type: 'cancel' } + | { type: 'error'; message: string }; + +export interface WhiteboardPanelOptions { + interactionId: string; + title: string; + session: WhiteboardSession; +} + +export interface WhiteboardPanelResult { + submitted: boolean; + action: WhiteboardReviewAction; + canvases: WhiteboardCanvasSubmission[]; +} // File search result for autocomplete export interface FileSearchResult { name: string; diff --git a/src/webview/uiIntegration.test.ts b/src/webview/uiIntegration.test.ts new file mode 100644 index 0000000..a7a7635 --- /dev/null +++ b/src/webview/uiIntegration.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +const repoRoot = process.cwd(); +const webviewHtml = fs.readFileSync(path.join(repoRoot, 'media', 'webview.html'), 'utf8'); +const mainTs = fs.readFileSync(path.join(repoRoot, 'src', 'webview', 'main.ts'), 'utf8'); +const localizationTs = fs.readFileSync(path.join(repoRoot, 'src', 'localization.ts'), 'utf8'); + +const localeFiles = [ + 'package.nls.json', + 'package.nls.pt-br.json', + 'package.nls.pt.json', +] as const; + +const requiredLocaleKeys = [ + 'history.filter.whiteboard', + 'debug.sectionWhiteboard', + 'debug.mockWhiteboard', + 'detail.whiteboard', + 'detail.whiteboardContext', + 'detail.whiteboardCanvases', + 'detail.whiteboardSubmittedCanvases', + 'detail.whiteboardNoCanvases', + 'detail.whiteboardSession', + 'detail.whiteboardStatus', + 'status.submitted', +] as const; + +describe('whiteboard UI integration', () => { + it('adds a dedicated whiteboard history filter to the webview template', () => { + assert.match(webviewHtml, /data-filter="whiteboard"/); + assert.match(webviewHtml, /\{\{historyFilterWhiteboard\}\}/); + }); + + it('wires whiteboard filter and debug labels through localized webview strings', () => { + assert.match(mainTs, /filter === 'whiteboard'/); + assert.match(mainTs, /historyFilterWhiteboard/); + assert.match(mainTs, /debugSectionWhiteboard/); + assert.match(mainTs, /debugMockWhiteboard/); + }); + + it('localizes whiteboard detail labels instead of hardcoding them in the webview', () => { + assert.match(mainTs, /detailWhiteboard/); + assert.doesNotMatch(mainTs, /const contextLabel = 'Context';/); + assert.doesNotMatch(mainTs, /const canvasesLabel = 'Canvases';/); + assert.doesNotMatch(mainTs, /const submittedLabel = 'Submitted canvases';/); + assert.doesNotMatch(mainTs, /const statusLabel = 'Status';/); + assert.doesNotMatch(mainTs, /'No canvases stored'/); + assert.doesNotMatch(mainTs, /'Open Whiteboard'/); + }); + + it('reopens whiteboard history items in the whiteboard panel instead of the detail view', () => { + assert.match(mainTs, /else if \(type === 'whiteboard'\)/); + assert.match(mainTs, /type: 'openWhiteboardPanel', interactionId: id/); + }); + + it('defines whiteboard integration localization accessors', () => { + assert.match(localizationTs, /get historyFilterWhiteboard\(\)/); + assert.match(localizationTs, /get debugSectionWhiteboard\(\)/); + assert.match(localizationTs, /get debugMockWhiteboard\(\)/); + assert.match(localizationTs, /get detailWhiteboard\(\)/); + assert.match(localizationTs, /get detailWhiteboardContext\(\)/); + assert.match(localizationTs, /get detailWhiteboardCanvases\(\)/); + assert.match(localizationTs, /get detailWhiteboardSubmittedCanvases\(\)/); + assert.match(localizationTs, /get detailWhiteboardNoCanvases\(\)/); + assert.match(localizationTs, /get detailWhiteboardSession\(\)/); + assert.match(localizationTs, /get detailWhiteboardStatus\(\)/); + assert.match(localizationTs, /get submitted\(\)/); + }); + + it('ships the required whiteboard integration localization keys in every locale bundle', () => { + for (const file of localeFiles) { + const bundle = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')) as Record; + + for (const key of requiredLocaleKeys) { + assert.ok(bundle[key], `Expected ${file} to define ${key}`); + } + } + }); +}); diff --git a/src/webview/utils/mockToolCall.ts b/src/webview/utils/mockToolCall.ts index 0389792..a79772d 100644 --- a/src/webview/utils/mockToolCall.ts +++ b/src/webview/utils/mockToolCall.ts @@ -1,3 +1,6 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; import { Logger } from '../../logging'; import type { PlanReviewResult } from '../types'; import type { AgentInteractionProvider } from '../webviewProvider'; @@ -5,6 +8,43 @@ import type { AgentInteractionProvider } from '../webviewProvider'; export class MockToolCallService { + private static loadWhiteboardDebugPayload(webviewProvider: AgentInteractionProvider, fileName: string): any { + const filePath = path.join(webviewProvider.getContext().extensionUri.fsPath, fileName); + Logger.log('[Debug Mock] loading whiteboard payload:', filePath); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } + + private static async openDebugWhiteboard( + webviewProvider: AgentInteractionProvider, + tokenSource: vscode.CancellationTokenSource, + payload: any, + label: string, + ): Promise { + const { openWhiteboard } = await import('../../tools/openWhiteboard'); + Logger.log('[Debug Mock] opening whiteboard payload:', { + label, + title: payload?.title, + canvasCount: Array.isArray(payload?.initialCanvases) ? payload.initialCanvases.length : 0, + elementCounts: Array.isArray(payload?.initialCanvases) + ? payload.initialCanvases.map((canvas: any) => Array.isArray(canvas?.seedElements) ? canvas.seedElements.length : 0) + : [], + }); + + return openWhiteboard( + payload, + webviewProvider.getContext(), + webviewProvider, + tokenSource.token, + { isDebug: true } + ).then(result => { + Logger.log(`[Debug Mock] ${label} result:`, result); + }).catch((err: any) => { + Logger.error(`[Debug Mock] ${label} error:`, err); + }).finally(() => { + tokenSource.dispose(); + }); + } + public static async mockToolCall(mockType: string, webviewProvider: AgentInteractionProvider): Promise { const storage = webviewProvider.getChatHistoryStorage(); @@ -196,6 +236,95 @@ export class MockToolCallService { break; } + case 'whiteboard': { + const tokenSource = new vscode.CancellationTokenSource(); + void this.openDebugWhiteboard( + webviewProvider, + tokenSource, + { + title: 'Debug: Whiteboard', + context: 'Sketch a deployment diagram or annotate the placeholder canvases.', + initialCanvases: [ + { + name: 'System diagram', + seedElements: [ + { + type: 'rectangle', + x: 80, + y: 80, + width: 260, + height: 140, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.15)', + }, + { + type: 'text', + x: 120, + y: 135, + text: 'API', + color: '#1e3a8a', + fontSize: 30, + }, + { + type: 'line', + start: { x: 340, y: 150 }, + end: { x: 540, y: 150 }, + strokeColor: '#f97316', + strokeWidth: 6, + }, + { + type: 'circle', + x: 650, + y: 150, + radius: 70, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.15)', + } + ] + }, + { + name: 'Notes', + seedElements: [ + { + type: 'triangle', + x: 100, + y: 120, + width: 180, + height: 150, + strokeColor: '#16a34a', + fillColor: 'rgba(22,163,74,0.18)', + }, + { + type: 'text', + x: 70, + y: 320, + text: 'Add notes here', + color: '#111827', + fontSize: 26, + } + ] + } + ] + }, + 'whiteboard', + ); + break; + } + + case 'whiteboardTest1': { + const tokenSource = new vscode.CancellationTokenSource(); + const payload = this.loadWhiteboardDebugPayload(webviewProvider, 'whiteboard_input test 1.json'); + void this.openDebugWhiteboard(webviewProvider, tokenSource, payload, 'whiteboardTest1'); + break; + } + + case 'whiteboardTest2': { + const tokenSource = new vscode.CancellationTokenSource(); + const payload = this.loadWhiteboardDebugPayload(webviewProvider, 'whiteboard_input test 2.json'); + void this.openDebugWhiteboard(webviewProvider, tokenSource, payload, 'whiteboardTest2'); + break; + } + case 'walkthroughReview': { const mockWalkthrough = `# Getting Started Guide\n\n## Step 1: Install Dependencies\n\`\`\`bash\nnpm install\n\`\`\`\n\n## Step 2: Configure Environment\nCreate a \`.env\` file:\n\`\`\`\nDATABASE_URL=postgresql://localhost:5432/mydb\nAPI_KEY=your-api-key\n\`\`\`\n\n## Step 3: Run Migrations\n\`\`\`bash\nnpm run db:migrate\n\`\`\`\n\n## Step 4: Start Development Server\n\`\`\`bash\nnpm run dev\n\`\`\`\n\nVisit http://localhost:3000 to see the app running.`; @@ -235,4 +364,4 @@ export class MockToolCallService { } } } -} \ No newline at end of file +} diff --git a/src/webview/webviewProvider.ts b/src/webview/webviewProvider.ts index a7377cb..8c2f451 100644 --- a/src/webview/webviewProvider.ts +++ b/src/webview/webviewProvider.ts @@ -21,6 +21,8 @@ import { UserResponseResult, AskUserOptions, PlanReviewResult, + mergeSubmittedWhiteboardCanvases, + resolveWhiteboardSubmittedCanvases, } from "./types"; import { truncate } from './utils'; import { Logger } from '../logging'; @@ -397,27 +399,35 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { private _showHome(): void { const pendingRequests = Array.from(this._pendingRequests.values()).map(p => p.item); const pendingPlanReviews = this._chatHistoryStorage.getPendingPlanReviews(); + const pendingWhiteboards = this._chatHistoryStorage.getPendingWhiteboards(); const historyInteractions = this._chatHistoryStorage.getCompletedInteractions(); Logger.debug('_showHome called:', { pendingRequestsCount: pendingRequests.length, pendingPlanReviewsCount: pendingPlanReviews.length, + pendingWhiteboardsCount: pendingWhiteboards.length, historyInteractionsCount: historyInteractions.length, - pendingPlanReviews: pendingPlanReviews.map(r => ({ id: r.id, title: r.title, status: r.status })) + pendingPlanReviews: pendingPlanReviews.map(r => ({ id: r.id, title: r.title, status: r.status })), + pendingWhiteboards: pendingWhiteboards.map((interaction) => ({ + id: interaction.id, + title: interaction.title, + status: interaction.whiteboardSession?.status, + })) }); const message: ToWebviewMessage = { type: 'showHome', pendingRequests, pendingPlanReviews, + pendingWhiteboards, historyInteractions, recentInteractions: this._recentInteractions, selectedRequestId: this._lastOpenedRequestId || undefined }; this._view?.webview.postMessage(message); - // Update badge with total pending count (requests + plan reviews) - const totalPending = pendingRequests.length + pendingPlanReviews.length; + // Update badge with total pending count (requests + stored interactions) + const totalPending = pendingRequests.length + pendingPlanReviews.length + pendingWhiteboards.length; this._setBadge(totalPending); } @@ -529,6 +539,9 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { case 'openPlanReviewPanel': this._handleOpenPlanReviewPanel(message.interactionId); break; + case 'openWhiteboardPanel': + this._handleOpenWhiteboardPanel(message.interactionId); + break; case 'deleteInteraction': this._handleDeleteInteraction(message.interactionId); break; @@ -595,7 +608,10 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { } const canceled = this.cancelRequest(requestId); if (!canceled) { - await this.cancelReview(requestId); + const cancelledReview = await this.cancelReview(requestId); + if (!cancelledReview) { + await this.cancelWhiteboard(requestId); + } } } finally { this._showHome(); @@ -612,6 +628,24 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { return closed; } + private async cancelWhiteboard(interactionId: string): Promise { + const interaction = this._chatHistoryStorage.getInteraction(interactionId); + if (!interaction || interaction.type !== 'whiteboard') { + return false; + } + + const { WhiteboardPanel } = await import('./whiteboardPanel'); + const closed = WhiteboardPanel.closeIfOpen(interactionId); + if (!closed) { + this._chatHistoryStorage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + } + return true; + } + /** * Handle adding an attachment via VS Code's file picker (Quick Pick) */ @@ -1439,6 +1473,74 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { } } + private async _handleOpenWhiteboardPanel(interactionId: string): Promise { + const interaction = this._chatHistoryStorage.getInteraction(interactionId); + const session = interaction?.whiteboardSession; + + if (!interaction || interaction.type !== 'whiteboard' || !session) { + return; + } + + const { WhiteboardPanel } = await import('./whiteboardPanel'); + const title = interaction.title || session.title || 'Whiteboard'; + const panelOptions = { + interactionId, + title, + session: { + ...session, + id: session.id || interactionId, + interactionId, + title, + }, + }; + + if (session.status === 'pending' && WhiteboardPanel.hasPendingResolver(interactionId)) { + if (WhiteboardPanel.reopenPending(this._extensionUri, interactionId, panelOptions)) { + return; + } + } + + try { + const result = await WhiteboardPanel.showWithOptions(this._extensionUri, panelOptions); + if (session.status !== 'pending') { + return; + } + + const resolvedCanvases = result.submitted + ? mergeSubmittedWhiteboardCanvases(result.canvases, panelOptions.session.canvases) + : panelOptions.session.canvases; + const submittedCanvases = result.submitted + ? resolveWhiteboardSubmittedCanvases(result.canvases, resolvedCanvases) + : []; + + this._chatHistoryStorage.updateWhiteboardInteraction(interactionId, { + title, + whiteboardSession: { + status: result.action, + submittedAt: result.submitted ? Date.now() : undefined, + submittedCanvases, + ...(result.submitted + ? { + canvases: resolvedCanvases, + activeCanvasId: panelOptions.session.activeCanvasId, + } + : {}), + }, + }); + this._showHome(); + } catch (error) { + Logger.error('Error showing whiteboard panel:', error); + if (session.status === 'pending') { + this._chatHistoryStorage.updateWhiteboardInteraction(interactionId, { + whiteboardSession: { + status: 'cancelled', + }, + }); + this._showHome(); + } + } + } + private _getHtmlContent(webview: vscode.Webview): string { // Get URIs for resources const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'main.css')); @@ -1532,6 +1634,17 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { '{{historyFilterAll}}': strings.historyFilterAll, '{{historyFilterAskUser}}': strings.historyFilterAskUser, '{{historyFilterPlanReview}}': strings.historyFilterPlanReview, + '{{historyFilterWhiteboard}}': strings.historyFilterWhiteboard, + '{{whiteboard}}': strings.whiteboard, + '{{openWhiteboard}}': strings.openWhiteboard, + '{{whiteboardSubmitted}}': strings.whiteboardSubmitted, + '{{detailWhiteboard}}': strings.detailWhiteboard, + '{{detailWhiteboardContext}}': strings.detailWhiteboardContext, + '{{detailWhiteboardCanvases}}': strings.detailWhiteboardCanvases, + '{{detailWhiteboardSubmittedCanvases}}': strings.detailWhiteboardSubmittedCanvases, + '{{detailWhiteboardNoCanvases}}': strings.detailWhiteboardNoCanvases, + '{{detailWhiteboardSession}}': strings.detailWhiteboardSession, + '{{detailWhiteboardStatus}}': strings.detailWhiteboardStatus, // Batch selection '{{batchSelectMode}}': strings.batchSelectMode, '{{batchExitSelectMode}}': strings.batchExitSelectMode, @@ -1550,12 +1663,15 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { '{{debugSectionAskUser}}': strings.debugSectionAskUser, '{{debugSectionPlanReview}}': strings.debugSectionPlanReview, '{{debugSectionWalkthroughReview}}': strings.debugSectionWalkthroughReview, + '{{debugSectionWhiteboard}}': strings.debugSectionWhiteboard, '{{debugMockAskUser}}': strings.debugMockAskUser, '{{debugMockAskUserOptions}}': strings.debugMockAskUserOptions, '{{debugMockAskUserMultiStep}}': strings.debugMockAskUserMultiStep, '{{debugMockAskUserMultiStepLongText}}': strings.debugMockAskUserMultiStepLongText, '{{debugMockPlanReview}}': strings.debugMockPlanReview, '{{debugMockWalkthroughReview}}': strings.debugMockWalkthroughReview, + '{{debugMockWhiteboard}}': strings.debugMockWhiteboard, + '{{submitted}}': strings.submitted, '{{enableToolDebug}}': String(enableToolDebug), }; diff --git a/src/webview/whiteboard.test.ts b/src/webview/whiteboard.test.ts new file mode 100644 index 0000000..f038945 --- /dev/null +++ b/src/webview/whiteboard.test.ts @@ -0,0 +1,735 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { + applyStyleControlsToObject, + applyToolMode, + applyWhiteboardHydrationErrorState, + applyCanvasCollectionAction, + buildArrowPath, + clearFabricCanvas, + createWhiteboardHydrationErrorMessage, + createBlankFabricCanvasState, + eraseObjectsAtPoint, + ensureWhiteboardSessionHasUsableCanvas, + getShapeDraftStrokeWidthUpdate, + getBrushSettings, + normalizeCircleDraftGeometry, + normalizeSerializedCanvasState, + normalizeSerializedCanvasStateOrThrow, + parseWhiteboardDocumentState, + pushUndoSnapshot, + serializeCanvasState, + serializeWhiteboardDocumentState, + stepUndoRedoHistory, + summarizeSerializedCanvasState, +} from './whiteboard'; + +describe('whiteboard state helpers', () => { + it('creates, switches, and deletes canvases while keeping a valid active canvas', () => { + const created = applyCanvasCollectionAction({ + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + fabricState: '{"version":"1"}', + createdAt: 1, + updatedAt: 1, + }, + ], + activeCanvasId: 'canvas_1', + }, { + type: 'create', + canvas: { + id: 'canvas_2', + name: 'Canvas 2', + fabricState: '{"version":"2"}', + createdAt: 2, + updatedAt: 2, + }, + }); + + assert.equal(created.activeCanvasId, 'canvas_2'); + assert.equal(created.canvases.length, 2); + + const unchangedSwitch = applyCanvasCollectionAction(created, { + type: 'switch', + canvasId: 1 as unknown as string, + }); + assert.equal(unchangedSwitch.activeCanvasId, 'canvas_2'); + + const switched = applyCanvasCollectionAction(created, { + type: 'switch', + canvasId: 'canvas_1', + }); + assert.equal(switched.activeCanvasId, 'canvas_1'); + + const deleted = applyCanvasCollectionAction(switched, { + type: 'delete', + canvasId: 'canvas_1', + }); + + assert.deepStrictEqual(deleted.canvases.map((canvas) => canvas.id), ['canvas_2']); + assert.equal(deleted.activeCanvasId, 'canvas_2'); + }); + + it('creates an empty Fabric-backed canvas state', () => { + const state = createBlankFabricCanvasState(); + + assert.equal(typeof state.version, 'string'); + assert.equal(state.width, 1600); + assert.equal(state.height, 900); + assert.equal(state.backgroundColor, '#ffffff'); + assert.deepStrictEqual(state.objects, []); + }); + + it('throws instead of silently blanking invalid serialized canvas state when strict hydration is used', () => { + assert.throws( + () => normalizeSerializedCanvasStateOrThrow('not valid json'), + /Canvas data is not valid JSON/, + ); + }); + + it('migrates legacy custom whiteboard documents into fabric-compatible json', () => { + const normalized = normalizeSerializedCanvasState(JSON.stringify({ + version: 1, + width: 800, + height: 600, + backgroundColor: '#fefefe', + objects: [ + { + id: 'path_1', + type: 'path', + stroke: '#111111', + fill: 'transparent', + strokeWidth: 3, + opacity: 1, + points: [{ x: 10, y: 20 }, { x: 30, y: 40 }], + }, + { + id: 'rect_1', + type: 'rectangle', + stroke: '#222222', + fill: '#eeeeee', + strokeWidth: 2, + opacity: 0.75, + x: 5, + y: 6, + width: 70, + height: 80, + }, + { + id: 'arrow_1', + type: 'arrow', + stroke: '#333333', + fill: 'transparent', + strokeWidth: 4, + opacity: 1, + start: { x: 0, y: 0 }, + end: { x: 100, y: 50 }, + }, + { + id: 'text_1', + type: 'text', + stroke: '#444444', + fill: '#ffffff', + strokeWidth: 1, + opacity: 1, + x: 12, + y: 34, + text: 'Hello', + fontSize: 24, + fontFamily: 'sans-serif', + }, + { + id: 'image_1', + type: 'image', + stroke: '#000000', + fill: 'transparent', + strokeWidth: 0, + opacity: 1, + x: 15, + y: 25, + width: 120, + height: 90, + src: 'data:image/png;base64,AAAA', + mimeType: 'image/png', + sourceUri: 'clipboard.png', + }, + ], + })); + + assert.equal(normalized.width, 800); + assert.equal(normalized.height, 600); + assert.equal(normalized.backgroundColor, '#fefefe'); + assert.deepStrictEqual( + normalized.objects.map((object) => [object.type, object.whiteboardObjectType, object.whiteboardId]), + [ + ['path', 'path', 'path_1'], + ['rect', 'rectangle', 'rect_1'], + ['path', 'arrow', 'arrow_1'], + ['i-text', 'text', 'text_1'], + ['image', 'image', 'image_1'], + ], + ); + assert.equal(normalized.objects[4].whiteboardSourceUri, 'clipboard.png'); + assert.equal(normalized.objects[4].whiteboardMimeType, 'image/png'); + }); + + it('canonicalizes serialized circle objects into path-backed circles without losing center geometry', () => { + const normalized = normalizeSerializedCanvasState(JSON.stringify({ + version: '6.9.1', + width: 800, + height: 600, + backgroundColor: '#ffffff', + objects: [ + { + type: 'circle', + whiteboardId: 'circle_1', + whiteboardObjectType: 'circle', + left: 120, + top: 80, + originX: 'center', + originY: 'center', + radius: 20, + stroke: '#ffffff', + fill: 'rgba(255,255,255,0.3)', + strokeWidth: 2, + }, + ], + })); + + assert.equal(normalized.objects[0]?.type, 'path'); + assert.equal(normalized.objects[0]?.whiteboardObjectType, 'circle'); + + assert.deepStrictEqual(parseWhiteboardDocumentState(JSON.stringify(normalized)), { + version: 1, + width: 800, + height: 600, + backgroundColor: '#ffffff', + objects: [ + { + id: 'circle_1', + type: 'circle', + stroke: '#ffffff', + fill: 'rgba(255,255,255,0.3)', + strokeWidth: 2, + opacity: 1, + x: 120, + y: 80, + radius: 20, + }, + ], + }); + }); + + it('normalizes supported Fabric runtime class names before strict hydration', () => { + const normalized = normalizeSerializedCanvasStateOrThrow(JSON.stringify({ + version: '6.9.1', + width: 800, + height: 600, + backgroundColor: '#ffffff', + objects: [ + { + type: 'Rect', + whiteboardId: 'rect_1', + whiteboardObjectType: 'rectangle', + left: 20, + top: 30, + width: 140, + height: 80, + stroke: '#111111', + fill: 'rgba(17,17,17,0.1)', + strokeWidth: 2, + }, + ], + })); + + assert.equal(normalized.objects[0]?.type, 'rect'); + assert.equal(normalized.objects[0]?.whiteboardObjectType, 'rectangle'); + }); + + it('round-trips legacy whiteboard documents through fabric serialization helpers', () => { + const legacySerialized = serializeWhiteboardDocumentState({ + version: 1, + width: 1024, + height: 768, + backgroundColor: '#fdfdfd', + objects: [ + { + id: 'rect_1', + type: 'rectangle', + stroke: '#111111', + fill: '#f0f0f0', + strokeWidth: 2, + opacity: 1, + x: 40, + y: 50, + width: 200, + height: 120, + }, + { + id: 'text_1', + type: 'text', + stroke: '#222222', + fill: '#ffffff', + strokeWidth: 1, + opacity: 1, + x: 80, + y: 120, + text: 'Annotate me', + fontSize: 24, + fontFamily: 'sans-serif', + }, + ], + }); + + assert.deepStrictEqual(parseWhiteboardDocumentState(legacySerialized), { + version: 1, + width: 1024, + height: 768, + backgroundColor: '#fdfdfd', + objects: [ + { + id: 'rect_1', + type: 'rectangle', + stroke: '#111111', + fill: '#f0f0f0', + strokeWidth: 2, + opacity: 1, + x: 40, + y: 50, + width: 200, + height: 120, + }, + { + id: 'text_1', + type: 'text', + stroke: '#222222', + fill: '#222222', + strokeWidth: 1, + opacity: 1, + x: 80, + y: 120, + text: 'Annotate me', + fontSize: 24, + fontFamily: 'sans-serif', + }, + ], + }); + }); + + it('summarizes serialized fabric objects for persistence metadata', () => { + const serialized = serializeCanvasState({ + version: '6.9.1', + width: 640, + height: 480, + backgroundColor: '#ffffff', + objects: [ + { type: 'rect', whiteboardId: 'rect_1', whiteboardObjectType: 'rectangle' }, + { type: 'path', whiteboardId: 'arrow_1', whiteboardObjectType: 'arrow' }, + { type: 'i-text', whiteboardId: 'text_1', whiteboardObjectType: 'text', text: 'Label' }, + { + type: 'image', + whiteboardId: 'image_1', + whiteboardObjectType: 'image', + whiteboardSourceUri: 'drop.png', + whiteboardMimeType: 'image/png', + width: 320, + height: 240, + }, + ], + }); + + assert.deepStrictEqual(summarizeSerializedCanvasState(serialized), { + shapes: [ + { id: 'rect_1', objectType: 'rectangle' }, + { id: 'arrow_1', objectType: 'arrow' }, + { id: 'text_1', objectType: 'text', label: 'Label' }, + { id: 'image_1', objectType: 'image' }, + ], + images: [ + { + id: 'image_1', + sourceUri: 'drop.png', + mimeType: 'image/png', + width: 320, + height: 240, + }, + ], + }); + }); + + it('backfills a usable blank canvas for empty sessions', () => { + const normalized = ensureWhiteboardSessionHasUsableCanvas({ + id: 'wb_blank', + interactionId: 'wb_blank', + status: 'pending', + canvases: [], + }, 42); + + assert.equal(normalized.canvases.length, 1); + assert.equal(normalized.activeCanvasId, 'canvas_42_1'); + assert.equal(normalized.canvases[0]?.name, 'Canvas 1'); + assert.match(normalized.canvases[0]?.fabricState ?? '', /"objects":\[\]/); + assert.equal(normalized.canvases[0]?.createdAt, 42); + assert.equal(normalized.canvases[0]?.updatedAt, 42); + }); + + it('formats and applies a visible hydration error state instead of leaving a silent blank canvas', () => { + const canvasPanel = { dataset: {} as Record }; + const status = { textContent: '', dataset: {} as Record }; + const errorBanner = { hidden: true, textContent: '' }; + const submitButton = { disabled: false }; + const canvasElement = { + attributes: {} as Record, + setAttribute(name: string, value: string) { + this.attributes[name] = value; + }, + removeAttribute(name: string) { + delete this.attributes[name]; + }, + }; + + const message = createWhiteboardHydrationErrorMessage('Canvas 1', new Error('fabric: No class registered for rectangle')); + applyWhiteboardHydrationErrorState({ canvasPanel, status, errorBanner, submitButton, canvasElement }, message); + + assert.equal(status.dataset.state, 'error'); + assert.equal(status.textContent, message); + assert.equal(canvasPanel.dataset.state, 'error'); + assert.equal(errorBanner.hidden, false); + assert.equal(errorBanner.textContent, message); + assert.equal(submitButton.disabled, true); + assert.equal(canvasElement.attributes['aria-invalid'], 'true'); + + applyWhiteboardHydrationErrorState({ canvasPanel, status, errorBanner, submitButton, canvasElement }); + + assert.equal(canvasPanel.dataset.state, 'ready'); + assert.equal(errorBanner.hidden, true); + assert.equal(errorBanner.textContent, ''); + assert.equal(submitButton.disabled, false); + assert.equal(canvasElement.attributes['aria-invalid'], undefined); + }); + + it('tracks undo and redo snapshots without duplicating the current state', () => { + const baseHistory = { + past: [] as string[], + present: '{"objects":[]}', + future: [] as string[], + }; + + const unchanged = pushUndoSnapshot(baseHistory, '{"objects":[]}'); + assert.deepStrictEqual(unchanged, baseHistory); + + const withFirstChange = pushUndoSnapshot(baseHistory, '{"objects":[{"id":"a"}]}'); + assert.deepStrictEqual(withFirstChange, { + past: ['{"objects":[]}'], + present: '{"objects":[{"id":"a"}]}', + future: [], + }); + + const undone = stepUndoRedoHistory(withFirstChange, 'undo'); + assert.deepStrictEqual(undone, { + past: [], + present: '{"objects":[]}', + future: ['{"objects":[{"id":"a"}]}'], + }); + + const redone = stepUndoRedoHistory(undone, 'redo'); + assert.deepStrictEqual(redone, withFirstChange); + }); + + it('exposes accessible whiteboard status and canvas semantics in the markup', () => { + const markup = readFileSync(path.join(process.cwd(), 'media', 'whiteboard.html'), 'utf8'); + + assert.match(markup, /id="whiteboard-status"[^>]*role="status"/); + assert.match(markup, /id="whiteboard-status"[^>]*aria-live="polite"/); + assert.match(markup, /id="whiteboard-hydration-error"[^>]*role="alert"/); + assert.match(markup, /id="canvas-tabs"[^>]*role="tablist"/); + assert.match(markup, /id="whiteboard-canvas-panel"[^>]*role="tabpanel"/); + assert.match(markup, /id="whiteboard-canvas"[^>]*tabindex="0"/); + assert.match(markup, /id="whiteboard-canvas"[^>]*aria-label="Whiteboard drawing surface"/); + }); + + it('keeps keyboard shortcuts and full tab semantics wired for accessibility', () => { + const source = readFileSync(path.join(process.cwd(), 'src', 'webview', 'whiteboard.ts'), 'utf8'); + + assert.match(source, /document\.addEventListener\('keydown'/); + assert.match(source, /event\.key\.toLowerCase\(\) === 'z'/); + assert.match(source, /event\.key === 'Delete' \|\| event\.key === 'Backspace'/); + assert.match(source, /role="tab"/); + assert.match(source, /id="\$\{escapeHtml\(getCanvasTabId\(entry\.id\)\)\}"/); + assert.match(source, /aria-selected="\$\{entry\.id === activeCanvasId \? 'true' : 'false'\}"/); + assert.match(source, /aria-controls="whiteboard-canvas-panel"/); + assert.match(source, /document\.getElementById\('whiteboard-canvas-panel'\)/); + assert.match(source, /canvasPanel\.setAttribute\('aria-labelledby', activeTabId\)/); + }); + + it('serializes large canvases within a practical performance budget', () => { + const largeState = { + version: '6.9.1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + objects: Array.from({ length: 2500 }, (_, index) => ({ + type: index % 5 === 0 ? 'image' : 'rect', + whiteboardId: `object_${index}`, + whiteboardObjectType: index % 5 === 0 ? 'image' : 'rectangle', + whiteboardSourceUri: index % 5 === 0 ? `image-${index}.png` : undefined, + whiteboardMimeType: index % 5 === 0 ? 'image/png' : undefined, + left: (index % 50) * 20, + top: Math.floor(index / 50) * 10, + width: 120, + height: 80, + fill: '#f0f0f0', + stroke: '#1f1f1f', + strokeWidth: 2, + text: index % 7 === 0 ? `Label ${index}` : undefined, + })), + } satisfies Parameters[0]; + + const startedAt = performance.now(); + const serialized = serializeCanvasState(largeState); + const normalized = normalizeSerializedCanvasState(serialized); + const summary = summarizeSerializedCanvasState(serialized); + const elapsedMs = performance.now() - startedAt; + + assert.equal(normalized.objects.length, 2500); + assert.equal(summary.shapes.length, 2500); + assert.equal(summary.images.length, 500); + assert.ok(serialized.length > 100000, 'expected serialized output to be meaningfully large'); + assert.ok(elapsedMs < 1500, `large canvas serialization took ${elapsedMs}ms`); + }); + + it('exposes the circle and annotation tools in the toolbar markup', () => { + const markup = readFileSync(path.join(process.cwd(), 'media', 'whiteboard.html'), 'utf8'); + + assert.match(markup, /data-tool="circle"/); + assert.match(markup, />\s*Circle\s*\s*Annotation\s* { + assert.deepStrictEqual(getBrushSettings('pen', '#123456', 3), { + color: '#123456', + width: 3, + }); + assert.deepStrictEqual(getBrushSettings('highlighter', '#123456', 3), { + color: 'rgba(18, 52, 86, 0.35)', + width: 12, + }); + }); + + it('normalizes circle drafts to a square bounding box', () => { + assert.deepStrictEqual(normalizeCircleDraftGeometry({ x: 10, y: 20 }, { x: 50, y: 35 }), { + centerX: 30, + centerY: 40, + radius: 20, + }); + assert.deepStrictEqual(normalizeCircleDraftGeometry({ x: 50, y: 50 }, { x: 20, y: 10 }), { + centerX: 30, + centerY: 30, + radius: 20, + }); + }); + + it('enables selection only for the select tool', () => { + const updates: Array> = []; + let discarded = 0; + let rendered = 0; + + const canvas = { + isDrawingMode: true, + selection: false, + getObjects: () => [ + { + set: (value: Record) => updates.push(value), + }, + ], + discardActiveObject: () => { + discarded += 1; + }, + requestRenderAll: () => { + rendered += 1; + }, + }; + + applyToolMode(canvas, 'select', '#000000', 2); + + assert.equal(canvas.isDrawingMode, false); + assert.equal(canvas.selection, true); + assert.deepStrictEqual(updates, [{ selectable: true, evented: true }]); + assert.equal(discarded, 0); + assert.equal(rendered, 1); + }); + + it('applies style controls consistently across supported object types', () => { + const rectangleUpdates: Array> = []; + const circleUpdates: Array> = []; + const lineUpdates: Array> = []; + const arrowUpdates: Array> = []; + const textUpdates: Array> = []; + const imageUpdates: Array> = []; + + assert.equal(applyStyleControlsToObject({ + type: 'rect', + set: (value: Record) => rectangleUpdates.push(value), + }, { + strokeColor: '#101010', + fillColor: '#fafafa', + strokeWidth: 7, + }), true); + assert.deepStrictEqual(rectangleUpdates, [{ + stroke: '#101010', + fill: '#fafafa', + strokeWidth: 7, + }]); + + assert.equal(applyStyleControlsToObject({ + type: 'ellipse', + whiteboardObjectType: 'circle', + set: (value: Record) => circleUpdates.push(value), + }, { + strokeColor: '#151515', + fillColor: '#f5f5f5', + strokeWidth: 5, + }), true); + assert.deepStrictEqual(circleUpdates, [{ + stroke: '#151515', + fill: '#f5f5f5', + strokeWidth: 5, + }]); + + assert.equal(applyStyleControlsToObject({ + type: 'line', + set: (value: Record) => lineUpdates.push(value), + }, { + strokeColor: '#202020', + fillColor: '#ededed', + strokeWidth: 4, + }), true); + assert.deepStrictEqual(lineUpdates, [{ + stroke: '#202020', + strokeWidth: 4, + }]); + + const arrowPath = buildArrowPath({ x: 10, y: 15 }, { x: 80, y: 45 }, 3); + assert.equal(applyStyleControlsToObject({ + type: 'path', + whiteboardObjectType: 'arrow', + path: arrowPath, + get: (key: string) => (key === 'path' ? arrowPath : undefined), + set: (value: Record) => arrowUpdates.push(value), + }, { + strokeColor: '#252525', + fillColor: '#dddddd', + strokeWidth: 9, + }), true); + assert.deepStrictEqual(arrowUpdates, [{ + stroke: '#252525', + strokeWidth: 9, + path: buildArrowPath({ x: 10, y: 15 }, { x: 80, y: 45 }, 9), + }]); + + assert.equal(applyStyleControlsToObject({ + type: 'i-text', + set: (value: Record) => textUpdates.push(value), + }, { + strokeColor: '#303030', + fillColor: '#dedede', + strokeWidth: 9, + }), true); + assert.deepStrictEqual(textUpdates, [{ + fill: '#303030', + }]); + + assert.equal(applyStyleControlsToObject({ + type: 'image', + set: (value: Record) => imageUpdates.push(value), + }, { + strokeColor: '#404040', + fillColor: '#cdcdcd', + strokeWidth: 2, + }), false); + assert.deepStrictEqual(imageUpdates, []); + }); + + it('preserves the active arrow draft endpoint when stroke width changes', () => { + const arrowPath = buildArrowPath({ x: 10, y: 15 }, { x: 80, y: 45 }, 3); + + assert.deepStrictEqual(getShapeDraftStrokeWidthUpdate({ + tool: 'arrow', + origin: { x: 10, y: 15 }, + object: { + path: arrowPath, + get: (key: string) => (key === 'path' ? arrowPath : undefined), + }, + }, 9), { + strokeWidth: 9, + path: buildArrowPath({ x: 10, y: 15 }, { x: 80, y: 45 }, 9), + }); + }); + + it('erases the topmost object containing the pointer', () => { + const removed: unknown[] = []; + let discarded = 0; + let rendered = 0; + + const bottomObject = { + getBoundingRect: () => ({ left: 0, top: 0, width: 80, height: 80 }), + }; + const topObject = { + getBoundingRect: () => ({ left: 10, top: 10, width: 30, height: 30 }), + }; + + const canvas = { + getObjects: () => [bottomObject, topObject], + remove: (value: unknown) => removed.push(value), + discardActiveObject: () => { + discarded += 1; + }, + requestRenderAll: () => { + rendered += 1; + }, + }; + + assert.equal(eraseObjectsAtPoint(canvas, { x: 25, y: 25 }), true); + assert.deepStrictEqual(removed, [topObject]); + assert.equal(discarded, 1); + assert.equal(rendered, 1); + assert.equal(eraseObjectsAtPoint(canvas, { x: 120, y: 120 }), false); + }); + + it('clears a fabric canvas while restoring the background color', () => { + let backgroundColor = '#111111'; + let cleared = 0; + let rendered = 0; + + const canvas = { + clear: () => { + cleared += 1; + backgroundColor = ''; + }, + requestRenderAll: () => { + rendered += 1; + }, + set backgroundColor(value: string) { + backgroundColor = value; + }, + get backgroundColor() { + return backgroundColor; + }, + }; + + clearFabricCanvas(canvas, '#ffffff'); + + assert.equal(cleared, 1); + assert.equal(rendered, 1); + assert.equal(canvas.backgroundColor, '#ffffff'); + }); +}); diff --git a/src/webview/whiteboard.ts b/src/webview/whiteboard.ts new file mode 100644 index 0000000..cc1313e --- /dev/null +++ b/src/webview/whiteboard.ts @@ -0,0 +1,2360 @@ +import * as fabric from 'fabric'; +import { + createBlankFabricCanvasState, + DEFAULT_WHITEBOARD_CANVAS_BACKGROUND as DEFAULT_BACKGROUND, + DEFAULT_WHITEBOARD_CANVAS_HEIGHT as DEFAULT_HEIGHT, + DEFAULT_WHITEBOARD_CANVAS_NAME as DEFAULT_CANVAS_NAME, + DEFAULT_WHITEBOARD_CANVAS_WIDTH as DEFAULT_WIDTH, +} from '../whiteboard/canvasState'; +import { createCirclePathFabricObject } from '../whiteboard/circlePath'; +import { + assertWhiteboardFabricObjectsSupported, + ensureWhiteboardFabricRegistry, + normalizeWhiteboardFabricObjectType, +} from '../whiteboard/fabricRegistry'; +export { createBlankFabricCanvasState } from '../whiteboard/canvasState'; + +import type { + ExtensionToWhiteboardMessage, + VSCodeAPI, + WhiteboardCanvas, + WhiteboardImageReference, + WhiteboardReviewAction, + WhiteboardSession, + WhiteboardShapeSummary, + WhiteboardToExtensionMessage, +} from './types'; +import { getLogger } from './utils'; + +export type WhiteboardTool = 'select' | 'pen' | 'highlighter' | 'rectangle' | 'circle' | 'line' | 'arrow' | 'text' | 'annotation' | 'eraser'; + +type ShapeTool = Extract; + +export interface WhiteboardDocumentObjectBase { + id: string; + type: 'path' | 'rectangle' | 'circle' | 'ellipse' | 'line' | 'arrow' | 'text' | 'image'; + stroke: string; + fill: string; + strokeWidth: number; + opacity: number; +} + +export interface WhiteboardPoint { + x: number; + y: number; +} + +export interface WhiteboardPathObject extends WhiteboardDocumentObjectBase { + type: 'path'; + points: WhiteboardPoint[]; +} + +export interface WhiteboardRectangleObject extends WhiteboardDocumentObjectBase { + type: 'rectangle'; + x: number; + y: number; + width: number; + height: number; +} + +export interface WhiteboardCircleObject extends WhiteboardDocumentObjectBase { + type: 'circle'; + x: number; + y: number; + radius: number; +} + +export interface WhiteboardEllipseObject extends WhiteboardDocumentObjectBase { + type: 'ellipse'; + x: number; + y: number; + radiusX: number; + radiusY: number; +} + +export interface WhiteboardLineObject extends WhiteboardDocumentObjectBase { + type: 'line' | 'arrow'; + start: WhiteboardPoint; + end: WhiteboardPoint; +} + +export interface WhiteboardTextObject extends WhiteboardDocumentObjectBase { + type: 'text'; + x: number; + y: number; + text: string; + fontSize: number; + fontFamily: string; +} + +export interface WhiteboardImageObject extends WhiteboardDocumentObjectBase { + type: 'image'; + x: number; + y: number; + width: number; + height: number; + src: string; + mimeType?: string; + sourceUri?: string; +} + +export type WhiteboardDocumentObject = + | WhiteboardPathObject + | WhiteboardRectangleObject + | WhiteboardCircleObject + | WhiteboardEllipseObject + | WhiteboardLineObject + | WhiteboardTextObject + | WhiteboardImageObject; + +export interface WhiteboardDocumentState { + version: 1; + width: number; + height: number; + backgroundColor: string; + objects: WhiteboardDocumentObject[]; +} + +export interface SerializedCanvasObject { + type: string; + whiteboardId?: string; + whiteboardObjectType?: string; + whiteboardSourceUri?: string; + whiteboardMimeType?: string; + text?: string; + radius?: number; + width?: number; + height?: number; + objects?: SerializedCanvasObject[]; + [key: string]: unknown; +} + +export interface SerializedCanvasState { + version: string; + width: number; + height: number; + backgroundColor: string; + objects: SerializedCanvasObject[]; + [key: string]: unknown; +} + +export interface WhiteboardCanvasCollectionState { + canvases: WhiteboardCanvas[]; + activeCanvasId?: string; +} + +export type WhiteboardCanvasCollectionAction = + | { type: 'create'; canvas: WhiteboardCanvas } + | { type: 'switch'; canvasId: string } + | { type: 'delete'; canvasId: string }; + +export interface UndoHistoryState { + past: string[]; + present: string; + future: string[]; +} + +export interface WhiteboardHydrationErrorStateElements { + canvasPanel: { dataset: Record }; + status: { textContent: string | null; dataset: Record }; + errorBanner: { hidden: boolean; textContent: string | null }; + submitButton: { disabled: boolean }; + requestChangesButton?: { disabled: boolean }; + canvasElement: { + setAttribute(name: string, value: string): void; + removeAttribute(name: string): void; + }; +} + +type ShapeDraft = { + tool: ShapeTool; + origin: WhiteboardPoint; + object: any; +}; + +type AnnotationRole = 'bubble' | 'text' | 'pointer' | 'handle'; + +const MAX_HISTORY = 50; +const SERIALIZED_OBJECT_CUSTOM_PROPERTIES = [ + 'whiteboardId', + 'whiteboardObjectType', + 'whiteboardSourceUri', + 'whiteboardMimeType', + 'annotationId', + 'annotationRole', + 'annotationBubbleLeft', + 'annotationBubbleTop', + 'annotationBubbleWidth', + 'annotationBubbleHeight', + 'annotationTargetX', + 'annotationTargetY', +]; +const ANNOTATION_PADDING_X = 16; +const ANNOTATION_PADDING_Y = 12; +const ANNOTATION_MIN_WIDTH = 220; +const ANNOTATION_MIN_HEIGHT = 84; + +export function createEmptyWhiteboardDocument(): WhiteboardDocumentState { + return { + version: 1, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + backgroundColor: DEFAULT_BACKGROUND, + objects: [], + }; +} + +export function createDefaultWhiteboardCanvas(now: number = Date.now()): WhiteboardCanvas { + return { + id: `canvas_${now}_1`, + name: DEFAULT_CANVAS_NAME, + fabricState: serializeCanvasState(createBlankFabricCanvasState()), + createdAt: now, + updatedAt: now, + }; +} + +export function ensureWhiteboardSessionHasUsableCanvas(session: WhiteboardSession, now: number = Date.now()): WhiteboardSession { + if (session.canvases.length === 0) { + const defaultCanvas = createDefaultWhiteboardCanvas(now); + return { + ...session, + canvases: [defaultCanvas], + activeCanvasId: defaultCanvas.id, + }; + } + + if (session.activeCanvasId && session.canvases.some((canvas) => canvas.id === session.activeCanvasId)) { + return session; + } + + return { + ...session, + activeCanvasId: session.canvases[0]?.id, + }; +} + +export function serializeCanvasState(state: SerializedCanvasState): string { + return JSON.stringify(state); +} + +export function parseWhiteboardDocumentState(serialized?: string): WhiteboardDocumentState { + const normalized = normalizeSerializedCanvasState(serialized); + const legacyObjects = normalized.objects + .map((object) => convertSerializedObjectToLegacyObject(object)) + .filter((object): object is WhiteboardDocumentObject => Boolean(object)); + + return { + version: 1, + width: normalized.width, + height: normalized.height, + backgroundColor: normalized.backgroundColor, + objects: legacyObjects, + }; +} + +export function serializeWhiteboardDocumentState(state: WhiteboardDocumentState): string { + return serializeCanvasState(convertLegacyDocumentToSerializedCanvasState(state)); +} + +export function normalizeSerializedCanvasState(serialized?: string): SerializedCanvasState { + try { + return normalizeSerializedCanvasStateOrThrow(serialized); + } catch { + return createBlankFabricCanvasState(); + } +} + +export function normalizeSerializedCanvasStateOrThrow(serialized?: string): SerializedCanvasState { + if (!serialized) { + return createBlankFabricCanvasState(); + } + + let parsed: Partial; + try { + parsed = JSON.parse(serialized) as Partial; + } catch { + throw new Error('Canvas data is not valid JSON'); + } + + if (looksLikeLegacyDocumentState(parsed)) { + return convertLegacyDocumentToSerializedCanvasState(parsed); + } + + const normalized = { + ...createBlankFabricCanvasState(), + ...parsed, + version: typeof parsed.version === 'string' ? parsed.version : fabric.version, + width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WIDTH, + height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_HEIGHT, + backgroundColor: typeof parsed.backgroundColor === 'string' ? parsed.backgroundColor : DEFAULT_BACKGROUND, + objects: Array.isArray(parsed.objects) + ? canonicalizeSerializedCanvasObjects(parsed.objects as SerializedCanvasObject[]) + : [], + }; + + assertWhiteboardFabricObjectsSupported(normalized.objects); + return normalized; +} + +function canonicalizeSerializedCanvasObjects(objects: SerializedCanvasObject[]): SerializedCanvasObject[] { + return objects.map((object) => canonicalizeSerializedCanvasObject(object)); +} + +function canonicalizeSerializedCanvasObject(object: SerializedCanvasObject): SerializedCanvasObject { + const nestedObjects = Array.isArray(object.objects) + ? canonicalizeSerializedCanvasObjects(object.objects) + : undefined; + const baseObject = (nestedObjects + ? { + ...object, + type: typeof object.type === 'string' ? normalizeWhiteboardFabricObjectType(object.type) : object.type, + objects: nestedObjects, + } + : { + ...object, + type: typeof object.type === 'string' ? normalizeWhiteboardFabricObjectType(object.type) : object.type, + }) as SerializedCanvasObject & Record; + + if (getSerializedObjectType(baseObject) !== 'circle') { + return baseObject; + } + + const radius = typeof baseObject.radius === 'number' + ? baseObject.radius + : typeof baseObject.rx === 'number' + ? baseObject.rx + : typeof baseObject.ry === 'number' + ? baseObject.ry + : typeof baseObject.width === 'number' + ? baseObject.width / 2 + : typeof baseObject.height === 'number' + ? baseObject.height / 2 + : 0; + const left = typeof baseObject.left === 'number' ? baseObject.left : 0; + const top = typeof baseObject.top === 'number' ? baseObject.top : 0; + const centerX = baseObject.originX === 'center' ? left : left + radius; + const centerY = baseObject.originY === 'center' ? top : top + radius; + + return createCirclePathFabricObject({ + centerX, + centerY, + radius, + stroke: typeof baseObject.stroke === 'string' ? baseObject.stroke : '#111827', + fill: typeof baseObject.fill === 'string' ? baseObject.fill : 'rgba(0,0,0,0)', + strokeWidth: typeof baseObject.strokeWidth === 'number' ? baseObject.strokeWidth : 1, + opacity: typeof baseObject.opacity === 'number' ? baseObject.opacity : 1, + whiteboardId: typeof baseObject.whiteboardId === 'string' ? baseObject.whiteboardId : makeId('object'), + whiteboardObjectType: 'circle', + ...(typeof (baseObject as Record).whiteboardZIndex === 'number' + ? { whiteboardZIndex: (baseObject as Record).whiteboardZIndex as number } + : {}), + ...(typeof (baseObject as Record).angle === 'number' + ? { angle: (baseObject as Record).angle as number } + : {}), + }) as SerializedCanvasObject; +} + +export function createWhiteboardHydrationErrorMessage(canvasName: string, error: unknown): string { + const normalizedCanvasName = canvasName.trim().length > 0 ? canvasName : 'this canvas'; + const detail = error instanceof Error && error.message.trim().length > 0 + ? ` ${error.message.trim()}` + : ''; + return `Failed to load ${normalizedCanvasName}. The saved canvas data could not be hydrated in Fabric.js.${detail}`; +} + +export function applyWhiteboardHydrationErrorState( + elements: WhiteboardHydrationErrorStateElements, + message?: string, +): void { + if (message) { + elements.canvasPanel.dataset.state = 'error'; + elements.status.textContent = message; + elements.status.dataset.state = 'error'; + elements.errorBanner.hidden = false; + elements.errorBanner.textContent = message; + elements.submitButton.disabled = true; + if (elements.requestChangesButton) { + elements.requestChangesButton.disabled = true; + } + elements.canvasElement.setAttribute('aria-invalid', 'true'); + return; + } + + elements.canvasPanel.dataset.state = 'ready'; + elements.errorBanner.hidden = true; + elements.errorBanner.textContent = ''; + elements.submitButton.disabled = false; + if (elements.requestChangesButton) { + elements.requestChangesButton.disabled = false; + } + elements.canvasElement.removeAttribute('aria-invalid'); + + if (elements.status.dataset.state === 'error') { + elements.status.dataset.state = 'info'; + } +} + +export function summarizeSerializedCanvasState(serialized?: string): { + shapes: WhiteboardShapeSummary[]; + images: WhiteboardImageReference[]; +} { + const normalized = normalizeSerializedCanvasState(serialized); + const shapes: WhiteboardShapeSummary[] = []; + const images: WhiteboardImageReference[] = []; + + visitSerializedObjects(normalized.objects, (object) => { + if (typeof (object as Record).annotationId === 'string' + && (object as Record).annotationRole !== 'text') { + return; + } + + const id = getSerializedObjectId(object); + const objectType = getSerializedObjectType(object); + shapes.push({ + id, + objectType, + ...(objectType === 'text' && typeof object.text === 'string' ? { label: object.text } : {}), + }); + + if (objectType === 'image') { + images.push({ + id, + sourceUri: typeof object.whiteboardSourceUri === 'string' ? object.whiteboardSourceUri : undefined, + mimeType: typeof object.whiteboardMimeType === 'string' ? object.whiteboardMimeType : undefined, + width: typeof object.width === 'number' ? object.width : undefined, + height: typeof object.height === 'number' ? object.height : undefined, + }); + } + }); + + return { shapes, images }; +} + +export function applyCanvasCollectionAction(state: WhiteboardCanvasCollectionState, action: WhiteboardCanvasCollectionAction): WhiteboardCanvasCollectionState { + switch (action.type) { + case 'create': + return { + canvases: [...state.canvases.filter((canvas) => canvas.id !== action.canvas.id), action.canvas], + activeCanvasId: action.canvas.id, + }; + case 'switch': + return state.canvases.some((canvas) => canvas.id === action.canvasId) + ? { ...state, activeCanvasId: action.canvasId } + : state; + case 'delete': { + const canvases = state.canvases.filter((canvas) => canvas.id !== action.canvasId); + return { + canvases, + activeCanvasId: state.activeCanvasId === action.canvasId ? canvases[0]?.id : state.activeCanvasId, + }; + } + } +} + +export function pushUndoSnapshot(history: UndoHistoryState, nextSerializedState: string, maxEntries: number = MAX_HISTORY): UndoHistoryState { + if (history.present === nextSerializedState) { + return history; + } + + const past = [...history.past, history.present]; + return { + past: past.slice(Math.max(0, past.length - maxEntries)), + present: nextSerializedState, + future: [], + }; +} + +export function stepUndoRedoHistory(history: UndoHistoryState, direction: 'undo' | 'redo'): UndoHistoryState { + if (direction === 'undo') { + const previous = history.past.at(-1); + if (!previous) { + return history; + } + + return { + past: history.past.slice(0, -1), + present: previous, + future: [history.present, ...history.future], + }; + } + + const next = history.future[0]; + if (!next) { + return history; + } + + return { + past: [...history.past, history.present], + present: next, + future: history.future.slice(1), + }; +} + +function createHistory(serialized: string): UndoHistoryState { + return { past: [], present: serialized, future: [] }; +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} + +function makeId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function escapeHtml(value: string): string { + const element = document.createElement('div'); + element.textContent = value; + return element.innerHTML; +} + +function getCanvasTabId(canvasId: string): string { + return `whiteboard-canvas-tab-${canvasId.replace(/[^a-zA-Z0-9_-]/g, '-')}`; +} + +function normalizeRect(start: WhiteboardPoint, end: WhiteboardPoint): { x: number; y: number; width: number; height: number } { + return { + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + width: Math.abs(end.x - start.x), + height: Math.abs(end.y - start.y), + }; +} + +function hexToRgba(color: string, alpha: number): string { + const normalized = color.trim().replace('#', ''); + if (!/^[0-9a-f]{6}$/i.test(normalized)) { + return color; + } + + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +function buildPathData(points: WhiteboardPoint[]): string { + if (points.length === 0) { + return ''; + } + + return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); +} + +export function buildArrowPath(start: WhiteboardPoint, end: WhiteboardPoint, strokeWidth: number): string { + const headLength = Math.max(12, strokeWidth * 5); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const left = { + x: end.x - headLength * Math.cos(angle - Math.PI / 6), + y: end.y - headLength * Math.sin(angle - Math.PI / 6), + }; + const right = { + x: end.x - headLength * Math.cos(angle + Math.PI / 6), + y: end.y - headLength * Math.sin(angle + Math.PI / 6), + }; + + return [ + `M ${start.x} ${start.y}`, + `L ${end.x} ${end.y}`, + `M ${left.x} ${left.y}`, + `L ${end.x} ${end.y}`, + `L ${right.x} ${right.y}`, + ].join(' '); +} + + +export function normalizeCircleDraftGeometry(origin: WhiteboardPoint, point: WhiteboardPoint): { + centerX: number; + centerY: number; + radius: number; +} { + const deltaX = point.x - origin.x; + const deltaY = point.y - origin.y; + const diameter = Math.max(Math.abs(deltaX), Math.abs(deltaY)); + const left = origin.x + (deltaX < 0 ? -diameter : 0); + const top = origin.y + (deltaY < 0 ? -diameter : 0); + + return { + centerX: left + diameter / 2, + centerY: top + diameter / 2, + radius: diameter / 2, + }; +} + +function getArrowPathValue(object: any): unknown { + if (typeof object?.get === 'function') { + const path = object.get('path'); + if (path !== undefined) { + return path; + } + } + + return object?.path; +} + +type ArrowPathCommand = [string, ...number[]]; + +function normalizeArrowPathCommands(path: unknown): ArrowPathCommand[] { + if (typeof path === 'string') { + return buildArrowPathCommandsFromString(path); + } + + if (!Array.isArray(path)) { + return []; + } + + return path + .filter((segment): segment is unknown[] => Array.isArray(segment) && typeof segment[0] === 'string') + .map((segment) => [String(segment[0]), ...segment.slice(1).map((value) => Number(value))]); +} + +function buildArrowPathCommandsFromString(path: string): ArrowPathCommand[] { + const tokens = path.trim().split(/\s+/); + const commands: ArrowPathCommand[] = []; + for (let index = 0; index < tokens.length; index += 3) { + const command = tokens[index]; + const x = Number(tokens[index + 1]); + const y = Number(tokens[index + 2]); + if ((command === 'M' || command === 'L') && Number.isFinite(x) && Number.isFinite(y)) { + commands.push([command, x, y]); + } + } + return commands; +} + +function getArrowEndpointsFromObject(object: any): { start: WhiteboardPoint; end: WhiteboardPoint; pathType: 'string' | 'parsed' } | undefined { + const path = getArrowPathValue(object); + const commands = normalizeArrowPathCommands(path); + if (commands.length < 2) { + return undefined; + } + + const [startCommand, endCommand] = commands; + const [, startX, startY] = startCommand; + const [, endX, endY] = endCommand; + if (!Number.isFinite(startX) || !Number.isFinite(startY) || !Number.isFinite(endX) || !Number.isFinite(endY)) { + return undefined; + } + + return { + start: { x: startX, y: startY }, + end: { x: endX, y: endY }, + pathType: typeof path === 'string' ? 'string' : 'parsed', + }; +} + +export function getShapeDraftStrokeWidthUpdate( + draft: { tool: ShapeTool; origin: WhiteboardPoint; object: any }, + strokeWidth: number, +): Record { + const update: Record = { strokeWidth }; + if (draft.tool !== 'arrow') { + return update; + } + + const endpoints = getArrowEndpointsFromObject(draft.object); + const pointer = draft.object.getPointByOrigin?.('right', 'bottom'); + const currentPoint = endpoints?.end + ?? (pointer && typeof pointer.x === 'number' && typeof pointer.y === 'number' + ? { x: pointer.x, y: pointer.y } + : draft.origin); + + return { + ...update, + path: buildArrowPath(draft.origin, currentPoint, strokeWidth), + }; +} + +function looksLikeLegacyDocumentState(value: Partial): value is WhiteboardDocumentState { + if (!Array.isArray(value.objects)) { + return false; + } + + return value.objects.some((object) => isLegacyDocumentObject(object)); +} + +function isLegacyDocumentObject(value: unknown): value is WhiteboardDocumentObject { + if (!value || typeof value !== 'object') { + return false; + } + + const object = value as Record; + const objectType = object.type; + if (objectType === 'rectangle') { + return typeof object.x === 'number' && typeof object.y === 'number'; + } + if (objectType === 'circle') { + return typeof object.x === 'number' && typeof object.y === 'number' && typeof object.radius === 'number'; + } + if (objectType === 'ellipse') { + return typeof object.x === 'number' && typeof object.y === 'number' + && typeof object.radiusX === 'number' && typeof object.radiusY === 'number'; + } + if (objectType === 'line' || objectType === 'arrow') { + return typeof object.start === 'object' && typeof object.end === 'object'; + } + if (objectType === 'text') { + return typeof object.x === 'number' && typeof object.y === 'number' && typeof object.text === 'string'; + } + if (objectType === 'path') { + return Array.isArray(object.points); + } + + return false; +} + +function convertLegacyDocumentToSerializedCanvasState(documentState: WhiteboardDocumentState): SerializedCanvasState { + return { + version: fabric.version, + width: documentState.width, + height: documentState.height, + backgroundColor: documentState.backgroundColor, + objects: documentState.objects.map((object) => convertLegacyObjectToSerializedObject(object)), + }; +} + +function convertLegacyObjectToSerializedObject(object: WhiteboardDocumentObject): SerializedCanvasObject { + const base = { + whiteboardId: object.id, + whiteboardObjectType: object.type, + stroke: object.stroke, + strokeWidth: object.strokeWidth, + opacity: object.opacity, + fill: object.fill === 'transparent' ? 'rgba(0,0,0,0)' : object.fill, + }; + + switch (object.type) { + case 'path': + return { + type: 'path', + ...base, + fill: '', + path: buildPathData(object.points), + }; + case 'rectangle': + return { + type: 'rect', + ...base, + left: object.x, + top: object.y, + width: object.width, + height: object.height, + }; + case 'circle': + return createCirclePathFabricObject({ + centerX: object.x, + centerY: object.y, + radius: object.radius, + stroke: base.stroke, + fill: String(base.fill), + strokeWidth: base.strokeWidth, + opacity: base.opacity, + whiteboardId: String(base.whiteboardId), + whiteboardObjectType: String(base.whiteboardObjectType), + }) as SerializedCanvasObject; + case 'ellipse': + return { + type: 'ellipse', + ...base, + left: object.x, + top: object.y, + originX: 'center', + originY: 'center', + rx: object.radiusX, + ry: object.radiusY, + width: object.radiusX * 2, + height: object.radiusY * 2, + }; + case 'line': + return { + type: 'line', + ...base, + fill: '', + x1: object.start.x, + y1: object.start.y, + x2: object.end.x, + y2: object.end.y, + }; + case 'arrow': + return { + type: 'path', + ...base, + fill: '', + path: buildArrowPath(object.start, object.end, object.strokeWidth), + }; + case 'text': + return { + type: 'i-text', + ...base, + left: object.x, + top: object.y, + text: object.text, + fontSize: object.fontSize, + fontFamily: object.fontFamily, + fill: object.stroke, + }; + case 'image': + return { + type: 'image', + ...base, + left: object.x, + top: object.y, + width: object.width, + height: object.height, + src: object.src, + whiteboardSourceUri: object.sourceUri, + whiteboardMimeType: object.mimeType, + }; + } +} + +function convertSerializedObjectToLegacyObject(object: SerializedCanvasObject): WhiteboardDocumentObject | undefined { + const objectType = getSerializedObjectType(object); + const id = getSerializedObjectId(object); + const stroke = typeof object.stroke === 'string' ? object.stroke : '#000000'; + const fill = typeof object.fill === 'string' && object.fill !== '' ? object.fill : 'transparent'; + const strokeWidth = typeof object.strokeWidth === 'number' ? object.strokeWidth : 1; + const opacity = typeof object.opacity === 'number' ? object.opacity : 1; + + switch (objectType) { + case 'rectangle': + return { + id, + type: 'rectangle', + stroke, + fill, + strokeWidth, + opacity, + x: typeof object.left === 'number' ? object.left : 0, + y: typeof object.top === 'number' ? object.top : 0, + width: typeof object.width === 'number' ? object.width : 0, + height: typeof object.height === 'number' ? object.height : 0, + }; + case 'circle': { + const radius = typeof object.radius === 'number' + ? object.radius + : Math.max( + typeof object.rx === 'number' ? object.rx : 0, + typeof object.ry === 'number' ? object.ry : 0, + ); + const left = typeof object.left === 'number' ? object.left : 0; + const top = typeof object.top === 'number' ? object.top : 0; + return { + id, + type: 'circle', + stroke, + fill, + strokeWidth, + opacity, + x: object.originX === 'center' ? left : left + radius, + y: object.originY === 'center' ? top : top + radius, + radius, + }; + } + case 'ellipse': + return { + id, + type: 'ellipse', + stroke, + fill, + strokeWidth, + opacity, + x: typeof object.left === 'number' ? object.left : 0, + y: typeof object.top === 'number' ? object.top : 0, + radiusX: typeof object.rx === 'number' ? object.rx : 0, + radiusY: typeof object.ry === 'number' ? object.ry : 0, + }; + case 'line': + return { + id, + type: 'line', + stroke, + fill: 'transparent', + strokeWidth, + opacity, + start: { + x: typeof object.x1 === 'number' ? object.x1 : 0, + y: typeof object.y1 === 'number' ? object.y1 : 0, + }, + end: { + x: typeof object.x2 === 'number' ? object.x2 : 0, + y: typeof object.y2 === 'number' ? object.y2 : 0, + }, + }; + case 'arrow': + case 'path': + return { + id, + type: objectType === 'arrow' ? 'arrow' : 'path', + stroke, + fill: 'transparent', + strokeWidth, + opacity, + ...(objectType === 'arrow' + ? { + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + } + : { + points: [], + }), + } as WhiteboardDocumentObject; + case 'text': + return { + id, + type: 'text', + stroke, + fill, + strokeWidth, + opacity, + x: typeof object.left === 'number' ? object.left : 0, + y: typeof object.top === 'number' ? object.top : 0, + text: typeof object.text === 'string' ? object.text : '', + fontSize: typeof object.fontSize === 'number' ? object.fontSize : 16, + fontFamily: typeof object.fontFamily === 'string' ? object.fontFamily : 'sans-serif', + }; + case 'image': + return { + id, + type: 'image', + stroke, + fill: 'transparent', + strokeWidth, + opacity, + x: typeof object.left === 'number' ? object.left : 0, + y: typeof object.top === 'number' ? object.top : 0, + width: typeof object.width === 'number' ? object.width : 0, + height: typeof object.height === 'number' ? object.height : 0, + src: typeof object.src === 'string' ? object.src : '', + mimeType: typeof object.whiteboardMimeType === 'string' ? object.whiteboardMimeType : undefined, + sourceUri: typeof object.whiteboardSourceUri === 'string' ? object.whiteboardSourceUri : undefined, + }; + default: + return undefined; + } +} + +function visitSerializedObjects(objects: SerializedCanvasObject[], visit: (object: SerializedCanvasObject) => void): void { + for (const object of objects) { + visit(object); + if (Array.isArray(object.objects)) { + visitSerializedObjects(object.objects, visit); + } + } +} + +function getSerializedObjectId(object: SerializedCanvasObject): string { + return typeof object.whiteboardId === 'string' && object.whiteboardId.length > 0 + ? object.whiteboardId + : makeId('object'); +} + +function getSerializedObjectType(object: SerializedCanvasObject): string { + if (typeof object.whiteboardObjectType === 'string' && object.whiteboardObjectType.length > 0) { + return object.whiteboardObjectType; + } + + const normalizedType = typeof object.type === 'string' ? object.type.toLowerCase() : ''; + + switch (normalizedType) { + case 'rect': + return 'rectangle'; + case 'circle': + return 'circle'; + case 'ellipse': + return 'ellipse'; + case 'line': + return 'line'; + case 'i-text': + case 'textbox': + case 'text': + return 'text'; + case 'image': + return 'image'; + default: + return normalizedType; + } +} + +function serializeFabricCanvas(canvas: any): string { + const json = canvas.toJSON(SERIALIZED_OBJECT_CUSTOM_PROPERTIES) as Record; + const objects = Array.isArray(json.objects) + ? canonicalizeSerializedCanvasObjects(json.objects as SerializedCanvasObject[]) + : []; + return serializeCanvasState({ + ...json, + version: typeof json.version === 'string' ? json.version : fabric.version, + width: canvas.getWidth(), + height: canvas.getHeight(), + backgroundColor: getCanvasBackgroundColor(canvas), + objects, + }); +} + +export function clearFabricCanvas(canvas: any, backgroundColor: string = DEFAULT_BACKGROUND): void { + canvas.clear(); + canvas.backgroundColor = backgroundColor; + canvas.requestRenderAll(); +} + +async function loadSerializedStateIntoCanvas(canvas: any, serialized?: string): Promise { + const normalized = normalizeSerializedCanvasStateOrThrow(serialized); + clearFabricCanvas(canvas, normalized.backgroundColor); + canvas.setDimensions({ width: normalized.width, height: normalized.height }); + await canvas.loadFromJSON({ + version: normalized.version, + objects: normalized.objects, + }); + canvas.backgroundColor = normalized.backgroundColor; + canvas.requestRenderAll(); + return normalized; +} + +function getCanvasBackgroundColor(canvas: any): string { + return typeof canvas.backgroundColor === 'string' && canvas.backgroundColor.length > 0 + ? canvas.backgroundColor + : DEFAULT_BACKGROUND; +} + +export function getBrushSettings(tool: WhiteboardTool, strokeColor: string, strokeWidth: number): { color: string; width: number } { + return { + width: tool === 'highlighter' ? Math.max(8, strokeWidth * 4) : strokeWidth, + color: tool === 'highlighter' ? hexToRgba(strokeColor, 0.35) : strokeColor, + }; +} + +function createBrush(canvas: any, tool: WhiteboardTool, strokeColor: string, strokeWidth: number): any { + const brush = new fabric.PencilBrush(canvas); + const settings = getBrushSettings(tool, strokeColor, strokeWidth); + brush.width = settings.width; + brush.color = settings.color; + return brush; +} + +export function applyToolMode(canvas: any, tool: WhiteboardTool, strokeColor: string, strokeWidth: number): void { + const selectionEnabled = tool === 'select'; + canvas.isDrawingMode = tool === 'pen' || tool === 'highlighter'; + canvas.selection = selectionEnabled; + + if (canvas.isDrawingMode) { + canvas.freeDrawingBrush = createBrush(canvas, tool, strokeColor, strokeWidth); + } + + for (const object of canvas.getObjects()) { + const objectSelectable = shouldObjectBeSelectable(tool, object); + object.set({ + selectable: objectSelectable, + evented: objectSelectable, + }); + } + + if (!selectionEnabled) { + canvas.discardActiveObject(); + } + + canvas.requestRenderAll(); +} + +function getFabricObjectType(object: any): string { + if (typeof object?.get === 'function') { + const whiteboardObjectType = object.get('whiteboardObjectType'); + if (typeof whiteboardObjectType === 'string' && whiteboardObjectType.length > 0) { + return whiteboardObjectType; + } + } + + if (typeof object?.whiteboardObjectType === 'string' && object.whiteboardObjectType.length > 0) { + return object.whiteboardObjectType; + } + + return typeof object?.type === 'string' ? object.type : ''; +} + +export function applyStyleControlsToObject( + object: any, + styles: { + strokeColor: string; + fillColor: string; + strokeWidth: number; + }, +): boolean { + const objectType = getFabricObjectType(object); + const updates: Record = {}; + + if (objectType === 'rectangle' || objectType === 'circle' || objectType === 'ellipse' || objectType === 'rect') { + updates.stroke = styles.strokeColor; + updates.fill = styles.fillColor; + updates.strokeWidth = styles.strokeWidth; + } else if (objectType === 'line' || objectType === 'arrow' || objectType === 'path') { + updates.stroke = styles.strokeColor; + updates.strokeWidth = styles.strokeWidth; + if (objectType === 'arrow') { + const arrow = getArrowEndpointsFromObject(object); + if (arrow) { + const path = buildArrowPath(arrow.start, arrow.end, styles.strokeWidth); + updates.path = arrow.pathType === 'parsed' ? fabric.util.parsePath(path) : path; + } + } + } else if (objectType === 'text' || objectType === 'i-text' || objectType === 'textbox') { + updates.fill = styles.strokeColor; + } + + if (Object.keys(updates).length === 0) { + return false; + } + + object.set(updates); + if (typeof object.setCoords === 'function') { + object.setCoords(); + } + return true; +} + +function applyStyleControlsToSelection( + canvas: any, + styles: { + strokeColor: string; + fillColor: string; + strokeWidth: number; + }, +): boolean { + let changed = false; + for (const object of canvas.getActiveObjects()) { + changed = applyStyleControlsToObject(object, styles) || changed; + } + + if (changed) { + canvas.requestRenderAll(); + } + + return changed; +} + +function objectContainsPoint(object: any, point: WhiteboardPoint): boolean { + if (typeof object?.containsPoint === 'function') { + try { + return object.containsPoint(new fabric.Point(point.x, point.y)); + } catch { + return object.containsPoint(point); + } + } + + if (typeof object?.getBoundingRect === 'function') { + const rect = object.getBoundingRect(); + const width = typeof rect?.width === 'number' ? rect.width : 0; + const height = typeof rect?.height === 'number' ? rect.height : 0; + const left = typeof rect?.left === 'number' ? rect.left : 0; + const top = typeof rect?.top === 'number' ? rect.top : 0; + return point.x >= left && point.x <= left + width && point.y >= top && point.y <= top + height; + } + + return false; +} + +export function eraseObjectsAtPoint(canvas: any, point: WhiteboardPoint): boolean { + const objects = canvas.getObjects(); + for (let index = objects.length - 1; index >= 0; index -= 1) { + const object = objects[index]; + if (!objectContainsPoint(object, point)) { + continue; + } + + const annotationId = getAnnotationId(object); + if (annotationId) { + return removeAnnotation(canvas, annotationId); + } + + canvas.remove(object); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + return true; + } + + return false; +} + +function decorateFabricObject( + object: any, + metadata: { + id?: string; + objectType: string; + sourceUri?: string; + mimeType?: string; + }, +): any { + object.set({ + whiteboardId: metadata.id ?? makeId('object'), + whiteboardObjectType: metadata.objectType, + ...(metadata.sourceUri ? { whiteboardSourceUri: metadata.sourceUri } : {}), + ...(metadata.mimeType ? { whiteboardMimeType: metadata.mimeType } : {}), + }); + return object; +} + +function getAnnotationId(object: any): string | undefined { + const annotationId = typeof object?.get === 'function' ? object.get('annotationId') : object?.annotationId; + return typeof annotationId === 'string' && annotationId.length > 0 ? annotationId : undefined; +} + +function getAnnotationRole(object: any): AnnotationRole | undefined { + const annotationRole = typeof object?.get === 'function' ? object.get('annotationRole') : object?.annotationRole; + return annotationRole === 'bubble' || annotationRole === 'text' || annotationRole === 'pointer' || annotationRole === 'handle' + ? annotationRole + : undefined; +} + +function isAnnotationObject(object: any): boolean { + return Boolean(getAnnotationId(object) && getAnnotationRole(object)); +} + +function shouldObjectBeSelectable(tool: WhiteboardTool, object: any): boolean { + if (tool !== 'select') { + return false; + } + + const role = getAnnotationRole(object); + if (!role) { + return true; + } + + return role === 'text' || role === 'handle'; +} + +function getAnnotationObjects(canvas: any, annotationId: string): { + bubble?: any; + text?: any; + pointer?: any; + handle?: any; +} { + const objects = canvas.getObjects().filter((object: any) => getAnnotationId(object) === annotationId); + return { + bubble: objects.find((object: any) => getAnnotationRole(object) === 'bubble'), + text: objects.find((object: any) => getAnnotationRole(object) === 'text'), + pointer: objects.find((object: any) => getAnnotationRole(object) === 'pointer'), + handle: objects.find((object: any) => getAnnotationRole(object) === 'handle'), + }; +} + +function getAnnotationAnchor(bounds: { left: number; top: number; width: number; height: number }, target: WhiteboardPoint): WhiteboardPoint { + const centerX = bounds.left + bounds.width / 2; + const centerY = bounds.top + bounds.height / 2; + const deltaX = target.x - centerX; + const deltaY = target.y - centerY; + + if (deltaX === 0 && deltaY === 0) { + return { x: centerX, y: centerY + bounds.height / 2 }; + } + + const scale = 1 / Math.max( + Math.abs(deltaX) / Math.max(bounds.width / 2, 1), + Math.abs(deltaY) / Math.max(bounds.height / 2, 1), + ); + + return { + x: centerX + deltaX * scale, + y: centerY + deltaY * scale, + }; +} + +function syncAnnotationLayout(canvas: any, annotationId: string): void { + const { bubble, text, pointer, handle } = getAnnotationObjects(canvas, annotationId); + if (!bubble || !text || !pointer || !handle) { + return; + } + + const textLeft = typeof text.left === 'number' ? text.left : 0; + const textTop = typeof text.top === 'number' ? text.top : 0; + const textWidth = typeof text.width === 'number' ? text.width : ANNOTATION_MIN_WIDTH - ANNOTATION_PADDING_X * 2; + const textHeight = typeof text.height === 'number' + ? text.height + : (typeof text.getScaledHeight === 'function' ? text.getScaledHeight() : ANNOTATION_MIN_HEIGHT - ANNOTATION_PADDING_Y * 2); + const bubbleLeft = textLeft - ANNOTATION_PADDING_X; + const bubbleTop = textTop - ANNOTATION_PADDING_Y; + const bubbleWidth = Math.max(ANNOTATION_MIN_WIDTH, textWidth + ANNOTATION_PADDING_X * 2); + const bubbleHeight = Math.max(ANNOTATION_MIN_HEIGHT, textHeight + ANNOTATION_PADDING_Y * 2); + const targetX = typeof handle.left === 'number' ? handle.left : bubbleLeft + bubbleWidth / 2; + const targetY = typeof handle.top === 'number' ? handle.top : bubbleTop + bubbleHeight; + const anchor = getAnnotationAnchor({ left: bubbleLeft, top: bubbleTop, width: bubbleWidth, height: bubbleHeight }, { x: targetX, y: targetY }); + + bubble.set({ + left: bubbleLeft, + top: bubbleTop, + width: bubbleWidth, + height: bubbleHeight, + }); + pointer.set({ x1: anchor.x, y1: anchor.y, x2: targetX, y2: targetY }); + text.set({ + annotationBubbleLeft: bubbleLeft, + annotationBubbleTop: bubbleTop, + annotationBubbleWidth: bubbleWidth, + annotationBubbleHeight: bubbleHeight, + annotationTargetX: targetX, + annotationTargetY: targetY, + }); + + bubble.setCoords(); + pointer.setCoords(); + handle.setCoords(); + text.setCoords(); +} + +function moveAnnotation(canvas: any, annotationId: string, deltaX: number, deltaY: number): void { + if (deltaX === 0 && deltaY === 0) { + return; + } + + const { handle } = getAnnotationObjects(canvas, annotationId); + if (!handle) { + return; + } + + handle.set({ + left: (typeof handle.left === 'number' ? handle.left : 0) + deltaX, + top: (typeof handle.top === 'number' ? handle.top : 0) + deltaY, + }); + syncAnnotationLayout(canvas, annotationId); +} + +function removeAnnotation(canvas: any, annotationId: string): boolean { + const annotationObjects = canvas.getObjects().filter((object: any) => getAnnotationId(object) === annotationId); + if (annotationObjects.length === 0) { + return false; + } + + annotationObjects.forEach((object: any) => canvas.remove(object)); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + return true; +} + +function attachTextPersistence(object: any, persist: () => Promise): void { + if ((object?.type !== 'i-text' && object?.type !== 'textbox') || typeof object.on !== 'function') { + return; + } + + object.on('editing:exited', () => { + void persist(); + }); +} + +function attachAnnotationTextSync(object: any, canvas: any): void { + if (getAnnotationRole(object) !== 'text' || typeof object.on !== 'function') { + return; + } + + object.on('changed', () => { + const annotationId = getAnnotationId(object); + if (!annotationId) { + return; + } + + syncAnnotationLayout(canvas, annotationId); + canvas.requestRenderAll(); + }); +} + +function exportCanvasAsPng(canvas: any): string { + return canvas.toDataURL({ + format: 'png', + multiplier: 1, + enableRetinaScaling: false, + }); +} + +async function exportSerializedStateAsPng(serialized: string): Promise { + const canvasElement = document.createElement('canvas'); + const exportCanvas = new fabric.StaticCanvas(canvasElement, { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + backgroundColor: DEFAULT_BACKGROUND, + }); + + try { + await loadSerializedStateIntoCanvas(exportCanvas, serialized); + return exportCanvasAsPng(exportCanvas); + } finally { + exportCanvas.dispose(); + } +} + +async function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsDataURL(file); + }); +} + +function bootstrap(): void { + const vscode = acquireVsCodeApi(); + const logger = getLogger(vscode); + const canvasElement = document.getElementById('whiteboard-canvas') as HTMLCanvasElement | null; + if (!canvasElement) { + return; + } + + const fabricCanvas = new fabric.Canvas(canvasElement, { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + backgroundColor: DEFAULT_BACKGROUND, + preserveObjectStacking: true, + selection: false, + }); + + const postMessage = (message: WhiteboardToExtensionMessage): void => { + vscode.postMessage(message as any); + }; + + const canvasTabs = document.getElementById('canvas-tabs') as HTMLDivElement; + const canvasPanel = document.getElementById('whiteboard-canvas-panel') as HTMLElement; + const hydrationErrorBanner = document.getElementById('whiteboard-hydration-error') as HTMLDivElement; + const contextText = document.getElementById('whiteboard-context') as HTMLParagraphElement; + const status = document.getElementById('whiteboard-status') as HTMLDivElement; + const fileInput = document.getElementById('image-file-input') as HTMLInputElement; + const strokeColorInput = document.getElementById('stroke-color') as HTMLInputElement; + const fillColorInput = document.getElementById('fill-color') as HTMLInputElement; + const strokeWidthInput = document.getElementById('stroke-width') as HTMLInputElement; + const undoButton = document.getElementById('undo-btn') as HTMLButtonElement; + const redoButton = document.getElementById('redo-btn') as HTMLButtonElement; + const clearButton = document.getElementById('clear-btn') as HTMLButtonElement; + const addCanvasButton = document.getElementById('add-canvas-btn') as HTMLButtonElement; + const deleteCanvasButton = document.getElementById('delete-canvas-btn') as HTMLButtonElement; + const importButton = document.getElementById('import-image-btn') as HTMLButtonElement; + const approveButton = document.getElementById('approve-btn') as HTMLButtonElement; + const requestChangesButton = document.getElementById('request-changes-btn') as HTMLButtonElement; + const cancelButton = document.getElementById('cancel-btn') as HTMLButtonElement; + const toolButtons = Array.from(document.querySelectorAll('[data-tool]')); + + let currentTool: WhiteboardTool = 'pen'; + let session: WhiteboardSession | undefined; + let activeCanvasId: string | undefined; + let currentShapeDraft: ShapeDraft | undefined; + let isErasing = false; + let hasErasedObjects = false; + let isHydrating = false; + let hydrationErrorMessage: string | undefined; + const histories = new Map(); + + function setStatus(message: string, state: 'info' | 'error' = 'info'): void { + if (hydrationErrorMessage && state === 'info') { + return; + } + + status.textContent = message; + status.dataset.state = state; + } + + function setHydrationErrorState(message?: string): void { + hydrationErrorMessage = message; + applyWhiteboardHydrationErrorState({ + canvasPanel, + status, + errorBanner: hydrationErrorBanner, + submitButton: approveButton, + requestChangesButton, + canvasElement: canvasElement as HTMLCanvasElement, + }, message); + } + + function getCanvasById(canvasId?: string): WhiteboardCanvas | undefined { + return session?.canvases.find((entry) => entry.id === canvasId); + } + + function ensureSessionHasUsableCanvas(): WhiteboardSession | undefined { + if (!session) { + return undefined; + } + + const normalizedSession = ensureWhiteboardSessionHasUsableCanvas({ + ...session, + activeCanvasId, + }); + const didChange = normalizedSession.canvases !== session.canvases + || normalizedSession.activeCanvasId !== activeCanvasId; + + session = normalizedSession; + activeCanvasId = normalizedSession.activeCanvasId; + + if (didChange) { + vscode.setState({ session, activeCanvasId }); + renderTabs(); + } + + return session; + } + + function updateHistoryButtons(): void { + const history = activeCanvasId ? histories.get(activeCanvasId) : undefined; + undoButton.disabled = !history || history.past.length === 0; + redoButton.disabled = !history || history.future.length === 0; + } + + function setTool(tool: WhiteboardTool): void { + currentTool = tool; + applyToolMode( + fabricCanvas, + tool, + strokeColorInput.value, + Number.parseInt(strokeWidthInput.value, 10) || 2, + ); + toolButtons.forEach((button) => button.classList.toggle('active', button.dataset.tool === tool)); + setStatus(`Tool: ${tool}`); + } + + function renderTabs(): void { + if (!session) { + canvasTabs.innerHTML = ''; + canvasPanel.removeAttribute('aria-labelledby'); + return; + } + + canvasTabs.innerHTML = session.canvases + .map((entry) => ``) + .join(''); + + const activeTabId = activeCanvasId ? getCanvasTabId(activeCanvasId) : undefined; + if (activeTabId) { + canvasPanel.setAttribute('aria-labelledby', activeTabId); + } else { + canvasPanel.removeAttribute('aria-labelledby'); + } + + canvasTabs.querySelectorAll('.canvas-tab').forEach((button) => { + button.addEventListener('click', () => { + const canvasId = button.dataset.canvasId; + if (!canvasId) { + return; + } + + void switchCanvas(canvasId); + }); + }); + + deleteCanvasButton.disabled = !session || session.canvases.length <= 1; + } + + async function persistActiveCanvas(options: { pushHistory?: boolean } = {}): Promise { + const currentSession = ensureSessionHasUsableCanvas(); + const currentCanvas = getCanvasById(activeCanvasId); + if (!currentSession || !activeCanvasId || !currentCanvas) { + return; + } + + if (hydrationErrorMessage) { + setStatus(hydrationErrorMessage, 'error'); + return; + } + + const serialized = serializeFabricCanvas(fabricCanvas); + const currentHistory = histories.get(activeCanvasId) ?? createHistory(serialized); + histories.set( + activeCanvasId, + options.pushHistory === false + ? { ...currentHistory, present: serialized } + : pushUndoSnapshot(currentHistory, serialized), + ); + + const preview = exportCanvasAsPng(fabricCanvas); + const summary = summarizeSerializedCanvasState(serialized); + const updatedCanvas: WhiteboardCanvas = { + ...currentCanvas, + fabricState: serialized, + updatedAt: Date.now(), + thumbnail: preview, + shapes: summary.shapes, + images: summary.images, + }; + + session = { + ...currentSession, + canvases: currentSession.canvases.map((entry) => entry.id === updatedCanvas.id ? updatedCanvas : entry), + activeCanvasId, + }; + vscode.setState({ session, activeCanvasId }); + postMessage({ + type: 'saveCanvas', + canvasId: updatedCanvas.id, + name: updatedCanvas.name, + fabricState: serialized, + thumbnail: preview, + shapes: summary.shapes, + images: summary.images, + }); + renderTabs(); + updateHistoryButtons(); + } + + async function hydrateActiveCanvas(serialized?: string): Promise { + isHydrating = true; + try { + setHydrationErrorState(undefined); + let normalized: SerializedCanvasState; + try { + normalized = await loadSerializedStateIntoCanvas(fabricCanvas, serialized); + } catch (error) { + throw new Error(`during Fabric load: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + for (const object of fabricCanvas.getObjects()) { + if (!object.get('whiteboardId')) { + decorateFabricObject(object, { + objectType: getSerializedObjectType(object.toObject(SERIALIZED_OBJECT_CUSTOM_PROPERTIES) as SerializedCanvasObject), + }); + } + attachTextPersistence(object, () => persistActiveCanvas()); + attachAnnotationTextSync(object, fabricCanvas); + } + } catch (error) { + throw new Error(`while decorating hydrated objects: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + const currentHistorySerialized = serializeCanvasState(normalized); + if (activeCanvasId && !histories.has(activeCanvasId)) { + histories.set(activeCanvasId, createHistory(currentHistorySerialized)); + } + } catch (error) { + throw new Error(`while initializing canvas history: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + applyToolMode( + fabricCanvas, + currentTool, + strokeColorInput.value, + Number.parseInt(strokeWidthInput.value, 10) || 2, + ); + } catch (error) { + throw new Error(`while applying tool mode: ${error instanceof Error ? error.message : String(error)}`); + } + return true; + } catch (error) { + const serializedSummary = summarizeSerializedCanvasState(serialized); + const normalized = normalizeSerializedCanvasState(serialized); + const objectTypes = normalized.objects.map((object) => getSerializedObjectType(object)); + console.error('[Whiteboard] hydrate failure', { + canvasName: getCanvasById(activeCanvasId)?.name ?? 'this canvas', + error, + objectCount: normalized.objects.length, + objectTypes, + shapeSummary: serializedSummary.shapes, + }); + logger.error('Failed to hydrate whiteboard canvas', { + message: error instanceof Error ? error.message : String(error), + canvasName: getCanvasById(activeCanvasId)?.name ?? 'this canvas', + objectCount: normalized.objects.length, + objectTypes, + shapeSummary: serializedSummary.shapes, + }); + clearFabricCanvas(fabricCanvas, DEFAULT_BACKGROUND); + setHydrationErrorState(createWhiteboardHydrationErrorMessage( + getCanvasById(activeCanvasId)?.name ?? 'this canvas', + error, + )); + return false; + } finally { + isHydrating = false; + } + } + + async function switchCanvas(canvasId: string): Promise { + if (!session || !session.canvases.some((canvas) => canvas.id === canvasId)) { + return; + } + + activeCanvasId = canvasId; + session = { ...session, activeCanvasId: canvasId }; + vscode.setState({ session, activeCanvasId }); + postMessage({ type: 'switchCanvas', canvasId }); + renderTabs(); + const hydrated = await hydrateActiveCanvas(getCanvasById(canvasId)?.fabricState); + updateHistoryButtons(); + if (hydrated) { + setStatus(`Canvas: ${getCanvasById(canvasId)?.name ?? canvasId}`); + } + } + + function createShapeDraft(tool: ShapeTool, point: WhiteboardPoint): ShapeDraft { + const strokeWidth = Number.parseInt(strokeWidthInput.value, 10) || 2; + const common = { + stroke: strokeColorInput.value, + fill: fillColorInput.value, + strokeWidth, + opacity: 1, + selectable: false, + evented: false, + }; + + switch (tool) { + case 'rectangle': + return { + tool, + origin: point, + object: decorateFabricObject(new fabric.Rect({ + ...common, + left: point.x, + top: point.y, + width: 0, + height: 0, + }), { objectType: 'rectangle' }), + }; + case 'circle': + return { + tool, + origin: point, + object: decorateFabricObject(new fabric.Ellipse({ + ...common, + left: point.x, + top: point.y, + originX: 'center', + originY: 'center', + rx: 0, + ry: 0, + }), { objectType: 'circle' }), + }; + case 'line': + return { + tool, + origin: point, + object: decorateFabricObject(new fabric.Line([point.x, point.y, point.x, point.y], { + ...common, + fill: '', + }), { objectType: 'line' }), + }; + case 'arrow': + return { + tool, + origin: point, + object: decorateFabricObject(new fabric.Path(buildArrowPath(point, point, strokeWidth), { + ...common, + fill: '', + }), { objectType: 'arrow' }), + }; + } + } + + function updateShapeDraft(draft: ShapeDraft, point: WhiteboardPoint): void { + switch (draft.tool) { + case 'rectangle': { + const rect = normalizeRect(draft.origin, point); + draft.object.set({ + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + }); + break; + } + case 'circle': { + const circle = normalizeCircleDraftGeometry(draft.origin, point); + draft.object.set({ + left: circle.centerX, + top: circle.centerY, + rx: circle.radius, + ry: circle.radius, + width: circle.radius * 2, + height: circle.radius * 2, + }); + break; + } + case 'line': + draft.object.set({ x2: point.x, y2: point.y }); + break; + case 'arrow': { + const strokeWidth = Number.parseInt(strokeWidthInput.value, 10) || 2; + draft.object.set({ path: fabric.util.parsePath(buildArrowPath(draft.origin, point, strokeWidth)) }); + break; + } + } + + draft.object.setCoords(); + fabricCanvas.requestRenderAll(); + } + + function createAnnotation(point: WhiteboardPoint): any { + const annotationId = makeId('annotation'); + const bubbleLeft = Math.max(24, Math.min(fabricCanvas.getWidth() - ANNOTATION_MIN_WIDTH - 24, point.x + 28)); + const bubbleTop = Math.max(24, Math.min(fabricCanvas.getHeight() - ANNOTATION_MIN_HEIGHT - 24, point.y - 40)); + const bubbleFill = fillColorInput.value; + const bubbleStroke = strokeColorInput.value; + const strokeWidth = Math.max(2, Number.parseInt(strokeWidthInput.value, 10) || 2); + + const pointer = decorateFabricObject(new fabric.Line([point.x, point.y, point.x, point.y], { + stroke: bubbleStroke, + strokeWidth, + selectable: false, + evented: false, + }), { objectType: 'annotationPointer' }); + pointer.set({ annotationId, annotationRole: 'pointer' as AnnotationRole }); + + const bubble = decorateFabricObject(new fabric.Rect({ + left: bubbleLeft, + top: bubbleTop, + width: ANNOTATION_MIN_WIDTH, + height: ANNOTATION_MIN_HEIGHT, + rx: 18, + ry: 18, + fill: bubbleFill, + stroke: bubbleStroke, + strokeWidth, + selectable: false, + evented: false, + }), { objectType: 'annotationBubble' }); + bubble.set({ annotationId, annotationRole: 'bubble' as AnnotationRole }); + + const text = decorateFabricObject(new fabric.Textbox('Comment', { + left: bubbleLeft + ANNOTATION_PADDING_X, + top: bubbleTop + ANNOTATION_PADDING_Y, + width: ANNOTATION_MIN_WIDTH - ANNOTATION_PADDING_X * 2, + fontSize: 18, + fontFamily: 'sans-serif', + fill: bubbleStroke, + editable: true, + selectable: false, + evented: false, + }), { objectType: 'annotation' }); + text.set({ annotationId, annotationRole: 'text' as AnnotationRole }); + attachTextPersistence(text, () => persistActiveCanvas()); + attachAnnotationTextSync(text, fabricCanvas); + + const handle = decorateFabricObject(new fabric.Circle({ + left: point.x, + top: point.y, + radius: 7, + originX: 'center', + originY: 'center', + fill: bubbleStroke, + stroke: '#ffffff', + strokeWidth: 2, + selectable: false, + evented: false, + }), { objectType: 'annotationHandle' }); + handle.set({ annotationId, annotationRole: 'handle' as AnnotationRole }); + + fabricCanvas.add(pointer); + fabricCanvas.add(bubble); + fabricCanvas.add(text); + fabricCanvas.add(handle); + syncAnnotationLayout(fabricCanvas, annotationId); + fabricCanvas.setActiveObject(text); + text.enterEditing(); + fabricCanvas.requestRenderAll(); + return text; + } + + async function importImageFile(file: File): Promise { + if (!file.type.startsWith('image/')) { + setStatus('Only image files can be imported.', 'error'); + return; + } + + const dataUrl = await readFileAsDataUrl(file); + const image = await fabric.FabricImage.fromURL(dataUrl); + decorateFabricObject(image, { + objectType: 'image', + sourceUri: file.name, + mimeType: file.type, + }); + image.set({ + left: Math.max(40, fabricCanvas.getWidth() / 2 - 160), + top: Math.max(40, fabricCanvas.getHeight() / 2 - 120), + scaleX: 1, + scaleY: 1, + selectable: currentTool === 'select', + evented: currentTool === 'select', + }); + image.scaleToWidth(Math.min(480, image.width ?? 320)); + fabricCanvas.add(image); + fabricCanvas.setActiveObject(image); + fabricCanvas.requestRenderAll(); + await persistActiveCanvas(); + setStatus(`Imported ${file.name}`); + } + + async function submitWhiteboard(action: Exclude): Promise { + const currentSession = ensureSessionHasUsableCanvas(); + if (!currentSession) { + return; + } + + if (hydrationErrorMessage) { + setStatus(hydrationErrorMessage, 'error'); + return; + } + + await persistActiveCanvas({ pushHistory: false }); + + const latestSession = ensureSessionHasUsableCanvas() ?? currentSession; + const submitted: Array<{ + id: string; + name: string; + imageUri: string; + fabricState: string; + thumbnail?: string; + shapes?: WhiteboardShapeSummary[]; + images?: WhiteboardImageReference[]; + }> = []; + for (const currentCanvas of latestSession.canvases) { + const imageUri = currentCanvas.id === activeCanvasId + ? exportCanvasAsPng(fabricCanvas) + : currentCanvas.thumbnail ?? await exportSerializedStateAsPng(currentCanvas.fabricState); + submitted.push({ + id: currentCanvas.id, + name: currentCanvas.name, + imageUri, + fabricState: currentCanvas.fabricState, + thumbnail: currentCanvas.thumbnail, + shapes: currentCanvas.shapes, + images: currentCanvas.images, + }); + } + + postMessage({ type: 'submit', action, canvases: submitted }); + } + + toolButtons.forEach((button) => { + button.addEventListener('click', () => { + const tool = button.dataset.tool as WhiteboardTool | undefined; + if (tool) { + setTool(tool); + } + }); + }); + + strokeColorInput.addEventListener('input', () => { + const styles = { + strokeColor: strokeColorInput.value, + fillColor: fillColorInput.value, + strokeWidth: Number.parseInt(strokeWidthInput.value, 10) || 2, + }; + if (applyStyleControlsToSelection(fabricCanvas, styles)) { + void persistActiveCanvas(); + return; + } + + if (fabricCanvas.isDrawingMode) { + fabricCanvas.freeDrawingBrush = createBrush( + fabricCanvas, + currentTool, + styles.strokeColor, + styles.strokeWidth, + ); + } + }); + + strokeWidthInput.addEventListener('input', () => { + const styles = { + strokeColor: strokeColorInput.value, + fillColor: fillColorInput.value, + strokeWidth: Number.parseInt(strokeWidthInput.value, 10) || 2, + }; + if (applyStyleControlsToSelection(fabricCanvas, styles)) { + void persistActiveCanvas(); + return; + } + + if (currentShapeDraft) { + const update = getShapeDraftStrokeWidthUpdate(currentShapeDraft, styles.strokeWidth); + currentShapeDraft.object.set( + currentShapeDraft.tool === 'arrow' && typeof update.path === 'string' + ? { ...update, path: fabric.util.parsePath(update.path) } + : update, + ); + fabricCanvas.requestRenderAll(); + } + + if (fabricCanvas.isDrawingMode) { + fabricCanvas.freeDrawingBrush = createBrush( + fabricCanvas, + currentTool, + styles.strokeColor, + styles.strokeWidth, + ); + } + }); + + fillColorInput.addEventListener('input', () => { + if (applyStyleControlsToSelection(fabricCanvas, { + strokeColor: strokeColorInput.value, + fillColor: fillColorInput.value, + strokeWidth: Number.parseInt(strokeWidthInput.value, 10) || 2, + })) { + void persistActiveCanvas(); + } + }); + + undoButton.addEventListener('click', () => { + if (!activeCanvasId) { + return; + } + + const history = histories.get(activeCanvasId); + if (!history) { + return; + } + + const nextHistory = stepUndoRedoHistory(history, 'undo'); + histories.set(activeCanvasId, nextHistory); + void hydrateActiveCanvas(nextHistory.present).then((hydrated) => hydrated ? persistActiveCanvas({ pushHistory: false }) : undefined); + }); + + redoButton.addEventListener('click', () => { + if (!activeCanvasId) { + return; + } + + const history = histories.get(activeCanvasId); + if (!history) { + return; + } + + const nextHistory = stepUndoRedoHistory(history, 'redo'); + histories.set(activeCanvasId, nextHistory); + void hydrateActiveCanvas(nextHistory.present).then((hydrated) => hydrated ? persistActiveCanvas({ pushHistory: false }) : undefined); + }); + + clearButton.addEventListener('click', () => { + clearFabricCanvas(fabricCanvas, DEFAULT_BACKGROUND); + applyToolMode( + fabricCanvas, + currentTool, + strokeColorInput.value, + Number.parseInt(strokeWidthInput.value, 10) || 2, + ); + void persistActiveCanvas(); + }); + + addCanvasButton.addEventListener('click', () => { + if (!session) { + return; + } + + const name = window.prompt('Name for the new canvas', `Canvas ${session.canvases.length + 1}`)?.trim(); + if (!name) { + return; + } + + const now = Date.now(); + const blankState = serializeCanvasState(createBlankFabricCanvasState()); + const newCanvas: WhiteboardCanvas = { + id: makeId('canvas'), + name, + fabricState: blankState, + createdAt: now, + updatedAt: now, + }; + const nextState = applyCanvasCollectionAction(session, { type: 'create', canvas: newCanvas }); + session = { + ...session, + canvases: nextState.canvases, + activeCanvasId: nextState.activeCanvasId, + }; + activeCanvasId = nextState.activeCanvasId; + histories.set(newCanvas.id, createHistory(blankState)); + vscode.setState({ session, activeCanvasId }); + postMessage({ + type: 'createCanvas', + canvasId: newCanvas.id, + name, + fabricState: newCanvas.fabricState, + }); + renderTabs(); + void hydrateActiveCanvas(newCanvas.fabricState); + updateHistoryButtons(); + }); + + deleteCanvasButton.addEventListener('click', () => { + if (!session || !activeCanvasId || session.canvases.length <= 1) { + return; + } + + const canvasIdToDelete = activeCanvasId; + const nextState = applyCanvasCollectionAction(session, { type: 'delete', canvasId: canvasIdToDelete }); + histories.delete(canvasIdToDelete); + session = { + ...session, + canvases: nextState.canvases, + activeCanvasId: nextState.activeCanvasId, + }; + activeCanvasId = nextState.activeCanvasId; + vscode.setState({ session, activeCanvasId }); + postMessage({ type: 'deleteCanvas', canvasId: canvasIdToDelete }); + renderTabs(); + void hydrateActiveCanvas(getCanvasById(activeCanvasId)?.fabricState); + updateHistoryButtons(); + }); + + importButton.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + const files = Array.from(fileInput.files ?? []); + fileInput.value = ''; + void Promise.all(files.map((file) => importImageFile(file))); + }); + + approveButton.addEventListener('click', () => { + void submitWhiteboard('approved'); + }); + requestChangesButton.addEventListener('click', () => { + void submitWhiteboard('recreateWithChanges'); + }); + cancelButton.addEventListener('click', () => postMessage({ type: 'cancel' })); + + fabricCanvas.on('path:created', (event: any) => { + if (isHydrating || !event.path) { + return; + } + + decorateFabricObject(event.path, { objectType: 'path' }); + event.path.set({ selectable: currentTool === 'select', evented: currentTool === 'select' }); + void persistActiveCanvas(); + }); + + fabricCanvas.on('object:modified', () => { + if (isHydrating) { + return; + } + + void persistActiveCanvas(); + }); + + fabricCanvas.on('object:moving', (event: any) => { + const target = event.target; + const annotationId = getAnnotationId(target); + const annotationRole = getAnnotationRole(target); + if (!annotationId || !annotationRole) { + return; + } + + if (annotationRole === 'text') { + const previousLeft = typeof target.__annotationPrevLeft === 'number' ? target.__annotationPrevLeft : target.left; + const previousTop = typeof target.__annotationPrevTop === 'number' ? target.__annotationPrevTop : target.top; + const deltaX = (typeof target.left === 'number' ? target.left : 0) - (typeof previousLeft === 'number' ? previousLeft : 0); + const deltaY = (typeof target.top === 'number' ? target.top : 0) - (typeof previousTop === 'number' ? previousTop : 0); + + if (deltaX !== 0 || deltaY !== 0) { + moveAnnotation(fabricCanvas, annotationId, deltaX, deltaY); + } else { + syncAnnotationLayout(fabricCanvas, annotationId); + } + + target.__annotationPrevLeft = target.left; + target.__annotationPrevTop = target.top; + fabricCanvas.requestRenderAll(); + return; + } + + if (annotationRole === 'handle') { + syncAnnotationLayout(fabricCanvas, annotationId); + target.__annotationPrevLeft = target.left; + target.__annotationPrevTop = target.top; + fabricCanvas.requestRenderAll(); + } + }); + + fabricCanvas.on('mouse:down', (event: any) => { + if (isHydrating || !event.e) { + return; + } + + if (currentTool === 'select' && event.target) { + event.target.__annotationPrevLeft = event.target.left; + event.target.__annotationPrevTop = event.target.top; + } + + const pointer = fabricCanvas.getPointer(event.e) as WhiteboardPoint; + if (currentTool === 'eraser') { + isErasing = true; + hasErasedObjects = eraseObjectsAtPoint(fabricCanvas, pointer) || hasErasedObjects; + return; + } + + if (currentTool === 'text') { + const text = new fabric.IText('Text', { + left: pointer.x, + top: pointer.y, + fontSize: Math.max(16, (Number.parseInt(strokeWidthInput.value, 10) || 2) * 6), + fontFamily: 'sans-serif', + fill: strokeColorInput.value, + editable: true, + selectable: false, + evented: false, + }); + decorateFabricObject(text, { objectType: 'text' }); + attachTextPersistence(text, () => persistActiveCanvas()); + fabricCanvas.add(text); + fabricCanvas.setActiveObject(text); + text.enterEditing(); + void persistActiveCanvas(); + return; + } + + if (currentTool === 'annotation') { + createAnnotation(pointer); + void persistActiveCanvas(); + return; + } + + if (currentTool === 'rectangle' || currentTool === 'circle' || currentTool === 'line' || currentTool === 'arrow') { + currentShapeDraft = createShapeDraft(currentTool, pointer); + fabricCanvas.add(currentShapeDraft.object); + fabricCanvas.setActiveObject(currentShapeDraft.object); + } + }); + + fabricCanvas.on('mouse:move', (event: any) => { + if (currentTool === 'eraser' && isErasing && event.e) { + hasErasedObjects = eraseObjectsAtPoint(fabricCanvas, fabricCanvas.getPointer(event.e) as WhiteboardPoint) || hasErasedObjects; + return; + } + + if (!currentShapeDraft || !event.e) { + return; + } + + updateShapeDraft(currentShapeDraft, fabricCanvas.getPointer(event.e) as WhiteboardPoint); + }); + + fabricCanvas.on('mouse:up', () => { + if (isErasing) { + const didErase = hasErasedObjects; + isErasing = false; + hasErasedObjects = false; + if (didErase) { + void persistActiveCanvas(); + } + return; + } + + if (!currentShapeDraft) { + return; + } + + const finalized = currentShapeDraft.object; + currentShapeDraft = undefined; + finalized.set({ selectable: currentTool === 'select', evented: currentTool === 'select' }); + finalized.setCoords(); + void persistActiveCanvas(); + }); + + document.addEventListener('dragover', (event) => event.preventDefault()); + document.addEventListener('drop', (event) => { + event.preventDefault(); + void Promise.all(Array.from(event.dataTransfer?.files ?? []).map((file) => importImageFile(file))); + }); + document.addEventListener('paste', (event) => { + const files = Array.from(event.clipboardData?.items ?? []) + .filter((item) => item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((item): item is File => Boolean(item)); + void Promise.all(files.map((file) => importImageFile(file))); + }); + document.addEventListener('keydown', (event) => { + const ctrlOrMeta = event.ctrlKey || event.metaKey; + if (ctrlOrMeta && event.key.toLowerCase() === 'z' && !event.shiftKey) { + event.preventDefault(); + undoButton.click(); + return; + } + + if ((ctrlOrMeta && event.key.toLowerCase() === 'y') || (ctrlOrMeta && event.shiftKey && event.key.toLowerCase() === 'z')) { + event.preventDefault(); + redoButton.click(); + return; + } + + if ((event.key === 'Delete' || event.key === 'Backspace') && currentTool === 'select') { + const target = event.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } + + const activeObjects = fabricCanvas.getActiveObjects(); + if (activeObjects.length === 0) { + return; + } + + event.preventDefault(); + const removedAnnotationIds = new Set(); + activeObjects.forEach((object: any) => { + const annotationId = getAnnotationId(object); + if (annotationId) { + if (!removedAnnotationIds.has(annotationId)) { + removedAnnotationIds.add(annotationId); + removeAnnotation(fabricCanvas, annotationId); + } + return; + } + + fabricCanvas.remove(object); + }); + fabricCanvas.discardActiveObject(); + fabricCanvas.requestRenderAll(); + void persistActiveCanvas(); + } + }); + + window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (message.type === 'initialize') { + session = ensureWhiteboardSessionHasUsableCanvas({ + ...message.session, + canvases: message.session.canvases.map((canvas) => ({ ...canvas })), + }); + activeCanvasId = session.activeCanvasId; + contextText.textContent = session.context || 'No additional context provided.'; + for (const currentCanvas of session.canvases) { + const normalized = normalizeSerializedCanvasState(currentCanvas.fabricState); + histories.set(currentCanvas.id, createHistory(serializeCanvasState(normalized))); + } + vscode.setState({ session, activeCanvasId }); + renderTabs(); + void hydrateActiveCanvas(getCanvasById(activeCanvasId)?.fabricState).then((hydrated) => { + updateHistoryButtons(); + if (hydrated) { + setStatus('Whiteboard ready.'); + } + }); + return; + } + + if (message.type === 'error') { + setStatus(message.message, 'error'); + return; + } + + if (message.type === 'cancel') { + postMessage({ type: 'cancel' }); + } + }); + + try { + const persistedState = vscode.getState() as { session?: WhiteboardSession; activeCanvasId?: string } | undefined; + if (persistedState?.session) { + session = ensureWhiteboardSessionHasUsableCanvas({ + ...persistedState.session, + activeCanvasId: persistedState.activeCanvasId ?? persistedState.session.activeCanvasId, + }); + activeCanvasId = session.activeCanvasId; + contextText.textContent = session.context || 'No additional context provided.'; + vscode.setState({ session, activeCanvasId }); + renderTabs(); + } + } catch (error) { + logger.warn('Failed to restore whiteboard webview state', error); + } + + setTool('pen'); + setStatus('Loading whiteboard…'); + postMessage({ type: 'ready' }); +} + +ensureWhiteboardFabricRegistry(); + +if (isBrowser()) { + bootstrap(); +} + +declare function acquireVsCodeApi(): VSCodeAPI; diff --git a/src/webview/whiteboardPanel.test.ts b/src/webview/whiteboardPanel.test.ts new file mode 100644 index 0000000..f090054 --- /dev/null +++ b/src/webview/whiteboardPanel.test.ts @@ -0,0 +1,614 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +type MockDisposable = { dispose(): void }; +type MockPanel = { + webview: { + html: string; + cspSource: string; + asWebviewUri(uri: { fsPath: string }): { toString(): string }; + postMessage(message: unknown): Thenable; + onDidReceiveMessage( + callback: (message: unknown) => void, + thisArg?: unknown, + disposables?: MockDisposable[], + ): MockDisposable; + }; + onDidDispose(callback: () => void, thisArg?: unknown, disposables?: MockDisposable[]): MockDisposable; + reveal(): void; + dispose(): void; +}; + +type MockVscodeModule = { + window: { + activeTextEditor: undefined; + createWebviewPanel(): MockPanel; + }; + Uri: { + joinPath(...parts: Array<{ fsPath: string } | string>): { fsPath: string }; + file(filePath: string): { fsPath: string; toString(): string }; + }; + ViewColumn: { + One: number; + }; +}; + +function createMockVscode() { + let lastPanel: MockPanel | undefined; + const postedMessages: unknown[] = []; + let receiveMessageCallback: ((message: unknown) => void) | undefined; + + const mockVscode: MockVscodeModule = { + window: { + activeTextEditor: undefined, + createWebviewPanel() { + const disposeListeners: Array<() => void> = []; + let disposed = false; + + const panel: MockPanel = { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(uri) { + return { + toString: () => `webview:${uri.fsPath}`, + }; + }, + postMessage: async (message) => { + postedMessages.push(message); + return true; + }, + onDidReceiveMessage(callback, _thisArg, disposables) { + receiveMessageCallback = callback as (message: unknown) => void; + const disposable = { dispose() { } }; + disposables?.push(disposable); + return disposable; + }, + }, + onDidDispose(callback, _thisArg, disposables) { + disposeListeners.push(callback); + const disposable = { dispose() { } }; + disposables?.push(disposable); + return disposable; + }, + reveal() { }, + dispose() { + if (disposed) { + return; + } + disposed = true; + for (const listener of [...disposeListeners]) { + listener(); + } + }, + }; + + lastPanel = panel; + return panel; + }, + }, + Uri: { + joinPath(...parts: Array<{ fsPath: string } | string>) { + const normalized = parts.map((part) => typeof part === 'string' ? part : part.fsPath); + return { fsPath: path.join(...normalized) }; + }, + file(filePath: string) { + return { + fsPath: filePath, + toString: () => `file://${filePath}`, + }; + }, + }, + ViewColumn: { + One: 1, + }, + }; + + return { + mockVscode, + getLastPanel() { + return lastPanel; + }, + getPostedMessages() { + return postedMessages; + }, + sendMessage(message: unknown) { + receiveMessageCallback?.(message); + }, + }; +} + +describe('WhiteboardPanel', () => { + const modulePath = require.resolve('./whiteboardPanel.ts'); + let originalLoad: typeof Module._load; + let tempRoot: string; + const extensionUri = { fsPath: '/Users/muhammadfaiz/Custom APP/seamless_agent' } as any; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'whiteboard-panel-test-')); + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('keeps the pending resolver alive when the user manually closes the panel', async () => { + const mock = createMockVscode(); + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return mock.mockVscode; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction() { }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_manual_close'; + const resultPromise = WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard', + context: 'Review this sketch', + canvases: [], + activeCanvasId: undefined, + status: 'pending', + }, + }); + + const panel = mock.getLastPanel(); + assert.ok(panel, 'expected whiteboard panel to be created'); + panel.dispose(); + + const resolution = await Promise.race([ + resultPromise.then((value) => ({ state: 'resolved' as const, value })), + new Promise<{ state: 'pending' }>((resolve) => setTimeout(() => resolve({ state: 'pending' }), 0)), + ]); + + assert.deepStrictEqual(resolution, { state: 'pending' }); + assert.strictEqual(WhiteboardPanel.hasPendingResolver(interactionId), true); + assert.strictEqual(WhiteboardPanel.closeIfOpen(interactionId), true); + assert.deepStrictEqual(await resultPromise, { + submitted: false, + action: 'cancelled', + canvases: [], + }); + }); + + it('reopens a manually closed pending panel with the surviving resolver', async () => { + const mock = createMockVscode(); + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return mock.mockVscode; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction() { }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_reopen_pending'; + const resultPromise = WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard', + context: 'Reopen this sketch', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas One', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 1, + }, + ], + activeCanvasId: 'canvas_1', + status: 'pending', + }, + }); + + const firstPanel = mock.getLastPanel(); + assert.ok(firstPanel, 'expected initial whiteboard panel to be created'); + firstPanel.dispose(); + + const reopened = WhiteboardPanel.reopenPending(extensionUri, interactionId, { + interactionId, + title: 'Whiteboard (reopened)', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard (reopened)', + context: 'Reopen this sketch', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas One', + fabricState: '{"objects":[{\"type\":\"rect\"}]}', + createdAt: 1, + updatedAt: 2, + }, + ], + activeCanvasId: 'canvas_1', + status: 'pending', + }, + }); + + assert.strictEqual(reopened, true); + const reopenedPanel = mock.getLastPanel(); + assert.ok(reopenedPanel, 'expected pending panel to be recreated'); + assert.notStrictEqual(reopenedPanel, firstPanel, 'expected a new panel instance after reopen'); + + mock.sendMessage({ type: 'ready' }); + const initializeMessage = mock.getPostedMessages().at(-1); + assert.deepStrictEqual(initializeMessage, { + type: 'initialize', + title: 'Whiteboard (reopened)', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard (reopened)', + context: 'Reopen this sketch', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas One', + fabricState: '{"objects":[{\"type\":\"rect\"}]}', + createdAt: 1, + updatedAt: 2, + }, + ], + activeCanvasId: 'canvas_1', + status: 'pending', + }, + }); + + mock.sendMessage({ type: 'cancel' }); + assert.deepStrictEqual(await resultPromise, { + submitted: false, + action: 'cancelled', + canvases: [], + }); + }); + + it('returns recreateWithChanges when the user clicks request changes', async () => { + const mock = createMockVscode(); + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return mock.mockVscode; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction() { }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_request_changes'; + const resultPromise = WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard', + context: 'Review this sketch', + canvases: [], + activeCanvasId: undefined, + status: 'pending', + }, + }); + + mock.sendMessage({ + type: 'submit', + action: 'recreateWithChanges', + canvases: [], + }); + + assert.deepStrictEqual(await resultPromise, { + submitted: true, + action: 'recreateWithChanges', + canvases: [], + }); + }); + + it('reuses an existing panel with the latest session data', async () => { + const mock = createMockVscode(); + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return mock.mockVscode; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction() { }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_reuse'; + + void WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Original Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Original Whiteboard', + context: 'Old context', + canvases: [ + { + id: 'canvas_old', + name: 'Old Canvas', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 1, + }, + ], + activeCanvasId: 'canvas_old', + status: 'pending', + }, + }); + + const reusedPromise = WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Updated Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Updated Whiteboard', + context: 'Fresh context', + canvases: [ + { + id: 'canvas_new', + name: 'New Canvas', + fabricState: '{"objects":[]}', + createdAt: 2, + updatedAt: 2, + }, + ], + activeCanvasId: 'canvas_new', + status: 'pending', + }, + }); + + mock.sendMessage({ type: 'ready' }); + void reusedPromise; + + const initializeMessage = mock.getPostedMessages().at(-1); + assert.deepStrictEqual(initializeMessage, { + type: 'initialize', + title: 'Updated Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Updated Whiteboard', + context: 'Fresh context', + canvases: [ + { + id: 'canvas_new', + name: 'New Canvas', + fabricState: '{"objects":[]}', + createdAt: 2, + updatedAt: 2, + }, + ], + activeCanvasId: 'canvas_new', + status: 'pending', + }, + }); + }); + + it('uses the canonical blank fabric state when creating a canvas without an explicit payload', async () => { + const mock = createMockVscode(); + const updateCalls: Array<{ interactionId: string; updates: unknown }> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return mock.mockVscode; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction(interactionId: string, updates: unknown) { + updateCalls.push({ interactionId, updates }); + }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_canonical_blank'; + void WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard', + canvases: [], + activeCanvasId: undefined, + status: 'pending', + }, + }); + + mock.sendMessage({ + type: 'createCanvas', + name: 'Canvas Two', + }); + + assert.equal(updateCalls.length, 1); + const createdCanvas = ((updateCalls[0]?.updates as any)?.whiteboardSession?.canvases as any[])?.[0]; + assert.ok(createdCanvas, 'expected createCanvas to persist a canvas'); + const fabricState = JSON.parse(createdCanvas.fabricState); + assert.match(fabricState.version, /\S+/); + assert.deepStrictEqual(fabricState, { + version: fabricState.version, + width: 1600, + height: 900, + backgroundColor: '#ffffff', + objects: [], + }); + }); + + it('persists canvas lifecycle updates and exports submitted png files', async () => { + const mock = createMockVscode(); + const updateCalls: Array<{ interactionId: string; updates: unknown }> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ...mock.mockVscode, + Uri: { + joinPath: (...parts: Array<{ fsPath: string } | string>) => { + const normalized = parts.map((part) => typeof part === 'string' ? part : part.fsPath); + return { fsPath: path.join(...normalized) }; + }, + file: (filePath: string) => ({ + fsPath: filePath, + toString: () => `file://${filePath}`, + }), + }, + }; + } + if (request === '../storage/chatHistoryStorage') { + return { + getChatHistoryStorage: () => ({ + updateWhiteboardInteraction(interactionId: string, updates: unknown) { + updateCalls.push({ interactionId, updates }); + }, + }), + getExtensionContext: () => ({ + globalStorageUri: { fsPath: tempRoot }, + }), + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const { WhiteboardPanel } = require('./whiteboardPanel.ts') as typeof import('./whiteboardPanel'); + const interactionId = 'wb_submit'; + const resultPromise = WhiteboardPanel.showWithOptions(extensionUri, { + interactionId, + title: 'Whiteboard', + session: { + id: interactionId, + interactionId, + title: 'Whiteboard', + context: 'Persist this', + canvases: [ + { + id: 'canvas_1', + name: 'Canvas One', + fabricState: '{"objects":[]}', + createdAt: 1, + updatedAt: 1, + }, + ], + activeCanvasId: 'canvas_1', + status: 'pending', + }, + }); + + mock.sendMessage({ + type: 'saveCanvas', + canvasId: 'canvas_1', + name: 'Canvas One', + fabricState: '{"objects":[{"type":"path"}]}', + thumbnail: 'data:image/png;base64,AAAA', + shapes: [{ id: 'shape_1', objectType: 'path' }], + images: [], + }); + mock.sendMessage({ + type: 'createCanvas', + canvasId: 'canvas_2', + name: 'Canvas Two', + fabricState: '{"objects":[]}', + }); + mock.sendMessage({ + type: 'switchCanvas', + canvasId: 'canvas_2', + }); + mock.sendMessage({ + type: 'submit', + canvases: [ + { + id: 'canvas_1', + imageUri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2WZs8AAAAASUVORK5CYII=', + fabricState: '{"objects":[{"type":"rect"}]}', + thumbnail: 'data:image/png;base64,AAAA', + shapes: [{ id: 'shape_1', objectType: 'path' }], + images: [], + }, + ], + }); + + const result = await resultPromise; + assert.equal(result.submitted, true); + assert.equal(result.canvases.length, 1); + assert.match(result.canvases[0].imageUri, /^file:\/\//); + assert.equal((result.canvases[0] as { fabricState?: string }).fabricState, '{"objects":[{"type":"rect"}]}'); + + const exportedPath = result.canvases[0].imageUri.replace('file://', ''); + assert.ok(fs.existsSync(exportedPath), 'expected exported png file to exist'); + assert.ok(exportedPath.includes(path.join('temp-whiteboard-images', `${interactionId}_canvas_1_`))); + + assert.deepStrictEqual(updateCalls.map((entry) => entry.interactionId), [ + interactionId, + interactionId, + interactionId, + ]); + }); +}); diff --git a/src/webview/whiteboardPanel.ts b/src/webview/whiteboardPanel.ts new file mode 100644 index 0000000..64d6a5d --- /dev/null +++ b/src/webview/whiteboardPanel.ts @@ -0,0 +1,426 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { serializeBlankFabricCanvasState } from '../whiteboard/canvasState'; +import { + getChatHistoryStorage, + getExtensionContext, +} from '../storage/chatHistoryStorage'; +import { + normalizeWhiteboardSubmittedCanvas, +} from './types'; +import type { + ExtensionToWhiteboardMessage as ToWebviewMessage, + NormalizedWhiteboardCanvasSubmission, + WhiteboardCanvas, + WhiteboardCanvasSubmission, + WhiteboardPanelOptions, + WhiteboardPanelResult, + WhiteboardToExtensionMessage as FromWebviewMessage, +} from './types'; + +const TEMP_IMAGE_DIRECTORY = 'temp-whiteboard-images'; + +export async function exportSubmittedWhiteboardCanvases( + interactionId: string, + canvases: WhiteboardCanvasSubmission[], + storageRootPath: string, +): Promise { + cleanupWhiteboardTempImages(interactionId, storageRootPath); + const tempDir = path.join(storageRootPath, TEMP_IMAGE_DIRECTORY); + fs.mkdirSync(tempDir, { recursive: true }); + + return canvases.map((canvasSubmission) => { + const canvas = normalizeWhiteboardSubmittedCanvas(canvasSubmission); + if (!canvas.imageUri.startsWith('data:image/png;base64,')) { + return canvas; + } + + const fileName = `${interactionId}_${canvas.id}_${Date.now()}.png`; + const filePath = path.join(tempDir, fileName); + const base64 = canvas.imageUri.replace(/^data:image\/png;base64,/, ''); + fs.writeFileSync(filePath, Buffer.from(base64, 'base64')); + + return { + ...canvas, + imageUri: vscode.Uri.file(filePath).toString(), + }; + }); +} + +export function cleanupWhiteboardTempImages(interactionId: string, storageRootPath: string): void { + const tempDir = path.join(storageRootPath, TEMP_IMAGE_DIRECTORY); + if (!fs.existsSync(tempDir)) { + return; + } + + for (const file of fs.readdirSync(tempDir)) { + if (!file.startsWith(`${interactionId}_`)) { + continue; + } + fs.rmSync(path.join(tempDir, file), { force: true }); + } +} + +export class WhiteboardPanel { + public static readonly viewType = 'seamlessAgent.whiteboard'; + + private static _panels: Map = new Map(); + private static _pendingResolvers: Map void> = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _options: WhiteboardPanelOptions; + private _disposables: vscode.Disposable[] = []; + private _resolvePromise?: (result: WhiteboardPanelResult) => void; + private _closedByAgent = false; + private _webviewReady = false; + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + options: WhiteboardPanelOptions, + resolve: (result: WhiteboardPanelResult) => void, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._options = options; + this._resolvePromise = resolve; + + this._panel.webview.html = this._getHtmlContent(); + this._panel.onDidDispose(() => this._dispose(), null, this._disposables); + this._panel.webview.onDidReceiveMessage( + (message: FromWebviewMessage) => void this._handleMessage(message), + null, + this._disposables, + ); + } + + public static async showWithOptions( + extensionUri: vscode.Uri, + options: WhiteboardPanelOptions, + ): Promise { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + const existingPanel = WhiteboardPanel._panels.get(options.interactionId); + + if (existingPanel) { + existingPanel._applyOptions(options); + existingPanel._panel.reveal(column); + } + + return new Promise((resolve) => { + WhiteboardPanel._pendingResolvers.set(options.interactionId, resolve); + + if (existingPanel) { + existingPanel._resolvePromise = resolve; + void existingPanel._postInitialize(); + return; + } + + WhiteboardPanel._createPanel(extensionUri, options, resolve, column); + }); + } + + public static closeIfOpen(interactionId: string): boolean { + const panel = WhiteboardPanel._panels.get(interactionId); + if (panel) { + panel._closedByAgent = true; + panel._panel.dispose(); + return true; + } + + const pendingResolver = WhiteboardPanel._pendingResolvers.get(interactionId); + if (pendingResolver) { + pendingResolver({ submitted: false, action: 'cancelled', canvases: [] }); + WhiteboardPanel._pendingResolvers.delete(interactionId); + return true; + } + + return false; + } + + public static hasPendingResolver(interactionId: string): boolean { + return WhiteboardPanel._pendingResolvers.has(interactionId); + } + + public static reopenPending( + extensionUri: vscode.Uri, + interactionId: string, + options: WhiteboardPanelOptions, + ): boolean { + const existingPanel = WhiteboardPanel._panels.get(interactionId); + if (existingPanel) { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + existingPanel._applyOptions(options); + existingPanel._panel.reveal(column); + void existingPanel._postInitialize(); + return true; + } + + const pendingResolver = WhiteboardPanel._pendingResolvers.get(interactionId); + if (!pendingResolver) { + return false; + } + + WhiteboardPanel._createPanel(extensionUri, options, pendingResolver); + return true; + } + + private async _handleMessage(message: FromWebviewMessage): Promise { + switch (message.type) { + case 'ready': + this._webviewReady = true; + await this._postInitialize(); + return; + case 'saveCanvas': + this._saveCanvas(message); + return; + case 'createCanvas': + this._createCanvas(message); + return; + case 'deleteCanvas': + this._deleteCanvas(message.canvasId); + return; + case 'switchCanvas': + this._switchCanvas(message.canvasId); + return; + case 'submit': { + try { + const exportedCanvases = await exportSubmittedWhiteboardCanvases( + this._options.interactionId, + message.canvases, + getExtensionContext().globalStorageUri.fsPath, + ); + this._resolve({ + submitted: true, + action: message.action, + canvases: exportedCanvases, + }); + } catch (error) { + await this._showError(`Failed to export whiteboard images: ${error instanceof Error ? error.message : String(error)}`); + } + return; + } + case 'cancel': + cleanupWhiteboardTempImages(this._options.interactionId, getExtensionContext().globalStorageUri.fsPath); + this._resolve({ submitted: false, action: 'cancelled', canvases: [] }); + return; + default: + return; + } + } + + private _saveCanvas(message: Extract): void { + const canvasIndex = this._options.session.canvases.findIndex((canvas) => canvas.id === message.canvasId); + const currentTimestamp = Date.now(); + const existingCanvas = canvasIndex >= 0 ? this._options.session.canvases[canvasIndex] : undefined; + const nextCanvas: WhiteboardCanvas = { + id: message.canvasId, + name: message.name ?? existingCanvas?.name ?? `Canvas ${this._options.session.canvases.length + 1}`, + fabricState: message.fabricState, + thumbnail: message.thumbnail, + createdAt: existingCanvas?.createdAt ?? currentTimestamp, + updatedAt: currentTimestamp, + shapes: message.shapes, + images: message.images, + }; + + const nextCanvases = [...this._options.session.canvases]; + if (canvasIndex >= 0) { + nextCanvases[canvasIndex] = nextCanvas; + } else { + nextCanvases.push(nextCanvas); + } + + this._options = { + ...this._options, + session: { + ...this._options.session, + canvases: nextCanvases, + activeCanvasId: message.canvasId, + }, + }; + this._persistSession(); + } + + private _createCanvas(message: Extract): void { + const canvasId = message.canvasId ?? `canvas_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + if (!this._options.session.canvases.some((canvas) => canvas.id === canvasId)) { + const currentTimestamp = Date.now(); + this._options = { + ...this._options, + session: { + ...this._options.session, + canvases: [ + ...this._options.session.canvases, + { + id: canvasId, + name: message.name, + fabricState: message.fabricState ?? serializeBlankFabricCanvasState(), + createdAt: currentTimestamp, + updatedAt: currentTimestamp, + }, + ], + activeCanvasId: canvasId, + }, + }; + this._persistSession(); + } + } + + private _deleteCanvas(canvasId: string): void { + const nextCanvases = this._options.session.canvases.filter((canvas) => canvas.id !== canvasId); + this._options = { + ...this._options, + session: { + ...this._options.session, + canvases: nextCanvases, + activeCanvasId: this._options.session.activeCanvasId === canvasId + ? nextCanvases[0]?.id + : this._options.session.activeCanvasId, + }, + }; + this._persistSession(); + } + + private _switchCanvas(canvasId: string): void { + if (!this._options.session.canvases.some((canvas) => canvas.id === canvasId)) { + return; + } + + this._options = { + ...this._options, + session: { + ...this._options.session, + activeCanvasId: canvasId, + }, + }; + this._persistSession(); + } + + private _persistSession(): void { + getChatHistoryStorage().updateWhiteboardInteraction(this._options.interactionId, { + title: this._options.title, + whiteboardSession: { + title: this._options.title, + context: this._options.session.context, + canvases: this._options.session.canvases, + activeCanvasId: this._options.session.activeCanvasId, + }, + }); + } + + private _resolve(result: WhiteboardPanelResult): void { + if (this._resolvePromise) { + this._resolvePromise(result); + this._resolvePromise = undefined; + WhiteboardPanel._pendingResolvers.delete(this._options.interactionId); + } + + this._panel.dispose(); + } + + private _dispose(): void { + if (this._closedByAgent && this._resolvePromise) { + this._resolvePromise({ submitted: false, action: 'cancelled', canvases: [] }); + WhiteboardPanel._pendingResolvers.delete(this._options.interactionId); + } + this._resolvePromise = undefined; + + while (this._disposables.length > 0) { + this._disposables.pop()?.dispose(); + } + } + + private _applyOptions(options: WhiteboardPanelOptions): void { + this._options = options; + this._panel.title = options.title; + if (this._webviewReady) { + void this._postInitialize(); + } + } + + private async _postInitialize(): Promise { + if (!this._webviewReady) { + return; + } + + await this._panel.webview.postMessage({ + type: 'initialize', + title: this._options.title, + session: this._options.session, + } as ToWebviewMessage); + } + + private async _showError(message: string): Promise { + await this._panel.webview.postMessage({ type: 'error', message } as ToWebviewMessage); + } + + private _getHtmlContent(): string { + const webview = this._panel.webview; + const nonce = getNonce(); + const templatePath = path.join(this._extensionUri.fsPath, 'media', 'whiteboard.html'); + let template = fs.readFileSync(templatePath, 'utf8'); + + const replacements: Record = { + '{{cspSource}}': webview.cspSource, + '{{nonce}}': nonce, + '{{title}}': escapeHtml(this._options.title), + '{{styleUri}}': webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'whiteboard.css')).toString(), + '{{scriptUri}}': webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'dist', 'whiteboard.js')).toString(), + '{{codiconsUri}}': webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.css')).toString(), + }; + + for (const [placeholder, value] of Object.entries(replacements)) { + template = template.split(placeholder).join(value); + } + + return template; + } + + private static _createPanel( + extensionUri: vscode.Uri, + options: WhiteboardPanelOptions, + resolve: (result: WhiteboardPanelResult) => void, + column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One, + ): void { + const panel = vscode.window.createWebviewPanel( + WhiteboardPanel.viewType, + options.title, + column, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, 'media'), + vscode.Uri.joinPath(extensionUri, 'dist'), + vscode.Uri.joinPath(extensionUri, 'node_modules', '@vscode', 'codicons', 'dist'), + ], + }, + ); + + const whiteboardPanel = new WhiteboardPanel(panel, extensionUri, options, resolve); + WhiteboardPanel._panels.set(options.interactionId, whiteboardPanel); + panel.onDidDispose(() => { + WhiteboardPanel._panels.delete(options.interactionId); + }); + } +} + +function getNonce(): string { + let value = ''; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let index = 0; index < 32; index += 1) { + value += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return value; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/whiteboard/canvasState.ts b/src/whiteboard/canvasState.ts new file mode 100644 index 0000000..0a0b7a1 --- /dev/null +++ b/src/whiteboard/canvasState.ts @@ -0,0 +1,29 @@ +import * as fabric from 'fabric'; + +export const DEFAULT_WHITEBOARD_CANVAS_WIDTH = 1600; +export const DEFAULT_WHITEBOARD_CANVAS_HEIGHT = 900; +export const DEFAULT_WHITEBOARD_CANVAS_BACKGROUND = '#ffffff'; +export const DEFAULT_WHITEBOARD_CANVAS_NAME = 'Canvas 1'; + +export interface BlankFabricCanvasState { + version: string; + width: number; + height: number; + backgroundColor: string; + objects: []; + [key: string]: unknown; +} + +export function createBlankFabricCanvasState(): BlankFabricCanvasState { + return { + version: fabric.version, + width: DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + backgroundColor: DEFAULT_WHITEBOARD_CANVAS_BACKGROUND, + objects: [], + }; +} + +export function serializeBlankFabricCanvasState(): string { + return JSON.stringify(createBlankFabricCanvasState()); +} diff --git a/src/whiteboard/circlePath.ts b/src/whiteboard/circlePath.ts new file mode 100644 index 0000000..b9d928c --- /dev/null +++ b/src/whiteboard/circlePath.ts @@ -0,0 +1,50 @@ +export type FabricPathCommand = [string, ...number[]]; + +const KAPPA = 0.5522847498307936; + +export function buildCirclePathCommands(centerX: number, centerY: number, radius: number): FabricPathCommand[] { + const control = radius * KAPPA; + + return [ + ['M', centerX + radius, centerY], + ['C', centerX + radius, centerY + control, centerX + control, centerY + radius, centerX, centerY + radius], + ['C', centerX - control, centerY + radius, centerX - radius, centerY + control, centerX - radius, centerY], + ['C', centerX - radius, centerY - control, centerX - control, centerY - radius, centerX, centerY - radius], + ['C', centerX + control, centerY - radius, centerX + radius, centerY - control, centerX + radius, centerY], + ['Z'], + ]; +} + +export function createCirclePathFabricObject(properties: { + centerX: number; + centerY: number; + radius: number; + stroke: string; + fill: string; + strokeWidth: number; + opacity: number; + whiteboardId: string; + whiteboardObjectType: string; + whiteboardZIndex?: number; + angle?: number; +}): Record { + const { centerX, centerY, radius } = properties; + + return { + type: 'path', + whiteboardId: properties.whiteboardId, + whiteboardObjectType: properties.whiteboardObjectType, + ...(typeof properties.whiteboardZIndex === 'number' ? { whiteboardZIndex: properties.whiteboardZIndex } : {}), + ...(typeof properties.angle === 'number' ? { angle: properties.angle } : {}), + stroke: properties.stroke, + fill: properties.fill, + strokeWidth: properties.strokeWidth, + opacity: properties.opacity, + radius, + left: centerX - radius, + top: centerY - radius, + width: radius * 2, + height: radius * 2, + path: buildCirclePathCommands(centerX, centerY, radius), + }; +} \ No newline at end of file diff --git a/src/whiteboard/fabricRegistry.test.ts b/src/whiteboard/fabricRegistry.test.ts new file mode 100644 index 0000000..45114f2 --- /dev/null +++ b/src/whiteboard/fabricRegistry.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as esbuild from 'esbuild'; + +describe('whiteboard fabric browser registry', () => { + it('keeps seeded object classes registered in the browser bundle', async () => { + const { JSDOM } = require('jsdom') as { + JSDOM: new (html?: string, options?: Record) => { + window: Window & { eval(code: string): void }; + }; + }; + + const bundle = await esbuild.build({ + stdin: { + contents: ` + import { ensureWhiteboardFabricRegistry } from './src/whiteboard/fabricRegistry.ts'; + import { classRegistry } from 'fabric'; + + ensureWhiteboardFabricRegistry(); + const requiredTypes = ['rect', 'ellipse', 'triangle', 'line', 'path', 'i-text', 'image']; + globalThis.__whiteboardFabricRegistryResult = requiredTypes.map((type) => { + try { + classRegistry.getClass(type); + return [type, true]; + } catch { + return [type, false]; + } + }); + `, + resolveDir: process.cwd(), + sourcefile: 'whiteboard-fabric-registry-entry.ts', + loader: 'ts', + }, + bundle: true, + format: 'iife', + platform: 'browser', + write: false, + }); + + const code = bundle.outputFiles[0]?.text; + assert.ok(code, 'Expected esbuild to produce an in-memory bundle'); + + const dom = new JSDOM('', { + pretendToBeVisual: true, + runScripts: 'outside-only', + url: 'https://example.test/', + }); + + dom.window.eval(code); + const result = JSON.parse(JSON.stringify((dom.window as Window & { + __whiteboardFabricRegistryResult?: Array<[string, boolean]>; + }).__whiteboardFabricRegistryResult)); + + assert.deepStrictEqual(result, [ + ['rect', true], + ['ellipse', true], + ['triangle', true], + ['line', true], + ['path', true], + ['i-text', true], + ['image', true], + ]); + }); +}); diff --git a/src/whiteboard/fabricRegistry.ts b/src/whiteboard/fabricRegistry.ts new file mode 100644 index 0000000..17d350d --- /dev/null +++ b/src/whiteboard/fabricRegistry.ts @@ -0,0 +1,110 @@ +import { + ActiveSelection, + Circle, + Ellipse, + FabricImage, + Group, + IText, + Line, + Path, + Polygon, + Polyline, + Rect, + Textbox, + Triangle, + classRegistry, +} from 'fabric'; + +const WHITEBOARD_FABRIC_REGISTRATIONS = [ + [Rect, 'rect'], + [Ellipse, 'ellipse'], + [Triangle, 'triangle'], + [Line, 'line'], + [Path, 'path'], + [IText, 'i-text'], + [Textbox, 'textbox'], + [Circle, 'circle'], + [FabricImage, 'image'], + [Group, 'group'], + [ActiveSelection, 'activeSelection'], + [Polygon, 'polygon'], + [Polyline, 'polyline'], +] as const; + +export const WHITEBOARD_SUPPORTED_FABRIC_TYPES = new Set( + WHITEBOARD_FABRIC_REGISTRATIONS.map(([, type]) => type), +); + +export function normalizeWhiteboardFabricObjectType(type: string): string { + const normalized = type.trim(); + const lower = normalized.toLowerCase(); + + switch (lower) { + case 'rect': + case 'ellipse': + case 'triangle': + case 'line': + case 'path': + case 'textbox': + case 'circle': + case 'image': + case 'group': + case 'polygon': + case 'polyline': + return lower; + case 'itext': + case 'i-text': + return 'i-text'; + case 'activeselection': + case 'active-selection': + return 'activeSelection'; + default: + return normalized; + } +} + +let whiteboardFabricRegistryInitialized = false; + +export function ensureWhiteboardFabricRegistry(): void { + if (whiteboardFabricRegistryInitialized) { + return; + } + + for (const [constructor, type] of WHITEBOARD_FABRIC_REGISTRATIONS) { + classRegistry.setClass(constructor, type); + } + + whiteboardFabricRegistryInitialized = true; +} + +export function assertWhiteboardFabricObjectTypeSupported(type: string): void { + ensureWhiteboardFabricRegistry(); + + const normalizedType = normalizeWhiteboardFabricObjectType(type); + + if (!WHITEBOARD_SUPPORTED_FABRIC_TYPES.has(normalizedType)) { + throw new Error(`Canvas fabricState contains unsupported Fabric object type "${type}"`); + } + + classRegistry.getClass(normalizedType); +} + +export function assertWhiteboardFabricObjectsSupported(objects: unknown[]): void { + for (const object of objects) { + if (!object || typeof object !== 'object' || Array.isArray(object)) { + throw new Error('Canvas fabricState objects must be valid Fabric.js object records'); + } + + const serializedObject = object as Record; + const type = typeof serializedObject.type === 'string' ? serializedObject.type : undefined; + if (!type) { + throw new Error('Canvas fabricState objects must include a Fabric object type'); + } + + assertWhiteboardFabricObjectTypeSupported(type); + + if (Array.isArray(serializedObject.objects)) { + assertWhiteboardFabricObjectsSupported(serializedObject.objects); + } + } +} diff --git a/src/whiteboard/sceneSummary.test.ts b/src/whiteboard/sceneSummary.test.ts new file mode 100644 index 0000000..d7a1241 --- /dev/null +++ b/src/whiteboard/sceneSummary.test.ts @@ -0,0 +1,297 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { summarizeWhiteboardScene } from './sceneSummary'; + +describe('whiteboard scene summary', () => { + it('summarizes coordinate geometry for common Fabric object types', () => { + const summary = summarizeWhiteboardScene([ + { + id: 'canvas_1', + name: 'Canvas 1', + fabricState: JSON.stringify({ + version: '6.9.1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + objects: [ + { + type: 'rect', + whiteboardId: 'rect_1', + whiteboardObjectType: 'rectangle', + left: 40, + top: 50, + width: 220, + height: 120, + angle: 15, + stroke: '#2563eb', + fill: 'rgba(37,99,235,0.18)', + strokeWidth: 2, + }, + { + type: 'path', + whiteboardId: 'circle_1', + whiteboardObjectType: 'circle', + left: 300, + top: 80, + width: 120, + height: 120, + radius: 60, + path: [ + ['M', 360, 80], + ['C', 393.1370849898476, 80, 420, 106.86291501015239, 420, 140], + ['C', 420, 173.1370849898476, 393.1370849898476, 200, 360, 200], + ['C', 326.8629150101524, 200, 300, 173.1370849898476, 300, 140], + ['C', 300, 106.86291501015239, 326.8629150101524, 80, 360, 80], + ['Z'], + ], + stroke: '#dc2626', + fill: 'rgba(220,38,38,0.18)', + strokeWidth: 2, + }, + { + type: 'line', + whiteboardId: 'line_1', + whiteboardObjectType: 'line', + x1: 780, + y1: 80, + x2: 1040, + y2: 220, + stroke: '#f97316', + strokeWidth: 6, + }, + { + type: 'i-text', + whiteboardId: 'text_1', + whiteboardObjectType: 'text', + text: 'Whiteboard Demo', + left: 60, + top: 260, + fontSize: 32, + fontFamily: 'sans-serif', + stroke: '#111827', + fill: '#111827', + strokeWidth: 1, + }, + { + type: 'path', + whiteboardId: 'arrow_1', + whiteboardObjectType: 'arrow', + path: 'M 0 0 L 20 20 M 15 5 L 20 20 L 5 15', + stroke: '#111111', + strokeWidth: 2, + }, + ], + }), + }, + ]); + + assert.deepStrictEqual(summary, { + totalCanvases: 1, + totalElements: 5, + canvases: [ + { + id: 'canvas_1', + name: 'Canvas 1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 5, + elements: [ + { + id: 'rect_1', + objectType: 'rectangle', + bounds: { x: 40, y: 50, width: 220, height: 120 }, + center: { x: 150, y: 110 }, + zIndex: 0, + rotation: 15, + strokeColor: '#2563eb', + fillColor: 'rgba(37,99,235,0.18)', + strokeWidth: 2, + opacity: 1, + }, + { + id: 'circle_1', + objectType: 'circle', + bounds: { x: 300, y: 80, width: 120, height: 120 }, + center: { x: 360, y: 140 }, + zIndex: 1, + strokeColor: '#dc2626', + fillColor: 'rgba(220,38,38,0.18)', + strokeWidth: 2, + opacity: 1, + }, + { + id: 'line_1', + objectType: 'line', + bounds: { x: 780, y: 80, width: 260, height: 140 }, + center: { x: 910, y: 150 }, + zIndex: 2, + strokeColor: '#f97316', + strokeWidth: 6, + opacity: 1, + }, + { + id: 'text_1', + objectType: 'text', + label: 'Whiteboard Demo', + zIndex: 3, + fontSize: 32, + fontFamily: 'sans-serif', + strokeColor: '#111827', + fillColor: '#111827', + strokeWidth: 1, + opacity: 1, + }, + { + id: 'arrow_1', + objectType: 'arrow', + bounds: { x: 0, y: 0, width: 20, height: 20 }, + center: { x: 10, y: 10 }, + points: [ + { x: 0, y: 0 }, + { x: 20, y: 20 }, + { x: 15, y: 5 }, + { x: 20, y: 20 }, + { x: 5, y: 15 }, + ], + zIndex: 4, + strokeColor: '#111111', + strokeWidth: 2, + opacity: 1, + }, + ], + }, + ], + }); + }); + + it('returns an empty scene summary when canvases are missing or invalid', () => { + assert.deepStrictEqual(summarizeWhiteboardScene([]), { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }); + + assert.deepStrictEqual(summarizeWhiteboardScene([ + { + id: 'canvas_invalid', + name: 'Canvas Invalid', + fabricState: 'not json', + }, + ]), { + totalCanvases: 1, + totalElements: 0, + canvases: [ + { + id: 'canvas_invalid', + name: 'Canvas Invalid', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 0, + elements: [], + }, + ], + }); + }); + + it('collapses linked annotation helper objects into a single annotation summary', () => { + const summary = summarizeWhiteboardScene([ + { + id: 'canvas_annotation', + name: 'Canvas Annotation', + fabricState: JSON.stringify({ + version: '6.9.1', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + objects: [ + { + type: 'line', + whiteboardId: 'annotation_pointer_1', + whiteboardObjectType: 'annotationPointer', + annotationId: 'annotation_1', + annotationRole: 'pointer', + x1: 320, + y1: 260, + x2: 180, + y2: 360, + }, + { + type: 'rect', + whiteboardId: 'annotation_bubble_1', + whiteboardObjectType: 'annotationBubble', + annotationId: 'annotation_1', + annotationRole: 'bubble', + left: 240, + top: 180, + width: 220, + height: 96, + }, + { + type: 'textbox', + whiteboardId: 'annotation_text_1', + whiteboardObjectType: 'annotation', + annotationId: 'annotation_1', + annotationRole: 'text', + text: 'Check this edge case', + left: 256, + top: 192, + width: 188, + annotationBubbleLeft: 240, + annotationBubbleTop: 180, + annotationBubbleWidth: 220, + annotationBubbleHeight: 96, + annotationTargetX: 180, + annotationTargetY: 360, + fill: '#111827', + fontSize: 18, + }, + { + type: 'circle', + whiteboardId: 'annotation_handle_1', + whiteboardObjectType: 'annotationHandle', + annotationId: 'annotation_1', + annotationRole: 'handle', + left: 180, + top: 360, + radius: 7, + originX: 'center', + originY: 'center', + }, + ], + }), + }, + ]); + + assert.deepStrictEqual(summary, { + totalCanvases: 1, + totalElements: 1, + canvases: [ + { + id: 'canvas_annotation', + name: 'Canvas Annotation', + width: 1600, + height: 900, + backgroundColor: '#ffffff', + elementCount: 1, + elements: [ + { + id: 'annotation_text_1', + objectType: 'annotation', + bounds: { x: 240, y: 180, width: 220, height: 96 }, + center: { x: 350, y: 228 }, + target: { x: 180, y: 360 }, + label: 'Check this edge case', + zIndex: 0, + fontSize: 18, + fillColor: '#111827', + opacity: 1, + }, + ], + }, + ], + }); + }); +}); diff --git a/src/whiteboard/sceneSummary.ts b/src/whiteboard/sceneSummary.ts new file mode 100644 index 0000000..2bff2de --- /dev/null +++ b/src/whiteboard/sceneSummary.ts @@ -0,0 +1,361 @@ +import { + DEFAULT_WHITEBOARD_CANVAS_BACKGROUND, + DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + DEFAULT_WHITEBOARD_CANVAS_WIDTH, + serializeBlankFabricCanvasState, +} from './canvasState'; + +export interface WhiteboardScenePoint { + x: number; + y: number; +} + +export interface WhiteboardSceneBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface WhiteboardSceneElementSummary { + id: string; + objectType: string; + zIndex?: number; + bounds?: WhiteboardSceneBounds; + center?: WhiteboardScenePoint; + target?: WhiteboardScenePoint; + points?: WhiteboardScenePoint[]; + label?: string; + rotation?: number; + fontSize?: number; + fontFamily?: string; + strokeColor?: string; + fillColor?: string; + strokeWidth?: number; + opacity?: number; +} + +export interface WhiteboardSceneCanvasSummary { + id: string; + name: string; + width: number; + height: number; + backgroundColor: string; + elementCount: number; + elements: WhiteboardSceneElementSummary[]; +} + +export interface WhiteboardSceneSummary { + totalCanvases: number; + totalElements: number; + canvases: WhiteboardSceneCanvasSummary[]; +} + +export function createEmptyWhiteboardSceneSummary(): WhiteboardSceneSummary { + return { + totalCanvases: 0, + totalElements: 0, + canvases: [], + }; +} + +export interface WhiteboardSceneSourceCanvas { + id: string; + name: string; + fabricState?: string; +} + +interface SerializedCanvasObject { + type?: unknown; + whiteboardId?: unknown; + whiteboardObjectType?: unknown; + whiteboardZIndex?: unknown; + text?: unknown; + stroke?: unknown; + fill?: unknown; + strokeWidth?: unknown; + opacity?: unknown; + angle?: unknown; + fontSize?: unknown; + fontFamily?: unknown; + left?: unknown; + top?: unknown; + width?: unknown; + height?: unknown; + x1?: unknown; + y1?: unknown; + x2?: unknown; + y2?: unknown; + radius?: unknown; + rx?: unknown; + ry?: unknown; + originX?: unknown; + originY?: unknown; + path?: unknown; + annotationId?: unknown; + annotationRole?: unknown; + annotationBubbleLeft?: unknown; + annotationBubbleTop?: unknown; + annotationBubbleWidth?: unknown; + annotationBubbleHeight?: unknown; + annotationTargetX?: unknown; + annotationTargetY?: unknown; +} + +interface SerializedCanvasState { + width: number; + height: number; + backgroundColor: string; + objects: SerializedCanvasObject[]; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function normalizeObjectType(value: unknown): string { + if (typeof value !== 'string') { + return 'unknown'; + } + + const lower = value.toLowerCase(); + if (lower === 'rect') { + return 'rectangle'; + } + if (lower === 'i-text' || lower === 'textbox' || lower === 'text') { + return 'text'; + } + return lower; +} + +function getObjectType(object: SerializedCanvasObject): string { + if (typeof object.whiteboardObjectType === 'string' && object.whiteboardObjectType.length > 0) { + return object.whiteboardObjectType; + } + + return normalizeObjectType(object.type); +} + +function getObjectId(object: SerializedCanvasObject, index: number): string { + if (typeof object.whiteboardId === 'string' && object.whiteboardId.length > 0) { + return object.whiteboardId; + } + + return `element_${index + 1}`; +} + +function toPoint(x: number, y: number): WhiteboardScenePoint { + return { x, y }; +} + +function toBounds(x: number, y: number, width: number, height: number): WhiteboardSceneBounds { + return { x, y, width, height }; +} + +function toCenter(bounds: WhiteboardSceneBounds): WhiteboardScenePoint { + return { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + }; +} + +function parsePathPoints(path: unknown): WhiteboardScenePoint[] { + if (typeof path === 'string') { + const tokens = path.trim().split(/\s+/); + const points: WhiteboardScenePoint[] = []; + for (let index = 0; index < tokens.length; index += 3) { + const command = tokens[index]; + const x = Number(tokens[index + 1]); + const y = Number(tokens[index + 2]); + if ((command === 'M' || command === 'L') && Number.isFinite(x) && Number.isFinite(y)) { + points.push({ x, y }); + } + } + return points; + } + + if (!Array.isArray(path)) { + return []; + } + + const points: WhiteboardScenePoint[] = []; + for (const segment of path) { + if (!Array.isArray(segment) || typeof segment[0] !== 'string') { + continue; + } + const command = segment[0]; + const x = Number(segment[1]); + const y = Number(segment[2]); + if ((command === 'M' || command === 'L') && Number.isFinite(x) && Number.isFinite(y)) { + points.push({ x, y }); + } + } + + return points; +} + +function boundsFromPoints(points: WhiteboardScenePoint[]): WhiteboardSceneBounds | undefined { + if (points.length === 0) { + return undefined; + } + + let minX = points[0].x; + let minY = points[0].y; + let maxX = points[0].x; + let maxY = points[0].y; + + for (const point of points.slice(1)) { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +function buildElementSummary(object: SerializedCanvasObject, index: number): WhiteboardSceneElementSummary { + const objectType = getObjectType(object); + const left = asFiniteNumber(object.left); + const top = asFiniteNumber(object.top); + const width = asFiniteNumber(object.width); + const height = asFiniteNumber(object.height); + const rx = asFiniteNumber(object.rx); + const ry = asFiniteNumber(object.ry); + const radius = asFiniteNumber(object.radius); + const x1 = asFiniteNumber(object.x1); + const y1 = asFiniteNumber(object.y1); + const x2 = asFiniteNumber(object.x2); + const y2 = asFiniteNumber(object.y2); + const points = parsePathPoints(object.path); + + const zIndex = asFiniteNumber(object.whiteboardZIndex) ?? index; + const rotation = asFiniteNumber(object.angle); + const fontSize = asFiniteNumber(object.fontSize); + const fontFamily = typeof object.fontFamily === 'string' && object.fontFamily.length > 0 + ? object.fontFamily + : undefined; + const annotationBubbleLeft = asFiniteNumber(object.annotationBubbleLeft); + const annotationBubbleTop = asFiniteNumber(object.annotationBubbleTop); + const annotationBubbleWidth = asFiniteNumber(object.annotationBubbleWidth); + const annotationBubbleHeight = asFiniteNumber(object.annotationBubbleHeight); + const annotationTargetX = asFiniteNumber(object.annotationTargetX); + const annotationTargetY = asFiniteNumber(object.annotationTargetY); + + let bounds: WhiteboardSceneBounds | undefined; + let center: WhiteboardScenePoint | undefined; + let target: WhiteboardScenePoint | undefined; + + if (objectType === 'annotation' + && annotationBubbleLeft !== undefined + && annotationBubbleTop !== undefined + && annotationBubbleWidth !== undefined + && annotationBubbleHeight !== undefined) { + bounds = toBounds(annotationBubbleLeft, annotationBubbleTop, annotationBubbleWidth, annotationBubbleHeight); + center = toCenter(bounds); + if (annotationTargetX !== undefined && annotationTargetY !== undefined) { + target = toPoint(annotationTargetX, annotationTargetY); + } + } else if (objectType === 'line' && x1 !== undefined && y1 !== undefined && x2 !== undefined && y2 !== undefined) { + bounds = toBounds(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1)); + center = toPoint((x1 + x2) / 2, (y1 + y2) / 2); + } else if ((objectType === 'circle' || objectType === 'ellipse') && left !== undefined && top !== undefined) { + const resolvedRadiusX = rx ?? radius; + const resolvedRadiusY = ry ?? radius ?? resolvedRadiusX; + if (resolvedRadiusX !== undefined && resolvedRadiusY !== undefined) { + const normalizedLeft = object.originX === 'center' ? left - resolvedRadiusX : left; + const normalizedTop = object.originY === 'center' ? top - resolvedRadiusY : top; + bounds = toBounds(normalizedLeft, normalizedTop, resolvedRadiusX * 2, resolvedRadiusY * 2); + center = toCenter(bounds); + } else if (points.length > 0) { + bounds = boundsFromPoints(points); + center = bounds ? toCenter(bounds) : undefined; + } else if (width !== undefined && height !== undefined) { + bounds = toBounds(left, top, width, height); + center = toCenter(bounds); + } + } else if (left !== undefined && top !== undefined && width !== undefined && height !== undefined) { + bounds = toBounds(left, top, width, height); + center = toCenter(bounds); + } else if (points.length > 0) { + bounds = boundsFromPoints(points); + center = bounds ? toCenter(bounds) : undefined; + } + + const summary: WhiteboardSceneElementSummary = { + id: getObjectId(object, index), + objectType, + zIndex, + ...(bounds ? { bounds } : {}), + ...(center ? { center } : {}), + ...(target ? { target } : {}), + ...(points.length > 0 && objectType !== 'circle' && objectType !== 'ellipse' ? { points } : {}), + ...(typeof object.text === 'string' && object.text.length > 0 ? { label: object.text } : {}), + ...(rotation !== undefined ? { rotation } : {}), + ...(fontSize !== undefined ? { fontSize } : {}), + ...(fontFamily ? { fontFamily } : {}), + ...(typeof object.stroke === 'string' ? { strokeColor: object.stroke } : {}), + ...(typeof object.fill === 'string' ? { fillColor: object.fill } : {}), + ...(typeof object.strokeWidth === 'number' ? { strokeWidth: object.strokeWidth } : {}), + ...(typeof object.opacity === 'number' ? { opacity: object.opacity } : { opacity: 1 }), + }; + + return summary; +} + +function parseCanvasState(serialized?: string): SerializedCanvasState { + const fallback = JSON.parse(serializeBlankFabricCanvasState()) as SerializedCanvasState; + if (!serialized || serialized.trim().length === 0) { + return fallback; + } + + try { + const parsed = JSON.parse(serialized) as Partial; + return { + width: asFiniteNumber(parsed.width) ?? DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: asFiniteNumber(parsed.height) ?? DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + backgroundColor: typeof parsed.backgroundColor === 'string' + ? parsed.backgroundColor + : DEFAULT_WHITEBOARD_CANVAS_BACKGROUND, + objects: Array.isArray(parsed.objects) ? parsed.objects : [], + }; + } catch { + return fallback; + } +} + +export function summarizeWhiteboardScene( + canvases: WhiteboardSceneSourceCanvas[], +): WhiteboardSceneSummary { + if (canvases.length === 0) { + return createEmptyWhiteboardSceneSummary(); + } + + const summarizedCanvases = canvases.map((canvas) => { + const parsedState = parseCanvasState(canvas.fabricState); + const elements = parsedState.objects + .filter((object) => typeof object.annotationId !== 'string' || object.annotationRole === 'text') + .map((object, index) => buildElementSummary(object, index)); + + return { + id: canvas.id, + name: canvas.name, + width: parsedState.width, + height: parsedState.height, + backgroundColor: parsedState.backgroundColor, + elementCount: elements.length, + elements, + } satisfies WhiteboardSceneCanvasSummary; + }); + + return { + totalCanvases: summarizedCanvases.length, + totalElements: summarizedCanvases.reduce((total, canvas) => total + canvas.elementCount, 0), + canvases: summarizedCanvases, + }; +} diff --git a/src/whiteboard/seededCanvas.ts b/src/whiteboard/seededCanvas.ts new file mode 100644 index 0000000..155ab1e --- /dev/null +++ b/src/whiteboard/seededCanvas.ts @@ -0,0 +1,211 @@ +import { createBlankFabricCanvasState } from './canvasState'; +import { createCirclePathFabricObject } from './circlePath'; +import { assertWhiteboardFabricObjectsSupported } from './fabricRegistry'; + +export interface WhiteboardSeedPoint { + x: number; + y: number; +} + +interface WhiteboardSeedElementBase { + id?: string; + strokeColor?: string; + fillColor?: string; + strokeWidth?: number; + opacity?: number; +} + +export interface WhiteboardSeedRectangle extends WhiteboardSeedElementBase { + type: 'rectangle'; + x: number; + y: number; + width: number; + height: number; + rx?: number; + ry?: number; +} + +export interface WhiteboardSeedCircle extends WhiteboardSeedElementBase { + type: 'circle'; + x: number; + y: number; + radius: number; +} + +export interface WhiteboardSeedTriangle extends WhiteboardSeedElementBase { + type: 'triangle'; + x: number; + y: number; + width: number; + height: number; +} + +export interface WhiteboardSeedLine extends WhiteboardSeedElementBase { + type: 'line'; + start: WhiteboardSeedPoint; + end: WhiteboardSeedPoint; +} + +export interface WhiteboardSeedText extends Omit { + type: 'text'; + x: number; + y: number; + text: string; + color?: string; + fontSize?: number; + fontWeight?: number; + fontStyle?: 'normal' | 'italic' | 'oblique'; + textAlign?: 'left' | 'center' | 'right' | 'justify'; + fontFamily?: string; +} + +export type WhiteboardSeedElement = + | WhiteboardSeedRectangle + | WhiteboardSeedCircle + | WhiteboardSeedTriangle + | WhiteboardSeedLine + | WhiteboardSeedText; + +const TRANSPARENT_FILL = 'rgba(0,0,0,0)'; +const DEFAULT_STROKE_COLOR = '#111827'; +const DEFAULT_FILL_COLOR = TRANSPARENT_FILL; +const DEFAULT_STROKE_WIDTH = 2; +const DEFAULT_TEXT_FONT_SIZE = 24; +const DEFAULT_TEXT_FONT_FAMILY = 'sans-serif'; + +function getSeedObjectId(element: WhiteboardSeedElement, index: number): string { + return element.id ?? `seed_${index + 1}`; +} + +function getCommonSeedProperties(element: WhiteboardSeedElement, index: number) { + const seedElementWithOrder = element as WhiteboardSeedElement & { zIndex?: number; rotation?: number }; + + return { + whiteboardId: getSeedObjectId(element, index), + whiteboardObjectType: element.type, + ...(typeof seedElementWithOrder.zIndex === 'number' ? { whiteboardZIndex: seedElementWithOrder.zIndex } : {}), + stroke: element.strokeColor ?? DEFAULT_STROKE_COLOR, + strokeWidth: element.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ...(typeof seedElementWithOrder.rotation === 'number' ? { angle: seedElementWithOrder.rotation } : {}), + opacity: element.opacity ?? 1, + }; +} + +function convertSeedElementToFabricObject(element: WhiteboardSeedElement, index: number): Record { + const common = getCommonSeedProperties(element, index); + + switch (element.type) { + case 'rectangle': + return { + type: 'rect', + ...common, + left: element.x, + top: element.y, + width: element.width, + height: element.height, + ...(typeof element.rx === 'number' ? { rx: element.rx } : {}), + ...(typeof element.ry === 'number' ? { ry: element.ry } : {}), + fill: element.fillColor ?? DEFAULT_FILL_COLOR, + }; + case 'circle': + return createCirclePathFabricObject({ + centerX: element.x, + centerY: element.y, + radius: element.radius, + stroke: common.stroke, + fill: element.fillColor ?? DEFAULT_FILL_COLOR, + strokeWidth: common.strokeWidth, + opacity: common.opacity, + whiteboardId: String(common.whiteboardId), + whiteboardObjectType: String(common.whiteboardObjectType), + ...(typeof common.whiteboardZIndex === 'number' ? { whiteboardZIndex: common.whiteboardZIndex } : {}), + ...(typeof common.angle === 'number' ? { angle: common.angle } : {}), + }); + case 'triangle': + return { + type: 'triangle', + ...common, + left: element.x, + top: element.y, + width: element.width, + height: element.height, + fill: element.fillColor ?? DEFAULT_FILL_COLOR, + }; + case 'line': + return { + type: 'line', + ...common, + x1: element.start.x, + y1: element.start.y, + x2: element.end.x, + y2: element.end.y, + fill: '', + }; + case 'text': { + const textColor = element.color ?? element.strokeColor ?? DEFAULT_STROKE_COLOR; + return { + type: 'i-text', + ...common, + left: element.x, + top: element.y, + ...(element.textAlign === 'center' + ? { originX: 'center' } + : element.textAlign === 'right' + ? { originX: 'right' } + : {}), + text: element.text, + fontSize: element.fontSize ?? DEFAULT_TEXT_FONT_SIZE, + ...(typeof element.fontWeight === 'number' ? { fontWeight: element.fontWeight } : {}), + ...(element.fontStyle ? { fontStyle: element.fontStyle } : {}), + ...(element.textAlign ? { textAlign: element.textAlign } : {}), + fontFamily: element.fontFamily ?? DEFAULT_TEXT_FONT_FAMILY, + fill: textColor, + stroke: textColor, + strokeWidth: 1, + }; + } + } +} + +export function serializeSeedElementsAsFabricState( + seedElements: WhiteboardSeedElement[], +): string { + const blankState = createBlankFabricCanvasState(); + const objects = seedElements.map((element, index) => convertSeedElementToFabricObject(element, index)); + assertWhiteboardFabricObjectsSupported(objects); + return JSON.stringify({ + ...blankState, + objects, + }); +} + +export function normalizeAndValidateFabricState(serialized: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(serialized); + } catch { + throw new Error('Canvas fabricState must be valid JSON with an objects array'); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Canvas fabricState must be valid JSON with an objects array'); + } + + const parsedState = parsed as Record; + if (!Array.isArray(parsedState.objects)) { + throw new Error('Canvas fabricState must be valid JSON with an objects array'); + } + + return JSON.stringify({ + ...createBlankFabricCanvasState(), + ...parsedState, + objects: parsedState.objects, + }); +} + +export function normalizeAndValidateLoadableFabricState(serialized: string): string { + const normalized = normalizeAndValidateFabricState(serialized); + const parsed = JSON.parse(normalized) as Record; + assertWhiteboardFabricObjectsSupported(parsed.objects as unknown[]); + return normalized; +} From 638f48a9fe2e2e8595db253c4930b0640015982e Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Tue, 10 Mar 2026 20:06:32 +0800 Subject: [PATCH 2/8] feat(tools): add render_ui tool and refactor whiteboard to image-first contract - Add A2UI surface rendering system with layout, content, and form components - Add render_ui tool for flat component list with parentId adjacency - Refactor open_whiteboard to image-first contract, removing seedElements - Add importImages for preloading images onto canvas for annotation - Simplify whiteboard result to return only exported PNG images - Remove coordinate-based seeding and sceneSummary serialization - Add inline whiteboard button to ask_user attachments - Update MCP server and tool registrations for new tools --- esbuild.js | 16 +- media/a2ui.css | 406 +++++ media/a2ui.html | 20 + media/main.css | 16 +- media/webview.html | 12 +- media/whiteboard.css | 13 +- package-lock.json | 1410 ++++++++++++++++- package.json | 510 ++---- resources/debug/whiteboard/test-1.json | 267 ++++ resources/debug/whiteboard/test-2.json | 186 +++ src/a2ui/catalog.ts | 24 + src/a2ui/engine.ts | 262 +++ src/a2ui/panel.test.ts | 271 ++++ src/a2ui/panel.ts | 213 +++ src/a2ui/renderer.ts | 262 +++ src/a2ui/types.ts | 55 + src/a2ui/webview.ts | 136 ++ src/mcp/mcpServer.test.ts | 548 ++++--- src/mcp/mcpServer.ts | 36 +- src/tools/index.ts | 36 +- src/tools/openWhiteboard.test.ts | 901 ++--------- src/tools/openWhiteboard.ts | 186 ++- src/tools/packageMetadata.test.ts | 71 +- src/tools/renderUI.test.ts | 672 ++++++++ src/tools/renderUI.ts | 100 ++ src/tools/schemas.ts | 131 +- src/tools/whiteboard.test.ts | 347 +--- src/tools/whiteboardToolResult.test.ts | 133 +- src/tools/whiteboardToolResult.ts | 9 +- src/webview/main.ts | 19 +- src/webview/types.ts | 6 +- src/webview/uiIntegration.test.ts | 9 + src/webview/utils/mockToolCall.ts | 182 ++- .../webviewProvider.inlineWhiteboard.test.ts | 182 +++ src/webview/webviewProvider.ts | 73 + src/webview/whiteboard.ts | 38 +- src/webview/whiteboardPanel.ts | 4 +- src/whiteboard/sceneSummary.test.ts | 297 ---- src/whiteboard/sceneSummary.ts | 361 ----- src/whiteboard/seededCanvas.ts | 29 +- 40 files changed, 5789 insertions(+), 2660 deletions(-) create mode 100644 media/a2ui.css create mode 100644 media/a2ui.html create mode 100644 resources/debug/whiteboard/test-1.json create mode 100644 resources/debug/whiteboard/test-2.json create mode 100644 src/a2ui/catalog.ts create mode 100644 src/a2ui/engine.ts create mode 100644 src/a2ui/panel.test.ts create mode 100644 src/a2ui/panel.ts create mode 100644 src/a2ui/renderer.ts create mode 100644 src/a2ui/types.ts create mode 100644 src/a2ui/webview.ts create mode 100644 src/tools/renderUI.test.ts create mode 100644 src/tools/renderUI.ts create mode 100644 src/webview/webviewProvider.inlineWhiteboard.test.ts delete mode 100644 src/whiteboard/sceneSummary.test.ts delete mode 100644 src/whiteboard/sceneSummary.ts diff --git a/esbuild.js b/esbuild.js index 69159b1..0c24c04 100644 --- a/esbuild.js +++ b/esbuild.js @@ -112,7 +112,21 @@ async function main() { plugins: [esbuildProblemMatcherPlugin], }); - const contexts = [extensionCtx, webviewCtx, planReviewCtx, whiteboardCtx]; + // A2UI panel bundle (browser) + const a2uiCtx = await esbuild.context({ + entryPoints: ['src/a2ui/webview.ts'], + bundle: true, + format: 'iife', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'browser', + outfile: 'dist/a2ui.js', + logLevel: 'info', + plugins: [esbuildProblemMatcherPlugin], + }); + + const contexts = [extensionCtx, webviewCtx, planReviewCtx, whiteboardCtx, a2uiCtx]; // CLI bundle (Node.js standalone) - Only for Antigravity if (antigravity) { diff --git a/media/a2ui.css b/media/a2ui.css new file mode 100644 index 0000000..f8605c8 --- /dev/null +++ b/media/a2ui.css @@ -0,0 +1,406 @@ +/* A2UI Surface Styles */ + +:root { + --a2ui-gap: 8px; + --a2ui-padding: 12px; + --a2ui-radius: 6px; + --a2ui-font: var(--vscode-font-family, system-ui, sans-serif); + --a2ui-font-size: var(--vscode-font-size, 13px); + --a2ui-fg: var(--vscode-foreground, #cccccc); + --a2ui-bg: var(--vscode-editor-background, #1e1e1e); + --a2ui-card-bg: var(--vscode-sideBar-background, #252526); + --a2ui-border: var(--vscode-panel-border, #3c3c3c); + --a2ui-button-bg: var(--vscode-button-background, #0078d4); + --a2ui-button-fg: var(--vscode-button-foreground, #ffffff); + --a2ui-button-hover: var(--vscode-button-hoverBackground, #006cbd); + --a2ui-input-bg: var(--vscode-input-background, #3c3c3c); + --a2ui-input-fg: var(--vscode-input-foreground, #cccccc); + --a2ui-input-border: var(--vscode-input-border, #3c3c3c); + --a2ui-badge-bg: var(--vscode-badge-background, #4d4d4d); + --a2ui-badge-fg: var(--vscode-badge-foreground, #ffffff); +} + +body { + font-family: var(--a2ui-font); + font-size: var(--a2ui-font-size); + color: var(--a2ui-fg); + background: var(--a2ui-bg); + margin: 0; + padding: var(--a2ui-padding); +} + +.a2ui-surface { + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); +} + +.a2ui-diagnostics { + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + background: color-mix(in srgb, var(--a2ui-card-bg) 84%, transparent); +} + +.a2ui-diagnostics-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.a2ui-diagnostics-title, +.a2ui-diagnostics-score { + font-weight: 700; +} + +.a2ui-diagnostics-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.a2ui-diagnostics-body h2 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 6px; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-diagnostics-list { + margin: 0; + padding-left: 18px; +} + +.a2ui-diagnostics-empty { + margin: 0; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +/* Layout */ +.a2ui-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--a2ui-gap); + align-items: flex-start; +} + +.a2ui-column { + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); + flex: 1; +} + +/* Card */ +.a2ui-card { + background: var(--a2ui-card-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + display: flex; + flex-direction: column; + gap: var(--a2ui-gap); +} + +/* Divider */ +.a2ui-divider { + border: none; + border-top: 1px solid var(--a2ui-border); + margin: var(--a2ui-gap) 0; +} + +/* Text & Heading */ +.a2ui-text { + margin: 0; + line-height: 1.5; +} + +.a2ui-heading { + margin: 0 0 4px; + font-weight: 600; + line-height: 1.3; +} + +/* Image */ +.a2ui-image { + max-width: 100%; + height: auto; + border-radius: var(--a2ui-radius); +} + +/* Markdown / CodeBlock */ +.a2ui-markdown { + line-height: 1.5; +} + +.a2ui-markdown > :first-child { + margin-top: 0; +} + +.a2ui-markdown > :last-child { + margin-bottom: 0; +} + +.a2ui-markdown code { + background: var(--a2ui-input-bg); + border-radius: 4px; + padding: 1px 5px; + font-family: var(--vscode-editor-font-family, monospace); +} + +.a2ui-markdown pre { + margin: 0; +} + +.a2ui-markdown blockquote { + margin: 0; + padding-left: 12px; + border-left: 3px solid var(--a2ui-border); + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-markdown ul, +.a2ui-markdown ol { + margin: 0; + padding-left: 20px; +} + +.a2ui-codeblock { + background: var(--a2ui-input-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + overflow-x: auto; + margin: 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: calc(var(--a2ui-font-size) - 1px); + line-height: 1.4; +} + +.a2ui-codeblock code { + background: none; + padding: 0; +} + +/* Button */ +.a2ui-button { + background: var(--a2ui-button-bg); + color: var(--a2ui-button-fg); + border: none; + border-radius: var(--a2ui-radius); + padding: 6px 14px; + cursor: pointer; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + transition: background 0.15s; +} + +.a2ui-button:hover { + background: var(--a2ui-button-hover); +} + +.a2ui-button-secondary { + background: var(--vscode-button-secondaryBackground, var(--a2ui-card-bg)); + color: var(--vscode-button-secondaryForeground, var(--a2ui-fg)); + border: 1px solid var(--a2ui-border); +} + +.a2ui-button-secondary:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--a2ui-input-bg)); +} + +.a2ui-button-danger { + background: var(--vscode-errorForeground, #c2410c); +} + +.a2ui-button-danger:hover { + filter: brightness(1.05); +} + +.a2ui-button:active { + opacity: 0.85; +} + +.a2ui-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.a2ui-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.a2ui-field-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-required { + margin-left: 4px; + color: var(--vscode-errorForeground, #f44747); +} + +.a2ui-field-helper { + font-size: 12px; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-invalid .a2ui-textfield, +.a2ui-invalid .a2ui-select, +.a2ui-invalid .a2ui-checkbox { + border-color: var(--vscode-errorForeground, #f44747); + outline-color: var(--vscode-errorForeground, #f44747); +} + +.a2ui-field-error { + font-size: 12px; + color: var(--vscode-errorForeground, #f44747); +} + +/* TextField */ +.a2ui-textfield { + background: var(--a2ui-input-bg); + color: var(--a2ui-input-fg); + border: 1px solid var(--a2ui-input-border); + border-radius: var(--a2ui-radius); + padding: 5px 8px; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + width: 100%; + box-sizing: border-box; +} + +.a2ui-textfield:focus { + outline: 1px solid var(--vscode-focusBorder, #007fd4); +} + +/* Checkbox */ +.a2ui-checkbox-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} + +.a2ui-checkbox { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--a2ui-button-bg); +} + +/* Select */ +.a2ui-select { + background: var(--a2ui-input-bg); + color: var(--a2ui-input-fg); + border: 1px solid var(--a2ui-input-border); + border-radius: var(--a2ui-radius); + padding: 5px 8px; + font-size: var(--a2ui-font-size); + font-family: var(--a2ui-font); + cursor: pointer; +} + +/* MermaidDiagram */ +.a2ui-mermaid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.a2ui-mermaid-label { + font-size: 12px; + font-weight: 600; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-mermaid-source { + white-space: pre; + font-family: var(--vscode-editor-font-family, monospace); + font-size: calc(var(--a2ui-font-size) - 1px); + background: var(--a2ui-input-bg); + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: var(--a2ui-padding); + overflow-x: auto; + margin: 0; +} + +.a2ui-mermaid-target { + background: #ffffff; + border: 1px solid var(--a2ui-border); + border-radius: var(--a2ui-radius); + padding: 12px; + overflow-x: auto; +} + +.a2ui-mermaid-target svg { + max-width: 100%; + height: auto; +} + +.a2ui-mermaid-details summary { + cursor: pointer; + color: var(--vscode-descriptionForeground, var(--a2ui-fg)); +} + +.a2ui-mermaid-error { + color: var(--vscode-errorForeground, #f44747); +} + +/* ProgressBar */ +.a2ui-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.a2ui-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.a2ui-progress-label, +.a2ui-progress-value { + font-size: 12px; + font-weight: 600; +} + +.a2ui-progressbar { + width: 100%; + height: 8px; + border-radius: 4px; + border: none; + background: var(--a2ui-input-bg); + accent-color: var(--a2ui-button-bg); +} + +/* Badge */ +.a2ui-badge { + display: inline-block; + background: var(--a2ui-badge-bg); + color: var(--a2ui-badge-fg); + border-radius: 10px; + padding: 2px 8px; + font-size: calc(var(--a2ui-font-size) - 1px); + font-weight: 600; + white-space: nowrap; +} + +/* Error state */ +.a2ui-error { + color: var(--vscode-errorForeground, #f44747); + padding: var(--a2ui-padding); + border: 1px solid var(--vscode-errorForeground, #f44747); + border-radius: var(--a2ui-radius); +} diff --git a/media/a2ui.html b/media/a2ui.html new file mode 100644 index 0000000..60a9243 --- /dev/null +++ b/media/a2ui.html @@ -0,0 +1,20 @@ + + + + + + + {{title}} + + + +
+ {{diagnosticsHtml}} + {{surfaceHtml}} +
+ + + diff --git a/media/main.css b/media/main.css index ef6b80c..8885399 100644 --- a/media/main.css +++ b/media/main.css @@ -1071,19 +1071,29 @@ button:disabled { .input-area { display: flex; align-items: stretch; + gap: 4px; } -.attach-btn { +.input-tools-stack { flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 2px; + padding: 4px 0; +} + +.attach-btn { width: 32px; + min-height: 32px; display: flex; - align-items: flex-start; + align-items: center; justify-content: center; background: transparent; border: none; cursor: pointer; color: var(--vscode-descriptionForeground); - padding: 8px 4px; + padding: 6px 4px; border-radius: 4px; } diff --git a/media/webview.html b/media/webview.html index 7d6ee19..22f5484 100644 --- a/media/webview.html +++ b/media/webview.html @@ -175,10 +175,14 @@

- - +
+ + +
diff --git a/media/whiteboard.css b/media/whiteboard.css index 63a2c6f..fd39c6c 100644 --- a/media/whiteboard.css +++ b/media/whiteboard.css @@ -189,19 +189,28 @@ input[type='range'] { } .canvas-stage .canvas-container { - display: inline-block; + display: block; + width: 100%; + height: auto !important; + aspect-ratio: var(--whiteboard-aspect-ratio, 16 / 9); } #whiteboard-canvas { display: block; width: 100%; max-width: 100%; - height: auto; + height: 100%; + aspect-ratio: var(--whiteboard-aspect-ratio, 16 / 9); background: #fff; border-radius: 10px; box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.25); } +.canvas-stage .canvas-container canvas { + width: 100% !important; + height: 100% !important; +} + .canvas-help { padding: 12px 16px; } diff --git a/package-lock.json b/package-lock.json index f65370f..244d333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", + "mermaid": "^11.13.0", "zod": "^4.1.13" }, "devDependencies": { @@ -29,6 +30,64 @@ "vscode": "^1.104.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -483,6 +542,23 @@ "hono": "^4" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmmirror.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -517,6 +593,15 @@ "node": ">=10" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", + "license": "MIT", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", @@ -566,6 +651,265 @@ "node": ">= 10" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -601,6 +945,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/vscode": { "version": "1.106.1", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.106.1.tgz", @@ -608,6 +959,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vscode/codicons": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.43.tgz", @@ -647,7 +1008,6 @@ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "optional": true, "bin": { "acorn": "bin/acorn" }, @@ -978,6 +1338,32 @@ "node": ">=4" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz", @@ -1015,126 +1401,661 @@ "color-support": "bin.js" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "optional": true, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", "dependencies": { - "delayed-stream": "~1.0.0" + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">= 0.8" + "node": ">=12" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", "license": "ISC", - "optional": true - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=12" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", "engines": { - "node": ">= 0.6" + "node": ">=12" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=12" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, "engines": { - "node": ">=6.6.0" + "node": ">=12" } }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "d3-time": "1 - 3" }, "engines": { - "node": ">= 0.10" + "node": ">=12" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { - "node": ">= 8" + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "license": "MIT", - "optional": true - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "license": "MIT", - "optional": true, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { - "cssom": "~0.3.6" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmmirror.com/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", - "optional": true + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } }, "node_modules/data-urls": { "version": "3.0.2", @@ -1205,6 +2126,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1278,6 +2205,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1328,6 +2264,18 @@ "node": ">=12" } }, + "node_modules/dompurify": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2097,6 +3045,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -2333,6 +3287,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2818,6 +3781,59 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/katex": { + "version": "0.16.38", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2843,6 +3859,12 @@ "node": ">=4" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz", @@ -2886,6 +3908,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2931,6 +3965,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mermaid": { + "version": "11.13.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -3032,6 +4095,18 @@ "node": ">=10" } }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3337,6 +4412,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -3386,6 +4467,12 @@ "node": ">= 0.8" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3435,6 +4522,12 @@ "node": ">=4" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, "node_modules/pidtree": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", @@ -3467,6 +4560,33 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -3706,6 +4826,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -3722,6 +4860,12 @@ "node": ">= 18" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -4267,6 +5411,12 @@ "node": ">=4" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4318,6 +5468,15 @@ "node": ">=10" } }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4356,6 +5515,15 @@ "node": ">=12" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -4488,6 +5656,12 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -4551,6 +5725,19 @@ "license": "MIT", "optional": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -4571,6 +5758,55 @@ "node": ">= 0.8" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 4db56b5..ae195d4 100644 --- a/package.json +++ b/package.json @@ -357,7 +357,7 @@ ], "toolReferenceName": "openWhiteboard", "displayName": "Open Whiteboard", - "modelDescription": "Open a standalone whiteboard so the user can sketch, annotate, or submit visual context back to the agent. Treat this as a coordinate scene graph tool, not an image generation tool: seed content with initialCanvases[].seedElements using explicit positions/sizes, and rely on the returned sceneSummary for text-based reasoning over coordinates and labels. The result includes an explicit action: `approved` means use the submitted sketch as confirmed user input, while `recreateWithChanges` means the user requested revisions and you MUST address the annotated feedback and call open_whiteboard again with an updated sketch before concluding. For agent-authored starter content, prefer initialCanvases[].seedElements because Seamless Agent converts simple shapes/text into reliable whiteboard drawings. Reserve initialCanvases[].fabricState for advanced/reopen flows. If you intentionally want an empty board, set blankCanvas to true. Otherwise, provide initialCanvases so the extension does not silently open a blank board when starter content was expected.", + "modelDescription": "Open a standalone whiteboard so the user can sketch or annotate visual context for the agent. This remains an image-first tool: the whiteboard returns exported PNG image URIs, and the model should reason over those images instead of coordinate metadata. Use blankCanvas for an empty board, importImages to preload screenshots or mockups for annotation, or initialCanvases with seedElements/fabricState when you need seeded starter content. The result includes an explicit action: `approved` means use the returned whiteboard images as confirmed user input, while `recreateWithChanges` means the user requested revisions and you MUST address the annotated feedback and call open_whiteboard again before concluding.", "canBeReferencedInPrompt": true, "icon": "$(symbol-color)", "inputSchema": { @@ -365,368 +365,78 @@ "properties": { "context": { "type": "string", - "description": "Optional context or instructions for the whiteboard session" + "description": "Instructions for the user about what to draw or annotate." }, "title": { "type": "string", - "description": "Optional title for the whiteboard panel" + "description": "Title for the whiteboard panel." }, "blankCanvas": { "type": "boolean", - "description": "Set to true only when you intentionally open an empty whiteboard. If omitted or false, provide initialCanvases with starter content." + "description": "Open a blank canvas. Defaults to true." }, "initialCanvases": { "type": "array", - "description": "Provide starter canvases for seeded or reopened sessions. Required unless blankCanvas is true.", + "description": "Optional starter canvases. Use seedElements for coordinate-first starter sketches or fabricState to reopen an existing canvas session.", "items": { "type": "object", "properties": { - "name": { - "type": "string", - "description": "Display name for the pre-populated canvas" - }, - "fabricState": { - "type": "string", - "description": "Advanced path for reopening sessions: serialized Fabric.js JSON. It must be valid JSON with an objects array. Prefer seedElements for new agent-authored starter sketches." - }, - "seedElements": { - "type": "array", - "description": "Preferred agent-friendly path for coordinate-first starter sketches. Provide basic shapes/text with explicit positions and Seamless Agent will convert them into Fabric.js canvas content.", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "rectangle" - ] - }, - "id": { - "type": "string", - "description": "Optional stable object id" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "width": { - "type": "number" - }, - "height": { - "type": "number" - }, - "strokeColor": { - "type": "string", - "description": "Optional outline color such as '#2563eb'" - }, - "fillColor": { - "type": "string", - "description": "Optional fill color such as 'rgba(37,99,235,0.18)'" - }, - "strokeWidth": { - "type": "number" - }, - "zIndex": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Optional stacking order hint. Lower values render behind higher values." - }, - "rotation": { - "type": "number", - "minimum": -360, - "maximum": 360, - "description": "Optional clockwise rotation in degrees." - }, - "opacity": { - "type": "number" - } - }, - "required": [ - "type", - "x", - "y", - "width", - "height" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "circle" - ] - }, - "id": { - "type": "string", - "description": "Optional stable object id" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "radius": { - "type": "number" - }, - "strokeColor": { - "type": "string" - }, - "fillColor": { - "type": "string" - }, - "strokeWidth": { - "type": "number" - }, - "zIndex": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Optional stacking order hint. Lower values render behind higher values." - }, - "rotation": { - "type": "number", - "minimum": -360, - "maximum": 360, - "description": "Optional clockwise rotation in degrees." - }, - "opacity": { - "type": "number" - } - }, - "required": [ - "type", - "x", - "y", - "radius" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "triangle" - ] - }, - "id": { - "type": "string", - "description": "Optional stable object id" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "width": { - "type": "number" - }, - "height": { - "type": "number" - }, - "strokeColor": { - "type": "string" - }, - "fillColor": { - "type": "string" - }, - "strokeWidth": { - "type": "number" - }, - "zIndex": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Optional stacking order hint. Lower values render behind higher values." - }, - "rotation": { - "type": "number", - "minimum": -360, - "maximum": 360, - "description": "Optional clockwise rotation in degrees." - }, - "opacity": { - "type": "number" - } - }, - "required": [ - "type", - "x", - "y", - "width", - "height" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "line" - ] - }, - "id": { - "type": "string", - "description": "Optional stable object id" - }, - "start": { - "type": "object", - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "x", - "y" - ] - }, - "end": { - "type": "object", - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - } - }, - "required": [ - "x", - "y" - ] - }, - "strokeColor": { - "type": "string" - }, - "strokeWidth": { - "type": "number" - }, - "zIndex": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Optional stacking order hint. Lower values render behind higher values." - }, - "rotation": { - "type": "number", - "minimum": -360, - "maximum": 360, - "description": "Optional clockwise rotation in degrees." - }, - "opacity": { - "type": "number" - } - }, - "required": [ - "type", - "start", - "end" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] - }, - "id": { - "type": "string", - "description": "Optional stable object id" - }, - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "text": { - "type": "string", - "description": "Text to place on the canvas" - }, - "color": { - "type": "string", - "description": "Optional text color such as '#111827'" - }, - "fontSize": { - "type": "number" - }, - "fontWeight": { - "type": "integer", - "minimum": 100, - "maximum": 900, - "description": "Optional text weight from 100 to 900." - }, - "fontStyle": { - "type": "string", - "enum": [ - "normal", - "italic", - "oblique" - ], - "description": "Optional text style." - }, - "textAlign": { - "type": "string", - "enum": [ - "left", - "center", - "right", - "justify" - ], - "description": "Optional text alignment." - }, - "fontFamily": { - "type": "string" - }, - "zIndex": { - "type": "integer", - "minimum": 0, - "maximum": 10000, - "description": "Optional stacking order hint. Lower values render behind higher values." - }, - "rotation": { - "type": "number", - "minimum": -360, - "maximum": 360, - "description": "Optional clockwise rotation in degrees." - }, - "opacity": { - "type": "number" - } - }, - "required": [ - "type", - "x", - "y", - "text" - ] - } - ] - } + "name": { + "type": "string", + "description": "Display name for the pre-populated canvas." + }, + "fabricState": { + "type": "string", + "description": "Optional serialized Fabric.js canvas state. Prefer seedElements for new starter content." + }, + "seedElements": { + "type": "array", + "description": "Optional coordinate-first starter elements for agent-authored sketches.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "rectangle", + "circle", + "triangle", + "line", + "text" + ] + } + }, + "required": [ + "type" + ] } + } + }, + "required": [ + "name" + ] + } + }, + "importImages": { + "type": "array", + "description": "Optional images to pre-load onto the canvas for the user to annotate.", + "items": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "File URI of an image to import onto the canvas." }, - "required": [ - "name" - ] - } + "label": { + "type": "string", + "description": "Optional label for the imported image." + } + }, + "required": [ + "uri" + ] } + } } } }, @@ -764,6 +474,109 @@ "plan" ] } + }, + { + "name": "render_ui", + "tags": [ + "ui", + "rendering", + "surface", + "user-interaction", + "seamless-agent" + ], + "toolReferenceName": "renderUI", + "displayName": "Render UI Surface", + "modelDescription": "Render a structured UI surface in a dedicated panel using a flat component list with parentId adjacency. Pass surfaceId to identify or reuse a panel. Author component-specific fields inside component.props; top-level component fields are still accepted for compatibility. Supports layout components (Row, Column, Card, Divider), content components (Text, Heading, Image, Markdown, CodeBlock, MermaidDiagram), form components (Button, TextField, Checkbox, Select), and indicator components (ProgressBar, Badge). Use enableA2UI to run the built-in validation and enhancement pass, and a2uiLevel to choose basic or strict behavior. Markdown content is rendered as formatted HTML, form controls collect values into userAction.data, and waitForAction: true blocks until the user fires a Button action. When A2UI is enabled, the result also includes diagnostics and applied enhancements. Use waitForAction: false (default) to render immediately and continue without waiting. The result always includes surfaceId and rendered fields.", + "canBeReferencedInPrompt": true, + "icon": "$(layout)", + "inputSchema": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + "description": "Optional unique surface identifier. Re-using the same surfaceId will update an existing panel." + }, + "title": { + "type": "string", + "description": "Optional panel title displayed in the webview header." + }, + "components": { + "type": "array", + "description": "Flat list of UI components with optional parentId adjacency for nesting.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique component identifier." + }, + "component": { + "type": "object", + "description": "Component definition. Must include a 'type' field.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Row", + "Column", + "Card", + "Divider", + "Text", + "Heading", + "Image", + "Markdown", + "CodeBlock", + "Button", + "TextField", + "Checkbox", + "Select", + "MermaidDiagram", + "ProgressBar", + "Badge" + ] + }, + "props": { + "type": "object", + "description": "Component-specific properties. Supports $data.path binding syntax." + } + } + }, + "parentId": { + "type": "string", + "description": "ID of the parent component. Omit for root-level components." + } + }, + "required": [ + "id", + "component" + ] + } + }, + "dataModel": { + "type": "object", + "description": "Data model for $data.path binding resolution in component props." + }, + "enableA2UI": { + "type": "boolean", + "description": "Enable the built-in A2UI validation and enhancement pass before rendering. Defaults to false." + }, + "a2uiLevel": { + "type": "string", + "enum": [ + "basic", + "strict" + ], + "description": "A2UI processing level. Use strict for stronger validation and helper affordances." + }, + "waitForAction": { + "type": "boolean", + "description": "If true, block until the user fires a Button action. Defaults to false." + } + }, + "required": [ + "components" + ] + } } ], "commands": [ @@ -821,6 +634,7 @@ "fabric": "^6.9.1", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", + "mermaid": "^11.13.0", "zod": "^4.1.13" } } diff --git a/resources/debug/whiteboard/test-1.json b/resources/debug/whiteboard/test-1.json new file mode 100644 index 0000000..7c09364 --- /dev/null +++ b/resources/debug/whiteboard/test-1.json @@ -0,0 +1,267 @@ +{ + "title": "Android App UI - Seeded Layout", + "context": "Review the starter Android dashboard mockup, adjust the layout if needed, and submit the final whiteboard.", + "initialCanvases": [ + { + "name": "Android Dashboard", + "seedElements": [ + { + "type": "rectangle", + "x": 80, + "y": 40, + "width": 760, + "height": 64, + "strokeColor": "#1d4ed8", + "fillColor": "#2563eb" + }, + { + "type": "text", + "x": 460, + "y": 78, + "text": "Android App Bar", + "fontSize": 22, + "fontWeight": 700, + "textAlign": "center", + "color": "#ffffff" + }, + { + "type": "rectangle", + "x": 80, + "y": 140, + "width": 250, + "height": 280, + "strokeColor": "#0f766e", + "fillColor": "rgba(15,118,110,0.12)" + }, + { + "type": "text", + "x": 160, + "y": 176, + "text": "Profile Card", + "fontSize": 18, + "fontWeight": 700, + "color": "#115e59" + }, + { + "type": "circle", + "x": 205, + "y": 245, + "radius": 46, + "strokeColor": "#0f766e", + "fillColor": "rgba(45,212,191,0.18)" + }, + { + "type": "text", + "x": 160, + "y": 320, + "text": "Name: John Doe", + "fontSize": 14, + "color": "#134e4a" + }, + { + "type": "text", + "x": 160, + "y": 346, + "text": "Role: Developer", + "fontSize": 13, + "color": "#134e4a" + }, + { + "type": "rectangle", + "x": 370, + "y": 140, + "width": 470, + "height": 210, + "strokeColor": "#b45309", + "fillColor": "rgba(251,191,36,0.14)" + }, + { + "type": "text", + "x": 540, + "y": 176, + "text": "Dashboard Stats", + "fontSize": 18, + "fontWeight": 700, + "color": "#92400e" + }, + { + "type": "rectangle", + "x": 392, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 432, + "y": 234, + "text": "Users", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 452, + "y": 262, + "text": "1234", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "rectangle", + "x": 548, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 588, + "y": 234, + "text": "Sales", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 608, + "y": 262, + "text": "$56K", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "rectangle", + "x": 704, + "y": 206, + "width": 120, + "height": 76, + "strokeColor": "#d97706", + "fillColor": "rgba(251,191,36,0.22)", + "rx": 10 + }, + { + "type": "text", + "x": 744, + "y": 234, + "text": "Tasks", + "fontSize": 12, + "fontWeight": 600, + "textAlign": "center", + "color": "#92400e" + }, + { + "type": "text", + "x": 764, + "y": 262, + "text": "89%", + "fontSize": 20, + "fontWeight": 700, + "textAlign": "center", + "color": "#78350f" + }, + { + "type": "line", + "start": { "x": 80, "y": 454 }, + "end": { "x": 840, "y": 454 }, + "strokeColor": "#cbd5e1", + "strokeWidth": 3 + }, + { + "type": "rectangle", + "x": 92, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#16a34a", + "fillColor": "#16a34a", + "rx": 24 + }, + { + "type": "text", + "x": 167, + "y": 515, + "text": "Add New", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#ffffff" + }, + { + "type": "rectangle", + "x": 276, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#2563eb", + "fillColor": "rgba(37,99,235,0.16)", + "rx": 24 + }, + { + "type": "text", + "x": 351, + "y": 515, + "text": "Edit", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#1d4ed8" + }, + { + "type": "rectangle", + "x": 460, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#dc2626", + "fillColor": "rgba(220,38,38,0.14)", + "rx": 24 + }, + { + "type": "text", + "x": 535, + "y": 515, + "text": "Delete", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#b91c1c" + }, + { + "type": "rectangle", + "x": 644, + "y": 484, + "width": 150, + "height": 50, + "strokeColor": "#6d28d9", + "fillColor": "rgba(109,40,217,0.14)", + "rx": 24 + }, + { + "type": "text", + "x": 719, + "y": 515, + "text": "Settings", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#5b21b6" + } + ] + } + ] +} \ No newline at end of file diff --git a/resources/debug/whiteboard/test-2.json b/resources/debug/whiteboard/test-2.json new file mode 100644 index 0000000..ac142c3 --- /dev/null +++ b/resources/debug/whiteboard/test-2.json @@ -0,0 +1,186 @@ +{ + "title": "Component Layout - Seeded Review", + "context": "Use this seeded board to review spacing, hierarchy, and action placement. Adjust it if needed, then submit.", + "initialCanvases": [ + { + "name": "Component Review", + "seedElements": [ + { + "type": "rectangle", + "x": 70, + "y": 50, + "width": 760, + "height": 90, + "strokeColor": "#1d4ed8", + "fillColor": "rgba(37,99,235,0.12)" + }, + { + "type": "text", + "x": 450, + "y": 98, + "text": "Header Region", + "fontSize": 22, + "fontWeight": 700, + "textAlign": "center", + "color": "#1e3a8a" + }, + { + "type": "rectangle", + "x": 70, + "y": 180, + "width": 360, + "height": 220, + "strokeColor": "#059669", + "fillColor": "rgba(5,150,105,0.1)" + }, + { + "type": "text", + "x": 130, + "y": 220, + "text": "Content Card A", + "fontSize": 18, + "fontWeight": 700, + "color": "#065f46" + }, + { + "type": "line", + "start": { "x": 100, "y": 246 }, + "end": { "x": 400, "y": 246 }, + "strokeColor": "#6ee7b7", + "strokeWidth": 2 + }, + { + "type": "text", + "x": 110, + "y": 282, + "text": "Primary content block", + "fontSize": 14, + "color": "#065f46" + }, + { + "type": "rectangle", + "x": 470, + "y": 180, + "width": 360, + "height": 220, + "strokeColor": "#c2410c", + "fillColor": "rgba(249,115,22,0.1)" + }, + { + "type": "text", + "x": 530, + "y": 220, + "text": "Content Card B", + "fontSize": 18, + "fontWeight": 700, + "color": "#9a3412" + }, + { + "type": "circle", + "x": 650, + "y": 300, + "radius": 34, + "strokeColor": "#f97316", + "fillColor": "rgba(249,115,22,0.16)" + }, + { + "type": "text", + "x": 650, + "y": 306, + "text": "Icon", + "fontSize": 12, + "fontWeight": 700, + "textAlign": "center", + "color": "#9a3412" + }, + { + "type": "line", + "start": { "x": 70, "y": 450 }, + "end": { "x": 830, "y": 450 }, + "strokeColor": "#cbd5e1", + "strokeWidth": 3 + }, + { + "type": "rectangle", + "x": 88, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#dc2626", + "fillColor": "rgba(220,38,38,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 173, + "y": 525, + "text": "Button A", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#991b1b" + }, + { + "type": "rectangle", + "x": 282, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#1d4ed8", + "fillColor": "rgba(37,99,235,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 367, + "y": 525, + "text": "Button B", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#1e3a8a" + }, + { + "type": "rectangle", + "x": 476, + "y": 490, + "width": 170, + "height": 56, + "strokeColor": "#0891b2", + "fillColor": "rgba(8,145,178,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 561, + "y": 525, + "text": "Button C", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#155e75" + }, + { + "type": "rectangle", + "x": 670, + "y": 490, + "width": 160, + "height": 56, + "strokeColor": "#475569", + "fillColor": "rgba(71,85,105,0.12)", + "rx": 12 + }, + { + "type": "text", + "x": 750, + "y": 525, + "text": "Button D", + "fontSize": 14, + "fontWeight": 700, + "textAlign": "center", + "color": "#334155" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/a2ui/catalog.ts b/src/a2ui/catalog.ts new file mode 100644 index 0000000..0e13d8e --- /dev/null +++ b/src/a2ui/catalog.ts @@ -0,0 +1,24 @@ +import type { A2UIComponentType } from './types'; + +export const ALLOWED_COMPONENT_TYPES: ReadonlySet = new Set([ + 'Row', + 'Column', + 'Card', + 'Divider', + 'Text', + 'Heading', + 'Image', + 'Markdown', + 'CodeBlock', + 'Button', + 'TextField', + 'Checkbox', + 'Select', + 'MermaidDiagram', + 'ProgressBar', + 'Badge', +]); + +export function isAllowedComponentType(type: string): type is A2UIComponentType { + return ALLOWED_COMPONENT_TYPES.has(type); +} diff --git a/src/a2ui/engine.ts b/src/a2ui/engine.ts new file mode 100644 index 0000000..3d2421c --- /dev/null +++ b/src/a2ui/engine.ts @@ -0,0 +1,262 @@ +import type { A2UIComponent } from './types'; + +export type A2UIIssueSeverity = 'error' | 'warning' | 'info'; +export type A2UIPrinciple = 'clarity' | 'accessibility' | 'error_prevention' | 'action_orientation' | 'progressive_disclosure'; +export type A2UILevel = 'basic' | 'strict'; + +export interface A2UIIssue { + severity: A2UIIssueSeverity; + principle: A2UIPrinciple; + message: string; + suggestion: string; + componentId?: string; +} + +export interface A2UIReport { + enabled: true; + level: A2UILevel; + score: number; + issues: A2UIIssue[]; + appliedEnhancements: string[]; +} + +export interface A2UIProcessingResult { + components: A2UIComponent[]; + report: A2UIReport; +} + +type MutableComponent = { + id: string; + parentId?: string; + component: Record; +}; + +const GENERIC_BUTTON_LABELS = new Set(['ok', 'yes', 'no', 'go', 'run', 'click']); +const DESTRUCTIVE_ACTION_PATTERN = /delete|remove|destroy|purge|wipe/i; + +function cloneComponents(components: A2UIComponent[]): MutableComponent[] { + return components.map((entry) => ({ + id: entry.id, + ...(entry.parentId ? { parentId: entry.parentId } : {}), + component: { ...entry.component }, + })); +} + +function extractProps(component: Record): Record { + const props = component.props; + if (props && typeof props === 'object' && !Array.isArray(props)) { + return { ...(props as Record) }; + } + + const extractedProps: Record = {}; + for (const [key, value] of Object.entries(component)) { + if (key === 'type') { + continue; + } + extractedProps[key] = value; + } + return extractedProps; +} + +function assignProps(component: MutableComponent, props: Record): void { + component.component = { + type: component.component.type, + props, + }; +} + +function pushIssue(issues: A2UIIssue[], severity: A2UIIssueSeverity, principle: A2UIPrinciple, message: string, suggestion: string, componentId?: string): void { + issues.push({ severity, principle, message, suggestion, ...(componentId ? { componentId } : {}) }); +} + +function computeScore(issues: A2UIIssue[]): number { + const penalty = issues.reduce((total, issue) => total + (issue.severity === 'error' ? 0.2 : issue.severity === 'warning' ? 0.1 : 0.04), 0); + return Math.max(0, Number((1 - penalty).toFixed(2))); +} + +export function processA2UIComponents(components: A2UIComponent[], level: A2UILevel): A2UIProcessingResult { + const mutableComponents = cloneComponents(components); + const issues: A2UIIssue[] = []; + const appliedEnhancements: string[] = []; + + let cancelButtonInjected = false; + const buttonEntries = mutableComponents.filter((entry) => entry.component.type === 'Button'); + const hasInteractiveFields = mutableComponents.some((entry) => entry.component.type === 'TextField' || entry.component.type === 'Select' || entry.component.type === 'Checkbox'); + const rootCount = mutableComponents.filter((entry) => !entry.parentId).length; + const hasStructuralContainers = mutableComponents.some((entry) => entry.component.type === 'Card' || entry.component.type === 'Divider'); + + if (hasInteractiveFields && buttonEntries.length === 0) { + pushIssue( + issues, + 'warning', + 'action_orientation', + 'Interactive controls are present without a submit or confirm action.', + 'Add a clear action button so the user knows how to complete the interaction.', + ); + } + + if (buttonEntries.length > 2 && buttonEntries.every((entry) => { + const props = extractProps(entry.component); + return typeof props.variant !== 'string'; + })) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'action_orientation', + 'The surface has several actions but no explicit emphasis hierarchy.', + 'Use variant or layout grouping to distinguish primary, secondary, and destructive actions.', + ); + } + + if (rootCount > 6 && !hasStructuralContainers) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'progressive_disclosure', + 'The surface exposes many root-level elements without structural grouping.', + 'Group related content into cards or sections so the user can scan the surface progressively.', + ); + } + + for (const entry of mutableComponents) { + const type = String(entry.component.type ?? ''); + const props = extractProps(entry.component); + let mutated = false; + + if (type === 'Button') { + const label = String(props.label ?? '').trim(); + const action = String(props.action ?? entry.id); + const variant = String(props.variant ?? ''); + const isDestructive = variant === 'danger' || DESTRUCTIVE_ACTION_PATTERN.test(label) || DESTRUCTIVE_ACTION_PATTERN.test(action); + + if (label.length > 0 && (label.length < 4 || GENERIC_BUTTON_LABELS.has(label.toLowerCase()))) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'clarity', + `Button \"${label}\" is underspecified.`, + 'Use a more descriptive button label so the action is obvious without extra context.', + entry.id, + ); + } + + if (typeof props.ariaLabel !== 'string' || props.ariaLabel.trim().length === 0) { + props.ariaLabel = label || action; + mutated = true; + appliedEnhancements.push(`Added ariaLabel to button ${entry.id}.`); + pushIssue( + issues, + 'info', + 'accessibility', + 'Button is missing ariaLabel metadata.', + 'Provide ariaLabel for actionable controls.', + entry.id, + ); + } + + if (isDestructive && !cancelButtonInjected) { + const siblingCancel = mutableComponents.some((candidate) => { + if (candidate.component.type !== 'Button') { + return false; + } + if (candidate.parentId !== entry.parentId) { + return false; + } + const candidateProps = extractProps(candidate.component); + const candidateLabel = String(candidateProps.label ?? '').toLowerCase(); + return candidateLabel.includes('cancel') || candidateLabel.includes('back'); + }); + + if (!siblingCancel) { + mutableComponents.push({ + id: `auto_cancel_${entry.id}`, + ...(entry.parentId ? { parentId: entry.parentId } : {}), + component: { + type: 'Button', + props: { + label: 'Cancel', + action: `cancel_${entry.id}`, + variant: 'secondary', + ariaLabel: 'Cancel and return without applying the destructive action', + }, + }, + }); + cancelButtonInjected = true; + appliedEnhancements.push(`Injected cancel safeguard next to destructive button ${entry.id}.`); + pushIssue( + issues, + 'warning', + 'error_prevention', + 'Destructive action did not include a cancel alternative.', + 'Pair destructive buttons with an adjacent cancel or safe alternative.', + entry.id, + ); + } + } + } + + if (type === 'TextField' || type === 'Select' || type === 'Checkbox') { + const label = String(props.label ?? '').trim(); + + if (label.length === 0) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'clarity', + `${type} is missing a visible label.`, + 'Provide a concise label so the control is understandable in isolation.', + entry.id, + ); + } + + if (typeof props.ariaLabel !== 'string' || props.ariaLabel.trim().length === 0) { + props.ariaLabel = label || entry.id; + mutated = true; + appliedEnhancements.push(`Added ariaLabel to ${type} ${entry.id}.`); + } + + if (level === 'strict' && props.required === true && typeof props.helperText !== 'string') { + props.helperText = 'Required field'; + mutated = true; + appliedEnhancements.push(`Added helper text to required ${type} ${entry.id}.`); + pushIssue( + issues, + 'info', + 'error_prevention', + `${type} is required but does not explain that state.`, + 'Add helper text for required inputs so the user understands what is expected.', + entry.id, + ); + } + } + + if (type === 'Image') { + const alt = String(props.alt ?? '').trim(); + if (!alt) { + pushIssue( + issues, + level === 'strict' ? 'warning' : 'info', + 'accessibility', + 'Image is missing alt text.', + 'Provide alt text so non-visual users understand the image purpose.', + entry.id, + ); + } + } + + if (mutated) { + assignProps(entry, props); + } + } + + return { + components: mutableComponents, + report: { + enabled: true, + level, + score: computeScore(issues), + issues, + appliedEnhancements, + }, + }; +} \ No newline at end of file diff --git a/src/a2ui/panel.test.ts b/src/a2ui/panel.test.ts new file mode 100644 index 0000000..000905a --- /dev/null +++ b/src/a2ui/panel.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); +const Module = require('node:module') as typeof import('node:module') & { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; +}; + +describe('A2UIPanel', () => { + const modulePath = require.resolve('./panel.ts'); + let originalLoad: typeof Module._load; + + beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; + }); + + afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; + }); + + it('settles the previous waiter when a second waitForAction call reuses the same surfaceId', async () => { + const messageHandlers: Array<(message: unknown) => void> = []; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { + One: 1, + }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + return { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { + return value; + }, + onDidReceiveMessage(handler: (message: unknown) => void) { + messageHandlers.push(handler); + return { + dispose() { }, + }; + }, + }, + onDidDispose(handler: () => void) { + disposeHandler = handler; + return { + dispose() { }, + }; + }, + reveal() { }, + dispose() { + disposeHandler?.(); + }, + }; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((part) => typeof part === 'string' ? part : part.path || part.fsPath || '').join('/'), + }), + }, + }; + } + + if (request === 'fs') { + return { + readFileSync() { + return '
{{surfaceHtml}}
'; + }, + }; + } + + if (request === 'path') { + return { + join: (...parts: string[]) => parts.join('/'), + }; + } + + if (request === 'crypto') { + return { + randomBytes() { + return { + toString() { + return 'nonce'; + }, + }; + }, + }; + } + + if (request === './renderer') { + return { + renderSurface() { + return ''; + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + const surface = { + surfaceId: 'surface_shared', + title: 'Shared surface', + components: [ + { + id: 'button_1', + component: { + type: 'Button', + props: { + label: 'Submit', + action: 'submit', + }, + }, + }, + ], + }; + + const firstPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, surface, true); + const secondPromise = A2UIPanel.showSurface({ fsPath: '/extension' } as any, surface, true); + + const firstResult = await Promise.race([ + firstPromise, + new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), + ]); + + assert.notEqual(firstResult, 'timeout', 'expected the previous waiter to be settled'); + assert.deepStrictEqual(firstResult, { dismissed: true }); + + const latestHandler = messageHandlers.at(-1); + assert.ok(latestHandler, 'expected a webview message handler'); + latestHandler({ + type: 'userAction', + name: 'submit', + data: { + value: 'ok', + }, + }); + + await assert.doesNotReject(() => secondPromise); + assert.deepStrictEqual(await secondPromise, { + dismissed: false, + userAction: { + name: 'submit', + data: { + value: 'ok', + }, + }, + }); + }); + + it('preserves literal dollar replacement patterns in rendered HTML', async () => { + let createdPanel: + | { + webview: { + html: string; + }; + } + | undefined; + + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ViewColumn: { + One: 1, + }, + window: { + activeTextEditor: undefined, + createWebviewPanel() { + let disposeHandler: (() => void) | undefined; + createdPanel = { + webview: { + html: '', + cspSource: 'vscode-webview://test', + asWebviewUri(value: { toString(): string }) { + return value; + }, + onDidReceiveMessage() { + return { + dispose() { }, + }; + }, + }, + onDidDispose(handler: () => void) { + disposeHandler = handler; + return { + dispose() { }, + }; + }, + reveal() { }, + dispose() { + disposeHandler?.(); + }, + } as any; + + return createdPanel; + }, + }, + Uri: { + joinPath: (...parts: Array<{ path?: string; fsPath?: string } | string>) => ({ + toString: () => parts.map((part) => typeof part === 'string' ? part : part.path || part.fsPath || '').join('/'), + }), + }, + }; + } + + if (request === 'fs') { + return { + readFileSync() { + return '{{surfaceHtml}}'; + }, + }; + } + + if (request === 'path') { + return { + join: (...parts: string[]) => parts.join('/'), + }; + } + + if (request === 'crypto') { + return { + randomBytes() { + return { + toString() { + return 'nonce'; + }, + }; + }, + }; + } + + if (request === './renderer') { + return { + renderSurface() { + return '

literal $& marker

'; + }, + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + const { A2UIPanel } = require('./panel.ts') as typeof import('./panel'); + await A2UIPanel.showSurface( + { fsPath: '/extension' } as any, + { + surfaceId: 'surface_literal', + components: [ + { + id: 'text_1', + component: { type: 'Text', props: { content: 'ignored' } }, + }, + ], + }, + false, + ); + + assert.ok(createdPanel, 'expected a panel to be created'); + assert.match(createdPanel.webview.html, /\$& marker/); + assert.doesNotMatch(createdPanel.webview.html, /\{\{surfaceHtml\}\}/); + }); +}); diff --git a/src/a2ui/panel.ts b/src/a2ui/panel.ts new file mode 100644 index 0000000..eebd11c --- /dev/null +++ b/src/a2ui/panel.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import type { A2UISurface, A2UIUserAction } from './types'; +import { renderSurface } from './renderer'; + +export interface A2UIPanelResult { + dismissed: boolean; + userAction?: A2UIUserAction; +} + +type FromWebviewMessage = + | { type: 'userAction'; name: string; data: Record }; + +function escHtml(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderA2UIDiagnostics(surface: A2UISurface): string { + const report = surface.a2uiReport; + if (!report) { + return ''; + } + + const issuesHtml = report.issues.length > 0 + ? `
    ${report.issues.map((issue) => `
  • ${escHtml(issue.principle)}: ${escHtml(issue.message)}
  • `).join('')}
` + : '

No validation findings.

'; + const enhancementsHtml = report.appliedEnhancements.length > 0 + ? `
    ${report.appliedEnhancements.map((item) => `
  • ${escHtml(item)}
  • `).join('')}
` + : '

No automatic enhancements applied.

'; + + return `
A2UI DiagnosticsScore ${escHtml(String(Math.round(report.score * 100)))}%

Findings

${issuesHtml}

Enhancements

${enhancementsHtml}
`; +} + +export class A2UIPanel { + public static readonly viewType = 'seamlessAgent.a2ui'; + + private static _panels: Map = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _surface: A2UISurface; + private readonly _surfaceKey: string; + private _disposables: vscode.Disposable[] = []; + private _resolvePromise?: (result: A2UIPanelResult) => void; + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + surface: A2UISurface, + surfaceKey: string, + resolve: (result: A2UIPanelResult) => void, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._surface = surface; + this._surfaceKey = surfaceKey; + this._resolvePromise = resolve; + + this._panel.webview.html = this._getHtmlContent(); + this._panel.onDidDispose(() => this._dispose(), null, this._disposables); + this._panel.webview.onDidReceiveMessage( + (message: FromWebviewMessage) => void this._handleMessage(message), + null, + this._disposables, + ); + } + + /** + * Shows a surface panel. + * If waitForAction is false, returns immediately after creating the panel. + * If waitForAction is true, blocks until the user fires an action or closes the panel. + */ + public static async showSurface( + extensionUri: vscode.Uri, + surface: A2UISurface, + waitForAction: boolean, + ): Promise { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + const key = surface.surfaceId ?? crypto.randomBytes(8).toString('hex'); + + if (!waitForAction) { + const existing = A2UIPanel._panels.get(key); + if (existing) { + existing._surface = surface; + existing._panel.title = surface.title ?? 'UI Surface'; + existing._panel.webview.html = existing._getHtmlContent(); + existing._panel.reveal(column); + } else { + const webviewPanel = vscode.window.createWebviewPanel( + A2UIPanel.viewType, + surface.title ?? 'UI Surface', + column, + A2UIPanel._webviewOptions(extensionUri), + ); + const instance = new A2UIPanel(webviewPanel, extensionUri, surface, key, () => { }); + A2UIPanel._panels.set(key, instance); + } + return { dismissed: false }; + } + + return new Promise((resolve) => { + const existing = A2UIPanel._panels.get(key); + if (existing) { + existing._resolve({ dismissed: true }); + existing._surface = surface; + existing._panel.title = surface.title ?? 'UI Surface'; + existing._resolvePromise = resolve; + existing._panel.webview.html = existing._getHtmlContent(); + existing._panel.reveal(column); + return; + } + + const webviewPanel = vscode.window.createWebviewPanel( + A2UIPanel.viewType, + surface.title ?? 'UI Surface', + column, + A2UIPanel._webviewOptions(extensionUri), + ); + const instance = new A2UIPanel(webviewPanel, extensionUri, surface, key, resolve); + A2UIPanel._panels.set(key, instance); + }); + } + + public static closeIfOpen(surfaceId: string): boolean { + const panel = A2UIPanel._panels.get(surfaceId); + if (panel) { + panel._panel.dispose(); + return true; + } + return false; + } + + private static _webviewOptions(extensionUri: vscode.Uri): vscode.WebviewPanelOptions & vscode.WebviewOptions { + return { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, 'media'), + vscode.Uri.joinPath(extensionUri, 'dist'), + ], + }; + } + + private async _handleMessage(message: FromWebviewMessage): Promise { + if (message.type === 'userAction') { + const action: A2UIUserAction = { + name: message.name, + data: message.data, + }; + this._resolve({ dismissed: false, userAction: action }); + this._panel.dispose(); + } + } + + private _resolve(result: A2UIPanelResult): void { + if (this._resolvePromise) { + this._resolvePromise(result); + this._resolvePromise = undefined; + } + } + + private _dispose(): void { + A2UIPanel._panels.delete(this._surfaceKey); + if (this._resolvePromise) { + this._resolve({ dismissed: true }); + } + for (const d of this._disposables) { + d.dispose(); + } + this._disposables = []; + } + + private _getHtmlContent(): string { + const webview = this._panel.webview; + const nonce = crypto.randomBytes(16).toString('hex'); + + const cssUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'media', 'a2ui.css'), + ); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'dist', 'a2ui.js'), + ); + const cspSource = webview.cspSource; + + let renderedHtml: string; + try { + renderedHtml = renderSurface(this._surface); + } catch { + renderedHtml = '

Failed to render surface.

'; + } + + const htmlPath = path.join(this._extensionUri.fsPath, 'media', 'a2ui.html'); + let html = fs.readFileSync(htmlPath, 'utf8'); + + html = html + .replace(/\{\{nonce\}\}/g, nonce) + .replace(/\{\{cspSource\}\}/g, cspSource) + .replace(/\{\{styleUri\}\}/g, cssUri.toString()) + .replace(/\{\{scriptUri\}\}/g, scriptUri.toString()) + .replace(/\{\{title\}\}/g, escHtml(this._surface.title ?? 'UI Surface')) + .replace(/\{\{surfaceId\}\}/g, escHtml(this._surfaceKey)); + + html = html.replace(/\{\{diagnosticsHtml\}\}/g, () => renderA2UIDiagnostics(this._surface)); + html = html.replace(/\{\{surfaceHtml\}\}/g, () => renderedHtml); + + return html; + } +} diff --git a/src/a2ui/renderer.ts b/src/a2ui/renderer.ts new file mode 100644 index 0000000..db5625e --- /dev/null +++ b/src/a2ui/renderer.ts @@ -0,0 +1,262 @@ +import MarkdownIt from 'markdown-it'; + +import type { A2UISurface, A2UIDataModel } from './types'; +import { isAllowedComponentType } from './catalog'; + +const markdownRenderer = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, +}); + +export class RendererError extends Error { + constructor(message: string) { + super(message); + this.name = 'RendererError'; + } +} + +function escHtml(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Resolves $data. bindings against the data model */ +function resolveBinding(value: unknown, data: A2UIDataModel): unknown { + if (typeof value !== 'string' || !value.startsWith('$data.')) { + return value; + } + const path = value.slice(6); // strip '$data.' + const parts = path.split('.'); + let current: unknown = data; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +function interpolateBindings(value: string, data: A2UIDataModel): string { + return value.replace(/\$data(?:\.[A-Za-z0-9_]+)+/g, (match) => { + const resolved = resolveBinding(match, data); + if (resolved === undefined || resolved === null) { + return ''; + } + if (typeof resolved === 'object') { + return JSON.stringify(resolved); + } + return String(resolved); + }); +} + +function resolveValue(value: unknown, data: A2UIDataModel): unknown { + if (typeof value === 'string' && value.includes('$data.')) { + if (value.startsWith('$data.') && !value.includes(' ')) { + return resolveBinding(value, data); + } + return interpolateBindings(value, data); + } + + if (Array.isArray(value)) { + return value.map((entry) => resolveValue(entry, data)); + } + + if (value && typeof value === 'object') { + const resolvedObject: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + resolvedObject[key] = resolveValue(nestedValue, data); + } + return resolvedObject; + } + + return resolveBinding(value, data); +} + +function resolveProps( + props: Record, + data: A2UIDataModel, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(props)) { + resolved[key] = resolveValue(value, data); + } + return resolved; +} + +function extractComponentProps(component: Record): Record { + const props = component.props; + if (props && typeof props === 'object' && !Array.isArray(props)) { + return props as Record; + } + + const extractedProps: Record = {}; + for (const [key, value] of Object.entries(component)) { + if (key === 'type') { + continue; + } + extractedProps[key] = value; + } + + return extractedProps; +} + +function renderTag( + type: string, + id: string, + props: Record, + children: string, +): string { + const labelText = typeof props.label === 'string' ? props.label : ''; + const disabled = props.disabled ? ' disabled' : ''; + const ariaLabel = typeof props.ariaLabel === 'string' && props.ariaLabel.trim().length > 0 + ? ` aria-label="${escHtml(props.ariaLabel)}"` + : ''; + const helperText = typeof props.helperText === 'string' ? props.helperText : ''; + const requiredMarker = props.required ? '' : ''; + const requiredAttribute = props.required ? ' required' : ''; + + switch (type) { + case 'Row': + return `
${children}
`; + + case 'Column': + return `
${children}
`; + + case 'Card': + return `
${children}
`; + + case 'Divider': + return `
`; + + case 'Text': + return `

${escHtml(String(props.text ?? props.content ?? ''))}

`; + + case 'Heading': { + const lvl = Math.min(6, Math.max(1, Number(props.level ?? 2))); + return `${escHtml(String(props.text ?? props.content ?? ''))}`; + } + + case 'Image': + return `${escHtml(String(props.alt ?? ''))}`; + + case 'Markdown': + return `
${markdownRenderer.render(String(props.text ?? props.content ?? ''))}
`; + + case 'CodeBlock': { + const lang = escHtml(String(props.language ?? 'text')); + return `
${escHtml(String(props.content ?? ''))}
`; + } + + case 'Button': + return ``; + + case 'TextField': + return ``; + + case 'Checkbox': { + const checked = props.checked ? ' checked' : ''; + return ``; + } + + case 'Select': { + const opts = Array.isArray(props.options) ? props.options : []; + const currentValue = String(props.value ?? ''); + const placeholder = typeof props.placeholder === 'string' ? props.placeholder : undefined; + const optsHtml = opts + .map((o: unknown) => { + const isObjectOption = o !== null && typeof o === 'object' && !Array.isArray(o); + const label = isObjectOption + ? String((o as Record).label ?? (o as Record).value ?? '') + : String(o); + const value = isObjectOption + ? String((o as Record).value ?? (o as Record).label ?? '') + : String(o); + const selected = value === currentValue ? ' selected' : ''; + return ``; + }) + .join(''); + const placeholderHtml = placeholder + ? `` + : ''; + return ``; + } + + case 'MermaidDiagram': + return `
${escHtml(String(props.label ?? 'Mermaid Diagram'))}
Diagram source
${escHtml(String(props.text ?? props.content ?? ''))}
`; + + case 'ProgressBar': { + const val = Number(props.value ?? 0); + const max = Number(props.max ?? 100); + const percent = max > 0 ? Math.round((val / max) * 100) : 0; + const progressLabel = typeof props.label === 'string' ? props.label : ''; + const showValue = props.showValue !== false; + return `
${escHtml(progressLabel)}${showValue ? `${escHtml(String(percent))}%` : ''}
${percent}%
`; + } + + case 'Badge': + return `${escHtml(String(props.label ?? ''))}`; + + default: + return ''; + } +} + +/** + * Converts a flat component list (A2UISurface) into an HTML string. + * Validates component types against the catalog; throws RendererError on failure. + * Root components are those without a parentId; nesting is determined by parentId adjacency. + */ +export function renderSurface(surface: A2UISurface): string { + const data = surface.dataModel ?? {}; + + // Build lookup maps from the flat array + const componentMap = new Map>(); + const childrenMap = new Map(); // parentId -> ordered child ids + + for (const entry of surface.components) { + componentMap.set(entry.id, entry.component); + if (entry.parentId !== undefined) { + if (!childrenMap.has(entry.parentId)) { + childrenMap.set(entry.parentId, []); + } + childrenMap.get(entry.parentId)!.push(entry.id); + } + } + + // Validate all component types upfront + for (const entry of surface.components) { + const type = entry.component.type; + if (typeof type !== 'string' || !isAllowedComponentType(type)) { + throw new RendererError( + `Unsupported component type: ${String(type)} (id: ${entry.id})`, + ); + } + } + + function renderComponent(id: string): string { + const component = componentMap.get(id); + if (!component) { + throw new RendererError(`Component not found: ${id}`); + } + + const type = String(component.type ?? ''); + const props = resolveProps(extractComponentProps(component), data); + const childIds = childrenMap.get(id) ?? []; + const childrenHtml = childIds.map((childId) => renderComponent(childId)).join(''); + + return renderTag(type, id, props, childrenHtml); + } + + // Render all root components (those with no parentId) in declaration order + const roots = surface.components + .filter((e) => e.parentId === undefined) + .map((e) => e.id); + + return roots.map((id) => renderComponent(id)).join(''); +} diff --git a/src/a2ui/types.ts b/src/a2ui/types.ts new file mode 100644 index 0000000..2bfd7f8 --- /dev/null +++ b/src/a2ui/types.ts @@ -0,0 +1,55 @@ +// A2UI Protocol Types – Phase 2 A2UI surface system + +import type { A2UIReport } from './engine'; + +export type A2UIComponentType = + | 'Row' + | 'Column' + | 'Card' + | 'Divider' + | 'Text' + | 'Heading' + | 'Image' + | 'Markdown' + | 'CodeBlock' + | 'Button' + | 'TextField' + | 'Checkbox' + | 'Select' + | 'MermaidDiagram' + | 'ProgressBar' + | 'Badge'; + +export interface A2UIComponent { + id: string; + component: Record; + parentId?: string; +} + +export type A2UIDataModel = Record; + +export interface A2UIUserAction { + name: string; + data: Record; +} + +export interface A2UISurface { + surfaceId?: string; + title?: string; + components: A2UIComponent[]; + dataModel?: A2UIDataModel; + a2uiReport?: A2UIReport; +} + +export interface RenderUIInput extends A2UISurface { + waitForAction?: boolean; +} + +export interface RenderUIToolResult { + surfaceId: string; + rendered: boolean; + userAction?: { + name: string; + data: Record; + }; +} diff --git a/src/a2ui/webview.ts b/src/a2ui/webview.ts new file mode 100644 index 0000000..497a692 --- /dev/null +++ b/src/a2ui/webview.ts @@ -0,0 +1,136 @@ +import mermaid from 'mermaid'; + +declare function acquireVsCodeApi(): { + postMessage(message: unknown): void; +}; + +type FormFieldElement = HTMLInputElement | HTMLSelectElement; + +const vscode = acquireVsCodeApi(); + +function collectFormData(): Record { + const data: Record = {}; + document.querySelectorAll('[data-field]').forEach((element) => { + const field = element.dataset.field; + if (!field) { + return; + } + + if (element instanceof HTMLInputElement && element.type === 'checkbox') { + data[field] = element.checked; + return; + } + + data[field] = element.value; + }); + return data; +} + +function clearValidationErrors(): void { + document.querySelectorAll('.a2ui-field, .a2ui-checkbox-label').forEach((container) => { + container.classList.remove('a2ui-invalid'); + }); + document.querySelectorAll('.a2ui-field-error').forEach((element) => { + element.remove(); + }); +} + +function appendValidationError(container: Element, message: string): void { + container.classList.add('a2ui-invalid'); + const error = document.createElement('span'); + error.className = 'a2ui-field-error'; + error.textContent = message; + container.appendChild(error); +} + +function validateRequiredFields(): boolean { + clearValidationErrors(); + + let isValid = true; + document.querySelectorAll('[data-field][required]').forEach((element) => { + const container = element.closest('.a2ui-field, .a2ui-checkbox-label'); + if (!container) { + return; + } + + if (element instanceof HTMLInputElement && element.type === 'checkbox') { + if (!element.checked) { + appendValidationError(container, 'This field is required.'); + isValid = false; + } + return; + } + + if (!element.value.trim()) { + appendValidationError(container, 'This field is required.'); + isValid = false; + } + }); + + return isValid; +} + +async function renderMermaidDiagrams(): Promise { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'neutral', + logLevel: 'fatal', + }); + + const diagrams = Array.from(document.querySelectorAll('.a2ui-mermaid')); + await Promise.all(diagrams.map(async (diagram, index) => { + const target = diagram.querySelector('.a2ui-mermaid-target'); + const source = diagram.querySelector('.a2ui-mermaid-source code'); + const details = diagram.querySelector('.a2ui-mermaid-details'); + if (!target || !source) { + return; + } + + const definition = source.textContent ?? ''; + if (!definition.trim()) { + return; + } + + try { + const { svg } = await mermaid.render(`a2ui_mermaid_${index}`, definition); + target.innerHTML = svg; + diagram.dataset.rendered = 'true'; + } catch (error) { + target.innerHTML = `
Failed to render Mermaid diagram: ${error instanceof Error ? error.message : String(error)}
`; + if (details instanceof HTMLDetailsElement) { + details.open = true; + } + diagram.dataset.rendered = 'error'; + } + })); +} + +function attachActionHandlers(): void { + document.addEventListener('click', (event) => { + const button = event.target instanceof Element + ? event.target.closest('button.a2ui-button') + : null; + if (!button || button.disabled) { + return; + } + + const action = button.dataset.action; + if (!action) { + return; + } + + if (!validateRequiredFields()) { + return; + } + + vscode.postMessage({ + type: 'userAction', + name: action, + data: collectFormData(), + }); + }); +} + +void renderMermaidDiagrams(); +attachActionHandlers(); \ No newline at end of file diff --git a/src/mcp/mcpServer.test.ts b/src/mcp/mcpServer.test.ts index 58165d6..008073a 100644 --- a/src/mcp/mcpServer.test.ts +++ b/src/mcp/mcpServer.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; import { z } from 'zod'; +import { createRequire } from 'node:module'; -import { WhiteboardInputSchema } from '../tools/schemas'; +import { RenderUIInputSchema, WhiteboardInputSchema } from '../tools/schemas'; const require = createRequire(__filename); const Module = require('node:module') as typeof import('node:module') & { _load: (request: string, parent: unknown, isMain: boolean) => unknown; }; +const modulePath = require.resolve('./mcpServer.ts'); +let originalLoad: typeof Module._load; type RegisteredTool = { name: string; @@ -33,248 +35,231 @@ function summarizeSchemaResult(schema: z.ZodTypeAny, input: unknown) { }; } -describe('McpServerManager open_whiteboard registration', () => { - const modulePath = require.resolve('./mcpServer.ts'); - let originalLoad: typeof Module._load; - - beforeEach(() => { - originalLoad = Module._load; - delete require.cache[modulePath]; - }); +beforeEach(() => { + originalLoad = Module._load; + delete require.cache[modulePath]; +}); - afterEach(() => { - Module._load = originalLoad; - delete require.cache[modulePath]; - }); +afterEach(() => { + Module._load = originalLoad; + delete require.cache[modulePath]; +}); - async function loadHarness(options: { - openWhiteboard?: (params: unknown) => Promise; - } = {}) { - const registeredTools: RegisteredTool[] = []; +async function loadHarness(options: { + openWhiteboard?: (params: unknown) => Promise; + renderUI?: (params: unknown) => Promise; +} = {}) { + const registeredTools: RegisteredTool[] = []; - class MockMcpServer { - registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) { - registeredTools.push({ name, config, handler }); - } + class MockMcpServer { + registerTool(name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) { + registeredTools.push({ name, config, handler }); + } - async connect() { - return undefined; - } + async connect() { + return undefined; + } - async close() { - return undefined; - } + async close() { + return undefined; } + } - class MockStreamableHTTPServerTransport { - constructor(_options: unknown) { } + class MockStreamableHTTPServerTransport { + constructor(_options: unknown) { } - async handleRequest() { - return undefined; - } + async handleRequest() { + return undefined; } + } - const httpMock = { - createServer() { - const server = { - listen(_port: number, _host: string, callback?: () => void) { - callback?.(); - }, - address() { - return { port: 43123 }; - }, - close(callback?: () => void) { - callback?.(); - }, - on() { - return server; - } - }; - - return server; - } - }; + const httpMock = { + createServer() { + const server = { + listen(_port: number, _host: string, callback?: () => void) { + callback?.(); + }, + address() { + return { port: 43123 }; + }, + close(callback?: () => void) { + callback?.(); + }, + on() { + return server; + }, + }; + + return server; + }, + }; - Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { - if (request === 'vscode') { - return { - CancellationTokenSource: class { - token = { isCancellationRequested: false }; - cancel() { - this.token.isCancellationRequested = true; - } - }, - window: { - showErrorMessage() { }, - showInformationMessage() { }, + Module._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + CancellationTokenSource: class { + token = { isCancellationRequested: false }; + cancel() { + this.token.isCancellationRequested = true; } - }; - } + }, + window: { + showErrorMessage() { }, + showInformationMessage() { }, + }, + }; + } - if (request === 'http') { - return httpMock; - } + if (request === 'http') { + return httpMock; + } - if (request === 'fs') { - return { - existsSync() { - return false; - }, - mkdirSync() { }, - readFileSync() { - throw new Error('not implemented'); - }, - writeFileSync() { }, - }; - } + if (request === 'fs') { + return { + existsSync() { + return false; + }, + mkdirSync() { }, + readFileSync() { + throw new Error('not implemented'); + }, + writeFileSync() { }, + }; + } - if (request === 'os') { - return { - homedir() { - return '/tmp'; - } - }; - } + if (request === 'os') { + return { + homedir() { + return '/tmp'; + }, + }; + } - if (request === 'crypto') { - return { - randomUUID() { - return 'uuid'; - } - }; - } + if (request === 'crypto') { + return { + randomUUID() { + return 'uuid'; + }, + }; + } - if (request === '@modelcontextprotocol/sdk/server/mcp.js') { - return { - McpServer: MockMcpServer, - }; - } + if (request === '@modelcontextprotocol/sdk/server/mcp.js') { + return { + McpServer: MockMcpServer, + }; + } - if (request === '@modelcontextprotocol/sdk/server/streamableHttp.js') { - return { - StreamableHTTPServerTransport: MockStreamableHTTPServerTransport, - }; - } + if (request === '@modelcontextprotocol/sdk/server/streamableHttp.js') { + return { + StreamableHTTPServerTransport: MockStreamableHTTPServerTransport, + }; + } - if (request === '../tools') { - return { - askUser: async () => ({ responded: true, response: 'ok', attachments: [] }), - openWhiteboard: options.openWhiteboard ?? (async () => ({ - submitted: false, - canvases: [], - interactionId: 'wb_test', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, - })), - planReviewApproval: async () => ({ status: 'approved', requiredRevisions: [], reviewId: 'review_1' }), - walkthroughReview: async () => ({ status: 'acknowledged', requiredRevisions: [], reviewId: 'review_2' }), - }; - } + if (request === '../tools') { + return { + askUser: async () => ({ responded: true, response: 'ok', attachments: [] }), + openWhiteboard: options.openWhiteboard ?? (async () => ({ + submitted: false, + images: [], + interactionId: 'wb_test', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + })), + renderUI: options.renderUI ?? (async () => ({ + surfaceId: 'surface_test', + rendered: true, + })), + planReviewApproval: async () => ({ status: 'approved', requiredRevisions: [], reviewId: 'review_1' }), + walkthroughReview: async () => ({ status: 'acknowledged', requiredRevisions: [], reviewId: 'review_2' }), + }; + } - if (request === '../logging') { - return { - Logger: { - log() { }, - warn() { }, - error() { }, - } - }; - } + if (request === '../logging') { + return { + Logger: { + log() { }, + warn() { }, + error() { }, + }, + }; + } - return originalLoad.call(this, request, parent, isMain); - }; + return originalLoad.call(this, request, parent, isMain); + }; - const { McpServerManager } = require('./mcpServer.ts') as typeof import('./mcpServer'); - const manager = new McpServerManager({} as any, {} as any); - await manager.start(); + const { McpServerManager } = require('./mcpServer.ts') as typeof import('./mcpServer'); + const manager = new McpServerManager({} as any, {} as any); + await manager.start(); - const openWhiteboardTool = registeredTools.find((tool) => tool.name === 'open_whiteboard'); - assert.ok(openWhiteboardTool, 'Expected open_whiteboard MCP tool to be registered'); + const openWhiteboardTool = registeredTools.find((tool) => tool.name === 'open_whiteboard'); + assert.ok(openWhiteboardTool, 'Expected open_whiteboard MCP tool to be registered'); + const renderUITool = registeredTools.find((tool) => tool.name === 'render_ui'); + assert.ok(renderUITool, 'Expected render_ui MCP tool to be registered'); - return { - openWhiteboardTool, - }; - } + return { + openWhiteboardTool, + renderUITool, + }; +} - it('accepts seedElements in the MCP schema and forwards parsed seeded canvases unchanged', async () => { +describe('McpServerManager open_whiteboard registration', () => { + it('accepts importImages in the MCP schema and forwards parsed image-first inputs', async () => { const receivedCalls: unknown[] = []; const { openWhiteboardTool } = await loadHarness({ async openWhiteboard(params) { receivedCalls.push(params); return { submitted: false, - canvases: [], - interactionId: 'wb_seeded', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, + images: [], + interactionId: 'wb_imports', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', }; - } + }, }); - const seededInput = { - title: 'Seeded whiteboard', - context: 'Sketch a basic flow.', - initialCanvases: [ + const importInput = { + title: 'Annotate screenshot', + context: 'Mark the risky areas.', + importImages: [ { - name: 'Sketch 1', - seedElements: [ - { - type: 'rectangle', - x: 40, - y: 60, - width: 220, - height: 120, - strokeColor: '#2563eb', - }, - { - type: 'text', - x: 72, - y: 96, - text: 'Start', - } - ] - } - ] + uri: 'file:///tmp/mockup.png', + label: 'Mockup', + }, + ], }; assert.deepStrictEqual( - summarizeSchemaResult(openWhiteboardTool.config.inputSchema, seededInput), - summarizeSchemaResult(WhiteboardInputSchema, seededInput), + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, importInput), + summarizeSchemaResult(WhiteboardInputSchema, importInput), ); - await openWhiteboardTool.handler(seededInput, {}); + await openWhiteboardTool.handler(importInput, {}); - assert.deepStrictEqual(receivedCalls, [seededInput]); + assert.deepStrictEqual(receivedCalls, [{ + ...importInput, + blankCanvas: true, + }]); }); - it('accepts explicit blankCanvas requests and forwards them unchanged', async () => { + it('defaults blankCanvas to true for blank whiteboard MCP requests', async () => { const receivedCalls: unknown[] = []; const { openWhiteboardTool } = await loadHarness({ async openWhiteboard(params) { receivedCalls.push(params); return { submitted: false, - canvases: [], + images: [], interactionId: 'wb_blank', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', }; - } + }, }); const blankInput = { title: 'Blank whiteboard', context: 'Start from scratch.', - blankCanvas: true, }; assert.deepStrictEqual( @@ -284,128 +269,173 @@ describe('McpServerManager open_whiteboard registration', () => { await openWhiteboardTool.handler(blankInput, {}); - assert.deepStrictEqual(receivedCalls, [blankInput]); + assert.deepStrictEqual(receivedCalls, [{ + ...blankInput, + blankCanvas: true, + }]); }); - it('rejects invalid seeded input before calling openWhiteboard', async () => { - let openWhiteboardCalls = 0; + it('accepts initialCanvases in the MCP schema and forwards seeded inputs', async () => { + const receivedCalls: unknown[] = []; const { openWhiteboardTool } = await loadHarness({ - async openWhiteboard() { - openWhiteboardCalls += 1; + async openWhiteboard(params) { + receivedCalls.push(params); return { submitted: false, - canvases: [], - interactionId: 'wb_invalid_seed', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, + images: [], + interactionId: 'wb_seeded_mcp', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', }; - } + }, }); - const invalidSeededInput = { - title: 'Broken seed', + const seededInput = { + title: 'Seeded starter content', initialCanvases: [ { - name: 'Broken canvas', + name: 'Sketch', seedElements: [ { type: 'text', - x: 10, - y: 20, - text: '', - } - ] - } - ] + x: 120, + y: 80, + text: 'Hello', + }, + ], + }, + ], }; assert.deepStrictEqual( - summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidSeededInput), - summarizeSchemaResult(WhiteboardInputSchema, invalidSeededInput), + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, seededInput), + summarizeSchemaResult(WhiteboardInputSchema, seededInput), ); - await assert.rejects( - () => openWhiteboardTool.handler(invalidSeededInput, {}), - /Seed text cannot be empty/, - ); - assert.strictEqual(openWhiteboardCalls, 0); + await openWhiteboardTool.handler(seededInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...seededInput, + blankCanvas: true, + }]); }); - it('rejects empty fabricState strings before runtime and never calls openWhiteboard', async () => { + it('rejects invalid imported-image input before calling openWhiteboard', async () => { let openWhiteboardCalls = 0; const { openWhiteboardTool } = await loadHarness({ async openWhiteboard() { openWhiteboardCalls += 1; return { submitted: false, - canvases: [], - interactionId: 'wb_invalid_fabric', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, + images: [], + interactionId: 'wb_invalid_import', + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', }; - } + }, }); - const invalidFabricStateInput = { - title: 'Broken fabric seed', - initialCanvases: [ + const invalidImportInput = { + title: 'Broken import', + importImages: [ { - name: 'Canvas 1', - fabricState: '', - } - ] + uri: '', + }, + ], }; assert.deepStrictEqual( - summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidFabricStateInput), - summarizeSchemaResult(WhiteboardInputSchema, invalidFabricStateInput), + summarizeSchemaResult(openWhiteboardTool.config.inputSchema, invalidImportInput), + summarizeSchemaResult(WhiteboardInputSchema, invalidImportInput), ); await assert.rejects( - () => openWhiteboardTool.handler(invalidFabricStateInput, {}), - /Canvas fabricState cannot be empty/, + () => openWhiteboardTool.handler(invalidImportInput, {}), + /Import image uri cannot be empty/, ); assert.strictEqual(openWhiteboardCalls, 0); }); +}); - it('rejects implicit blank requests before calling openWhiteboard', async () => { - let openWhiteboardCalls = 0; - const { openWhiteboardTool } = await loadHarness({ - async openWhiteboard() { - openWhiteboardCalls += 1; +describe('McpServerManager render_ui registration', () => { + it('accepts the flat render_ui schema and forwards parsed inputs', async () => { + const receivedCalls: unknown[] = []; + const { renderUITool } = await loadHarness({ + async renderUI(params) { + receivedCalls.push(params); return { - submitted: false, - canvases: [], - interactionId: 'wb_implicit_blank', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], + surfaceId: 'surface_architecture', + rendered: true, + }; + }, + }); + + const renderInput = { + surfaceId: 'surface_architecture', + title: 'Architecture', + components: [ + { + id: 'card_1', + component: { + type: 'Card', + }, + }, + { + id: 'text_1', + parentId: 'card_1', + component: { + type: 'Text', + props: { + content: '$data.summary', + }, }, + }, + ], + dataModel: { + summary: 'Rendered from data', + }, + }; + + assert.deepStrictEqual( + summarizeSchemaResult(renderUITool.config.inputSchema, renderInput), + summarizeSchemaResult(RenderUIInputSchema, renderInput), + ); + + await renderUITool.handler(renderInput, {}); + + assert.deepStrictEqual(receivedCalls, [{ + ...renderInput, + waitForAction: false, + enableA2UI: false, + a2uiLevel: 'basic', + }]); + }); + + it('rejects render_ui input missing components before calling renderUI', async () => { + let renderUICalls = 0; + const { renderUITool } = await loadHarness({ + async renderUI() { + renderUICalls += 1; + return { + surfaceId: 'surface_invalid', + rendered: true, }; - } + }, }); - const implicitBlankInput = { - title: 'Implicit blank whiteboard', - context: 'Start from scratch.', + const invalidInput = { + title: 'Missing components', }; assert.deepStrictEqual( - summarizeSchemaResult(openWhiteboardTool.config.inputSchema, implicitBlankInput), - summarizeSchemaResult(WhiteboardInputSchema, implicitBlankInput), + summarizeSchemaResult(renderUITool.config.inputSchema, invalidInput), + summarizeSchemaResult(RenderUIInputSchema, invalidInput), ); await assert.rejects( - () => openWhiteboardTool.handler(implicitBlankInput, {}), - /Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard/, + () => renderUITool.handler(invalidInput, {}), + /components/i, ); - assert.strictEqual(openWhiteboardCalls, 0); + assert.strictEqual(renderUICalls, 0); }); }); diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index 201be8a..a27ac79 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -8,8 +8,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { AgentInteractionProvider } from '../webview/webviewProvider'; -import { askUser, openWhiteboard, planReviewApproval, walkthroughReview } from '../tools'; -import { parseWhiteboardInput, WhiteboardInputSchema } from '../tools/schemas'; +import { askUser, openWhiteboard, planReviewApproval, walkthroughReview, renderUI } from '../tools'; +import { parseWhiteboardInput, parseRenderUIInput, WhiteboardInputSchema, RenderUIInputSchema } from '../tools/schemas'; import { Logger } from '../logging'; export class McpServerManager { @@ -230,6 +230,38 @@ export class McpServerManager { } ); + // Register render_ui tool (Phase 2 A2UI surface rendering) + this.mcpServer.registerTool( + "render_ui", + { + inputSchema: RenderUIInputSchema + }, + async (args: any, { signal }: { signal?: AbortSignal }) => { + const tokenSource = new vscode.CancellationTokenSource(); + if (signal) { + signal.onabort = () => tokenSource.cancel(); + } + + const params = parseRenderUIInput(args); + + const result = await renderUI( + params, + this.context, + this.provider, + tokenSource.token, + ); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ] + }; + } + ); + // ----------------------------- // Create transport this.transport = new StreamableHTTPServerTransport({ diff --git a/src/tools/index.ts b/src/tools/index.ts index cc01f9c..4d768b0 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -10,6 +10,7 @@ export * from './schemas'; export { askUser } from './askUser'; export { planReview, planReviewApproval, walkthroughReview } from './planReview'; export { openWhiteboard } from './openWhiteboard'; +export { renderUI } from './renderUI'; // Re-export utils export * from './utils'; @@ -18,6 +19,7 @@ export * from './utils'; import { askUser } from './askUser'; import { planReviewApproval, walkthroughReview } from './planReview'; import { openWhiteboard } from './openWhiteboard'; +import { renderUI } from './renderUI'; import { readFileAsBuffer, getImageMimeType, validateImageMagicNumber } from './utils'; import { createWhiteboardLanguageModelResultParts } from './whiteboardToolResult'; import { @@ -26,11 +28,13 @@ import { PlanReviewInput, WalkthroughReviewInput, WhiteboardInput, + RenderUIInput, parseAskUserInput, parseApprovePlanInput, parsePlanReviewInput, parseWalkthroughReviewInput, parseWhiteboardInput, + parseRenderUIInput, } from './schemas'; import { Logger } from '../logging'; @@ -281,12 +285,42 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: } }); + // Register the render_ui tool (Phase 2 A2UI surface rendering) + const renderUITool = vscode.lm.registerTool('render_ui', { + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + let params: RenderUIInput; + try { + params = parseRenderUIInput(options.input); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid input'; + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify({ + surfaceId: '', + rendered: false, + error: `Validation error: ${errorMessage}`, + })) + ]); + } + + const result = await renderUI(params, context, provider, token); + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + }, + prepareInvocation(options) { + return { + invocationMessage: options.input.title || 'Render UI surface' + }; + }, + }); + (context.subscriptions as unknown as Array).push( confirmationTool, approvePlanTool, planReviewTool, openWhiteboardTool, - walkthroughReviewTool + walkthroughReviewTool, + renderUITool, ); // Initialize chat history storage diff --git a/src/tools/openWhiteboard.test.ts b/src/tools/openWhiteboard.test.ts index 4b7b00a..99ea652 100644 --- a/src/tools/openWhiteboard.test.ts +++ b/src/tools/openWhiteboard.test.ts @@ -1,11 +1,10 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { readFileSync } from 'node:fs'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import path from 'node:path'; -import type { WhiteboardCanvasSubmission, WhiteboardSubmittedCanvas } from '../webview/types'; - const require = createRequire(__filename); const Module = require('node:module') as typeof import('node:module') & { _load: (request: string, parent: unknown, isMain: boolean) => unknown; @@ -26,9 +25,9 @@ function createTokenController(initiallyCancelled = false) { if (handler === callback) { handler = undefined; } - } + }, }; - } + }, }; return { @@ -36,7 +35,7 @@ function createTokenController(initiallyCancelled = false) { cancel() { isCancellationRequested = true; handler?.(); - } + }, }; } @@ -70,7 +69,7 @@ describe('openWhiteboard', () => { return (require('./openWhiteboard.ts') as typeof import('./openWhiteboard')).openWhiteboard; } - it('returns a cancelled result without touching dependencies when already cancelled', async () => { + it('returns a cancelled image result without touching dependencies when already cancelled', async () => { const openWhiteboard = loadOpenWhiteboard(); const tokenController = createTokenController(true); let saveCalls = 0; @@ -100,153 +99,31 @@ describe('openWhiteboard', () => { }, closeIfOpen() { return false; - } + }, }, now: () => 1000, - } - } + }, + }, ); assert.deepStrictEqual(result, { submitted: false, action: 'cancelled', instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', - canvases: [], + images: [], interactionId: '', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, }); assert.strictEqual(saveCalls, 0); assert.strictEqual(showCalls, 0); assert.strictEqual(refreshCalls, 0); }); - it('saves a pending interaction, refreshes home, and persists submitted canvases', async () => { - const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - const refreshLog: string[] = []; - const storageCalls: Array<{ type: 'save' | 'update'; payload: any }> = []; - - let submittedCanvases: WhiteboardSubmittedCanvas[] = []; - - const result = await openWhiteboard( - { - title: 'Design Whiteboard', - context: 'Sketch the service boundaries.', - initialCanvases: [ - { - name: 'Architecture sketch', - fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"seed_rect","whiteboardObjectType":"rectangle","left":40,"top":50,"width":220,"height":120,"stroke":"#2563eb","fill":"rgba(37,99,235,0.18)","strokeWidth":2}]}' - } - ] - }, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome: () => { refreshLog.push('refresh'); } } as any, - tokenController.token as any, - { - dependencies: { - storage: { - saveWhiteboardInteraction(payload) { - storageCalls.push({ type: 'save', payload }); - return 'wb_123'; - }, - updateWhiteboardInteraction(interactionId, payload) { - storageCalls.push({ type: 'update', payload: { interactionId, ...payload } }); - }, - }, - panel: { - async showWithOptions(_extensionUri, options) { - assert.strictEqual(options.interactionId, 'wb_123'); - assert.strictEqual(options.session.canvases.length, 1); - submittedCanvases = [ - { - id: options.session.canvases[0].id, - name: options.session.canvases[0].name, - imageUri: 'data:image/png;base64,abc123' - } - ]; - return { - submitted: true, - action: 'approved', - canvases: submittedCanvases.map(({ id, imageUri }) => ({ id, imageUri })) - }; - }, - closeIfOpen() { - return false; - } - }, - now: () => 1700000000000, - } - } - ); - - assert.deepStrictEqual(result, { - submitted: true, - action: 'approved', - instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', - canvases: submittedCanvases, - interactionId: 'wb_123', - sceneSummary: { - totalCanvases: 1, - totalElements: 1, - canvases: [ - { - id: 'canvas_1700000000000_1', - name: 'Architecture sketch', - width: 1600, - height: 900, - backgroundColor: '#ffffff', - elementCount: 1, - elements: [ - { - id: 'seed_rect', - objectType: 'rectangle', - bounds: { - x: 40, - y: 50, - width: 220, - height: 120, - }, - center: { - x: 150, - y: 110, - }, - zIndex: 0, - strokeColor: '#2563eb', - fillColor: 'rgba(37,99,235,0.18)', - strokeWidth: 2, - opacity: 1, - } - ], - } - ], - }, - }); - assert.strictEqual(refreshLog.length, 2); - assert.strictEqual(storageCalls.length, 2); - assert.strictEqual(storageCalls[0]?.type, 'save'); - assert.strictEqual(storageCalls[1]?.type, 'update'); - assert.deepStrictEqual(storageCalls[1]?.payload, { - interactionId: 'wb_123', - whiteboardSession: { - status: 'approved', - submittedAt: 1700000000000, - canvases: storageCalls[0]!.payload.canvases, - activeCanvasId: 'canvas_1700000000000_1', - submittedCanvases, - } - }); - }); - - it('returns recreateWithChanges when the user requests another whiteboard pass', async () => { + it('returns image-focused results for approved submissions', async () => { const openWhiteboard = loadOpenWhiteboard(); const result = await openWhiteboard( { - title: 'Edit and resubmit', + title: 'Blank Whiteboard', blankCanvas: true, }, { extensionUri: { fsPath: '/extension' } } as any, @@ -256,57 +133,7 @@ describe('openWhiteboard', () => { dependencies: { storage: { saveWhiteboardInteraction() { - return 'wb_changes'; - }, - updateWhiteboardInteraction() { }, - }, - panel: { - async showWithOptions(_extensionUri, options) { - return { - submitted: true, - action: 'recreateWithChanges', - canvases: [ - { - id: options.session.canvases[0]!.id, - imageUri: 'data:image/png;base64,updated', - }, - ], - }; - }, - closeIfOpen() { - return false; - } - }, - now: () => 1700000000001, - } - } - ); - - assert.equal(result.submitted, true); - assert.equal(result.action, 'recreateWithChanges'); - }); - - it('uses the submitted fabricState as the authoritative final scene summary', async () => { - const openWhiteboard = loadOpenWhiteboard(); - - const result = await openWhiteboard( - { - title: 'Edit and submit', - initialCanvases: [ - { - name: 'Canvas One', - fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"seed_rect","whiteboardObjectType":"rectangle","left":40,"top":50,"width":220,"height":120,"stroke":"#2563eb","fill":"rgba(37,99,235,0.18)","strokeWidth":2}]}' - } - ] - }, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome() { } } as any, - createTokenController().token as any, - { - dependencies: { - storage: { - saveWhiteboardInteraction() { - return 'wb_final_state'; + return 'wb_image_contract'; }, updateWhiteboardInteraction() { }, }, @@ -318,55 +145,75 @@ describe('openWhiteboard', () => { canvases: [ { id: options.session.canvases[0]!.id, - imageUri: 'data:image/png;base64,updated', - fabricState: '{"version":"6.9.1","width":1600,"height":900,"backgroundColor":"#ffffff","objects":[{"type":"rect","whiteboardId":"updated_rect","whiteboardObjectType":"rectangle","left":300,"top":180,"width":180,"height":90,"stroke":"#059669","fill":"rgba(5,150,105,0.18)","strokeWidth":3}]}' + imageUri: 'file:///tmp/canvas.png', }, ], }; }, closeIfOpen() { return false; - } + }, }, - now: () => 1700000001000, - } - } + now: () => 1700000002000, + }, + }, ); - assert.equal(result.sceneSummary.totalElements, 1); - assert.equal(result.sceneSummary.canvases[0]?.elements[0]?.id, 'updated_rect'); - assert.deepStrictEqual(result.sceneSummary.canvases[0]?.elements[0]?.center, { - x: 390, - y: 225, + assert.deepStrictEqual(result, { + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.', + images: [ + { + canvasId: 'canvas_1700000002000_1', + canvasName: 'Canvas 1', + imageUri: 'file:///tmp/canvas.png', + width: 1600, + height: 900, + }, + ], + interactionId: 'wb_image_contract', }); }); - it('rejects implicit blank whiteboards before saving or opening the panel', async () => { + it('preloads importImages into the initial canvas for annotation', async () => { const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - let saveCalls = 0; - let panelCalls = 0; + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'whiteboard-import-')); + const imagePath = path.join(tempDirectory, 'mockup.png'); + fs.writeFileSync(imagePath, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Zz6kAAAAASUVORK5CYII=', 'base64')); - await assert.rejects( - () => openWhiteboard( + try { + const fileUri = `file://${imagePath}`; + const result = await openWhiteboard( { - title: 'Implicit blank whiteboard', + title: 'Annotate imported image', + importImages: [ + { + uri: fileUri, + label: 'Mockup', + }, + ], }, { extensionUri: { fsPath: '/extension' } } as any, { refreshHome() { } } as any, - tokenController.token as any, + createTokenController().token as any, { dependencies: { storage: { saveWhiteboardInteraction() { - saveCalls += 1; - return 'wb_implicit_blank'; + return 'wb_imports'; }, updateWhiteboardInteraction() { }, }, panel: { - async showWithOptions() { - panelCalls += 1; + async showWithOptions(_extensionUri, options) { + const state = JSON.parse(options.session.canvases[0]!.fabricState) as { + objects: Array>; + }; + assert.equal(state.objects.length, 1); + assert.equal(state.objects[0]?.type, 'image'); + assert.match(String(state.objects[0]?.src ?? ''), /^data:image\/png;base64,/); + assert.equal(state.objects[0]?.whiteboardSourceUri, fileUri); return { submitted: false, action: 'cancelled', @@ -375,614 +222,182 @@ describe('openWhiteboard', () => { }, closeIfOpen() { return false; - } - }, - now: () => 1700000000004, - } - } - ), - /Provide initialCanvases for starter content, or set blankCanvas to true to intentionally open an empty whiteboard/, - ); - - assert.equal(saveCalls, 0); - assert.equal(panelCalls, 0); - }); - - it('opens an explicit blank canvas when blankCanvas is true', async () => { - const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - const storageCalls: Array<{ type: 'save' | 'update'; payload: any }> = []; - - const result = await openWhiteboard( - { - title: 'Blank Whiteboard', - blankCanvas: true, - }, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome() { } } as any, - tokenController.token as any, - { - dependencies: { - storage: { - saveWhiteboardInteraction(payload) { - storageCalls.push({ type: 'save', payload }); - return 'wb_blank'; - }, - updateWhiteboardInteraction(interactionId, payload) { - storageCalls.push({ type: 'update', payload: { interactionId, ...payload } }); - }, - }, - panel: { - async showWithOptions(_extensionUri, options) { - assert.equal(options.session.canvases.length, 1); - assert.equal(options.session.activeCanvasId, options.session.canvases[0]?.id); - assert.equal(options.session.canvases[0]?.name, 'Canvas 1'); - assert.match(options.session.canvases[0]?.fabricState ?? '', /"objects":\[\]/); - - return { - submitted: true, - action: 'approved', - canvases: [ - { - id: options.session.canvases[0]!.id, - imageUri: 'data:image/png;base64,blank-canvas', - } - ] - }; + }, }, - closeIfOpen() { - return false; - } + now: () => 1700000003000, }, - now: () => 1700000000005, - } - } - ); - - assert.strictEqual(storageCalls.length, 2); - assert.deepStrictEqual(storageCalls[0], { - type: 'save', - payload: { - title: 'Blank Whiteboard', - context: undefined, - canvases: [ - { - id: 'canvas_1700000000005_1', - name: 'Canvas 1', - fabricState: storageCalls[0]?.payload.canvases[0].fabricState, - createdAt: 1700000000005, - updatedAt: 1700000000005, - } - ], - activeCanvasId: 'canvas_1700000000005_1', - status: 'pending', - isDebug: undefined, - } - }); - assert.deepStrictEqual(JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState), { - version: JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState).version, - width: 1600, - height: 900, - backgroundColor: '#ffffff', - objects: [], - }); - assert.match(JSON.parse(storageCalls[0]!.payload.canvases[0].fabricState).version, /\S+/); - assert.deepStrictEqual(storageCalls[1], { - type: 'update', - payload: { - interactionId: 'wb_blank', - whiteboardSession: { - status: 'approved', - submittedAt: 1700000000005, - canvases: storageCalls[0]!.payload.canvases, - activeCanvasId: 'canvas_1700000000005_1', - submittedCanvases: [ - { - id: 'canvas_1700000000005_1', - name: 'Canvas 1', - imageUri: 'data:image/png;base64,blank-canvas', - } - ], - } - } - }); - assert.deepStrictEqual(result, { - submitted: true, - action: 'approved', - instruction: 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.', - canvases: [ - { - id: 'canvas_1700000000005_1', - name: 'Canvas 1', - imageUri: 'data:image/png;base64,blank-canvas', - } - ], - interactionId: 'wb_blank', - sceneSummary: { - totalCanvases: 1, - totalElements: 0, - canvases: [ - { - id: 'canvas_1700000000005_1', - name: 'Canvas 1', - width: 1600, - height: 900, - backgroundColor: '#ffffff', - elementCount: 0, - elements: [], - } - ], - }, - }); + }, + ); + + assert.deepStrictEqual(result, { + submitted: false, + action: 'cancelled', + instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', + images: [], + interactionId: 'wb_imports', + }); + } finally { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } }); - it('converts agent-friendly seed elements into fabric state before opening the panel', async () => { + it('builds starter canvases from seedElements and keeps the image result contract', async () => { const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - let panelValidated = false; const result = await openWhiteboard( { - title: 'Seeded demo', + title: 'Seeded whiteboard', initialCanvases: [ { - name: 'Demo canvas', + name: 'Seeded Canvas', seedElements: [ { type: 'rectangle', - x: 40, - y: 50, - width: 220, + x: 120, + y: 80, + width: 240, height: 120, - strokeColor: '#2563eb', - fillColor: 'rgba(37,99,235,0.18)', - }, - { - type: 'circle', - x: 360, - y: 140, - radius: 60, - strokeColor: '#dc2626', - fillColor: 'rgba(220,38,38,0.18)', - }, - { - type: 'triangle', - x: 520, - y: 60, - width: 180, - height: 150, - strokeColor: '#16a34a', - fillColor: 'rgba(22,163,74,0.18)', - }, - { - type: 'line', - start: { x: 780, y: 80 }, - end: { x: 1040, y: 220 }, - strokeColor: '#f97316', - strokeWidth: 6, + fillColor: '#dbeafe', }, { type: 'text', - x: 60, - y: 260, - text: 'Whiteboard Demo', - color: '#111827', - fontSize: 32, + x: 180, + y: 140, + text: 'Seeded', }, ], - } - ] - } as any, + }, + ], + }, { extensionUri: { fsPath: '/extension' } } as any, { refreshHome() { } } as any, - tokenController.token as any, + createTokenController().token as any, { dependencies: { storage: { saveWhiteboardInteraction() { - return 'wb_seeded_demo'; + return 'wb_seeded'; }, updateWhiteboardInteraction() { }, }, panel: { async showWithOptions(_extensionUri, options) { - panelValidated = true; assert.equal(options.session.canvases.length, 1); - const seededState = JSON.parse(options.session.canvases[0]!.fabricState); - assert.equal(seededState.width, 1600); - assert.equal(seededState.height, 900); - assert.equal(seededState.backgroundColor, '#ffffff'); - assert.deepStrictEqual( - seededState.objects.map((object: any) => [object.type, object.whiteboardObjectType]), - [ - ['rect', 'rectangle'], - ['path', 'circle'], - ['triangle', 'triangle'], - ['line', 'line'], - ['i-text', 'text'], - ], - ); - assert.equal(seededState.objects[0].stroke, '#2563eb'); - assert.equal(seededState.objects[1].stroke, '#dc2626'); - assert.equal(seededState.objects[2].stroke, '#16a34a'); - assert.equal(seededState.objects[3].stroke, '#f97316'); - assert.equal(seededState.objects[4].text, 'Whiteboard Demo'); - + assert.equal(options.session.canvases[0]?.name, 'Seeded Canvas'); + const state = JSON.parse(options.session.canvases[0]!.fabricState) as { + objects: Array>; + }; + assert.equal(state.objects.length, 2); + assert.equal(state.objects[0]?.type, 'rect'); + assert.equal(state.objects[1]?.type, 'i-text'); return { - submitted: false, - action: 'cancelled', - canvases: [], + submitted: true, + action: 'approved', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'file:///tmp/seeded.png', + }, + ], }; }, closeIfOpen() { return false; - } + }, }, - now: () => 1700000000010, - } - } + now: () => 1700000003500, + }, + }, ); - assert.equal(panelValidated, true); assert.deepStrictEqual(result, { - submitted: false, - action: 'cancelled', - instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', - canvases: [], - interactionId: 'wb_seeded_demo', - sceneSummary: { - totalCanvases: 1, - totalElements: 5, - canvases: [ - { - id: 'canvas_1700000000010_1', - name: 'Demo canvas', - width: 1600, - height: 900, - backgroundColor: '#ffffff', - elementCount: 5, - elements: [ - { - id: 'seed_1', - objectType: 'rectangle', - bounds: { - x: 40, - y: 50, - width: 220, - height: 120, - }, - center: { - x: 150, - y: 110, - }, - zIndex: 0, - strokeColor: '#2563eb', - fillColor: 'rgba(37,99,235,0.18)', - strokeWidth: 2, - opacity: 1, - }, - { - id: 'seed_2', - objectType: 'circle', - bounds: { - x: 300, - y: 80, - width: 120, - height: 120, - }, - center: { - x: 360, - y: 140, - }, - zIndex: 1, - strokeColor: '#dc2626', - fillColor: 'rgba(220,38,38,0.18)', - strokeWidth: 2, - opacity: 1, - }, - { - id: 'seed_3', - objectType: 'triangle', - bounds: { - x: 520, - y: 60, - width: 180, - height: 150, - }, - center: { - x: 610, - y: 135, - }, - zIndex: 2, - strokeColor: '#16a34a', - fillColor: 'rgba(22,163,74,0.18)', - strokeWidth: 2, - opacity: 1, - }, - { - id: 'seed_4', - objectType: 'line', - bounds: { - x: 780, - y: 80, - width: 260, - height: 140, - }, - center: { - x: 910, - y: 150, - }, - zIndex: 3, - strokeColor: '#f97316', - fillColor: '', - strokeWidth: 6, - opacity: 1, - }, - { - id: 'seed_5', - objectType: 'text', - label: 'Whiteboard Demo', - zIndex: 4, - fontSize: 32, - fontFamily: 'sans-serif', - strokeColor: '#111827', - fillColor: '#111827', - strokeWidth: 1, - opacity: 1, - }, - ], - } - ], - }, + submitted: true, + action: 'approved', + instruction: 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.', + images: [ + { + canvasId: 'canvas_1700000003500_1', + canvasName: 'Seeded Canvas', + imageUri: 'file:///tmp/seeded.png', + width: 1600, + height: 900, + }, + ], + interactionId: 'wb_seeded', }); }); - it('preserves the attached Android UI sample payload with rounded rectangles and centered seeded text', async () => { + it('preserves recreateWithChanges as an image-focused result action', async () => { const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - const sampleInput = JSON.parse(readFileSync(path.join(process.cwd(), 'whiteboard_input test 1.json'), 'utf8')); - let panelValidated = false; const result = await openWhiteboard( - sampleInput, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome() { } } as any, - tokenController.token as any, { - dependencies: { - storage: { - saveWhiteboardInteraction() { - return 'wb_android_ui_1'; - }, - updateWhiteboardInteraction() { }, - }, - panel: { - async showWithOptions(_extensionUri, options) { - panelValidated = true; - const seededState = JSON.parse(options.session.canvases[0]!.fabricState); - assert.equal(seededState.objects.length, 41); - assert.equal(seededState.objects[1].originX, 'center'); - assert.equal(seededState.objects[12].rx, 8); - assert.equal(seededState.objects[24].rx, 25); - return { - submitted: false, - action: 'cancelled', - canvases: [], - }; - }, - closeIfOpen() { - return false; - } - }, - now: () => 1700000000011, - } - } - ); - - assert.equal(panelValidated, true); - assert.equal(result.sceneSummary.totalCanvases, 1); - assert.equal(result.sceneSummary.totalElements, 41); - assert.equal(result.sceneSummary.canvases[0]?.elements[1]?.label, 'Android App Title Bar'); - }); - - it('preserves the second attached Android UI sample payload with full element count', async () => { - const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - const sampleInput = JSON.parse(readFileSync(path.join(process.cwd(), 'whiteboard_input test 2.json'), 'utf8')); - let panelValidated = false; - - const result = await openWhiteboard( - sampleInput, + title: 'Edit and resubmit', + blankCanvas: true, + }, { extensionUri: { fsPath: '/extension' } } as any, { refreshHome() { } } as any, - tokenController.token as any, + createTokenController().token as any, { dependencies: { storage: { saveWhiteboardInteraction() { - return 'wb_android_ui_2'; + return 'wb_changes'; }, updateWhiteboardInteraction() { }, }, panel: { async showWithOptions(_extensionUri, options) { - panelValidated = true; - const seededState = JSON.parse(options.session.canvases[0]!.fabricState); - assert.equal(seededState.objects.length, 45); return { - submitted: false, - action: 'cancelled', - canvases: [], + submitted: true, + action: 'recreateWithChanges', + canvases: [ + { + id: options.session.canvases[0]!.id, + imageUri: 'file:///tmp/updated.png', + }, + ], }; }, closeIfOpen() { return false; - } - }, - now: () => 1700000000012, - } - } - ); - - assert.equal(panelValidated, true); - assert.equal(result.sceneSummary.totalCanvases, 1); - assert.equal(result.sceneSummary.totalElements, 45); - assert.equal(result.sceneSummary.canvases[0]?.elements[44]?.label?.trim(), '📐 Android UI Mockup - All coordinates & sizes marked for reference'); - }); - - it('rejects JSON-valid but Fabric-invalid raw fabricState before opening the panel', async () => { - const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - let saveCalls = 0; - let panelCalls = 0; - - await assert.rejects( - () => openWhiteboard( - { - title: 'Broken raw fabricState', - initialCanvases: [ - { - name: 'Canvas 1', - fabricState: '{"version":"6.9.1","objects":[{"type":"rectangle","left":40,"top":50,"width":220,"height":120}]}' - } - ] - } as any, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome() { } } as any, - tokenController.token as any, - { - dependencies: { - storage: { - saveWhiteboardInteraction() { - saveCalls += 1; - return 'wb_invalid_raw'; - }, - updateWhiteboardInteraction() { }, - }, - panel: { - async showWithOptions() { - panelCalls += 1; - return { - submitted: false, - action: 'cancelled', - canvases: [], - }; - }, - closeIfOpen() { - return false; - } - }, - now: () => 1700000000011, - } - } - ), - /Canvas fabricState contains unsupported Fabric object type "rectangle"/, - ); - - assert.equal(saveCalls, 0); - assert.equal(panelCalls, 0); - }); - - it('marks the interaction as cancelled once and does not persist submitted data when the agent cancels mid-flight', async () => { - const openWhiteboard = loadOpenWhiteboard(); - const tokenController = createTokenController(); - const refreshLog: string[] = []; - const updateCalls: any[] = []; - let resolvePanel: ((value: { submitted: boolean; action: 'approved' | 'recreateWithChanges' | 'cancelled'; canvases: WhiteboardCanvasSubmission[] }) => void) | undefined; - const closeCalls: string[] = []; - - const resultPromise = openWhiteboard( - { - title: 'Cancelled Whiteboard', - blankCanvas: true, - }, - { extensionUri: { fsPath: '/extension' } } as any, - { refreshHome: () => { refreshLog.push('refresh'); } } as any, - tokenController.token as any, - { - dependencies: { - storage: { - saveWhiteboardInteraction() { - return 'wb_cancel'; - }, - updateWhiteboardInteraction(interactionId, payload) { - updateCalls.push({ interactionId, ...payload }); }, }, - panel: { - showWithOptions: async () => new Promise((resolve) => { - resolvePanel = resolve; - }), - closeIfOpen(interactionId) { - closeCalls.push(interactionId); - resolvePanel?.({ - submitted: true, - action: 'approved', - canvases: [ - { - id: 'canvas_1700000000001_1', - imageUri: 'data:image/png;base64,late-submit' - } - ] - }); - return true; - } - }, now: () => 1700000000001, - } - } + }, + }, ); - tokenController.cancel(); - const result = await resultPromise; - - assert.deepStrictEqual(result, { - submitted: false, - action: 'cancelled', - instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', - canvases: [], - interactionId: 'wb_cancel', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, - }); - assert.deepStrictEqual(closeCalls, ['wb_cancel']); - assert.deepStrictEqual(updateCalls, [ - { - interactionId: 'wb_cancel', - whiteboardSession: { - status: 'cancelled' - } - } - ]); - assert.strictEqual(refreshLog.length, 2); + assert.equal(result.submitted, true); + assert.equal(result.action, 'recreateWithChanges'); + assert.equal(result.images[0]?.imageUri, 'file:///tmp/updated.png'); }); it('logs and persists cancellation when the whiteboard panel throws', async () => { - const loggerCalls: unknown[][] = []; + let loggedError: unknown; const openWhiteboard = loadOpenWhiteboard({ error: (...args) => { - loggerCalls.push(args); + loggedError = args; }, }); - const tokenController = createTokenController(); + let updatedInteractionId = ''; + const result = await openWhiteboard( { - title: 'Broken Whiteboard', - blankCanvas: true, + title: 'Throwing panel', }, { extensionUri: { fsPath: '/extension' } } as any, { refreshHome() { } } as any, - tokenController.token as any, + createTokenController().token as any, { dependencies: { storage: { saveWhiteboardInteraction() { - return 'wb_error'; + return 'wb_throw'; + }, + updateWhiteboardInteraction(interactionId) { + updatedInteractionId = interactionId; }, - updateWhiteboardInteraction() { }, }, panel: { async showWithOptions() { @@ -990,27 +405,21 @@ describe('openWhiteboard', () => { }, closeIfOpen() { return false; - } + }, }, - now: () => 1700000000002, - } - } + now: () => 1700000004000, + }, + }, ); + assert.equal(updatedInteractionId, 'wb_throw'); + assert.ok(loggedError, 'expected panel errors to be logged'); assert.deepStrictEqual(result, { submitted: false, action: 'cancelled', instruction: 'The whiteboard was cancelled. Do not treat this submission as approved user input.', - canvases: [], - interactionId: 'wb_error', - sceneSummary: { - totalCanvases: 0, - totalElements: 0, - canvases: [], - }, + images: [], + interactionId: 'wb_throw', }); - assert.strictEqual(loggerCalls.length, 1); - assert.strictEqual(loggerCalls[0]?.[0], 'Error showing whiteboard panel:'); - assert.match(String(loggerCalls[0]?.[1]), /panel failed/); }); }); diff --git a/src/tools/openWhiteboard.ts b/src/tools/openWhiteboard.ts index 3187680..d081051 100644 --- a/src/tools/openWhiteboard.ts +++ b/src/tools/openWhiteboard.ts @@ -1,17 +1,15 @@ +import { fileURLToPath } from 'node:url'; import type * as vscode from 'vscode'; import { + DEFAULT_WHITEBOARD_CANVAS_HEIGHT, DEFAULT_WHITEBOARD_CANVAS_NAME, + DEFAULT_WHITEBOARD_CANVAS_WIDTH, serializeBlankFabricCanvasState, } from '../whiteboard/canvasState'; -import { - createEmptyWhiteboardSceneSummary, - summarizeWhiteboardScene, -} from '../whiteboard/sceneSummary'; import { normalizeAndValidateLoadableFabricState, serializeSeedElementsAsFabricState, } from '../whiteboard/seededCanvas'; - import { mergeSubmittedWhiteboardCanvases, resolveWhiteboardSubmittedCanvases, @@ -26,11 +24,9 @@ import type { WhiteboardSubmittedCanvas, } from '../webview/types'; import type { AgentInteractionProvider } from '../webview/webviewProvider'; -import { - WHITEBOARD_EXPLICIT_BLANK_MESSAGE, -} from './schemas'; -import type { WhiteboardInput, WhiteboardToolResult } from './schemas'; +import type { WhiteboardExportedImage, WhiteboardInput, WhiteboardToolResult } from './schemas'; import { Logger } from '../logging'; +import { getImageMimeType, readFileAsBuffer } from './utils/fileUtils'; export interface OpenWhiteboardDependencies { storage: { @@ -83,48 +79,152 @@ function toWhiteboardToolAction(result: WhiteboardPanelResult): WhiteboardReview function createWhiteboardInstruction(action: WhiteboardReviewAction): string { switch (action) { case 'approved': - return 'The user approved the submitted whiteboard. Use the sceneSummary and submitted canvases as confirmed input in your next response.'; + return 'The user approved the submitted whiteboard. Use the returned whiteboard images as confirmed visual input in your next response.'; case 'recreateWithChanges': - return 'The user requested changes to the submitted whiteboard. Address the annotated feedback and call open_whiteboard again with an updated sketch before concluding.'; + return 'The user requested changes to the submitted whiteboard. Address the annotated feedback and call open_whiteboard again with updated whiteboard images before concluding.'; case 'cancelled': default: return 'The whiteboard was cancelled. Do not treat this submission as approved user input.'; } } -function createCanvas( - initialCanvas: NonNullable[number], - index: number, - now: number, -): WhiteboardCanvas { - const fabricState = initialCanvas.seedElements - ? serializeSeedElementsAsFabricState(initialCanvas.seedElements) - : normalizeAndValidateLoadableFabricState(initialCanvas.fabricState ?? ''); - +function createCanvasRecord(name: string, fabricState: string, index: number, now: number): WhiteboardCanvas { return { id: `canvas_${now}_${index + 1}`, - name: initialCanvas.name, + name, fabricState, createdAt: now, updatedAt: now, }; } -function createInitialCanvasSeed(params: WhiteboardInput): NonNullable { - if (params.initialCanvases?.length) { - return params.initialCanvases; - } +function createImportedImageObject( + image: NonNullable[number], + mimeType: string, + dataUri: string, + index: number, +): Record { + return { + type: 'image', + src: dataUri, + left: 40 + (index % 3) * 80, + top: 40 + index * 80, + whiteboardId: `import_image_${index + 1}`, + whiteboardObjectType: 'image', + whiteboardSourceUri: image.uri, + whiteboardMimeType: mimeType, + ...(image.label ? { whiteboardLabel: image.label } : {}), + }; +} - if (params.blankCanvas !== true) { - throw new Error(WHITEBOARD_EXPLICIT_BLANK_MESSAGE); +async function createInitialCanvases( + params: WhiteboardInput, + now: number, +): Promise { + const baseState = JSON.parse(serializeBlankFabricCanvasState()) as { + version?: string; + width?: number; + height?: number; + backgroundColor?: string; + objects?: unknown[]; + }; + + const initialCanvases = (params.initialCanvases ?? []).map((canvas, index) => createCanvasRecord( + canvas.name, + typeof canvas.fabricState === 'string' + ? normalizeAndValidateLoadableFabricState(canvas.fabricState) + : serializeSeedElementsAsFabricState(canvas.seedElements ?? []), + index, + now, + )); + + const importedImages = params.importImages ?? []; + if (importedImages.length === 0) { + if (initialCanvases.length > 0) { + return initialCanvases; + } + + return [createCanvasRecord(DEFAULT_WHITEBOARD_CANVAS_NAME, JSON.stringify(baseState), 0, now)]; } - return [ - { - name: DEFAULT_WHITEBOARD_CANVAS_NAME, - fabricState: serializeBlankFabricCanvasState(), + const objects: Record[] = []; + for (const [index, image] of importedImages.entries()) { + let filePath: string; + try { + const parsedUri = new URL(image.uri); + if (parsedUri.protocol !== 'file:') { + throw new Error(`Import image uri must use the file scheme: ${image.uri}`); + } + filePath = fileURLToPath(parsedUri); + } catch (error) { + if (error instanceof Error && error.message.includes('Import image uri must use the file scheme')) { + throw error; + } + throw new Error(`Import image uri must be a valid file URI: ${image.uri}`); + } + + const mimeType = getImageMimeType(filePath); + if (mimeType === 'application/octet-stream') { + throw new Error(`Unsupported import image type: ${image.uri}`); } - ]; + + const fileData = await readFileAsBuffer(filePath); + const dataUri = `data:${mimeType};base64,${Buffer.from(fileData).toString('base64')}`; + objects.push(createImportedImageObject(image, mimeType, dataUri, index)); + } + + const importedImageCanvas = createCanvasRecord( + initialCanvases.length > 0 ? 'Imported Images' : DEFAULT_WHITEBOARD_CANVAS_NAME, + JSON.stringify({ + ...baseState, + objects, + }), + initialCanvases.length, + now, + ); + + return initialCanvases.length > 0 + ? [...initialCanvases, importedImageCanvas] + : [importedImageCanvas]; +} + +function getCanvasDimensions(fabricState?: string): { width: number; height: number } { + if (!fabricState) { + return { + width: DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } + + try { + const parsed = JSON.parse(fabricState) as { width?: unknown; height?: unknown }; + return { + width: typeof parsed.width === 'number' ? parsed.width : DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: typeof parsed.height === 'number' ? parsed.height : DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } catch { + return { + width: DEFAULT_WHITEBOARD_CANVAS_WIDTH, + height: DEFAULT_WHITEBOARD_CANVAS_HEIGHT, + }; + } +} + +function resolveWhiteboardExportedImages( + submittedCanvases: WhiteboardSubmittedCanvas[], + resolvedCanvases: WhiteboardCanvas[], +): WhiteboardExportedImage[] { + return submittedCanvases.map((canvas) => { + const storedCanvas = resolvedCanvases.find((candidate) => candidate.id === canvas.id); + const { width, height } = getCanvasDimensions(storedCanvas?.fabricState); + return { + canvasId: canvas.id, + canvasName: canvas.name, + imageUri: canvas.imageUri, + width, + height, + }; + }); } async function createDefaultDependencies(): Promise { @@ -160,16 +260,15 @@ export async function openWhiteboard( submitted: false, action: 'cancelled', instruction: createWhiteboardInstruction('cancelled'), - canvases: [], + images: [], interactionId: '', - sceneSummary: createEmptyWhiteboardSceneSummary(), }; } const hasAllDependencies = Boolean( options.dependencies?.storage && options.dependencies?.panel - && options.dependencies?.now + && options.dependencies?.now, ); const defaultDependencies = hasAllDependencies ? undefined @@ -190,7 +289,7 @@ export async function openWhiteboard( const now = dependencies.now(); const title = params.title || 'Whiteboard'; - const canvases = createInitialCanvasSeed(params).map((canvas, index) => createCanvas(canvas, index, now)); + const canvases = await createInitialCanvases(params, now); const activeCanvasId = canvases[0]?.id; const interactionId = dependencies.storage.saveWhiteboardInteraction({ @@ -238,9 +337,8 @@ export async function openWhiteboard( submitted: false, action: 'cancelled', instruction: createWhiteboardInstruction('cancelled'), - canvases: [], + images: [], interactionId, - sceneSummary: createEmptyWhiteboardSceneSummary(), }; } @@ -271,19 +369,12 @@ export async function openWhiteboard( }); provider.refreshHome(); - const sceneSummary = summarizeWhiteboardScene(resolvedCanvases.map((canvas) => ({ - id: canvas.id, - name: canvas.name, - fabricState: canvas.fabricState, - }))); - return { submitted: result.submitted, action, instruction: createWhiteboardInstruction(action), - canvases: submittedCanvases, + images: resolveWhiteboardExportedImages(submittedCanvases, resolvedCanvases), interactionId, - sceneSummary, }; } catch (error) { Logger.error('Error showing whiteboard panel:', error); @@ -299,9 +390,8 @@ export async function openWhiteboard( submitted: false, action: 'cancelled', instruction: createWhiteboardInstruction('cancelled'), - canvases: [], + images: [], interactionId, - sceneSummary: createEmptyWhiteboardSceneSummary(), }; } finally { cancellationDisposable.dispose(); diff --git a/src/tools/packageMetadata.test.ts b/src/tools/packageMetadata.test.ts index 8f8a796..dfe7c31 100644 --- a/src/tools/packageMetadata.test.ts +++ b/src/tools/packageMetadata.test.ts @@ -11,15 +11,16 @@ const packageJson = JSON.parse( name: string; tags?: string[]; icon?: string; + modelDescription?: string; inputSchema?: { - properties?: Record; + properties?: Record; }; }>; }; }; describe('package metadata', () => { - it('registers open_whiteboard as a language model tool', () => { + it('registers open_whiteboard as an image-first language model tool with optional starter canvases', () => { const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'open_whiteboard'); assert.ok(tool, 'Expected open_whiteboard to be declared in package.json'); @@ -28,12 +29,76 @@ describe('package metadata', () => { 'diagramming', 'visual-context', 'user-interaction', - 'seamless-agent' + 'seamless-agent', ]); assert.strictEqual(tool.icon, '$(symbol-color)'); assert.ok(tool.inputSchema?.properties?.context, 'Expected context input schema'); assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); assert.ok(tool.inputSchema?.properties?.blankCanvas, 'Expected blankCanvas input schema'); assert.ok(tool.inputSchema?.properties?.initialCanvases, 'Expected initialCanvases input schema'); + assert.ok(tool.inputSchema?.properties?.importImages, 'Expected importImages input schema'); + }); + + it('describes the image-first whiteboard contract in package metadata', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'open_whiteboard'); + + assert.ok(tool, 'Expected open_whiteboard to be declared in package.json'); + assert.match(tool?.modelDescription ?? '', /initialCanvases/); + assert.match(tool?.modelDescription ?? '', /importImages/); + assert.match(tool?.modelDescription ?? '', /image-first|PNG image URIs/i); + assert.match(tool?.modelDescription ?? '', /seedElements/); + assert.doesNotMatch(tool?.modelDescription ?? '', /scene summary|sceneSummary/i); + assert.match(tool?.inputSchema?.properties?.blankCanvas?.description ?? '', /defaults? to true|blank canvas/i); + assert.match(tool?.inputSchema?.properties?.initialCanvases?.description ?? '', /starter canvases|seedElements|fabricState/i); + assert.ok(tool?.inputSchema?.properties?.initialCanvases?.items?.properties?.seedElements?.items, 'Expected seedElements array items schema'); + assert.match(tool?.inputSchema?.properties?.importImages?.description ?? '', /pre-load|annotate/i); + assert.match(tool?.inputSchema?.properties?.importImages?.items?.properties?.uri?.description ?? '', /file uri/i); + }); + + it('registers render_ui as a language model tool', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + assert.ok(tool.tags?.includes('ui'), 'Expected ui tag'); + assert.ok(tool.tags?.includes('seamless-agent'), 'Expected seamless-agent tag'); + assert.ok(tool.inputSchema?.properties?.surfaceId, 'Expected surfaceId input schema'); + assert.ok(tool.inputSchema?.properties?.title, 'Expected title input schema'); + assert.ok(tool.inputSchema?.properties?.components, 'Expected components input schema'); + assert.ok(tool.inputSchema?.properties?.dataModel, 'Expected dataModel input schema'); + assert.ok(tool.inputSchema?.properties?.enableA2UI, 'Expected enableA2UI input schema'); + assert.ok(tool.inputSchema?.properties?.a2uiLevel, 'Expected a2uiLevel input schema'); + assert.ok(tool.inputSchema?.properties?.waitForAction, 'Expected waitForAction input schema'); + }); + + it('declares all catalog component types in render_ui schema', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + + const componentTypeEnum: string[] = + tool.inputSchema?.properties?.components?.items?.properties?.component?.properties?.type?.enum ?? []; + + const expectedTypes = [ + 'Row', 'Column', 'Card', 'Divider', + 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', + 'Button', 'TextField', 'Checkbox', 'Select', + 'MermaidDiagram', 'ProgressBar', 'Badge', + ]; + + for (const t of expectedTypes) { + assert.ok(componentTypeEnum.includes(t), `Expected ${t} in component type enum`); + } + }); + + it('render_ui modelDescription mentions waitForAction and component types', () => { + const tool = packageJson.contributes?.languageModelTools?.find((entry) => entry.name === 'render_ui'); + assert.ok(tool, 'Expected render_ui to be declared in package.json'); + assert.match(tool?.modelDescription ?? '', /waitForAction/); + assert.match(tool?.modelDescription ?? '', /Button/); + assert.match(tool?.modelDescription ?? '', /userAction/); + assert.match(tool?.modelDescription ?? '', /surfaceId/); + assert.match(tool?.modelDescription ?? '', /component\.props/); + assert.match(tool?.modelDescription ?? '', /Markdown content is rendered/i); + assert.match(tool?.modelDescription ?? '', /enableA2UI/); + assert.match(tool?.modelDescription ?? '', /diagnostics and applied enhancements/i); }); }); diff --git a/src/tools/renderUI.test.ts b/src/tools/renderUI.test.ts new file mode 100644 index 0000000..c4ff9ee --- /dev/null +++ b/src/tools/renderUI.test.ts @@ -0,0 +1,672 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(__filename); + +// ===================================================== +// Schema validation tests +// ===================================================== + +describe('render_ui schema', () => { + it('defaults waitForAction to false', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + surfaceId: 'surf1', + title: 'Sample surface', + components: [ + { + id: 'c1', + component: { type: 'Text', props: { content: 'Hello' } }, + }, + ], + }); + assert.strictEqual(result.waitForAction, false); + assert.strictEqual(result.enableA2UI, false); + assert.strictEqual(result.a2uiLevel, 'basic'); + assert.strictEqual(result.surfaceId, 'surf1'); + }); + + it('accepts waitForAction: true', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { type: 'Button', props: { label: 'OK', action: 'ok' } }, + }, + ], + waitForAction: true, + }); + assert.strictEqual(result.waitForAction, true); + }); + + it('rejects missing components', async () => { + const { RenderUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(RenderUIInputSchema, {}); + assert.strictEqual(result.success, false); + }); + + it('accepts arbitrary component records in schema and leaves catalog validation to runtime', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { + type: 'Table', + columns: ['$data.columns'], + }, + }, + ], + }); + assert.equal(result.components[0]?.component.type, 'Table'); + }); + + it('preserves parentId adjacency entries', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'row1', + component: { type: 'Row' }, + }, + { + id: 'text1', + parentId: 'row1', + component: { type: 'Text', props: { content: 'Child' } }, + }, + ], + }); + assert.equal(result.components[1]?.parentId, 'row1'); + }); + + it('rejects missing component payloads', async () => { + const { RenderUIInputSchema, safeParseInput } = await import('./schemas'); + const result = safeParseInput(RenderUIInputSchema, { + components: [ + { + id: 'c1', + }, + ], + }); + assert.strictEqual(result.success, false); + }); + + it('accepts a top-level dataModel', async () => { + const { parseRenderUIInput } = await import('./schemas'); + const result = parseRenderUIInput({ + components: [ + { + id: 'c1', + component: { type: 'Text', props: { content: '$data.greeting' } }, + }, + ], + dataModel: { + greeting: 'Hello from data', + }, + }); + assert.equal(result.dataModel?.greeting, 'Hello from data'); + }); +}); + +// ===================================================== +// Catalog tests +// ===================================================== + +describe('a2ui catalog', () => { + it('allows all catalog types', async () => { + const { isAllowedComponentType } = await import('../a2ui/catalog'); + const allowed = [ + 'Row', 'Column', 'Card', 'Divider', + 'Text', 'Heading', 'Image', 'Markdown', 'CodeBlock', + 'Button', 'TextField', 'Checkbox', 'Select', + 'MermaidDiagram', 'ProgressBar', 'Badge', + ]; + for (const type of allowed) { + assert.ok(isAllowedComponentType(type), `Expected ${type} to be allowed`); + } + }); + + it('rejects unsupported types', async () => { + const { isAllowedComponentType } = await import('../a2ui/catalog'); + assert.strictEqual(isAllowedComponentType('Unknown'), false); + assert.strictEqual(isAllowedComponentType('Table'), false); + assert.strictEqual(isAllowedComponentType(''), false); + assert.strictEqual(isAllowedComponentType('Grid'), false); + }); +}); + +// ===================================================== +// Renderer tests +// ===================================================== + +describe('a2ui renderer', () => { + it('renders a Text component', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: 'Hello World' } } }, + ], + }); + assert.ok(html.includes('Hello World'), 'Expected content to appear'); + assert.ok(html.includes('a2ui-text'), 'Expected class name'); + }); + + it('renders component fields when props wrapper is omitted', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', content: 'Direct content' } }, + ], + }); + assert.ok(html.includes('Direct content'), 'Expected direct component fields to be rendered'); + }); + + it('renders Markdown as HTML instead of escaped source text', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Markdown', props: { content: '## Title\n\n**Bold** text' } } }, + ], + }); + assert.ok(html.includes('

Title

'), 'Expected markdown heading output'); + assert.ok(html.includes('Bold'), 'Expected markdown emphasis output'); + }); + + it('interpolates bindings embedded inside literal text', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Badge', props: { label: 'Owner: $data.owner' } } }, + ], + dataModel: { owner: 'Platform Team' }, + }); + assert.ok(html.includes('Owner: Platform Team'), 'Expected embedded binding interpolation'); + }); + + it('renders Mermaid components with a target container and collapsible source', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'diagram', component: { type: 'MermaidDiagram', props: { content: 'graph LR\nA-->B' } } }, + ], + }); + assert.ok(html.includes('a2ui-mermaid-target'), 'Expected Mermaid render target'); + assert.ok(html.includes('a2ui-mermaid-details'), 'Expected Mermaid source details'); + }); + + it('throws RendererError for unsupported component type', async () => { + const { renderSurface, RendererError } = await import('../a2ui/renderer'); + assert.throws( + () => + renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Table', props: {} } }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof RendererError, 'Expected RendererError'); + assert.match(err.message, /Unsupported component type/); + return true; + }, + ); + }); + + it('resolves $data.path bindings', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: '$data.greeting' } } }, + ], + dataModel: { greeting: 'Hello from data' }, + }); + assert.ok(html.includes('Hello from data'), 'Expected resolved value'); + assert.ok(!html.includes('$data.greeting'), 'Should not contain unresolved binding'); + }); + + it('resolves nested $data.path bindings', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Heading', props: { content: '$data.user.name', level: 2 } } }, + ], + dataModel: { user: { name: 'Alice' } }, + }); + assert.ok(html.includes('Alice'), 'Expected nested resolved value'); + }); + + it('renders nested layout components', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'row1', component: { type: 'Row' } }, + { id: 'text1', parentId: 'row1', component: { type: 'Text', props: { content: 'First' } } }, + { id: 'text2', parentId: 'row1', component: { type: 'Text', props: { content: 'Second' } } }, + ], + }); + assert.ok(html.includes('a2ui-row'), 'Expected row class'); + assert.ok(html.includes('First'), 'Expected first child'); + assert.ok(html.includes('Second'), 'Expected second child'); + }); + + it('renders a Button component with action', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'btn1', component: { type: 'Button', props: { label: 'Submit', action: 'submit' } } }, + ], + }); + assert.ok(html.includes('a2ui-button'), 'Expected button class'); + assert.ok(html.includes('Submit'), 'Expected label'); + assert.ok(html.includes('data-action="submit"'), 'Expected action attribute'); + }); + + it('renders visible labels for text fields and selects', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'name', component: { type: 'TextField', props: { label: 'Name', placeholder: 'Enter your name' } } }, + { + id: 'color', + component: { + type: 'Select', + props: { + label: 'Favorite Color', + value: 'blue', + options: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], + }, + }, + }, + ], + }); + assert.ok(html.includes('a2ui-field-label'), 'Expected visible field labels'); + assert.ok(html.includes('Name'), 'Expected text field label'); + assert.ok(html.includes('Favorite Color'), 'Expected select label'); + assert.ok(html.includes('option value="blue" selected'), 'Expected selected object option value'); + }); + + it('renders helper text and required state for form controls', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { + id: 'name', + component: { + type: 'TextField', + props: { + label: 'Approver', + required: true, + helperText: 'Required field', + ariaLabel: 'Approver name', + }, + }, + }, + ], + }); + assert.ok(html.includes('a2ui-required'), 'Expected required marker'); + assert.ok(html.includes('Required field'), 'Expected helper text'); + assert.ok(html.includes('aria-label="Approver name"'), 'Expected aria-label'); + }); + + it('renders progress labels and values', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'progress', component: { type: 'ProgressBar', props: { label: 'Completion', value: 72, max: 100 } } }, + ], + }); + assert.ok(html.includes('Completion'), 'Expected progress label'); + assert.ok(html.includes('72%'), 'Expected progress percentage'); + }); + + it('renders a Card with nested children', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'card1', component: { type: 'Card' } }, + { id: 'txt1', parentId: 'card1', component: { type: 'Text', props: { content: 'Card content' } } }, + ], + }); + assert.ok(html.includes('a2ui-card'), 'Expected card class'); + assert.ok(html.includes('Card content'), 'Expected nested content'); + }); + + it('escapes HTML in text content', async () => { + const { renderSurface } = await import('../a2ui/renderer'); + const html = renderSurface({ + surfaceId: 'surf1', + components: [ + { id: 'c1', component: { type: 'Text', props: { content: '' } } }, + ], + }); + assert.ok(!html.includes(' diff --git a/media/main.css b/media/main.css index 8885399..a451a5e 100644 --- a/media/main.css +++ b/media/main.css @@ -1080,12 +1080,12 @@ button:disabled { flex-direction: column; justify-content: flex-start; gap: 2px; - padding: 4px 0; + padding: 2px 0; } .attach-btn { - width: 32px; - min-height: 32px; + width: 24px; + min-height: 24px; display: flex; align-items: center; justify-content: center; @@ -1093,8 +1093,9 @@ button:disabled { border: none; cursor: pointer; color: var(--vscode-descriptionForeground); - padding: 6px 4px; - border-radius: 4px; + padding: 2px; + border-radius: 3px; + transition: background-color 0.15s ease, color 0.15s ease; } .attach-btn:hover { @@ -1110,15 +1111,18 @@ button:disabled { .input-area .textarea-wrapper { flex: 1; position: relative; + display: flex; + flex-direction: column; + min-height: 52px; } .input-area .textarea-wrapper textarea { width: 100%; - min-height: auto !important; - max-height: none !important; + min-height: 52px; + max-height: none; /* Height controlled dynamically by JS + resize handle */ height: auto; - padding: 8px 12px 8px 0; + padding: 4px 8px 6px 6px; border: none !important; background: transparent !important; color: var(--vscode-input-foreground); @@ -1128,10 +1132,16 @@ button:disabled { resize: none; /* Focus is handled by parent .input-container:focus-within */ outline: 2px solid transparent; - /* Keeps a11y compliance while visually hidden */ + /* Keeps a11y compliance while vertically hidden */ box-sizing: border-box; + /* Ensure cursor is always text */ + cursor: text; + /* Fix clicking issue - ensure element receives pointer events */ + pointer-events: auto; + /* Let JS control overflow for auto-resize - hidden allows scrollHeight to work correctly */ overflow: hidden; - /* Height controlled dynamically by JS */ + /* Align text to top */ + vertical-align: top; } .input-area .textarea-wrapper textarea:focus { @@ -2269,3 +2279,57 @@ button:disabled { color: var(--vscode-testing-iconUnset); font-size: 12px; } + + +/* Settings Tab Styles */ +.settings-container { + padding: 16px; + max-width: 600px; + margin: 0 auto; +} + +.settings-section-title { + font-size: 1.1em; + font-weight: 600; + margin: 0 0 16px 0; + color: var(--vscode-foreground); + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 8px; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.settings-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border); + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: background 0.2s ease; +} + +.settings-btn:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.settings-btn .codicon { + font-size: 1.2em; +} + +.settings-description { + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + margin: 0; + padding: 0 4px; +} diff --git a/media/webview.html b/media/webview.html index 22f5484..19d1719 100644 --- a/media/webview.html +++ b/media/webview.html @@ -82,6 +82,10 @@

aria-label="{{historyFilterWhiteboard}}"> +
diff --git a/media/whiteboard.css b/media/whiteboard.css index fd39c6c..e68b930 100644 --- a/media/whiteboard.css +++ b/media/whiteboard.css @@ -146,22 +146,61 @@ input[type='range'] { .canvas-surface { display: grid; - grid-template-columns: minmax(0, 1fr) 240px; + grid-template-columns: minmax(0, 1fr) 300px; gap: 12px; flex: 1; min-height: 0; } -.canvas-stage, -.canvas-help { +.right-sidebar { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sidebar-panel { border: 1px solid var(--vscode-panel-border); border-radius: 10px; - background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + background: var(--vscode-sideBar-background); + padding: 12px; +} + +.sidebar-panel h3 { + margin: 0 0 8px 0; + font-size: 0.9rem; + color: var(--vscode-descriptionForeground); +} + +.style-controls { + display: flex; + flex-direction: column; + gap: 8px; +} + +.style-controls label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-actions .submit-btn { + width: 100%; + justify-content: center; } .canvas-stage { overflow: auto; padding: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); } .whiteboard-hydration-error { @@ -211,15 +250,6 @@ input[type='range'] { height: 100% !important; } -.canvas-help { - padding: 12px 16px; -} - -.canvas-help h2 { - margin-top: 0; - font-size: 1rem; -} - .canvas-help ul { margin: 0; padding-left: 18px; @@ -228,10 +258,67 @@ input[type='range'] { .whiteboard-footer { justify-content: flex-end; + flex-direction: column; + align-items: stretch; +} + +.comment-section { + width: 100%; + display: none; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.comment-section.visible { + display: flex; +} + +.comment-label { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.comment-textarea { + width: 100%; + min-height: 80px; + padding: 8px 12px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 6px; + background: var(--vscode-input-background, var(--vscode-editor-background)); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font-family: var(--vscode-font-family); + font-size: 13px; + line-height: 1.4; + resize: vertical; + box-sizing: border-box; +} + +.comment-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.comment-textarea::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); +} + +.comment-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; } @media (max-width: 1100px) { .canvas-surface { grid-template-columns: 1fr; } + + .right-sidebar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + } } diff --git a/media/whiteboard.html b/media/whiteboard.html index 3e66cbf..8101d06 100644 --- a/media/whiteboard.html +++ b/media/whiteboard.html @@ -36,9 +36,6 @@

{{title}}

- - - @@ -60,27 +57,54 @@

{{title}}

-