From e4b548db0de175066c9fd926dd87b9d8f2a3d695 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 10 Oct 2026 19:47:49 +0300 Subject: [PATCH 1/5] feat(platform): add @base44/platform/react, a hook that runs the builder chat One React hook, useBase44Chat, on a new subpath of the platform package. It opens the live session through the client, turns messages into items made for rendering, and binds every question the builder asks to the actions that answer it, typed per kind, so a component can only answer the question it renders. It renders nothing. React is an optional peer, loaded only by the /react entry; the root entry stays framework-free. The four Base44 calls come from a server object the partner implements, so no credential reaches the browser. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 530 ++++++++++++++++++ packages/platform/README.md | 25 + packages/platform/examples/react-chat.ts | 33 ++ packages/platform/package.json | 31 +- packages/platform/src/react/chat-items.ts | 80 +++ packages/platform/src/react/chat.types.ts | 194 +++++++ packages/platform/src/react/index.ts | 6 + packages/platform/src/react/useBase44Chat.ts | 154 +++++ packages/platform/tests/package.test.mjs | 29 +- packages/platform/tests/types/react.types.ts | 57 ++ .../tests/unit/react/chat-items.test.ts | 143 +++++ .../tests/unit/react/useBase44Chat.test.ts | 259 +++++++++ packages/platform/tsconfig.type-tests.json | 13 +- packages/platform/typedoc.json | 3 +- 14 files changed, 1549 insertions(+), 8 deletions(-) create mode 100644 packages/platform/examples/react-chat.ts create mode 100644 packages/platform/src/react/chat-items.ts create mode 100644 packages/platform/src/react/chat.types.ts create mode 100644 packages/platform/src/react/index.ts create mode 100644 packages/platform/src/react/useBase44Chat.ts create mode 100644 packages/platform/tests/types/react.types.ts create mode 100644 packages/platform/tests/unit/react/chat-items.test.ts create mode 100644 packages/platform/tests/unit/react/useBase44Chat.test.ts diff --git a/package-lock.json b/package-lock.json index b8a04189..bc1e4f70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,25 @@ "vitest": "^4.1.9" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "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://npm.dev.wixpress.com/api/npm/npm-repos/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "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 +325,116 @@ "node": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@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" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@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" + } + ], + "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://npm.dev.wixpress.com/api/npm/npm-repos/@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" + } + ], + "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://npm.dev.wixpress.com/api/npm/npm-repos/@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" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@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" + } + ], + "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 +1165,24 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "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 +2153,38 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "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 +2256,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3119,6 +3304,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "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 +3323,28 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "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://npm.dev.wixpress.com/api/npm/npm-repos/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "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 +3358,18 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "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 +3659,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3656,6 +3893,67 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "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://npm.dev.wixpress.com/api/npm/npm-repos/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "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 +4546,12 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4440,6 +4744,30 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "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 +4919,27 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "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 +5049,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "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 +5110,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5112,6 +5491,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5174,6 +5559,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -5184,6 +5587,30 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "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 +6105,62 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/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, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "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 +6314,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -5881,6 +6379,38 @@ "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/platform-react": { + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@base44/platform": "^0.1.0", + "@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": { + "@base44/platform": "^0.1.0", + "react": "^18.0.0 || ^19.0.0" } }, "packages/sdk": { diff --git a/packages/platform/README.md b/packages/platform/README.md index 4450e697..69b37981 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -169,3 +169,28 @@ 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.ts`](examples/react-chat.ts) shows a `fetch`-based `server`. diff --git a/packages/platform/examples/react-chat.ts b/packages/platform/examples/react-chat.ts new file mode 100644 index 00000000..b6c506f9 --- /dev/null +++ b/packages/platform/examples/react-chat.ts @@ -0,0 +1,33 @@ +import { useBase44Chat, type Base44App, type Base44ChatServer } from "@base44/platform/react"; + +// Your backend's four routes. Each one calls Base44 with your credentials and returns only +// what the browser needs; the hook never sees a workspace key or an access token. +async function post(url: string, body: unknown): Promise { + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + if (!response.ok) throw new Error(`${url} failed: ${response.status}`); + return response.status === 204 ? (undefined as T) : response.json(); +} +const server: Base44ChatServer = { + createApp: prompt => post("/api/builder/apps", { prompt }), + openLiveSession: appId => post(`/api/builder/apps/${appId}/live-session`, {}), + sendMessage: (appId, content) => post(`/api/builder/apps/${appId}/messages`, { content }), + submitToolCallInput: (appId, answer) => post(`/api/builder/apps/${appId}/answers`, answer), +}; + +// In a component: the hook returns items to render and actions to wire to the UI. +export function useBuilderChat(appId: string | null, onAppCreated: (app: Base44App) => void) { + const chat = useBase44Chat({ appId, server, onAppCreated }); + + for (const item of chat.items) { + console.log(item.role, item.text, item.steps.map(step => `${step.status} ${step.label}`)); + // An open question carries only the actions its kind allows; declining is always one of them. + if (item.question?.kind === "approval") console.log("waiting for approval of", item.question.action); + } + if (chat.error) console.error(chat.error.code ?? "server", chat.error.message); + + return { + ...chat, + // The first prompt creates the app; every later one goes to it. + submit: (prompt: string) => (appId ? chat.send(prompt) : chat.create(prompt)), + }; +} 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/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..df4244fa --- /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. */ + server: Base44ChatServer; + /** Called with the new app after {@link Base44Chat.create} succeeds. */ + 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/index.ts b/packages/platform/src/react/index.ts new file mode 100644 index 00000000..1b0172ed --- /dev/null +++ b/packages/platform/src/react/index.ts @@ -0,0 +1,6 @@ +/** The `@base44/platform/react` entry: one app's builder chat as a hook. React is an optional peer, loaded only here. */ +export { useBase44Chat } from "./useBase44Chat.js"; +export type { + ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatServer, ChatError, ChatItem, ChatPhase, ChatStep, + ChoiceEntry, ChoiceQuestion, InputField, InputQuestion, LiveSession, Question, ToolCallAnswer, UnknownQuestion, +} from "./chat.types.js"; diff --git a/packages/platform/src/react/useBase44Chat.ts b/packages/platform/src/react/useBase44Chat.ts new file mode 100644 index 00000000..192eb0a8 --- /dev/null +++ b/packages/platform/src/react/useBase44Chat.ts @@ -0,0 +1,154 @@ +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 live session reads the server through a ref, kept current after every render, so an + // inline `server={{…}}` never reopens the socket. The actions below read it directly. + const serverRef = useRef(server); + useEffect(() => { + serverRef.current = server; + }); + + 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 server.submitToolCallInput(appId, { toolCallId, messageId, approve, extraUserInput }).catch((e: Error) => { + setAnswered(ids => ids.filter(id => id !== toolCallId)); + fail(e); + }); + }, + [appId, server, 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 server.sendMessage(appId, prompt).catch(fail); + }, + [appId, server, fail], + ); + + const create = useCallback( + async (prompt: string) => { + setError(null); + setCreating(true); + try { + onAppCreated?.(await server.createApp(prompt)); + } catch (e) { + fail(e as Error); + } finally { + setCreating(false); + } + }, + [server, onAppCreated, 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..5975e89d 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,31 @@ test("the client resolves from the installed package without the runtime SDK", ( `]); }); +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 { useBase44Chat } from "@base44/platform/react"; + assert.equal(typeof useBase44Chat, "function"); + `]); +}); + const consumer = ` import { Base44PlatformClient, type PlatformEvent } from "@base44/platform"; +import { useBase44Chat, type Base44ChatServer, type 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() {}, +}; +declare const question: Question; +if (question.kind === "choice") void question.answer([["A"]]); +// @ts-expect-error A choice is answered, not approved. +if (question.kind === "choice") question.approve(); +void useBase44Chat; void server; 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..1d1af81b --- /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 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/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.type-tests.json b/packages/platform/tsconfig.type-tests.json index c4630f01..2f6e0855 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -5,8 +5,17 @@ "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", + "tests/types/**/*.ts", + "examples/**/*.ts" + ] } 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", From 716b9c4ae9dfcffd425428dcd3122be620c85da3 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 10 Oct 2026 20:20:58 +0300 Subject: [PATCH 2/5] feat(platform/react): add a provider, reading hooks and parts you replace Base44ChatProvider calls useBase44Chat once and shares the chat through three contexts: state (re-renders on events), actions (stable) and parts. Any component under it reads the chat with useChatState / useChatActions, so a header or a preview can follow the same chat as the message list. and draw an item with parts from the provider, grouped as { message: { Text, Step }, question: { Choice, Input, Approval, Unknown } }. A question part receives the question with its bound actions. A part left out is 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. Both also work over the bare hook. The hook's actions now read server and onAppCreated through refs, so inline props keep the actions' identity. The question union is exported as ChatQuestion, since Question is the component. Co-Authored-By: Claude Opus 5.5 --- eslint.config.js | 2 +- packages/platform/README.md | 32 +++ .../platform/src/react/Base44ChatProvider.tsx | 73 ++++++ packages/platform/src/react/Message.tsx | 60 +++++ packages/platform/src/react/chat.types.ts | 4 +- .../platform/src/react/components.types.ts | 80 +++++++ packages/platform/src/react/context.ts | 76 +++++++ packages/platform/src/react/fallbacks.tsx | 90 ++++++++ packages/platform/src/react/index.ts | 11 +- packages/platform/src/react/useBase44Chat.ts | 18 +- packages/platform/tests/package.test.mjs | 14 +- packages/platform/tests/types/react.types.ts | 2 +- .../tests/unit/react/provider.test.tsx | 211 ++++++++++++++++++ packages/platform/tsconfig.json | 8 +- packages/platform/tsconfig.type-tests.json | 2 + packages/platform/vitest.config.ts | 2 +- 16 files changed, 663 insertions(+), 22 deletions(-) create mode 100644 packages/platform/src/react/Base44ChatProvider.tsx create mode 100644 packages/platform/src/react/Message.tsx create mode 100644 packages/platform/src/react/components.types.ts create mode 100644 packages/platform/src/react/context.ts create mode 100644 packages/platform/src/react/fallbacks.tsx create mode 100644 packages/platform/tests/unit/react/provider.test.tsx 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/packages/platform/README.md b/packages/platform/README.md index 69b37981..477a1081 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -194,3 +194,35 @@ and the actions `send`, `create` and `clearError`. A question is a union on `kin (`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.ts`](examples/react-chat.ts) shows a `fetch`-based `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/src/react/Base44ChatProvider.tsx b/packages/platform/src/react/Base44ChatProvider.tsx new file mode 100644 index 00000000..d38c226f --- /dev/null +++ b/packages/platform/src/react/Base44ChatProvider.tsx @@ -0,0 +1,73 @@ +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..51b10882 --- /dev/null +++ b/packages/platform/src/react/Message.tsx @@ -0,0 +1,60 @@ +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.types.ts b/packages/platform/src/react/chat.types.ts index df4244fa..7fb1bd83 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -166,9 +166,9 @@ export interface ChatError { 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. */ + /** 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. */ + /** 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; } 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..e1f70480 --- /dev/null +++ b/packages/platform/src/react/context.ts @@ -0,0 +1,76 @@ +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..48367d5b --- /dev/null +++ b/packages/platform/src/react/fallbacks.tsx @@ -0,0 +1,90 @@ +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 index 1b0172ed..328766e4 100644 --- a/packages/platform/src/react/index.ts +++ b/packages/platform/src/react/index.ts @@ -1,6 +1,13 @@ -/** The `@base44/platform/react` entry: one app's builder chat as a hook. React is an optional peer, loaded only here. */ +/** 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, ToolCallAnswer, UnknownQuestion, + 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 index 192eb0a8..e2a51f82 100644 --- a/packages/platform/src/react/useBase44Chat.ts +++ b/packages/platform/src/react/useBase44Chat.ts @@ -59,11 +59,13 @@ export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions setAnswered([]); } - // The live session reads the server through a ref, kept current after every render, so an - // inline `server={{…}}` never reopens the socket. The actions below read it directly. + // 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 }), []); @@ -110,12 +112,12 @@ export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions if (!appId) return Promise.resolve(); setError(null); setAnswered(ids => [...ids, toolCallId]); - return server.submitToolCallInput(appId, { toolCallId, messageId, approve, extraUserInput }).catch((e: Error) => { + return serverRef.current.submitToolCallInput(appId, { toolCallId, messageId, approve, extraUserInput }).catch((e: Error) => { setAnswered(ids => ids.filter(id => id !== toolCallId)); fail(e); }); }, - [appId, server, fail], + [appId, fail], ); const sorted = useMemo(() => sortMessages(messages), [messages]); @@ -128,9 +130,9 @@ export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions (prompt: string) => { if (!appId) return Promise.resolve(); setError(null); - return server.sendMessage(appId, prompt).catch(fail); + return serverRef.current.sendMessage(appId, prompt).catch(fail); }, - [appId, server, fail], + [appId, fail], ); const create = useCallback( @@ -138,14 +140,14 @@ export function useBase44Chat({ appId, server, onAppCreated }: Base44ChatOptions setError(null); setCreating(true); try { - onAppCreated?.(await server.createApp(prompt)); + onAppCreatedRef.current?.(await serverRef.current.createApp(prompt)); } catch (e) { fail(e as Error); } finally { setCreating(false); } }, - [server, onAppCreated, fail], + [fail], ); const clearError = useCallback(() => setError(null), []); diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index 5975e89d..52d6a72a 100644 --- a/packages/platform/tests/package.test.mjs +++ b/packages/platform/tests/package.test.mjs @@ -47,25 +47,29 @@ test("react is an optional peer of the /react entry, never a dependency of the c assert.deepEqual(manifest.peerDependenciesMeta, { react: { optional: true } }); run(process.execPath, ["--input-type=module", "--eval", ` import assert from "node:assert/strict"; - import { useBase44Chat } from "@base44/platform/react"; - assert.equal(typeof useBase44Chat, "function"); + 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 { useBase44Chat, type Base44ChatServer, type Question } from "@base44/platform/react"; +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: Question; +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(); -void useBase44Chat; void server; +// 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 index 1d1af81b..01c55329 100644 --- a/packages/platform/tests/types/react.types.ts +++ b/packages/platform/tests/types/react.types.ts @@ -1,5 +1,5 @@ import type { ChatMessage } from "@base44/platform"; -import { useBase44Chat, type Base44Chat, type Base44ChatServer, type ChatItem, type Question } from "@base44/platform/react"; +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 }; }, 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/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 2f6e0855..ff00e3ed 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -15,7 +15,9 @@ }, "include": [ "src/**/*.ts", + "src/**/*.tsx", "tests/types/**/*.ts", + "tests/types/**/*.tsx", "examples/**/*.ts" ] } 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}"], }, }); From b53a29dce9e25fc56a3f2160005a5a99d368b12a Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 10 Oct 2026 20:33:38 +0300 Subject: [PATCH 3/5] fix(deps): resolve the new dev dependencies from the public registry The lockfile entries for react, react-dom, their types and jsdom pointed at an internal registry mirror that CI cannot reach, so `npm ci` failed in every job. Same versions, resolved from registry.npmjs.org like the rest of the lockfile. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 165 ++++++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 70 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc1e4f70..23641762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,9 +30,10 @@ }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "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", @@ -43,9 +44,10 @@ }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/lru-cache/-/lru-cache-10.4.3.tgz", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.29.7", @@ -327,7 +329,7 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "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": [ @@ -340,13 +342,14 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": ">=18" } }, "node_modules/@csstools/css-calc": { "version": "2.1.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ @@ -359,6 +362,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" }, @@ -369,7 +373,7 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ @@ -382,6 +386,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -396,7 +401,7 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "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": [ @@ -409,6 +414,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" }, @@ -418,7 +424,7 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ @@ -431,6 +437,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" } @@ -1167,18 +1174,20 @@ }, "node_modules/@types/react": { "version": "19.3.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/@types/react/-/react-19.3.0.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/@types/react-dom/-/react-dom-19.3.0.tgz", + "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" } @@ -2155,9 +2164,10 @@ }, "node_modules/cssstyle": { "version": "4.6.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/cssstyle/-/cssstyle-4.6.0.tgz", + "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" @@ -2168,15 +2178,17 @@ }, "node_modules/csstype": { "version": "3.2.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/csstype/-/csstype-3.2.3.tgz", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/data-urls/-/data-urls-5.0.0.tgz", + "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" @@ -2258,9 +2270,10 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/decimal.js/-/decimal.js-10.6.0.tgz", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", @@ -3306,9 +3319,10 @@ }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "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" }, @@ -3325,9 +3339,10 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "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" @@ -3338,9 +3353,10 @@ }, "node_modules/http-proxy-agent/node_modules/agent-base": { "version": "7.1.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/agent-base/-/agent-base-7.1.4.tgz", + "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" } @@ -3360,9 +3376,10 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/iconv-lite/-/iconv-lite-0.6.3.tgz", + "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" }, @@ -3661,9 +3678,10 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "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 + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", @@ -3895,9 +3913,10 @@ }, "node_modules/jsdom": { "version": "26.1.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/jsdom/-/jsdom-26.1.0.tgz", + "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", @@ -3934,18 +3953,20 @@ }, "node_modules/jsdom/node_modules/agent-base": { "version": "7.1.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/agent-base/-/agent-base-7.1.4.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "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" @@ -4548,9 +4569,10 @@ }, "node_modules/nwsapi": { "version": "2.2.28", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/nwsapi/-/nwsapi-2.2.28.tgz", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/object-inspect": { "version": "1.13.4", @@ -4746,9 +4768,10 @@ }, "node_modules/parse5": { "version": "7.3.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/parse5/-/parse5-7.3.0.tgz", + "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" }, @@ -4758,9 +4781,10 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/entities/-/entities-6.0.1.tgz", + "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" }, @@ -4921,18 +4945,20 @@ }, "node_modules/react": { "version": "19.3.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/react/-/react-19.3.0.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/react-dom/-/react-dom-19.3.0.tgz", + "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" }, @@ -5051,9 +5077,10 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -5112,15 +5139,17 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/saxes/-/saxes-6.0.0.tgz", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, @@ -5130,9 +5159,10 @@ }, "node_modules/scheduler": { "version": "0.28.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/scheduler/-/scheduler-0.28.0.tgz", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/semver": { "version": "7.7.3", @@ -5493,9 +5523,10 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/symbol-tree/-/symbol-tree-3.2.4.tgz", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", @@ -5561,9 +5592,10 @@ }, "node_modules/tldts": { "version": "6.1.86", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tldts/-/tldts-6.1.86.tgz", + "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" }, @@ -5573,9 +5605,10 @@ }, "node_modules/tldts-core": { "version": "6.1.86", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tldts-core/-/tldts-core-6.1.86.tgz", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/totalist": { "version": "3.0.1", @@ -5589,9 +5622,10 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tough-cookie/-/tough-cookie-5.1.2.tgz", + "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" }, @@ -5601,9 +5635,10 @@ }, "node_modules/tr46": { "version": "5.1.1", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/tr46/-/tr46-5.1.1.tgz", + "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" }, @@ -6107,9 +6142,10 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "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" }, @@ -6119,19 +6155,21 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "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" }, @@ -6141,18 +6179,20 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/whatwg-url/-/whatwg-url-14.2.0.tgz", + "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" @@ -6316,18 +6356,20 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", - "resolved": "https://npm.dev.wixpress.com/api/npm/npm-repos/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "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://npm.dev.wixpress.com/api/npm/npm-repos/xmlchars/-/xmlchars-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", @@ -6396,23 +6438,6 @@ } } }, - "packages/platform-react": { - "version": "0.1.0", - "extraneous": true, - "license": "MIT", - "devDependencies": { - "@base44/platform": "^0.1.0", - "@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": { - "@base44/platform": "^0.1.0", - "react": "^18.0.0 || ^19.0.0" - } - }, "packages/sdk": { "name": "@base44/sdk", "version": "0.8.52", From 2b1aebdf0a848f1085db7fbeaacbfb0d9f376788 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 10 Oct 2026 20:42:08 +0300 Subject: [PATCH 4/5] fix(platform/react): mark the hook and context modules "use client" Next.js App Router treats a module without the directive as a server module, so rendering the provider (or calling the hook) from a server component fails. The modules that use hooks or context now carry it, and the tarball test checks the compiled output keeps it. Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/Base44ChatProvider.tsx | 1 + packages/platform/src/react/Message.tsx | 1 + packages/platform/src/react/context.ts | 1 + packages/platform/src/react/fallbacks.tsx | 1 + packages/platform/src/react/useBase44Chat.ts | 1 + packages/platform/tests/package.test.mjs | 6 ++++++ 6 files changed, 11 insertions(+) diff --git a/packages/platform/src/react/Base44ChatProvider.tsx b/packages/platform/src/react/Base44ChatProvider.tsx index d38c226f..9908106f 100644 --- a/packages/platform/src/react/Base44ChatProvider.tsx +++ b/packages/platform/src/react/Base44ChatProvider.tsx @@ -1,3 +1,4 @@ +"use client"; import { useMemo } from "react"; import type { Base44ChatProviderProps, ChatComponents } from "./components.types.js"; import { ActionsContext, ComponentsContext, StateContext } from "./context.js"; diff --git a/packages/platform/src/react/Message.tsx b/packages/platform/src/react/Message.tsx index 51b10882..09a56a85 100644 --- a/packages/platform/src/react/Message.tsx +++ b/packages/platform/src/react/Message.tsx @@ -1,3 +1,4 @@ +"use client"; import type { MessageProps, QuestionProps } from "./components.types.js"; import { useChatComponents } from "./context.js"; diff --git a/packages/platform/src/react/context.ts b/packages/platform/src/react/context.ts index e1f70480..b12f6391 100644 --- a/packages/platform/src/react/context.ts +++ b/packages/platform/src/react/context.ts @@ -1,3 +1,4 @@ +"use client"; import { createContext, useContext } from "react"; import type { ChatActions, ChatComponents, ChatState } from "./components.types.js"; import { fallbackComponents } from "./fallbacks.js"; diff --git a/packages/platform/src/react/fallbacks.tsx b/packages/platform/src/react/fallbacks.tsx index 48367d5b..c5e0d7ce 100644 --- a/packages/platform/src/react/fallbacks.tsx +++ b/packages/platform/src/react/fallbacks.tsx @@ -1,3 +1,4 @@ +"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"; diff --git a/packages/platform/src/react/useBase44Chat.ts b/packages/platform/src/react/useBase44Chat.ts index e2a51f82..c1212871 100644 --- a/packages/platform/src/react/useBase44Chat.ts +++ b/packages/platform/src/react/useBase44Chat.ts @@ -1,3 +1,4 @@ +"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Base44PlatformClient } from "../client.js"; import type { ChatMessage } from "../modules/builder.events.types.js"; diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index 52d6a72a..299d3088 100644 --- a/packages/platform/tests/package.test.mjs +++ b/packages/platform/tests/package.test.mjs @@ -41,6 +41,12 @@ 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" }); From 569db4ae9ee8156f53d10644ba497885c5c5bf76 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 10 Oct 2026 21:01:14 +0300 Subject: [PATCH 5/5] docs(platform/react): Tiny Sunny's chat on the provider, as a self-contained example examples/react-chat/ replaces the single-file example: the chat (Chat.tsx), its parts (chatParts.tsx, grouped for the provider) and the four server functions (server.ts), with a README saying what to read first. It imports @base44/platform/react from the workspace, so it needs no copy of the package, and the type tests check it, JSX included. Co-Authored-By: Claude Opus 5.5 --- packages/platform/README.md | 2 +- packages/platform/examples/react-chat.ts | 33 ----- .../platform/examples/react-chat/Chat.tsx | 67 ++++++++++ .../platform/examples/react-chat/README.md | 15 +++ .../examples/react-chat/chatParts.tsx | 116 ++++++++++++++++++ .../platform/examples/react-chat/server.ts | 71 +++++++++++ packages/platform/tsconfig.type-tests.json | 3 +- 7 files changed, 272 insertions(+), 35 deletions(-) delete mode 100644 packages/platform/examples/react-chat.ts create mode 100644 packages/platform/examples/react-chat/Chat.tsx create mode 100644 packages/platform/examples/react-chat/README.md create mode 100644 packages/platform/examples/react-chat/chatParts.tsx create mode 100644 packages/platform/examples/react-chat/server.ts diff --git a/packages/platform/README.md b/packages/platform/README.md index 477a1081..b12fb65c 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -193,7 +193,7 @@ a `phase` (`idle`, `creating`, `loading`, `waiting`, `building`), an `error`, `c 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.ts`](examples/react-chat.ts) shows a `fetch`-based `server`. +[`examples/react-chat/`](examples/react-chat) shows Tiny Sunny's chat built on it, with its `server`. ### Provider and parts diff --git a/packages/platform/examples/react-chat.ts b/packages/platform/examples/react-chat.ts deleted file mode 100644 index b6c506f9..00000000 --- a/packages/platform/examples/react-chat.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useBase44Chat, type Base44App, type Base44ChatServer } from "@base44/platform/react"; - -// Your backend's four routes. Each one calls Base44 with your credentials and returns only -// what the browser needs; the hook never sees a workspace key or an access token. -async function post(url: string, body: unknown): Promise { - const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); - if (!response.ok) throw new Error(`${url} failed: ${response.status}`); - return response.status === 204 ? (undefined as T) : response.json(); -} -const server: Base44ChatServer = { - createApp: prompt => post("/api/builder/apps", { prompt }), - openLiveSession: appId => post(`/api/builder/apps/${appId}/live-session`, {}), - sendMessage: (appId, content) => post(`/api/builder/apps/${appId}/messages`, { content }), - submitToolCallInput: (appId, answer) => post(`/api/builder/apps/${appId}/answers`, answer), -}; - -// In a component: the hook returns items to render and actions to wire to the UI. -export function useBuilderChat(appId: string | null, onAppCreated: (app: Base44App) => void) { - const chat = useBase44Chat({ appId, server, onAppCreated }); - - for (const item of chat.items) { - console.log(item.role, item.text, item.steps.map(step => `${step.status} ${step.label}`)); - // An open question carries only the actions its kind allows; declining is always one of them. - if (item.question?.kind === "approval") console.log("waiting for approval of", item.question.action); - } - if (chat.error) console.error(chat.error.code ?? "server", chat.error.message); - - return { - ...chat, - // The first prompt creates the app; every later one goes to it. - submit: (prompt: string) => (appId ? chat.send(prompt) : chat.create(prompt)), - }; -} 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/tsconfig.type-tests.json b/packages/platform/tsconfig.type-tests.json index ff00e3ed..793d83ba 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -18,6 +18,7 @@ "src/**/*.tsx", "tests/types/**/*.ts", "tests/types/**/*.tsx", - "examples/**/*.ts" + "examples/**/*.ts", + "examples/**/*.tsx" ] }