diff --git a/eslint.config.js b/eslint.config.js index 595ecea0..6a887df0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ export default [ ignores: ["**/dist/**", "**/docs/**"], }, { - files: ["packages/*/src/**/*.ts"], + files: ["packages/*/src/**/*.{ts,tsx}"], languageOptions: { parser: tsParser, }, diff --git a/package-lock.json b/package-lock.json index b8a04189..23641762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,27 @@ "vitest": "^4.1.9" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -306,6 +327,121 @@ "node": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1036,6 +1172,26 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2006,6 +2162,41 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2077,6 +2268,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3119,6 +3317,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3126,6 +3337,30 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3139,6 +3374,19 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3428,6 +3676,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3656,6 +3911,70 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4248,6 +4567,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4440,6 +4766,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/partysocket": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.23.tgz", @@ -4591,6 +4943,29 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4700,6 +5075,13 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -4755,6 +5137,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5112,6 +5521,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5174,6 +5590,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -5184,6 +5620,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5678,6 +6140,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5831,6 +6354,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -5881,6 +6421,21 @@ "license": "MIT", "dependencies": { "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "packages/sdk": { diff --git a/packages/platform/README.md b/packages/platform/README.md index 4450e697..b12fb65c 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -169,3 +169,60 @@ exceptions, payloads and credentials are never attached. No SDK dependency, package version or lockfile changes are required. Socket.IO remains the existing SDK dependency. Chat commands and HTTP APIs are outside this entry point. + +## React + +`@base44/platform/react` adds one hook, `useBase44Chat`, that runs a builder chat for one +app on top of this client: it opens the live session, turns messages into items made for +rendering, and binds every question the builder asks to the actions that answer it. It +renders nothing. React is an optional peer dependency, loaded only by this entry; the root +entry stays framework-free. + +```tsx +import { useBase44Chat } from "@base44/platform/react"; + +const chat = useBase44Chat({ appId, server, onAppCreated }); +chat.items.map((item) => item.question?.kind === "approval" && ); +``` + +`server` is four functions your backend provides, in any language behind them: `createApp`, +`openLiveSession`, `sendMessage` and `submitToolCallInput`, each wrapping one Base44 REST +call with your credentials, so the browser never holds a key. The hook returns `items`, +a `phase` (`idle`, `creating`, `loading`, `waiting`, `building`), an `error`, `canSend` +(false while a question is open, because Base44 drops a message sent into a stopped turn), +and the actions `send`, `create` and `clearError`. A question is a union on `kind` +(`choice`, `input`, `approval`, `unknown`) and carries only the actions its kind allows; +declining is always one of them. Every shape has JSDoc in `src/react/chat.types.ts`, and +[`examples/react-chat/`](examples/react-chat) shows Tiny Sunny's chat built on it, with its `server`. + +### Provider and parts + +`` calls the hook once and shares the chat with the tree below it, so +components anywhere under it read the chat without props, and `` draws an item with +parts you replace: + +```tsx +import { Base44ChatProvider, Message, useChatActions, useChatState } from "@base44/platform/react"; + + + + +; + +function Messages() { + const { items } = useChatState(); + return items.map((item) =>
); +} +``` + +- `useChatState()` returns `items`, `phase`, `error`, `canSend` and `messages`, and re-renders + on every live event. `useChatActions()` returns `send`, `create` and `clearError`, which keep + their identity, so a component that reads only actions never re-renders for a message. +- `components` is `{ message: { Text, Step }, question: { Choice, Input, Approval, Unknown } }`. + A question part receives the question itself, with its bound actions. A part you leave out + is drawn as plain HTML with no classes and a `data-base44` attribute, so the package has no + look of its own and a question is never missing. +- `` adds no element of its own; wrap it to lay messages out. `` draws a + question elsewhere, such as above the composer. Both also work over the bare hook. +- Put the provider as high as the components that need the chat: a header that shows the + phase and a preview that waits for the build can read the same chat as the message list. diff --git a/packages/platform/examples/react-chat/Chat.tsx b/packages/platform/examples/react-chat/Chat.tsx new file mode 100644 index 00000000..1b7d597c --- /dev/null +++ b/packages/platform/examples/react-chat/Chat.tsx @@ -0,0 +1,67 @@ +"use client"; +import { useState } from "react"; +import { Base44ChatProvider, Message, useChatActions, useChatState, type Base44App, type Base44ChatServer } from "@base44/platform/react"; +import { Button, StatusLine, chatParts } from "./chatParts"; +import { createApp, openLiveSession, sendMessage, submitToolCallInput } from "./server"; + +// The four server functions, handed to the library. Next.js calls them on the server. +const server: Base44ChatServer = { createApp, openLiveSession, sendMessage, submitToolCallInput }; + +export type ChatProps = { + app: Base44App | null; + onAppCreated: (app: Base44App) => void; +}; + +// Option B, the provider: it runs the chat for the tree below it, and draws each item +// with the parts. The list and the composer read the chat from context. +export default function Chat({ app, onAppCreated }: ChatProps) { + return ( + +
+ + +
+
+ ); +} + +function Messages() { + const { items, phase, error } = useChatState(); + const { clearError } = useChatActions(); + return ( +
+ {items.map((item) => ( +
+ +
+ ))} + +
+ ); +} + +function Composer({ hasApp }: { hasApp: boolean }) { + const { canSend } = useChatState(); + const { send, create } = useChatActions(); + const [prompt, setPrompt] = useState(""); + + function submit() { + // No app yet: the first prompt creates one. Otherwise it goes to the selected app. + if (hasApp) send(prompt); + else create(prompt); + setPrompt(""); + } + + return ( +
{ e.preventDefault(); submit(); }} className="m-3 flex gap-2 rounded-2xl border p-2"> + setPrompt(e.target.value)} + placeholder={hasApp ? "Ask for a change…" : "Describe your app…"} + className="flex-1 bg-transparent px-2 outline-none" + /> + {/* canSend is false while a question is open: Base44 drops a message sent into a stopped turn. */} + +
+ ); +} diff --git a/packages/platform/examples/react-chat/README.md b/packages/platform/examples/react-chat/README.md new file mode 100644 index 00000000..cc89270d --- /dev/null +++ b/packages/platform/examples/react-chat/README.md @@ -0,0 +1,15 @@ +# Tiny Sunny's chat on `@base44/platform/react`, option B + +The headless chat from [Tiny Sunny](https://github.com/base44/base44-platform-starter/tree/amitb/tiny-sunny-lean/examples/white-label-minimal/lean), +rebuilt on the hook, the provider and replaceable parts. It is type-checked with the package's tests, +not run. Read the files in this order: + +1. [`Chat.tsx`](Chat.tsx): the chat itself. It wraps the chat in ``, and `` draws each item with Tiny's parts. +2. [`chatParts.tsx`](chatParts.tsx): Tiny's look. Each part takes exactly the props the library hands it; `chatParts` groups them for the provider. Tiny's shadcn + `Button` and lucide icons are replaced by a small local button and text glyphs, so the example has + no UI dependency. +3. [`server.ts`](server.ts): the four calls the partner's backend provides, as Next.js server + functions. Each wraps one Base44 REST call with the partner's credentials. + +No Base44 detail appears in `Chat.tsx` or `chatParts.tsx`: no socket, no message ordering, no tool +call ids, no answer formats. That all stays in the package. diff --git a/packages/platform/examples/react-chat/chatParts.tsx b/packages/platform/examples/react-chat/chatParts.tsx new file mode 100644 index 00000000..5b302b60 --- /dev/null +++ b/packages/platform/examples/react-chat/chatParts.tsx @@ -0,0 +1,116 @@ +"use client"; +import { useState, type ButtonHTMLAttributes } from "react"; +import type { ApprovalQuestion, ChatComponentOverrides, ChatError, ChatPhase, ChoiceQuestion, InputQuestion, StepProps, TextProps } from "@base44/platform/react"; + +// Tiny's look. Each part takes exactly the props the library hands it, and a question part gets +// the question with its bound actions. `chatParts` at the end groups them for the provider. + +// Stands in for Tiny's shadcn Button, so the example has no UI dependency. +export function Button({ variant = "default", className = "", ...props }: ButtonHTMLAttributes & { variant?: "default" | "outline" | "ghost" }) { + const look = { default: "bg-primary text-primary-foreground", outline: "border", ghost: "" }[variant]; + return + ))} + + + ))} +
+ + +
+ + ); +} + +export function Secrets({ fields, submit, decline }: InputQuestion) { + const [values, setValues] = useState>({}); + return ( +
+ {fields.map((field) => ( + setValues({ ...values, [field.name]: e.target.value })} + className="rounded-md border px-2 py-1" + /> + ))} +
+ + +
+
+ ); +} + +export function Approval({ action, reason, approve, decline }: ApprovalQuestion) { + return ( +
+

Allow {action}?

+ {reason &&

{reason}

} +
+ + +
+
+ ); +} + +// The wording for each phase is the partner's, not the library's. +const phaseText: Record = { + idle: "", + creating: "Creating app…", + loading: "Loading the conversation…", + waiting: "Waiting for your answer", + building: "Building…", +}; + +export function StatusLine({ phase, error, onDismiss }: { phase: ChatPhase; error: ChatError | null; onDismiss: () => void }) { + if (error) { + return ( +

+ {error.message} +

+ ); + } + return

{phaseText[phase]}

; +} + +// The same parts, grouped the way takes them. Unknown is left out, so the +// library's unstyled fallback draws it. +export const chatParts: ChatComponentOverrides = { message: { Text, Step }, question: { Choice, Input: Secrets, Approval } }; diff --git a/packages/platform/examples/react-chat/server.ts b/packages/platform/examples/react-chat/server.ts new file mode 100644 index 00000000..9a627f8f --- /dev/null +++ b/packages/platform/examples/react-chat/server.ts @@ -0,0 +1,71 @@ +"use server"; +// The four calls the hook needs, as Next.js server functions. They run only on the server, with +// the partner's credentials, so the browser never sees them. Any backend language works: the hook +// only needs functions with these signatures. +import type { Base44App, LiveSession, ToolCallAnswer } from "@base44/platform/react"; + +const host = process.env.BASE44_PLATFORM_HOST!; +const workspaceId = process.env.BASE44_WORKSPACE_ID!; + +// The Apps API takes the integration account's personal access token. +function headers() { + return { + Authorization: `Bearer ${process.env.BASE44_ACCESS_TOKEN}`, + "X-Active-Workspace-Id": workspaceId, + "Content-Type": "application/json", + }; +} + +// POST /api/apps: create an app from its first prompt. The build starts inside this call. +export async function createApp(prompt: string): Promise { + const response = await fetch(`${host}/api/apps`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ organization_id: workspaceId, name: prompt.slice(0, 80), initial_message: { content: prompt } }), + }); + if (!response.ok) throw new Error(`Create app failed: ${response.status}`); + const app = await response.json(); + return { id: app.id, name: app.name }; +} + +// POST /api/service/socket-sessions: a read-only live-updates session for one app. It takes the +// workspace key; the browser gets only the session token. +export async function openLiveSession(appId: string): Promise { + const response = await fetch(`${host}/api/service/socket-sessions`, { + method: "POST", + headers: { Authorization: `Bearer ${process.env.BASE44_SVC_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ app_ids: [appId] }), + }); + if (!response.ok) throw new Error(`Socket session failed: ${response.status}`); + const session = await response.json(); + return { serverUrl: new URL(session.socket_url).origin, sessionToken: session.session_token }; +} + +// POST /api/apps/{id}/chat/message: every prompt after the first. +export async function sendMessage(appId: string, content: string): Promise { + const request = fetch(`${host}/api/apps/${appId}/chat/message`, { method: "POST", headers: headers(), body: JSON.stringify({ content }) }); + await acceptedOrRunning(request, "Send message"); +} + +// POST /api/apps/{id}/chat/submit-tool-call-input: answer a question the builder waits on, or +// decline it. The request id stays the same on a retry, so Base44 resumes the turn only once. +export async function submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise { + const request = fetch(`${host}/api/apps/${appId}/chat/submit-tool-call-input`, { + method: "POST", + headers: { ...headers(), "X-Request-ID": `submit-${answer.toolCallId}` }, + body: JSON.stringify({ + tool_call_id: answer.toolCallId, + message_id: answer.messageId, + action: answer.approve ? "approved" : "rejected", + extra_user_input: answer.extraUserInput ?? {}, + }), + }); + await acceptedOrRunning(request, "Submit answer"); +} + +// Send and submit stay open for the whole turn. Wait only long enough to catch a refusal; the live +// session shows the rest. +async function acceptedOrRunning(request: Promise, call: string) { + const response = await Promise.race([request, new Promise((resolve) => setTimeout(resolve, 3000, null))]); + if (response && !response.ok) throw new Error(`${call} failed: ${response.status}`); +} diff --git a/packages/platform/package.json b/packages/platform/package.json index 09fd8654..8790cb8c 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -1,7 +1,7 @@ { "name": "@base44/platform", "version": "0.1.0", - "description": "Browser client for building on the Base44 platform", + "description": "Browser client for building on the Base44 platform, with a React hook for the builder chat", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", @@ -10,8 +10,19 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, "./package.json": "./package.json" }, + "typesVersions": { + "*": { + "react": [ + "dist/react/index.d.ts" + ] + } + }, "files": [ "dist" ], @@ -30,10 +41,26 @@ "dependencies": { "socket.io-client": "^4.8.3" }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, "keywords": [ "base44", "platform", - "white-label" + "white-label", + "react" ], "author": "Base44", "license": "MIT", diff --git a/packages/platform/src/react/Base44ChatProvider.tsx b/packages/platform/src/react/Base44ChatProvider.tsx new file mode 100644 index 00000000..9908106f --- /dev/null +++ b/packages/platform/src/react/Base44ChatProvider.tsx @@ -0,0 +1,74 @@ +"use client"; +import { useMemo } from "react"; +import type { Base44ChatProviderProps, ChatComponents } from "./components.types.js"; +import { ActionsContext, ComponentsContext, StateContext } from "./context.js"; +import { fallbackComponents } from "./fallbacks.js"; +import { useBase44Chat } from "./useBase44Chat.js"; + +/** + * Runs one app's builder chat for the tree below it. It calls {@link useBase44Chat} once and + * shares the result through context, so any component below reads it with + * {@link useChatState} and {@link useChatActions}, and {@link Message} finds its parts. + * + * Put it as high as the components that need the chat: around a header that shows the phase, + * a preview that waits for the build, and the message list, they all read the same chat. + * Renders nothing of its own. + * + * @param props - The {@link useBase44Chat} options, the parts to replace, and the children. + * @returns The children, inside the chat's contexts. + * + * @example + * ```tsx + * // The provider, a message list, and a composer of your own + * + * + * + * + * + * function Messages() { + * const { items } = useChatState(); + * return items.map((item) =>
); + * } + * + * function Composer() { + * const { canSend } = useChatState(); + * const { send } = useChatActions(); + * const [prompt, setPrompt] = useState(""); + * return ( + *
{ e.preventDefault(); send(prompt); setPrompt(""); }}> + * setPrompt(e.target.value)} /> + * + *
+ * ); + * } + * ``` + */ +export function Base44ChatProvider({ components, children, ...options }: Base44ChatProviderProps) { + const { items, phase, error, canSend, messages, send, create, clearError } = useBase44Chat(options); + const state = useMemo(() => ({ items, phase, error, canSend, messages }), [items, phase, error, canSend, messages]); + const actions = useMemo(() => ({ send, create, clearError }), [send, create, clearError]); + + // Merged part by part, so an inline `components={{…}}` with the same parts keeps the same identity. + const { Text, Step } = components?.message ?? {}; + const { Choice, Input, Approval, Unknown } = components?.question ?? {}; + const parts = useMemo( + () => ({ + message: { Text: Text ?? fallbackComponents.message.Text, Step: Step ?? fallbackComponents.message.Step }, + question: { + Choice: Choice ?? fallbackComponents.question.Choice, + Input: Input ?? fallbackComponents.question.Input, + Approval: Approval ?? fallbackComponents.question.Approval, + Unknown: Unknown ?? fallbackComponents.question.Unknown, + }, + }), + [Text, Step, Choice, Input, Approval, Unknown], + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/platform/src/react/Message.tsx b/packages/platform/src/react/Message.tsx new file mode 100644 index 00000000..09a56a85 --- /dev/null +++ b/packages/platform/src/react/Message.tsx @@ -0,0 +1,61 @@ +"use client"; +import type { MessageProps, QuestionProps } from "./components.types.js"; +import { useChatComponents } from "./context.js"; + +/** + * Draws one chat item with the parts from the nearest {@link Base44ChatProvider}: its text, + * one `Step` per tool call, and its open question. It adds no element of its own, so wrap it + * to lay messages out. Outside a provider it uses the unstyled fallbacks. + * + * @param props - The item to draw. + * @returns The item's parts. + * + * @example + * ```tsx + * // Align by role in your own wrapper + *
+ * + *
+ * ``` + */ +export function Message({ item }: MessageProps) { + const { Text, Step } = useChatComponents().message; + return ( + <> + {item.text && } + {item.steps.map(step => ( + + ))} + {item.question && } + + ); +} + +/** + * Draws an open question with the part for its kind, from the nearest + * {@link Base44ChatProvider}. The part receives the question with its bound actions. + * {@link Message} already draws an item's question; use this to show it somewhere else. + * + * @param props - The question to draw. + * @returns The part for the question's kind. + * + * @example + * ```tsx + * // The open question pinned above the composer + * const open = items.find((item) => item.question)?.question; + * return open ? : null; + * ``` + */ +export function Question({ question }: QuestionProps) { + const { Choice, Input, Approval, Unknown } = useChatComponents().question; + switch (question.kind) { + case "choice": + return ; + case "input": + return ; + case "approval": + return ; + default: + return ; + } +} diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts new file mode 100644 index 00000000..b93010cf --- /dev/null +++ b/packages/platform/src/react/chat-items.ts @@ -0,0 +1,80 @@ +import type { ChatMessage, ToolCall, ToolQuestionArguments, ToolSecretArguments } from "../modules/builder.events.types.js"; +import type { ChatItem, ChatStep, Question } from "./chat.types.js"; + +/** @internal Submits one reply for the tool call the question belongs to. */ +export type Reply = (approve: boolean, extraUserInput?: Record) => Promise; + +/** @internal Submits a reply for a given tool call in a given message. */ +export type Respond = (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => Promise; + +/** @internal Events can arrive out of order, so messages are ordered by when they were created. */ +export function sortMessages(messages: ChatMessage[]): ChatMessage[] { + return [...messages].sort((a, b) => (a.metadata?.created_date ?? "").localeCompare(b.metadata?.created_date ?? "")); +} + +/** @internal Replaces the message with the same id in place, or adds it at the end. */ +export function mergeMessage(all: ChatMessage[], message: ChatMessage): ChatMessage[] { + return all.some(m => m.id === message.id) ? all.map(m => (m.id === message.id ? message : m)) : [...all, message]; +} + +/** @internal Turns sorted messages into items. A tool call that waits and is not in `answered` becomes the item's question. */ +export function toItems(sorted: ChatMessage[], answered: string[], respond: Respond): ChatItem[] { + const isOpen = (tool: ToolCall) => tool.status === "waiting_for_user_input" && !answered.includes(tool.id!); + return sorted.map(m => { + const waiting = m.tool_calls?.find(isOpen); + return { + id: m.id!, + role: m.role === "user" ? "user" : "assistant", + text: m.content ?? "", + steps: (m.tool_calls ?? []).map(tool => ({ + id: tool.id!, + label: [tool.name, ...(tool.display?.file_paths ?? [])].join(" "), + status: stepStatus(tool), + })), + question: waiting && bindQuestion(waiting, (approve, extra) => respond(waiting.id!, m.id!, approve, extra)), + }; + }); +} + +/** @internal */ +export function stepStatus(tool: ToolCall): ChatStep["status"] { + if (tool.status === "running") return "running"; + if (tool.status === "waiting_for_user_input") return "waiting"; + if (tool.status === "error") return "error"; + return "done"; +} + +/** @internal Base44's question formats, as one simple shape per kind, each bound to the reply that + * answers it. Base44 keys choice answers by position, so `picked` follows `questions`. */ +export function bindQuestion(tool: ToolCall, reply: Reply): Question { + const kind = tool.waiting_on?.kind; + const decline = () => reply(false); + if (kind === "choice") { + const questions = (tool.arguments as ToolQuestionArguments | undefined)?.questions ?? []; + return { + kind, + questions: questions.map(q => ({ + text: q.question ?? "", + description: q.description, + options: (q.options ?? []).map(o => (typeof o === "string" ? o : o.label ?? "")), + multi: !!q.multi_select, + })), + answer: picked => + reply(true, { answers: picked.map((labels, i) => ({ question_index: i, selected_labels: labels })).filter(a => a.selected_labels.length) }), + decline, + }; + } + if (kind === "input") { + const fields = (tool.arguments as ToolSecretArguments | undefined)?.secrets_schema ?? []; + return { + kind, + fields: fields.map(f => ({ name: f.secretName ?? "", description: f.description })), + submit: values => reply(true, { secrets: values }), + decline, + }; + } + if (kind === "approval") { + return { kind, action: tool.name ?? "", reason: tool.approval?.reason ?? tool.approval?.details?.summary ?? "", approve: () => reply(true), decline }; + } + return { kind: "unknown", action: tool.name ?? "", decline }; +} diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts new file mode 100644 index 00000000..7fb1bd83 --- /dev/null +++ b/packages/platform/src/react/chat.types.ts @@ -0,0 +1,194 @@ +import type { ChatMessage } from "../modules/builder.events.types.js"; + +/** An app the builder works on, as the chat needs it. */ +export interface Base44App { + /** App identifier: 24 lowercase hexadecimal characters. */ + id: string; + /** Display name. */ + name: string; +} + +/** What {@link Base44ChatServer.openLiveSession} returns: where the socket is and the token that joins it. */ +export interface LiveSession { + /** The socket server's origin, with no path. Your backend takes it from the session's `socket_url`. */ + serverUrl: string; + /** The session token, and nothing else: the workspace key never reaches the browser. */ + sessionToken: string; +} + +/** A reply to a question the builder waits on. Declining is `approve: false`. */ +export interface ToolCallAnswer { + /** The tool call being answered. */ + toolCallId: string; + /** The assistant message that holds the tool call. */ + messageId: string; + /** Whether to let the call run (`true`) or turn it down (`false`). */ + approve: boolean; + /** Content the question asked for: `{ answers }` for a choice, `{ secrets }` for an input. */ + extraUserInput?: Record; +} + +/** + * The four calls your backend provides. Each wraps one Base44 REST call with your + * credentials, so the hook never holds a workspace key or an access token, and the + * backend can be written in any language: + * + * - `createApp`: `POST /api/apps` with the first prompt as `initial_message`. + * - `openLiveSession`: `POST /api/service/socket-sessions` with `{ app_ids: [appId] }`. + * - `sendMessage`: `POST /api/apps/{id}/chat/message`. + * - `submitToolCallInput`: `POST /api/apps/{id}/chat/submit-tool-call-input`, with an + * `X-Request-ID` derived from the tool call id so a retry resumes the turn only once. + * + * A rejected promise from any of them lands in {@link Base44Chat.error}. + */ +export interface Base44ChatServer { + /** Creates an app from its first prompt. The build starts inside this call. */ + createApp(prompt: string): Promise; + /** Opens a read-only live-updates session for one app. */ + openLiveSession(appId: string): Promise; + /** Sends a prompt to an existing app. */ + sendMessage(appId: string, content: string): Promise; + /** Answers, or declines, a question the builder waits on. */ + submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise; +} + +/** One message of the chat, in a shape made for rendering. */ +export interface ChatItem { + /** Message identifier. */ + id: string; + /** Who wrote it. */ + role: "user" | "assistant"; + /** The message text; empty when the message is only tool activity. */ + text: string; + /** The builder's tool calls in this message, in order. */ + steps: ChatStep[]; + /** The question this message waits on, bound to the actions that answer it. Absent otherwise. */ + question?: Question; +} + +/** One tool call, as a line of activity. */ +export interface ChatStep { + /** Tool call identifier. */ + id: string; + /** The tool's name followed by the file paths it touched, if any. */ + label: string; + /** Where the call is: `waiting` means it waits on the user. */ + status: "running" | "waiting" | "done" | "error"; +} + +/** + * An open question, with the actions that answer this question and no other. + * Each kind carries only the actions that make sense for it, and every kind can be declined. + * The actions never throw: a failure lands in {@link Base44Chat.error} and the question opens again. + */ +export type Question = ChoiceQuestion | InputQuestion | ApprovalQuestion | UnknownQuestion; + +/** One of the questions a `choice` asks. */ +export interface ChoiceEntry { + /** The question text. */ + text: string; + /** Optional supporting text. */ + description?: string; + /** The option labels to pick from. */ + options: string[]; + /** Whether more than one option may be picked. */ + multi: boolean; +} + +/** The builder offers options and wants a pick. */ +export interface ChoiceQuestion { + /** Discriminator. */ + kind: "choice"; + /** The questions asked, in order. */ + questions: ChoiceEntry[]; + /** Answers with the picked labels: one list per question, in the same order as `questions`. An empty list skips that question. */ + answer(picked: string[][]): Promise; + /** Declines: the builder continues without the answer. */ + decline(): Promise; +} + +/** A value the builder needs, such as an API key. */ +export interface InputField { + /** The field name, used as the key in `submit`. */ + name: string; + /** Optional explanation of where to obtain the value. */ + description?: string; +} + +/** The builder needs values, such as API keys. They go to Base44 and are never kept here. */ +export interface InputQuestion { + /** Discriminator, as the wire names it. */ + kind: "input"; + /** The fields to fill. */ + fields: InputField[]; + /** Submits the values, keyed by field name. */ + submit(values: Record): Promise; + /** Declines: the builder continues without the values. */ + decline(): Promise; +} + +/** The builder wants permission to run a proposed action. */ +export interface ApprovalQuestion { + /** Discriminator. */ + kind: "approval"; + /** The tool that wants to run. */ + action: string; + /** Why it was parked, when the guard says; empty otherwise. */ + reason: string; + /** Lets the action run. */ + approve(): Promise; + /** Declines: the action never runs and the builder continues. */ + decline(): Promise; +} + +/** A question kind Base44 added after this release. It can only be declined. */ +export interface UnknownQuestion { + /** Discriminator. */ + kind: "unknown"; + /** The tool that waits. */ + action: string; + /** Declines: the builder continues without it. */ + decline(): Promise; +} + +/** What the chat is doing. One phase at a time; an error is separate and stays until cleared. */ +export type ChatPhase = "idle" | "creating" | "loading" | "waiting" | "building"; + +/** A failure of a server call or of the live connection. */ +export interface ChatError { + /** What went wrong, for people. */ + message: string; + /** A `PlatformSocketErrorCode` for connection errors; absent for server-call errors. */ + code?: string; +} + +/** Options of {@link useBase44Chat}. */ +export interface Base44ChatOptions { + /** The app to watch, or `null` when no app is selected yet and there is nothing to connect to. */ + appId: string | null; + /** Your backend's four calls. Read on every action, so an inline object is fine and never reopens the session. */ + server: Base44ChatServer; + /** Called with the new app after {@link Base44Chat.create} succeeds. The latest one is called, so an inline arrow is fine. */ + onAppCreated?: (app: Base44App) => void; +} + +/** What {@link useBase44Chat} returns. */ +export interface Base44Chat { + /** The conversation, oldest first, with open questions bound to their actions. */ + items: ChatItem[]; + /** What the chat is doing now. */ + phase: ChatPhase; + /** The last failure, until {@link Base44Chat.clearError} or the next action clears it. */ + error: ChatError | null; + /** Whether the input should accept a prompt. False while an app is being created and while + * a question is open: Base44 drops a message sent into a stopped turn. */ + canSend: boolean; + /** Sends a prompt to the current app. Resolves after the request is accepted; the reply arrives through `items`. */ + send(prompt: string): Promise; + /** Creates a new app from its first prompt and reports it through `onAppCreated`. */ + create(prompt: string): Promise; + /** Clears {@link Base44Chat.error}. */ + clearError(): void; + /** Base44's raw messages, sorted, for UIs that map them themselves. */ + messages: ChatMessage[]; +} diff --git a/packages/platform/src/react/components.types.ts b/packages/platform/src/react/components.types.ts new file mode 100644 index 00000000..4dc32c6e --- /dev/null +++ b/packages/platform/src/react/components.types.ts @@ -0,0 +1,80 @@ +import type { ComponentType, ReactNode } from "react"; +import type { + ApprovalQuestion, Base44Chat, Base44ChatOptions, ChatItem, ChatStep, ChoiceQuestion, InputQuestion, Question, UnknownQuestion, +} from "./chat.types.js"; + +/** Props of the `Text` part: one message's text. */ +export interface TextProps { + /** The message text. Never empty: an item without text renders no `Text`. */ + text: string; + /** Who wrote it, to align or color it. */ + role: ChatItem["role"]; +} + +/** Props of the `Step` part: one tool call of a message. */ +export type StepProps = ChatStep; + +/** The parts of a message's own content. */ +export interface MessageComponents { + /** Draws a message's text. */ + Text: ComponentType; + /** Draws one tool call as a line of activity. */ + Step: ComponentType; +} + +/** + * One part per question kind. Each receives the question itself: its content and the actions + * bound to it, so a part can only ever answer the question it draws. + */ +export interface QuestionComponents { + /** Draws a pick between options. Call `answer` with one list of labels per question. */ + Choice: ComponentType; + /** Draws a form for values such as API keys. Call `submit` with the values keyed by field name. */ + Input: ComponentType; + /** Draws a request to run an action. Call `approve` or `decline`. */ + Approval: ComponentType; + /** Draws a kind added after this release. It can only `decline`. */ + Unknown: ComponentType; +} + +/** Every replaceable part, grouped the way the library draws them. */ +export interface ChatComponents { + /** The parts of a message. */ + message: MessageComponents; + /** The parts of an open question. */ + question: QuestionComponents; +} + +/** The parts you replace. Anything left out is drawn as plain, unstyled HTML. */ +export interface ChatComponentOverrides { + /** Replacements for message parts. */ + message?: Partial; + /** Replacements for question parts. */ + question?: Partial; +} + +/** What {@link useChatState} returns: everything that changes as the chat runs. */ +export type ChatState = Pick; + +/** What {@link useChatActions} returns: the actions, with the same identity for the life of the provider. */ +export type ChatActions = Pick; + +/** Props of {@link Base44ChatProvider}: the {@link useBase44Chat} options, the parts, and the tree that reads them. */ +export interface Base44ChatProviderProps extends Base44ChatOptions { + /** Parts to replace. Read part by part, so an inline object is fine. */ + components?: ChatComponentOverrides; + /** The components that read the chat. */ + children?: ReactNode; +} + +/** Props of {@link Message}. */ +export interface MessageProps { + /** The item to draw, one of the `items` from {@link useChatState} or {@link useBase44Chat}. */ + item: ChatItem; +} + +/** Props of {@link Question}. */ +export interface QuestionProps { + /** The open question to draw, from {@link ChatItem.question}. */ + question: Question; +} diff --git a/packages/platform/src/react/context.ts b/packages/platform/src/react/context.ts new file mode 100644 index 00000000..b12f6391 --- /dev/null +++ b/packages/platform/src/react/context.ts @@ -0,0 +1,77 @@ +"use client"; +import { createContext, useContext } from "react"; +import type { ChatActions, ChatComponents, ChatState } from "./components.types.js"; +import { fallbackComponents } from "./fallbacks.js"; + +/** @internal Filled by {@link Base44ChatProvider}. State changes on every event; actions and parts do not. */ +export const StateContext = createContext(null); +/** @internal */ +export const ActionsContext = createContext(null); +/** @internal */ +export const ComponentsContext = createContext(null); + +/** + * Reads the chat's state from the nearest {@link Base44ChatProvider}: the items, the phase, + * the error and `canSend`. A component that calls it re-renders on every live event. + * + * @returns The state of the chat the provider runs. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // The message list + * function Messages() { + * const { items } = useChatState(); + * return items.map((item) => ); + * } + * ``` + */ +export function useChatState(): ChatState { + return inside(useContext(StateContext), "useChatState"); +} + +/** + * Reads the chat's actions from the nearest {@link Base44ChatProvider}: `send`, `create` and + * `clearError`. They keep their identity for the life of the provider, so a component that + * reads only actions never re-renders because a message arrived. + * + * @returns The actions of the chat the provider runs. + * @throws {Error} When there is no provider above the component. + * + * @example + * ```tsx + * // A button outside the chat that starts a new app + * function NewAppButton({ prompt }: { prompt: string }) { + * const { create } = useChatActions(); + * return ; + * } + * ``` + */ +export function useChatActions(): ChatActions { + return inside(useContext(ActionsContext), "useChatActions"); +} + +/** + * Reads the parts from the nearest {@link Base44ChatProvider}, merged with the unstyled + * fallbacks. Outside a provider it returns the fallbacks, so {@link Message} also works over + * {@link useBase44Chat} alone. + * + * @returns Every part, with the provider's replacements applied. + * + * @example + * ```tsx + * // A part that draws another part + * function MyStepList({ steps }: { steps: ChatStep[] }) { + * const { message: { Step } } = useChatComponents(); + * return steps.map((step) => ); + * } + * ``` + */ +export function useChatComponents(): ChatComponents { + return useContext(ComponentsContext) ?? fallbackComponents; +} + +function inside(value: T | null, hook: string): T { + if (!value) throw new Error(`${hook} needs a above it`); + return value; +} diff --git a/packages/platform/src/react/fallbacks.tsx b/packages/platform/src/react/fallbacks.tsx new file mode 100644 index 00000000..c5e0d7ce --- /dev/null +++ b/packages/platform/src/react/fallbacks.tsx @@ -0,0 +1,91 @@ +"use client"; +import { useId, useState } from "react"; +import type { ChatComponents, StepProps, TextProps } from "./components.types.js"; +import type { ApprovalQuestion, ChoiceQuestion, InputQuestion, UnknownQuestion } from "./chat.types.js"; + +// The parts a partner does not replace: plain HTML, no classes, so the library has no look of +// its own and a question is never silently missing. Each carries a `data-base44` attribute for +// CSS to hook on. + +function Text({ text, role }: TextProps) { + return

{text}

; +} + +function Step({ label, status }: StepProps) { + return

{label}

; +} + +function Choice({ questions, answer, decline }: ChoiceQuestion) { + const name = useId(); + const [picked, setPicked] = useState(() => questions.map(() => [])); + function toggle(i: number, option: string, multi: boolean) { + setPicked(all => + all.map((labels, j) => { + if (j !== i) return labels; + if (!multi) return [option]; + return labels.includes(option) ? labels.filter(o => o !== option) : [...labels, option]; + }), + ); + } + return ( +
{ e.preventDefault(); void answer(picked); }}> + {questions.map((q, i) => ( +
+ {q.text} + {q.description &&

{q.description}

} + {q.options.map(option => ( + + ))} +
+ ))} + + +
+ ); +} + +function Input({ fields, submit, decline }: InputQuestion) { + const [values, setValues] = useState>({}); + return ( +
{ e.preventDefault(); void submit(values); }}> + {fields.map(field => ( + + ))} + + +
+ ); +} + +function Approval({ action, reason, approve, decline }: ApprovalQuestion) { + return ( +
+

Allow {action}?

+ {reason &&

{reason}

} + + +
+ ); +} + +function Unknown({ action, decline }: UnknownQuestion) { + return ( +
+

{action} is waiting for you.

+ +
+ ); +} + +/** @internal */ +export const fallbackComponents: ChatComponents = { + message: { Text, Step }, + question: { Choice, Input, Approval, Unknown }, +}; diff --git a/packages/platform/src/react/index.ts b/packages/platform/src/react/index.ts new file mode 100644 index 00000000..328766e4 --- /dev/null +++ b/packages/platform/src/react/index.ts @@ -0,0 +1,13 @@ +/** The `@base44/platform/react` entry: one app's builder chat as a hook, and a provider with parts you replace. React is an optional peer, loaded only here. */ +export { useBase44Chat } from "./useBase44Chat.js"; +export { Base44ChatProvider } from "./Base44ChatProvider.js"; +export { useChatActions, useChatComponents, useChatState } from "./context.js"; +export { Message, Question } from "./Message.js"; +export type { + ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatServer, ChatError, ChatItem, ChatPhase, ChatStep, + ChoiceEntry, ChoiceQuestion, InputField, InputQuestion, LiveSession, Question as ChatQuestion, ToolCallAnswer, UnknownQuestion, +} from "./chat.types.js"; +export type { + Base44ChatProviderProps, ChatActions, ChatComponentOverrides, ChatComponents, ChatState, MessageComponents, MessageProps, + QuestionComponents, QuestionProps, StepProps, TextProps, +} from "./components.types.js"; diff --git a/packages/platform/src/react/useBase44Chat.ts b/packages/platform/src/react/useBase44Chat.ts new file mode 100644 index 00000000..c1212871 --- /dev/null +++ b/packages/platform/src/react/useBase44Chat.ts @@ -0,0 +1,157 @@ +"use client"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Base44PlatformClient } from "../client.js"; +import type { ChatMessage } from "../modules/builder.events.types.js"; +import { mergeMessage, sortMessages, toItems } from "./chat-items.js"; +import type { Base44Chat, Base44ChatOptions, ChatError, ChatPhase } from "./chat.types.js"; + +/** + * Runs one app's builder chat, headless: it keeps the live connection through + * the platform client, turns Base44's messages into {@link ChatItem | items}, and binds + * every open question to the actions that answer it. You render the items. + * + * All Base44 calls go through the {@link Base44ChatServer | server} you pass, so no + * credential reaches the browser. Actions never throw: a failure lands in + * {@link Base44Chat.error}, and a question whose reply failed opens again. + * + * Requires a browser environment. One live session is opened per app; switching + * `appId` closes the old one and starts over. + * + * @param options - The app to watch, your backend's calls, and what to do with a created app. + * @returns The conversation, its phase and error, and the actions `send`, `create` and `clearError`. + * + * @example + * ```typescript + * // Render the items, answer a question + * const chat = useBase44Chat({ appId, server, onAppCreated: selectApp }); + * return ( + * <> + * {chat.items.map((item) => ( + *
+ *

{item.text}

+ * {item.steps.map((step) => {step.label})} + * {item.question?.kind === "approval" && ( + * + * )} + *
+ * ))} + * {chat.error &&

{chat.error.message}

} + * + * + * ); + * ``` + */ +export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions): Base44Chat { + const [messages, setMessages] = useState([]); + const [building, setBuilding] = useState(false); + const [loading, setLoading] = useState(!!appId); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [answered, setAnswered] = useState([]); + + // Switching apps starts over: the state resets in the same render that sees the new id. + const [shownAppId, setShownAppId] = useState(appId); + if (shownAppId !== appId) { + setShownAppId(appId); + setMessages([]); + setBuilding(false); + setLoading(!!appId); + setError(null); + setAnswered([]); + } + + // The server and the callback are read through refs, kept current after every render, so an + // inline `server={{…}}` or arrow never reopens the socket and never changes an action's identity. + const serverRef = useRef(server); + const onAppCreatedRef = useRef(onAppCreated); + useEffect(() => { + serverRef.current = server; + onAppCreatedRef.current = onAppCreated; + }); + + const fail = useCallback((e: { code?: string; message?: string }) => setError({ message: e.message || "Something went wrong", code: e.code }), []); + + // Live updates: a snapshot on every connect, then events. + useEffect(() => { + if (!appId) return; + let stopped = false; + let session: { close(): void } | undefined; + const failLive = (e: { code?: string; message?: string }) => fail({ message: e.message || "Live updates failed", code: e.code }); + + serverRef.current + .openLiveSession(appId) + .then(({ serverUrl, sessionToken }) => { + if (stopped) return; + const builder = new Base44PlatformClient({ serverUrl, getSessionToken: () => sessionToken }).builder.init({ onError: failLive }); + session = builder; + builder.subscribe(appId, { + onSnapshot: snapshot => { + setMessages(snapshot.messages); + setLoading(false); + setBuilding(snapshot.status?.state === "processing"); + }, + onEvent: event => { + if (event.type === "message.updated") setMessages(all => mergeMessage(all, event.data.message)); + if (event.type === "message.removed") setMessages(all => all.filter(m => m.id !== event.data.message_id)); + if (event.type === "app.status_changed") setBuilding(event.data.status?.state === "processing"); + }, + onError: failLive, + }); + return builder.connect(); + }) + .catch(failLive); + + return () => { + stopped = true; + session?.close(); + }; + }, [appId, fail]); + + // Answers one tool call. Its id leaves `answered` again if the submit fails, so the question comes back. + const respond = useCallback( + (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => { + if (!appId) return Promise.resolve(); + setError(null); + setAnswered(ids => [...ids, toolCallId]); + return serverRef.current.submitToolCallInput(appId, { toolCallId, messageId, approve, extraUserInput }).catch((e: Error) => { + setAnswered(ids => ids.filter(id => id !== toolCallId)); + fail(e); + }); + }, + [appId, fail], + ); + + const sorted = useMemo(() => sortMessages(messages), [messages]); + const items = useMemo(() => toItems(sorted, answered, respond), [sorted, answered, respond]); + + const waiting = items.some(item => item.question); + const phase: ChatPhase = creating ? "creating" : loading ? "loading" : waiting ? "waiting" : building ? "building" : "idle"; + + const send = useCallback( + (prompt: string) => { + if (!appId) return Promise.resolve(); + setError(null); + return serverRef.current.sendMessage(appId, prompt).catch(fail); + }, + [appId, fail], + ); + + const create = useCallback( + async (prompt: string) => { + setError(null); + setCreating(true); + try { + onAppCreatedRef.current?.(await serverRef.current.createApp(prompt)); + } catch (e) { + fail(e as Error); + } finally { + setCreating(false); + } + }, + [fail], + ); + + const clearError = useCallback(() => setError(null), []); + + return { items, phase, error, canSend: !creating && !waiting, send, create, clearError, messages: sorted }; +} diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index f0fdb71a..299d3088 100644 --- a/packages/platform/tests/package.test.mjs +++ b/packages/platform/tests/package.test.mjs @@ -17,8 +17,8 @@ const installed = path.join(scratch, "node_modules/@base44/platform"); mkdirSync(installed, { recursive: true }); run("tar", ["-xzf", path.join(scratch, packed.filename), "--strip-components=1", "-C", installed]); const manifest = JSON.parse(readFileSync(path.join(installed, "package.json"), "utf8")); -// Use the already-installed, lockfile-pinned dependencies; never resolve or install in this fixture. -for (const name of Object.keys(manifest.dependencies)) { +// Use the already-installed, lockfile-pinned dependencies and peers; never resolve or install in this fixture. +for (const name of [...Object.keys(manifest.dependencies), ...Object.keys(manifest.peerDependencies), "@types/react"]) { const destination = path.join(scratch, "node_modules", name); mkdirSync(path.dirname(destination), { recursive: true }); symlinkSync(path.dirname(require.resolve(`${name}/package.json`)), destination, "dir"); @@ -26,7 +26,7 @@ for (const name of Object.keys(manifest.dependencies)) { test("the tarball contains the compiled entry point and no source or test trees", () => { const files = new Set(packed.files.map(file => file.path)); - for (const file of ["dist/index.js", "dist/index.d.ts", "README.md"]) assert.ok(files.has(file), file); + for (const file of ["dist/index.js", "dist/index.d.ts", "dist/react/index.js", "dist/react/index.d.ts", "README.md"]) assert.ok(files.has(file), file); assert.equal([...files].some(file => /^(src|tests|examples)\//.test(file)), false); }); @@ -41,8 +41,41 @@ test("the client resolves from the installed package without the runtime SDK", ( `]); }); +test("the React modules that use hooks or context keep their \"use client\" directive", () => { + for (const file of ['dist/react/useBase44Chat.js', 'dist/react/context.js', 'dist/react/Base44ChatProvider.js', 'dist/react/Message.js', 'dist/react/fallbacks.js']) { + assert.match(readFileSync(path.join(installed, file), "utf8"), /^"use client";/, file); + } +}); + +test("react is an optional peer of the /react entry, never a dependency of the client", () => { + assert.equal("react" in manifest.dependencies, false); + assert.deepEqual(manifest.peerDependencies, { react: "^18.0.0 || ^19.0.0" }); + assert.deepEqual(manifest.peerDependenciesMeta, { react: { optional: true } }); + run(process.execPath, ["--input-type=module", "--eval", ` + import assert from "node:assert/strict"; + import { Base44ChatProvider, Message, Question, useBase44Chat, useChatActions, useChatComponents, useChatState } from "@base44/platform/react"; + for (const fn of [Base44ChatProvider, Message, Question, useBase44Chat, useChatActions, useChatComponents, useChatState]) assert.equal(typeof fn, "function"); + `]); +}); + const consumer = ` import { Base44PlatformClient, type PlatformEvent } from "@base44/platform"; +import { Base44ChatProvider, Message, useBase44Chat, useChatActions, useChatState, type Base44ChatServer, type ChatComponentOverrides, type ChatQuestion } from "@base44/platform/react"; +const server: Base44ChatServer = { + async createApp(prompt) { return { id: "a".repeat(24), name: prompt }; }, + async openLiveSession() { return { serverUrl: "https://example.test", sessionToken: "token" }; }, + async sendMessage() {}, + async submitToolCallInput() {}, +}; +declare const question: ChatQuestion; +if (question.kind === "choice") void question.answer([["A"]]); +// @ts-expect-error A choice is answered, not approved. +if (question.kind === "choice") question.approve(); +// A question part receives the question itself, actions included. +const parts: ChatComponentOverrides = { question: { Approval: ({ action, approve }) => { void action; void approve; return null; } } }; +// @ts-expect-error An approval part gets no answer action. +const wrong: ChatComponentOverrides = { question: { Approval: ({ answer }) => { void answer; return null; } } }; +void useBase44Chat; void server; void parts; void wrong; void Base44ChatProvider; void Message; void useChatState; void useChatActions; const platform = new Base44PlatformClient({ serverUrl: "https://example.test", getSessionToken: async () => "token" }); platform.builder.init({ onError() {} }).subscribe("a".repeat(24), { onSnapshot(snapshot) { void snapshot.messages; }, diff --git a/packages/platform/tests/types/react.types.ts b/packages/platform/tests/types/react.types.ts new file mode 100644 index 00000000..01c55329 --- /dev/null +++ b/packages/platform/tests/types/react.types.ts @@ -0,0 +1,57 @@ +import type { ChatMessage } from "@base44/platform"; +import { useBase44Chat, type Base44Chat, type Base44ChatServer, type ChatItem, type ChatQuestion as Question } from "@base44/platform/react"; + +const server: Base44ChatServer = { + async createApp(prompt) { return { id: "a".repeat(24), name: prompt }; }, + async openLiveSession() { return { serverUrl: "https://example.test", sessionToken: "token" }; }, + async sendMessage() {}, + async submitToolCallInput(appId, answer) { void answer.toolCallId; void answer.messageId; void answer.approve; void answer.extraUserInput; }, +}; +// @ts-expect-error All four calls are required. +const partial: Base44ChatServer = { createApp: server.createApp }; +void partial; + +declare const chat: Base44Chat; +const phase: "idle" | "creating" | "loading" | "waiting" | "building" = chat.phase; +void phase; +const sent: Promise = chat.send("prompt"); +void sent; +const raw: ChatMessage[] = chat.messages; +void raw; +// @ts-expect-error The hook is the API; there is no status string. +void chat.status; +// @ts-expect-error Answers belong to the question, not to the chat. +void chat.approve; + +declare const item: ChatItem; +const step: "running" | "waiting" | "done" | "error" = item.steps[0].status; +void step; + +declare const question: Question; +switch (question.kind) { + case "choice": + void question.answer([["A"], []]); + void question.questions[0].multi; + // @ts-expect-error A choice is answered, not approved. + void question.approve; + break; + case "input": + void question.submit({ API_KEY: "value" }); + void question.fields[0].name; + break; + case "approval": + void question.approve(); + void question.reason; + break; + case "unknown": + void question.decline(); + // @ts-expect-error An unknown kind can only be declined. + void question.approve; + break; +} +// @ts-expect-error Every kind must be handled; there is no fifth. +const exhaustive: never = question; +void exhaustive; + +// @ts-expect-error appId is required, as null when there is no app yet. +void useBase44Chat({ server }); diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts new file mode 100644 index 00000000..16413d2c --- /dev/null +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ChatMessage, ToolCall } from "../../../src/modules/builder.events.types.js"; +import { bindQuestion, mergeMessage, sortMessages, stepStatus, toItems } from "../../../src/react/chat-items.js"; + +const message = (id: string, created: string, extra: Partial = {}): ChatMessage => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); +const waiting = (id: string, extra: Partial = {}): ToolCall => ({ id, name: "tool", status: "waiting_for_user_input", ...extra }); + +describe("ordering", () => { + test("messages sort by created_date and a missing date sorts first", () => { + const sorted = sortMessages([message("b", "2026-01-02"), message("a", "2026-01-01"), message("none", "")]); + expect(sorted.map(m => m.id)).toEqual(["none", "a", "b"]); + }); + + test("an update replaces the message with the same id in place and a new one goes last", () => { + const all = [message("a", "1"), message("b", "2")]; + expect(mergeMessage(all, message("a", "1", { content: "edited" })).map(m => [m.id, m.content])).toEqual([["a", "edited"], ["b", undefined]]); + expect(mergeMessage(all, message("c", "3")).map(m => m.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("items", () => { + test("text, role, steps and their statuses come straight from the message", () => { + const tools: ToolCall[] = [ + { id: "t1", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }, + { id: "t2", name: "run", status: "running" }, + { id: "t3", name: "run", status: "error" }, + { id: "t4", name: "run", status: "stopped" }, + ]; + const [item] = toItems([message("m", "1", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn()); + expect(item.role).toBe("user"); + expect(item.text).toBe("hi"); + expect(item.steps.map(s => [s.label, s.status])).toEqual([["write_file src/App.jsx", "done"], ["run", "running"], ["run", "error"], ["run", "done"]]); + expect(item.question).toBeUndefined(); + }); + + test("an unknown role is rendered as the assistant and missing content as empty text", () => { + const [item] = toItems([message("m", "1", { role: "system" as ChatMessage["role"] })], [], vi.fn()); + expect(item.role).toBe("assistant"); + expect(item.text).toBe(""); + expect(item.steps).toEqual([]); + }); + + test("each waiting tool call becomes the question of its own message, bound to its own ids", async () => { + const respond = vi.fn(async () => {}); + const items = toItems( + [ + message("m1", "1", { tool_calls: [waiting("t1", { waiting_on: { kind: "approval" } })] }), + message("m2", "2", { tool_calls: [waiting("t2", { waiting_on: { kind: "approval" } })] }), + ], + [], + respond, + ); + expect(items.map(i => i.question?.kind)).toEqual(["approval", "approval"]); + await items[1].question!.decline(); + expect(respond).toHaveBeenCalledWith("t2", "m2", false, undefined); + await (items[0].question as { approve(): Promise }).approve(); + expect(respond).toHaveBeenCalledWith("t1", "m1", true, undefined); + }); + + test("an answered tool call is no longer a question, even while the server still reports it waiting", () => { + const [item] = toItems([message("m", "1", { tool_calls: [waiting("t1")] })], ["t1"], vi.fn()); + expect(item.question).toBeUndefined(); + expect(item.steps[0].status).toBe("waiting"); + }); +}); + +describe("questions", () => { + test("choice: questions, options as strings or objects, multi, and answers keyed by position", async () => { + const reply = vi.fn(async () => {}); + const question = bindQuestion( + waiting("t", { + waiting_on: { kind: "choice" }, + arguments: { + questions: [ + { question: "Which?", description: "Pick one", options: ["A", { label: "B" }, {}], multi_select: false }, + { question: "Extras?", options: ["X", "Y"], multi_select: true }, + ], + }, + }), + reply, + ); + expect(question.kind).toBe("choice"); + if (question.kind !== "choice") return; + expect(question.questions).toEqual([ + { text: "Which?", description: "Pick one", options: ["A", "B", ""], multi: false }, + { text: "Extras?", description: undefined, options: ["X", "Y"], multi: true }, + ]); + await question.answer([["A"], ["X", "Y"]]); + expect(reply).toHaveBeenCalledWith(true, { + answers: [ + { question_index: 0, selected_labels: ["A"] }, + { question_index: 1, selected_labels: ["X", "Y"] }, + ], + }); + await question.answer([[], ["Y"]]); + expect(reply).toHaveBeenLastCalledWith(true, { answers: [{ question_index: 1, selected_labels: ["Y"] }] }); + await question.decline(); + expect(reply).toHaveBeenLastCalledWith(false); + }); + + test("input: fields from secrets_schema, submitted as secrets", async () => { + const reply = vi.fn(async () => {}); + const question = bindQuestion( + waiting("t", { waiting_on: { kind: "input" }, arguments: { secrets_schema: [{ secretName: "API_KEY", description: "From the dashboard" }, {}] } }), + reply, + ); + expect(question.kind).toBe("input"); + if (question.kind !== "input") return; + expect(question.fields).toEqual([{ name: "API_KEY", description: "From the dashboard" }, { name: "", description: undefined }]); + await question.submit({ API_KEY: "secret" }); + expect(reply).toHaveBeenCalledWith(true, { secrets: { API_KEY: "secret" } }); + }); + + test("approval: the tool name, and the guard's reason or its summary", async () => { + const reply = vi.fn(async () => {}); + const withReason = bindQuestion(waiting("t", { name: "send_email", waiting_on: { kind: "approval" }, approval: { guard: "g", reason: "Sends mail" } }), reply); + expect(withReason).toMatchObject({ kind: "approval", action: "send_email", reason: "Sends mail" }); + const withSummary = bindQuestion(waiting("t", { name: "bash", waiting_on: { kind: "approval" }, approval: { guard: "g", details: { summary: "Lists files" } } }), reply); + expect(withSummary).toMatchObject({ kind: "approval", reason: "Lists files" }); + const bare = bindQuestion(waiting("t", { waiting_on: { kind: "approval" } }), reply); + expect(bare).toMatchObject({ kind: "approval", action: "tool", reason: "" }); + if (bare.kind !== "approval") return; + await bare.approve(); + expect(reply).toHaveBeenCalledWith(true); + }); + + test("a kind this release does not know can only be declined", async () => { + const reply = vi.fn(async () => {}); + const question = bindQuestion(waiting("t", { name: "new_tool", waiting_on: { kind: "plan" as "approval" } }), reply); + expect(question).toMatchObject({ kind: "unknown", action: "new_tool" }); + expect("approve" in question).toBe(false); + await question.decline(); + expect(reply).toHaveBeenCalledWith(false); + }); + + test("statuses map to four step states", () => { + expect(stepStatus({ status: "running" })).toBe("running"); + expect(stepStatus({ status: "waiting_for_user_input" })).toBe("waiting"); + expect(stepStatus({ status: "error" })).toBe("error"); + expect(stepStatus({ status: "success" })).toBe("done"); + expect(stepStatus({})).toBe("done"); + }); +}); diff --git a/packages/platform/tests/unit/react/provider.test.tsx b/packages/platform/tests/unit/react/provider.test.tsx new file mode 100644 index 00000000..ff504b8a --- /dev/null +++ b/packages/platform/tests/unit/react/provider.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +import { act, memo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ChatMessage, Snapshot } from "../../../src/modules/builder.events.types.js"; +import { + Base44ChatProvider, Message, useBase44Chat, useChatActions, useChatComponents, useChatState, + type ApprovalQuestion, type Base44ChatProviderProps, type Base44ChatServer, type StepProps, +} from "../../../src/react/index.js"; + +// The platform client is replaced by a recorder, as in the hook's own test. +type Sub = { onSnapshot(s: Snapshot): unknown; onEvent(e: unknown): unknown; onError(e: unknown): void }; +const fake = vi.hoisted(() => ({ subs: [] as Record[] })); +vi.mock("../../../src/client.js", () => ({ + Base44PlatformClient: class { + builder = { + init() { + const subs: Record = {}; + fake.subs.push(subs); + return { connect: async () => {}, close() {}, subscribe(appId: string, opts: Sub) { subs[appId] = opts; return { appId, active: true, unsubscribe() {} }; } }; + }, + }; + }, +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const app = "a".repeat(24); +const message = (id: string, created: string, extra: Partial = {}): ChatMessage => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); +const makeServer = () => ({ + createApp: vi.fn(async (prompt: string) => ({ id: "b".repeat(24), name: prompt })), + openLiveSession: vi.fn(async () => ({ serverUrl: "https://socket.test", sessionToken: "token" })), + sendMessage: vi.fn(async () => {}), + submitToolCallInput: vi.fn(async () => {}), +}) satisfies Base44ChatServer; + +const conversation: ChatMessage[] = [ + message("m1", "1", { role: "user", content: "A habit app" }), + message("m2", "2", { + tool_calls: [ + { id: "t0", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }, + { id: "t1", name: "ask_clarifying_questions", status: "waiting_for_user_input", waiting_on: { kind: "choice" }, arguments: { questions: [{ question: "Tracked how?", options: ["Daily", { label: "Weekly" }] }] } }, + ], + }), + message("m3", "3", { tool_calls: [{ id: "t2", name: "send_email", status: "waiting_for_user_input", waiting_on: { kind: "approval" }, approval: { guard: "g", reason: "Sends mail" } }] }), + message("m4", "4", { tool_calls: [{ id: "t3", name: "set_secrets", status: "waiting_for_user_input", waiting_on: { kind: "input" }, arguments: { secrets_schema: [{ secretName: "API_KEY" }] } }] }), +]; + +function Messages() { + const { items } = useChatState(); + return <>{items.map(item =>
)}; +} + +let container: HTMLDivElement; +let root: Root; +const settle = () => act(async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }); +async function render(node: React.ReactNode) { + await act(async () => root.render(node)); + await settle(); +} +const snapshot = (messages: ChatMessage[]) => act(async () => { await fake.subs.at(-1)![app].onSnapshot({ status: { state: "ready" }, messages }); }); +const event = (e: unknown) => act(async () => { await fake.subs.at(-1)![app].onEvent(e); }); +const $ = (selector: string) => container.querySelector(selector); +const click = (el: Element | null) => act(async () => { (el as HTMLElement).click(); }); +const button = (scope: string, text: string) => [...container.querySelectorAll(`${scope} button`)].find(b => b.textContent === text) ?? null; +function type(input: HTMLInputElement | null, value: string) { + return act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +beforeEach(() => { + fake.subs.length = 0; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +describe("fallback parts", () => { + test("every part has an unstyled fallback: text, steps and each question kind", async () => { + await render(); + await snapshot(conversation); + expect($('[data-item="m1"] [data-base44="text"]')?.textContent).toBe("A habit app"); + expect($('[data-item="m1"] [data-base44="text"]')?.dataset.role).toBe("user"); + const steps = [...container.querySelectorAll('[data-item="m2"] [data-base44="step"]')]; + expect(steps.map(s => [s.textContent, s.dataset.status])).toEqual([["write_file src/App.jsx", "done"], ["ask_clarifying_questions", "waiting"]]); + expect($('[data-item="m2"] legend')?.textContent).toBe("Tracked how?"); + expect($('[data-item="m3"] [data-kind="approval"]')?.textContent).toContain("Sends mail"); + expect($('[data-item="m4"] [data-kind="input"] input[type="password"]')).not.toBeNull(); + // A message renders no element of its own: its parts sit directly in the partner's wrapper. + expect($('[data-item="m1"]')?.children.length).toBe(1); + }); + + test("the fallback choice answers its own tool call with the picked labels", async () => { + const server = makeServer(); + await render(); + await snapshot(conversation); + const weekly = [...container.querySelectorAll('[data-item="m2"] input[type="radio"]')][1]; + await click(weekly); + await click(button('[data-item="m2"]', "Send answer")); + expect(server.submitToolCallInput).toHaveBeenCalledWith(app, { + toolCallId: "t1", messageId: "m2", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_labels: ["Weekly"] }] }, + }); + expect($('[data-item="m2"] [data-kind="choice"]')).toBeNull(); + }); + + test("the fallback input submits secrets and the fallback approval declines its own call", async () => { + const server = makeServer(); + await render(); + await snapshot(conversation); + await type($('[data-item="m4"] input[type="password"]') as HTMLInputElement, "secret"); + await click(button('[data-item="m4"]', "Save")); + expect(server.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t3", messageId: "m4", approve: true, extraUserInput: { secrets: { API_KEY: "secret" } } }); + await click(button('[data-item="m3"]', "Decline")); + expect(server.submitToolCallInput).toHaveBeenLastCalledWith(app, { toolCallId: "t2", messageId: "m3", approve: false, extraUserInput: undefined }); + }); + + test(" also works over the bare hook, without a provider", async () => { + function Bare() { + const chat = useBase44Chat({ appId: app, server: makeServer() }); + return <>{chat.items.map(item => )}; + } + await render(); + await snapshot([message("m1", "1", { content: "hello" })]); + expect($('[data-base44="text"]')?.textContent).toBe("hello"); + }); +}); + +describe("replaced parts", () => { + test("replaced parts draw, the rest fall back, and a question part gets its bound actions", async () => { + const server = makeServer(); + const Step = ({ label }: StepProps) => {label}; + const Approval = ({ action, approve }: ApprovalQuestion) => ; + await render(); + await snapshot(conversation); + expect([...container.querySelectorAll('[data-test="step"]')].map(s => s.textContent)).toContain("write_file src/App.jsx"); + expect($('[data-base44="step"]')).toBeNull(); + expect($('[data-item="m1"] [data-base44="text"]')).not.toBeNull(); + expect($('[data-item="m2"] [data-kind="choice"]')).not.toBeNull(); + await click($('[data-test="allow"]')); + expect(server.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t2", messageId: "m3", approve: true, extraUserInput: undefined }); + }); +}); + +describe("reading the chat", () => { + test("the reading hooks fail clearly outside a provider", async () => { + const errors: string[] = []; + function Outside() { + for (const read of [useChatState, useChatActions]) { + try { read(); } catch (e) { errors.push((e as Error).message); } + } + return null; + } + await render(); + expect(errors).toEqual(["useChatState needs a above it", "useChatActions needs a above it"]); + }); + + test("state readers re-render on events, action readers do not", async () => { + const renders = { state: 0, actions: 0 }; + function StateReader() { renders.state++; useChatState(); return null; } + function ActionsReader() { renders.actions++; useChatActions(); return null; } + await render(); + const before = { ...renders }; + await snapshot([message("m1", "1", { content: "one" })]); + await event({ type: "message.updated", appId: app, data: { message: message("m2", "2", { content: "two" }) } }); + expect(renders.state).toBeGreaterThan(before.state); + expect(renders.actions).toBe(before.actions); + }); + + test("inline server, callback and parts keep the actions and the parts stable", async () => { + const renders = { actions: 0, parts: 0 }; + const seen = new Set(); + const ActionsReader = memo(function ActionsReader() { renders.actions++; seen.add(useChatActions()); return null; }); + const PartsReader = memo(function PartsReader() { renders.parts++; seen.add(useChatComponents()); return null; }); + const Step = ({ label }: StepProps) => {label}; + const server = makeServer(); + const tree = (n: number, props: Partial = {}) => ( + void n} components={{ message: { Step } }} {...props}> + + + + ); + await render(tree(1)); + await render(tree(2)); + await render(tree(3)); + expect(renders).toEqual({ actions: 1, parts: 1 }); + expect(seen.size).toBe(2); + // A part that really changes does reach the readers. + await render(tree(4, { components: { message: { Step: ({ label }: StepProps) => {label} } } })); + expect(renders.parts).toBe(2); + expect(renders.actions).toBe(1); + }); + + test("actions from context reach the latest server and callback", async () => { + const first = makeServer(), second = makeServer(); + const onAppCreated = vi.fn(); + let actions!: ReturnType; + function Grab() { actions = useChatActions(); return null; } + await render( {}}>); + await render(); + await act(async () => { await actions.create("A tiny app"); }); + expect(first.createApp).not.toHaveBeenCalled(); + expect(second.createApp).toHaveBeenCalledWith("A tiny app"); + expect(onAppCreated).toHaveBeenCalledWith({ id: "b".repeat(24), name: "A tiny app" }); + }); +}); diff --git a/packages/platform/tests/unit/react/useBase44Chat.test.ts b/packages/platform/tests/unit/react/useBase44Chat.test.ts new file mode 100644 index 00000000..ff5905cd --- /dev/null +++ b/packages/platform/tests/unit/react/useBase44Chat.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment jsdom +import { createElement, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ChatMessage, Snapshot } from "../../../src/modules/builder.events.types.js"; +import { useBase44Chat } from "../../../src/react/index.js"; +import type { Base44Chat, Base44ChatOptions, Base44ChatServer } from "../../../src/react/index.js"; + +// The platform client is replaced by a recorder: the test plays snapshots and events into the +// subscription the hook opened, and checks which sessions were opened and closed. +type FakeSession = { subs: Record; closed: boolean; connect: () => Promise }; +const fake = vi.hoisted(() => ({ sessions: [] as FakeSession[], clients: [] as unknown[] })); +vi.mock("../../../src/client.js", () => ({ + Base44PlatformClient: class { + builder: { init(options: unknown): FakeSession }; + constructor(options: unknown) { + fake.clients.push(options); + this.builder = { + init() { + const session: FakeSession & { subscribe(appId: string, opts: FakeSession["subs"][string]): unknown; close(): void } = { + subs: {}, + closed: false, + connect: async () => {}, + subscribe(appId, opts) { session.subs[appId] = opts; return { appId, active: true, unsubscribe() {} }; }, + close() { session.closed = true; }, + }; + fake.sessions.push(session); + return session; + }, + }; + } + }, +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const app = "a".repeat(24), other = "b".repeat(24); +const message = (id: string, created: string, extra: Partial = {}): ChatMessage => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); +const waiting = (id: string, kind: "approval" | "choice" | "input" = "approval") => ({ id, name: "tool", status: "waiting_for_user_input" as const, waiting_on: { kind } }); + +function makeServer(): { [K in keyof Base44ChatServer]: ReturnType & Base44ChatServer[K] } { + return { + createApp: vi.fn(async (prompt: string) => ({ id: other, name: prompt })), + openLiveSession: vi.fn(async () => ({ serverUrl: "https://socket.test", sessionToken: "session-token" })), + sendMessage: vi.fn(async () => {}), + submitToolCallInput: vi.fn(async () => {}), + }; +} + +let latest!: Base44Chat; +let renders = 0; +function Probe(props: Base44ChatOptions) { + renders++; + latest = useBase44Chat(props); + return null; +} +const roots: Root[] = []; +const settle = () => act(async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }); +async function mount(props: Base44ChatOptions) { + const root = createRoot(document.createElement("div")); + roots.push(root); + const render = async (next: Base44ChatOptions) => { await act(async () => root.render(createElement(Probe, next))); await settle(); }; + await render(props); + return { render, unmount: () => act(async () => root.unmount()) }; +} +const session = () => fake.sessions.at(-1)!; +const snapshot = async (messages: ChatMessage[], state: "ready" | "processing" = "ready", appId = app) => + act(async () => { await session().subs[appId].onSnapshot({ status: { state }, messages }); }); +const event = async (e: unknown, appId = app) => act(async () => { await session().subs[appId].onEvent(e); }); + +beforeEach(() => { fake.sessions.length = 0; fake.clients.length = 0; renders = 0; }); +afterEach(async () => { for (const root of roots.splice(0)) await act(async () => root.unmount()); }); + +describe("connection", () => { + test("without an app nothing connects and the chat is idle", async () => { + const server = makeServer(); + await mount({ appId: null, server }); + expect(server.openLiveSession).not.toHaveBeenCalled(); + expect(latest).toMatchObject({ items: [], phase: "idle", error: null, canSend: true }); + await act(async () => { await latest.send("hello"); }); + expect(server.sendMessage).not.toHaveBeenCalled(); + }); + + test("with an app it opens one live session from the server's answer, loads, then shows the snapshot", async () => { + const server = makeServer(); + await mount({ appId: app, server }); + expect(server.openLiveSession).toHaveBeenCalledWith(app); + expect(fake.clients).toEqual([{ serverUrl: "https://socket.test", getSessionToken: expect.any(Function) }]); + expect(latest.phase).toBe("loading"); + await snapshot([message("m2", "2", { content: "second" }), message("m1", "1", { role: "user", content: "first" })], "processing"); + expect(latest.phase).toBe("building"); + expect(latest.items.map(i => [i.role, i.text])).toEqual([["user", "first"], ["assistant", "second"]]); + expect(latest.messages.map(m => m.id)).toEqual(["m1", "m2"]); + }); + + test("events update, add and remove messages, and the status drives the phase", async () => { + const { } = await mount({ appId: app, server: makeServer() }); + await snapshot([message("m1", "1", { content: "draft" })], "processing"); + await event({ type: "message.updated", appId: app, data: { message: message("m1", "1", { content: "final" }) } }); + await event({ type: "message.updated", appId: app, data: { message: message("m2", "2", { content: "more" }) } }); + expect(latest.items.map(i => i.text)).toEqual(["final", "more"]); + await event({ type: "message.removed", appId: app, data: { message_id: "m1" } }); + expect(latest.items.map(i => i.id)).toEqual(["m2"]); + await event({ type: "app.status_changed", appId: app, data: { status: { state: "ready" } } }); + expect(latest.phase).toBe("idle"); + }); + + test("a failed session, a connection error and a subscription error all land in error with their code", async () => { + const server = makeServer(); + server.openLiveSession.mockRejectedValueOnce(new Error("Socket session failed: 403")); + const { render } = await mount({ appId: app, server }); + expect(latest.error).toEqual({ message: "Socket session failed: 403", code: undefined }); + await render({ appId: other, server }); + expect(latest.error).toBeNull(); + await act(async () => { session().subs[other].onError({ code: "access_revoked", message: "revoked" }); }); + expect(latest.error).toEqual({ message: "revoked", code: "access_revoked" }); + await act(async () => { latest.clearError(); }); + expect(latest.error).toBeNull(); + }); + + test("switching apps closes the old session, starts over, and opens a new one", async () => { + const server = makeServer(); + const { render } = await mount({ appId: app, server }); + await snapshot([message("m1", "1", { content: "old" })]); + const first = session(); + await render({ appId: other, server }); + expect(first.closed).toBe(true); + expect(latest.items).toEqual([]); + expect(latest.phase).toBe("loading"); + expect(server.openLiveSession).toHaveBeenLastCalledWith(other); + expect(fake.sessions).toHaveLength(2); + await render({ appId: null, server }); + expect(session().closed).toBe(true); + expect(latest.phase).toBe("idle"); + }); + + test("an inline server object does not reopen the session, and the latest one is used", async () => { + const server = makeServer(); + const { render } = await mount({ appId: app, server }); + const replacement = makeServer(); + await render({ appId: app, server: { ...replacement } }); + await render({ appId: app, server: { ...replacement } }); + expect(fake.sessions).toHaveLength(1); + expect(server.openLiveSession).toHaveBeenCalledTimes(1); + await act(async () => { await latest.send("hi"); }); + expect(replacement.sendMessage).toHaveBeenCalledWith(app, "hi"); + expect(server.sendMessage).not.toHaveBeenCalled(); + }); + + test("unmounting closes the session", async () => { + const { unmount } = await mount({ appId: app, server: makeServer() }); + await unmount(); + expect(session().closed).toBe(true); + }); +}); + +describe("questions", () => { + test("an open question locks the input and answers exactly its own tool call", async () => { + const server = makeServer(); + await mount({ appId: app, server }); + await snapshot([ + message("m1", "1", { tool_calls: [waiting("t1")] }), + message("m2", "2", { tool_calls: [waiting("t2", "choice"), { id: "t3", name: "write_file", status: "success" }] }), + ]); + expect(latest.phase).toBe("waiting"); + expect(latest.canSend).toBe(false); + expect(latest.items.map(i => i.question?.kind)).toEqual(["approval", "choice"]); + const second = latest.items[1].question!; + if (second.kind !== "choice") throw new Error("expected a choice"); + await act(async () => { await second.answer([["A"]]); }); + expect(server.submitToolCallInput).toHaveBeenCalledWith(app, { + toolCallId: "t2", messageId: "m2", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_labels: ["A"] }] }, + }); + expect(latest.items.map(i => i.question?.kind)).toEqual(["approval", undefined]); + expect(latest.items[1].steps[0].status).toBe("waiting"); + const first = latest.items[0].question!; + if (first.kind !== "approval") throw new Error("expected an approval"); + await act(async () => { await first.decline(); }); + expect(server.submitToolCallInput).toHaveBeenLastCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: false, extraUserInput: undefined }); + expect(latest.phase).toBe("idle"); + expect(latest.canSend).toBe(true); + }); + + test("a reply the server refuses reopens the question and reports the error", async () => { + const server = makeServer(); + server.submitToolCallInput.mockRejectedValueOnce(new Error("Submit answer failed: 409")); + await mount({ appId: app, server }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const question = latest.items[0].question!; + if (question.kind !== "approval") throw new Error("expected an approval"); + await act(async () => { await question.approve(); }); + expect(latest.items[0].question?.kind).toBe("approval"); + expect(latest.error).toEqual({ message: "Submit answer failed: 409", code: undefined }); + expect(latest.phase).toBe("waiting"); + }); + + test("the question's actions keep working after an unrelated message arrives", async () => { + const server = makeServer(); + await mount({ appId: app, server }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const question = latest.items[0].question!; + await event({ type: "message.updated", appId: app, data: { message: message("m2", "2", { content: "meanwhile" }) } }); + await act(async () => { await question.decline(); }); + expect(server.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: false, extraUserInput: undefined }); + }); +}); + +describe("sending and creating", () => { + test("send goes to the current app and a refusal becomes the error", async () => { + const server = makeServer(); + await mount({ appId: app, server }); + await snapshot([]); + await act(async () => { await latest.send("make it blue"); }); + expect(server.sendMessage).toHaveBeenCalledWith(app, "make it blue"); + expect(latest.error).toBeNull(); + server.sendMessage.mockRejectedValueOnce(new Error("Send message failed: 429")); + await act(async () => { await latest.send("again"); }); + expect(latest.error).toEqual({ message: "Send message failed: 429", code: undefined }); + }); + + test("create reports the new app through onAppCreated and is the creating phase meanwhile", async () => { + const server = makeServer(); + let resolve!: (app: { id: string; name: string }) => void; + server.createApp.mockReturnValueOnce(new Promise(r => { resolve = r; })); + const onAppCreated = vi.fn(); + await mount({ appId: null, server, onAppCreated }); + let done!: Promise; + await act(async () => { done = latest.create("A tiny habit app"); }); + expect(latest.phase).toBe("creating"); + expect(latest.canSend).toBe(false); + await act(async () => { resolve({ id: other, name: "A tiny habit app" }); await done; }); + expect(onAppCreated).toHaveBeenCalledWith({ id: other, name: "A tiny habit app" }); + expect(latest.phase).toBe("idle"); + expect(server.openLiveSession).not.toHaveBeenCalled(); + }); + + test("a failed create sets the error and calls nobody", async () => { + const server = makeServer(); + server.createApp.mockRejectedValueOnce(new Error("Create app failed: 402")); + const onAppCreated = vi.fn(); + await mount({ appId: null, server, onAppCreated }); + await act(async () => { await latest.create("x"); }); + expect(onAppCreated).not.toHaveBeenCalled(); + expect(latest).toMatchObject({ phase: "idle", canSend: true, error: { message: "Create app failed: 402" } }); + }); + + test("actions and items keep their identity across renders that change nothing", async () => { + const server = makeServer(); + const { render } = await mount({ appId: app, server }); + await snapshot([message("m1", "1", { content: "hello" })]); + const { items, send, create, clearError } = latest; + await render({ appId: app, server }); + expect(latest.items).toBe(items); + expect(latest.send).toBe(send); + expect(latest.create).toBe(create); + expect(latest.clearError).toBe(clearError); + expect(renders).toBeGreaterThan(0); + }); +}); diff --git a/packages/platform/tsconfig.json b/packages/platform/tsconfig.json index df59da57..e1ba49c0 100644 --- a/packages/platform/tsconfig.json +++ b/packages/platform/tsconfig.json @@ -2,7 +2,11 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] } diff --git a/packages/platform/tsconfig.type-tests.json b/packages/platform/tsconfig.type-tests.json index c4630f01..793d83ba 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -5,8 +5,20 @@ "rootDir": ".", "baseUrl": ".", "paths": { - "@base44/platform": ["src/index.ts"] + "@base44/platform": [ + "src/index.ts" + ], + "@base44/platform/react": [ + "src/react/index.ts" + ] } }, - "include": ["src/**/*.ts", "tests/types/**/*.ts", "examples/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/types/**/*.ts", + "tests/types/**/*.tsx", + "examples/**/*.ts", + "examples/**/*.tsx" + ] } diff --git a/packages/platform/typedoc.json b/packages/platform/typedoc.json index 3e29c0b0..21edcf5e 100644 --- a/packages/platform/typedoc.json +++ b/packages/platform/typedoc.json @@ -1,7 +1,8 @@ { "$schema": "https://typedoc.org/schema.json", "entryPoints": [ - "./src/index.ts" + "./src/index.ts", + "./src/react/index.ts" ], "tsconfig": "./tsconfig.json", "out": "docs", diff --git a/packages/platform/vitest.config.ts b/packages/platform/vitest.config.ts index fbeac687..41544006 100644 --- a/packages/platform/vitest.config.ts +++ b/packages/platform/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["tests/unit/**/*.test.{js,ts}"], + include: ["tests/unit/**/*.test.{js,ts,tsx}"], }, });