From 1f80c842afed55470367293f10b12591ba9efe84 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sat, 17 Oct 2026 12:48:04 +0300 Subject: [PATCH 1/4] feat(platform): add @base44/platform/react, a hook that runs the AI chat A new subpath, @base44/platform/react, with one hook: useBase44Chat. It runs the AI chat for one app in the partner's own UI, on top of the platform client, and renders nothing. React 18 or 19 is an optional peer, so the client and server entries stay framework-free. - backend: four calls the partner implements (createApp, openLiveSession, sendMessage, submitToolCallInput). openLiveSession returns Base44's socket-session body unchanged, and is called again on every renewal. No credential reaches the browser. - items: the conversation, oldest first. Each step is one tool call, and a waiting step on the last assistant message carries its question, bound to the actions that answer it (choice, secrets, approval; every kind can be declined). A kind this release doesn't know is declined once per tool call. A sent prompt shows at once as a pending item until Base44's own message arrives. - status: error > waiting > building > submitted > ready. A failed build is error with code build_failed; submitted runs from the call until Base44 reports work, a reply or a queue change. - connection: connecting, connected, or disconnected once live updates stop for good, with error saying why. - send needs an app and create makes one; while appId is null the chat follows the app create made. - Rejoins keep messages older than the snapshot's window; a status older than the newest seen is ignored, since Base44 resends an earlier turn's. - Actions never throw: a failure lands in error, and a question whose answer failed opens again. The JSDoc follows the SDK docs guidelines, with @example blocks for the reference pages. Tests cover the item mapping, the hook under jsdom with a recorded platform client, the public types, and the published /react entry. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 555 +++++++++++++- packages/platform/README.md | 27 + packages/platform/package.json | 28 +- packages/platform/src/react/chat-items.ts | 200 +++++ packages/platform/src/react/chat.types.ts | 315 ++++++++ packages/platform/src/react/index.ts | 7 + packages/platform/src/react/useBase44Chat.ts | 329 ++++++++ packages/platform/tests/package.test.mjs | 36 +- packages/platform/tests/types/react.types.ts | 72 ++ .../tests/unit/react/chat-items.test.ts | 227 ++++++ .../tests/unit/react/useBase44Chat.test.ts | 716 ++++++++++++++++++ packages/platform/tsconfig.type-tests.json | 3 +- packages/platform/typedoc.json | 3 +- 13 files changed, 2508 insertions(+), 10 deletions(-) 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 ab902315..02893284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,27 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -324,6 +345,121 @@ "node": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1068,6 +1204,26 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2038,6 +2194,41 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2109,6 +2300,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3151,6 +3349,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3158,6 +3369,30 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3171,6 +3406,19 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3460,6 +3708,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3688,6 +3943,70 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4333,6 +4652,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4525,6 +4851,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/partysocket": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.23.tgz", @@ -4692,6 +5044,29 @@ "node": ">=6" } }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4801,6 +5176,13 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -4856,6 +5238,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5213,6 +5622,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5275,6 +5691,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -5285,6 +5721,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -5779,6 +6241,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5932,6 +6455,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", @@ -5984,7 +6524,20 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { - "json-schema-to-typescript": "16.0.0" + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "jsdom": "^26.0.0", + "json-schema-to-typescript": "16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "packages/sdk": { diff --git a/packages/platform/README.md b/packages/platform/README.md index 8f47f96c..1bc34831 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -170,3 +170,30 @@ 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 and renews it, turns messages into items +made for rendering, binds every question the builder asks to the step it belongs to. 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, backend, onAppCreated }); +chat.items.map((item) => + item.steps.map((step) => step.question?.kind === "approval" && ), +); +``` + +`backend` 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 +`status` (`ready`, `submitted`, `building`, `waiting`, `error`), a `connection` (`connecting`, +`connected`, `disconnected`), an `error`, and the actions `send`, `create` and `clearError`. `send` needs an app; `create` makes +the first, and the chat watches it while `appId` is `null`. A sent prompt shows at once as a +`pending` item until Base44's own message arrives; a prompt sent mid-build waits in Base44's +queue meanwhile. A question is a union on `kind` (`choice`, `secrets`, `approval`) and carries only the +actions its kind allows, and declining is always one of them. As in Base44's builder, only the +last assistant message makes the chat wait; a kind this release doesn't know is declined for you. Every shape has JSDoc in `src/react/chat.types.ts`. diff --git a/packages/platform/package.json b/packages/platform/package.json index 175dc185..a3feaa67 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -14,12 +14,19 @@ "types": "./dist/server/index.d.ts", "default": "./dist/server/index.js" }, + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, "./package.json": "./package.json" }, "typesVersions": { "*": { "server": [ "dist/server/index.d.ts" + ], + "react": [ + "dist/react/index.d.ts" ] } }, @@ -49,6 +56,22 @@ "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", + "json-schema-to-typescript": "16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, "keywords": [ "base44", "platform", @@ -64,8 +87,5 @@ "bugs": { "url": "https://github.com/base44/javascript-sdk/issues" }, - "homepage": "https://github.com/base44/javascript-sdk/tree/main/packages/platform#readme", - "devDependencies": { - "json-schema-to-typescript": "16.0.0" - } + "homepage": "https://github.com/base44/javascript-sdk/tree/main/packages/platform#readme" } diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts new file mode 100644 index 00000000..22a2675b --- /dev/null +++ b/packages/platform/src/react/chat-items.ts @@ -0,0 +1,200 @@ +import type { Message, QuestionArguments, SetSecretsArguments } from "../client/modules/builder.events.generated.js"; +import type { ToolCall } from "../client/modules/builder.events.types.js"; +import type { ChatItem, ChatQuestion, ChatStep } from "./chat.types.js"; + +/** @internal Sends the answer to the one tool call a question belongs to. */ +export type AnswerToolCall = (approve: boolean, extraUserInput?: Record) => Promise; + +/** @internal Sends an answer to a given tool call in a given message. */ +export type SubmitAnswer = (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: Message[]): Message[] { + return [...messages].sort((first, second) => (first.metadata?.created_date ?? "").localeCompare(second.metadata?.created_date ?? "")); +} + +/** @internal Replaces the message with the same id in place, or adds it at the end. */ +export function mergeMessage(messages: Message[], updatedMessage: Message): Message[] { + const isKnown = messages.some(message => message.id === updatedMessage.id); + if (!isKnown) return [...messages, updatedMessage]; + return messages.map(message => (message.id === updatedMessage.id ? updatedMessage : message)); +} + +/** @internal How many of the newest messages a snapshot holds. */ +export const SNAPSHOT_SIZE = 50; + +/** @internal A rejoin's snapshot holds only the newest messages: it is the truth for its window, + * and the messages before its oldest one stay. A snapshot with room to spare holds them all. */ +export function mergeSnapshot(heldMessages: Message[], snapshotMessages: Message[]): Message[] { + if (snapshotMessages.length < SNAPSHOT_SIZE) return snapshotMessages; + const oldestSnapshotDate = sortMessages(snapshotMessages)[0]?.metadata?.created_date ?? ""; + const snapshotIds = new Set(snapshotMessages.map(message => message.id)); + const olderMessages = heldMessages.filter( + message => !snapshotIds.has(message.id) && (message.metadata?.created_date ?? "") < oldestSnapshotDate, + ); + return [...olderMessages, ...snapshotMessages]; +} + +/** @internal An assistant message that answers a turn. Base44 injects notices that expect no reply. */ +function isTurnReply(message: Message | undefined): boolean { + return message?.role === "assistant" && !message.additional_message_params?.skip_ai_response; +} + +/** @internal The last assistant message that answers a turn, as Base44's own builder finds it. */ +function lastTurnReply(sortedMessages: Message[]): Message | undefined { + return [...sortedMessages].reverse().find(isTurnReply); +} + +/** @internal Turns sorted messages into items. A waiting tool call that is not answered yet and is + * of a kind this release knows carries its own question, in the last turn reply only: the one that + * makes the chat wait. Older waiting calls keep their status but no question. */ +export function toItems(sortedMessages: Message[], answeredToolCallIds: string[], submitAnswer: SubmitAnswer): ChatItem[] { + const lastReply = lastTurnReply(sortedMessages); + return sortedMessages.map(message => ({ + id: message.id!, + role: message.role === "user" ? "user" : "assistant", + text: message.content ?? "", + createdAt: message.metadata?.created_date ?? "", + pending: false, + steps: (message.tool_calls ?? []).map(toolCall => { + const isAnswerable = message === lastReply && !answeredToolCallIds.includes(toolCall.id!); + const answerToolCall: AnswerToolCall | undefined = isAnswerable + ? (approve, extraUserInput) => submitAnswer(toolCall.id!, message.id!, approve, extraUserInput) + : undefined; + return toStep(toolCall, answerToolCall); + }), + })); +} + +function toStep(toolCall: ToolCall, answerToolCall: AnswerToolCall | undefined): ChatStep { + const toolName = toolCall.name ?? ""; + // Only file tools carry `display.file_paths`. + const display = "display" in toolCall ? (toolCall.display as { file_paths?: string[] } | null | undefined) : undefined; + const files = display?.file_paths ?? []; + const status = stepStatus(toolCall); + const question = status === "waiting" && answerToolCall ? toQuestion(toolCall, answerToolCall) : undefined; + return { id: toolCall.id!, tool: toolName, files, label: [toolName, ...files].join(" "), status, ...(question && { question }) }; +} + +/** @internal Base44's tool call status, as a step's. */ +export function stepStatus(toolCall: ToolCall): ChatStep["status"] { + if (toolCall.status === "running") return "running"; + if (toolCall.status === "waiting_for_user_input") return "waiting"; + if (toolCall.status === "stopped") return "stopped"; + if (toolCall.status === "error") return "error"; + return "done"; +} + +/** @internal Whether the chat waits on the user. Like Base44's builder, only the last turn reply + * counts, and a call answered or declined here stops counting. */ +export function isWaitingForUser(sortedMessages: Message[], answeredToolCallIds: string[]): boolean { + const lastReply = lastTurnReply(sortedMessages); + return !!lastReply?.tool_calls?.some( + toolCall => toolCall.status === "waiting_for_user_input" && !answeredToolCallIds.includes(toolCall.id!), + ); +} + +const KNOWN_QUESTION_KINDS = ["approval", "choice", "input"]; + +/** @internal Waiting calls on the last turn reply whose kind Base44 names and this release doesn't + * know, to decline. A call with no kind comes only from old messages and is left alone. */ +export function unknownKindToolCalls(sortedMessages: Message[]): { toolCallId: string; messageId: string }[] { + const lastReply = lastTurnReply(sortedMessages); + if (!lastReply) return []; + return (lastReply.tool_calls ?? []) + .filter(toolCall => { + const kind = toolCall.waiting_on?.kind; + return toolCall.status === "waiting_for_user_input" && !!kind && !KNOWN_QUESTION_KINDS.includes(kind); + }) + .map(toolCall => ({ toolCallId: toolCall.id!, messageId: lastReply.id! })); +} + +/** @internal The ids of every tool call Base44 reports waiting. */ +export function waitingToolCallIds(sortedMessages: Message[]): Set { + return new Set( + sortedMessages.flatMap(message => + (message.tool_calls ?? []).filter(toolCall => toolCall.status === "waiting_for_user_input").map(toolCall => toolCall.id!), + ), + ); +} + +/** @internal A prompt sent from this chat that Base44 has not confirmed, for the app it went to. */ +export interface PendingPrompt { + id: string; + content: string; + createdAt: string; + appId: string; +} + +/** @internal Prompts that arrived from Base44 as messages settle the pending ones with the same + * text. Base44's public send carries no client id, so text is the only link. With + * `settleOldestIfUnmatched`, an arrival that matches none settles the oldest, so a prompt Base44 + * changed on the way never stays pending. */ +export function settlePendingPrompts( + pendingPrompts: PendingPrompt[], + appId: string, + arrivedPrompts: string[], + settleOldestIfUnmatched: boolean, +): PendingPrompt[] { + let stillPending = pendingPrompts; + for (const arrivedPrompt of arrivedPrompts) { + const pendingForApp = stillPending.filter(prompt => prompt.appId === appId); + const exactMatch = pendingForApp.find(prompt => prompt.content.trim() === arrivedPrompt.trim()); + const settled = exactMatch ?? (settleOldestIfUnmatched ? pendingForApp[0] : undefined); + if (settled) stillPending = stillPending.filter(prompt => prompt !== settled); + } + return stillPending; +} + +/** @internal Pending prompts as items, drawn after the messages Base44 has. */ +export function pendingPromptItems(pendingPrompts: PendingPrompt[]): ChatItem[] { + return pendingPrompts.map(prompt => ({ id: prompt.id, role: "user", text: prompt.content, createdAt: prompt.createdAt, steps: [], pending: true })); +} + +/** @internal Base44's question formats, as one simple shape per kind, each bound to the call that + * answers it. Base44 keys choice answers by position, so `picked` follows `questions`. A kind this + * release doesn't know has no shape. */ +export function toQuestion(toolCall: ToolCall, answerToolCall: AnswerToolCall): ChatQuestion | undefined { + const kind = toolCall.waiting_on?.kind; + const decline = () => answerToolCall(false); + if (kind === "choice") { + const questions = (toolArguments(toolCall) as QuestionArguments | undefined)?.questions ?? []; + return { + kind, + questions: questions.map(question => ({ + text: question.question ?? "", + description: question.description, + options: (question.options ?? []).map(option => (typeof option === "string" ? option : option.label ?? "")), + multi: !!question.multi_select, + })), + answer: picked => { + const answers = picked + .map((labels, questionIndex) => ({ question_index: questionIndex, selected_labels: labels })) + .filter(answer => answer.selected_labels.length); + return answerToolCall(true, { answers }); + }, + decline, + }; + } + if (kind === "input") { + const secretFields = (toolArguments(toolCall) as SetSecretsArguments | undefined)?.secrets_schema ?? []; + return { + kind: "secrets", + fields: secretFields.map(field => ({ name: field.secretName ?? "", description: field.description })), + submit: values => answerToolCall(true, { secrets: values }), + decline, + }; + } + if (kind === "approval") { + // Guards that declare a reason or a summary carry them; the others name only themselves. + const approval = toolCall.approval as { reason?: string | null; details?: { summary?: string | null } | null } | null | undefined; + const reason = approval?.reason ?? approval?.details?.summary ?? ""; + return { kind, action: toolCall.name ?? "", reason, approve: () => answerToolCall(true), decline }; + } + return undefined; +} + +// The waiting kind says which arguments a call carries; the tool's name only narrows the union. +function toolArguments(toolCall: ToolCall): unknown { + return "arguments" in toolCall ? toolCall.arguments : undefined; +} diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts new file mode 100644 index 00000000..a0681f7f --- /dev/null +++ b/packages/platform/src/react/chat.types.ts @@ -0,0 +1,315 @@ +import type { SocketSession } from "../client/modules/builder.types.js"; + +/** An app the AI chat works on. */ +export interface Base44App { + /** ID of the app, 24 lowercase hexadecimal characters. */ + id: string; + /** Display name of the app. */ + name: string; +} + +/** An answer to a question the AI waits on. Declining is `approve: false`. */ +export interface ToolCallAnswer { + /** ID of the tool call being answered. */ + toolCallId: string; + /** ID of the assistant message that holds the tool call. */ + messageId: string; + /** Whether to let the tool call run (`true`) or turn it down (`false`). */ + approve: boolean; + /** What the question asked for. A choice sends `{ answers }`, and a secrets question sends `{ secrets }`. */ + extraUserInput?: Record; +} + +/** + * The four calls your backend makes to Base44 for the chat. + * + * Each call wraps one Base44 API request with your credentials, so the browser never holds a + * workspace API key or an access token. Your backend can be written in any language. + * + * - `createApp`: [Create app](/api-reference/create-app), with the first prompt as `initial_message`. + * - `openLiveSession`: [Create socket session](/api-reference/create-socket-session) with `{ app_ids: [appId] }`. + * Return the response body unchanged. It holds a session token, never the workspace API key. + * - `sendMessage`: [Send chat message](/api-reference/send-chat-message). + * - `submitToolCallInput`: [Submit tool-call input](/api-reference/submit-tool-call-input). Send an + * `X-Request-ID` built from the tool call ID, so a retry resumes the turn only once. + * + * Base44 keeps the Send chat message and Submit tool-call input requests open until the AI + * finishes the turn. Resolve your call once Base44 accepts the request, not when it returns. The + * chat follows the rest live. A rejected promise from any call sets {@link Base44Chat.error}. + * + * @example + * ```typescript + * // Your four calls, behind your own API routes + * async function post(path: string, body: unknown): Promise { + * const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + * if (!response.ok) throw new Error(`${path} failed: ${response.status}`); + * return response.json(); + * } + * + * const backend: Base44ChatBackend = { + * createApp: (prompt) => post("/api/chat/apps", { prompt }), + * openLiveSession: (appId) => post("/api/chat/session", { appId }), + * sendMessage: (appId, content) => post("/api/chat/message", { appId, content }), + * submitToolCallInput: (appId, answer) => post("/api/chat/answer", { appId, answer }), + * }; + * ``` + */ +export interface Base44ChatBackend { + /** + * Creates an app from its first prompt. The AI starts building inside this call. + * + * @param prompt - The first prompt, which describes the app. + * @returns Promise resolving to the new app. + */ + createApp(prompt: string): Promise; + /** + * Opens a read-only socket session for one app. The chat calls it on the first connect and again + * whenever the session expires. Sessions last one hour. + * + * @param appId - ID of the app to follow. + * @returns Promise resolving to the Create socket session response body, unchanged. + */ + openLiveSession(appId: string): Promise; + /** + * Sends a prompt to an existing app. Base44 queues a prompt sent while the AI is working. When the + * app has no queue, Base44 refuses it with a 409, so reject the promise. + * + * @param appId - ID of the app. + * @param content - The prompt. + * @returns Promise resolving once Base44 accepts the prompt. + */ + sendMessage(appId: string, content: string): Promise; + /** + * Answers or declines a question the AI waits on. + * + * @param appId - ID of the app. + * @param answer - The tool call and the answer to it. + * @returns Promise resolving once Base44 accepts the answer. + */ + submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise; +} + +/** One message of the chat, shaped for rendering. */ +export interface ChatItem { + /** ID of the message. */ + id: string; + /** Who wrote the message. */ + role: "user" | "assistant"; + /** The message text, as raw markdown. It comes before the steps, and is empty when the message only holds tool calls. */ + text: string; + /** When the message was created, as an ISO timestamp. Base44's timestamps are UTC with no time zone. A pending prompt carries the browser's time. */ + createdAt: string; + /** One step per tool call, in order. */ + steps: ChatStep[]; + /** + * Whether this is a prompt sent from this chat that Base44 hasn't confirmed yet. It shows at once, + * and Base44's own message replaces it. A failed send removes it. A prompt sent while the AI is + * working waits in Base44's queue and stays pending until it runs. + */ + pending: boolean; +} + +/** One tool call, as a line of activity. */ +export interface ChatStep { + /** ID of the tool call. */ + id: string; + /** Name of the tool, such as `write_file`. */ + tool: string; + /** The file paths the tool call touched. */ + files: string[]; + /** The tool name and its files, ready to show. */ + label: string; + /** + * Where the tool call is. A `waiting` step waits on the user. A `stopped` step was declined, or the + * turn was stopped. + */ + status: "running" | "waiting" | "done" | "stopped" | "error"; + /** + * The question this step waits on, with the actions that answer it. Present only while the step is + * `waiting` in the last assistant message, which is the one that makes the chat wait. It goes away + * once answered, and is absent for a question kind this release doesn't know. + */ + question?: ChatQuestion; +} + +/** + * 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 sets {@link Base44Chat.error}, and the question opens again. + * + * The chat declines a question kind this release doesn't know, once per tool call, when Base44 + * names that kind on the last assistant message. If the decline fails, {@link Base44Chat.error} says + * so, and the chat tries again after it reconnects. + */ +export type ChatQuestion = ChoiceQuestion | SecretsQuestion | ApprovalQuestion; + +/** One of the questions in a {@link ChoiceQuestion}. */ +export interface ChoiceEntry { + /** The question text. */ + text: string; + /** Supporting text for the question. */ + description?: string; + /** The option labels to pick from. */ + options: string[]; + /** Whether more than one option can be picked. */ + multi: boolean; +} + +/** The AI offers options and waits for the user to pick. */ +export interface ChoiceQuestion { + /** Kind of question. */ + kind: "choice"; + /** The questions, in order. */ + questions: ChoiceEntry[]; + /** + * Answers with the picked labels. + * + * @param picked - One list of picked labels per question, in the order of `questions`. An empty list skips that question. + * @returns Promise resolving once the answer is sent. + */ + answer(picked: string[][]): Promise; + /** + * Declines the question. The AI continues without the answer. + * + * @returns Promise resolving once the decline is sent. + */ + decline(): Promise; +} + +/** A value the AI needs, such as an API key. */ +export interface SecretField { + /** Name of the field, used as its key in `submit`. */ + name: string; + /** Where to get the value. */ + description?: string; +} + +/** The AI needs secrets, such as API keys. The values go to Base44 and the chat keeps none of them. */ +export interface SecretsQuestion { + /** Kind of question. */ + kind: "secrets"; + /** The fields to fill. */ + fields: SecretField[]; + /** + * Submits the values. + * + * @param values - The values, keyed by field name. + * @returns Promise resolving once the values are sent. + */ + submit(values: Record): Promise; + /** + * Declines the question. The AI continues without the values. + * + * @returns Promise resolving once the decline is sent. + */ + decline(): Promise; +} + +/** The AI asks for permission to run an action. */ +export interface ApprovalQuestion { + /** Kind of question. */ + kind: "approval"; + /** Name of the tool that wants to run. */ + action: string; + /** Why the action needs approval, when Base44 gives a reason. Empty otherwise. */ + reason: string; + /** + * Lets the action run. + * + * @returns Promise resolving once the approval is sent. + */ + approve(): Promise; + /** + * Declines the action. It never runs, and the AI continues. + * + * @returns Promise resolving once the decline is sent. + */ + decline(): Promise; +} + +/** + * What the AI is doing. When several hold at once, the first in this list wins. + * + * - `error`: Base44 reports that the last build failed. + * - `waiting`: a question waits on the user. + * - `building`: the AI is working. + * - `submitted`: a prompt, a new app or an answer is on its way. It lasts until Base44 reports work, a reply or a queue change, or the call fails. + * - `ready`: none of the above. + */ +export type ChatStatus = "ready" | "submitted" | "building" | "waiting" | "error"; + +/** + * Whether the chat follows the app live. + * + * - `connecting`: until the first snapshot arrives. + * - `connected`: after the first snapshot. + * - `disconnected`: live updates stopped for good. The session couldn't open, the retries ran out, + * or Base44 ended the session or the app's subscription. {@link Base44Chat.error} says why. + * + * Watching another app, or mounting the chat again, starts over. + */ +export type ChatConnection = "connecting" | "connected" | "disconnected"; + +/** A failure of a call, of the live connection, or of the build. */ +export interface ChatError { + /** What went wrong, for people. */ + message: string; + /** + * What went wrong, for code. A connection error carries a `PlatformSocketErrorCode`, and a failed + * build carries `build_failed`. A failed call to your backend carries none. + */ + code?: string; + /** The original error, for logging. */ + cause?: unknown; +} + +/** Options for {@link useBase44Chat}. */ +export interface Base44ChatOptions { + /** + * ID of the app to follow, or `null` until an app exists. While it's `null`, the chat follows the + * app {@link Base44Chat.create} made. Any change to `appId` forgets that app. + */ + appId: string | null; + /** The four calls your backend makes to Base44. The chat reads the latest on every action, so an inline object works. */ + backend: Base44ChatBackend; + /** + * Called with the new app after {@link Base44Chat.create} succeeds. The chat already follows it. + * Pass its ID back as `appId` to keep it in your own state. + */ + onAppCreated?: (app: Base44App) => void; +} + +/** The chat that {@link useBase44Chat} returns. */ +export interface Base44Chat { + /** The conversation, oldest first, with open questions bound to their steps. */ + items: ChatItem[]; + /** What the AI is doing. */ + status: ChatStatus; + /** Whether the chat follows the app live. */ + connection: ChatConnection; + /** + * The last failure. It's set whenever `status` is `error`, and on any failed call or connection + * error. A failed call's error clears on the next action. A connection error stays until + * {@link Base44Chat.clearError}. A failed build stays while Base44 reports it. + */ + error: ChatError | null; + /** + * Sends a prompt to the current app. Base44 queues it while the AI is working. The prompt shows + * at once as a pending item, and the reply arrives through `items`. Without an app, it sets + * `error`. Use {@link Base44Chat.create} for the first prompt. + * + * @param prompt - The prompt to send. + * @returns Promise resolving once Base44 accepts the prompt. It never rejects. + */ + send(prompt: string): Promise; + /** + * Creates an app from its first prompt and reports it through `onAppCreated`. + * + * @param prompt - The first prompt, which describes the app. + * @returns Promise resolving once the app exists. It never rejects. + */ + create(prompt: string): Promise; + /** Clears {@link Base44Chat.error}. A failed build's error stays while Base44 reports it. */ + clearError(): void; +} diff --git a/packages/platform/src/react/index.ts b/packages/platform/src/react/index.ts new file mode 100644 index 00000000..1b5a4af1 --- /dev/null +++ b/packages/platform/src/react/index.ts @@ -0,0 +1,7 @@ +/** The `@base44/platform/react` entry: the AI chat for one app, as a React hook. React is an optional peer, loaded only here. */ +export { useBase44Chat } from "./useBase44Chat.js"; +export type { + ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatBackend, ChatConnection, ChatError, ChatItem, ChatQuestion, + ChatStatus, ChatStep, ChoiceEntry, ChoiceQuestion, SecretField, SecretsQuestion, ToolCallAnswer, +} from "./chat.types.js"; +export type { SocketSession } from "../client/modules/builder.types.js"; diff --git a/packages/platform/src/react/useBase44Chat.ts b/packages/platform/src/react/useBase44Chat.ts new file mode 100644 index 00000000..6a2104aa --- /dev/null +++ b/packages/platform/src/react/useBase44Chat.ts @@ -0,0 +1,329 @@ +"use client"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPlatformClient } from "../client/client.js"; +import type { Message, StatusObject } from "../client/modules/builder.events.generated.js"; +import { isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, sortMessages, toItems, unknownKindToolCalls, waitingToolCallIds, type PendingPrompt } from "./chat-items.js"; +import type { Base44Chat, Base44ChatOptions, ChatConnection, ChatError, ChatStatus } from "./chat.types.js"; + +// Base44's public status carries no reason, so a failed build has one error. +const BUILD_FAILED_ERROR: ChatError = { message: "The build failed", code: "build_failed" }; +// The target of `submittedTo` while `create` runs, before the new app has an id. +const NEW_APP = "new-app"; + +/** + * Runs the AI chat for one app in your own UI. + * + * The hook renders nothing. It gives you the conversation and the actions, and you draw them: + * - Follows the app live, and renews its socket session when it expires. + * - Turns Base44's messages into {@link ChatItem | items}, with each open question bound to the step it belongs to. + * - Reports what the AI is doing in {@link Base44Chat.status}, and the live connection in {@link Base44Chat.connection}. + * - Sends prompts, creates apps and answers questions through the {@link Base44ChatBackend | backend} you pass, so no credential reaches the browser. + * + * The actions never throw. A failure sets {@link Base44Chat.error}, and a question whose answer + * failed opens again. The chat declines a question kind this release doesn't know. + * + * Requires a browser environment and can't be used in the backend. + * + * The chat follows `appId` when it's set. While `appId` is `null`, it follows the app + * {@link Base44Chat.create} made, so passing the new ID back is optional. Any change to `appId` + * forgets that app, so setting it back to `null` starts a new chat. Switching apps closes the old + * session and starts over. + * + * @param options - The app to follow, your backend's calls, and what to do with a created app. + * @returns The conversation, its status, connection and error, and the actions `send`, `create` and `clearError`. + * + * @example + * ```typescript + * // Draw the chat and answer an approval + * const chat = useBase44Chat({ appId, backend, onAppCreated: selectApp }); + * return ( + * <> + * {chat.items.map((item) => ( + *
+ * {item.text &&

{item.text}

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

{chat.error.message}

} + * + * + * ); + * ``` + */ +export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOptions): Base44Chat { + const [messages, setMessages] = useState([]); + const [buildState, setBuildState] = useState(); + const [connection, setConnection] = useState("connecting"); + // A failed action's error, which the next action clears, and the connection's, which it doesn't. + const [actionError, setActionError] = useState(null); + const [connectionError, setConnectionError] = useState(null); + // Tool calls answered here while Base44 still reports them waiting. + const [answeredToolCallIds, setAnsweredToolCallIds] = useState([]); + // The app a prompt, a new app or an answer went to, until Base44 reacts to it. + const [submittedTo, setSubmittedTo] = useState(null); + // Prompts sent from here, shown until Base44 has them. + const [pendingPrompts, setPendingPrompts] = useState([]); + const pendingPromptCount = useRef(0); + // Tool calls declined for the user, and those whose decline failed and may try again. + const declinedToolCalls = useRef(new Set()); + const failedDeclines = useRef(new Set()); + + // The app `create` made, shown while `appId` is null. Any change to `appId` forgets it. + const [createdAppId, setCreatedAppId] = useState(null); + const [previousAppIdProp, setPreviousAppIdProp] = useState(appId); + const appIdPropChanged = previousAppIdProp !== appId; + if (appIdPropChanged) { + setPreviousAppIdProp(appId); + setCreatedAppId(null); + } + // The app this chat shows. + const currentAppId = appId ?? (appIdPropChanged ? null : createdAppId); + + // Switching apps starts over: the state resets in the same render that sees the new app. + // A submission aimed at the new app, the one `create` just made, stays. + const [stateAppId, setStateAppId] = useState(currentAppId); + if (stateAppId !== currentAppId) { + setStateAppId(currentAppId); + setMessages([]); + setBuildState(undefined); + setConnection("connecting"); + setActionError(null); + setConnectionError(null); + setAnsweredToolCallIds([]); + if (submittedTo !== currentAppId && submittedTo !== NEW_APP) setSubmittedTo(null); + setPendingPrompts(pendingPrompts.filter(prompt => prompt.appId === currentAppId || prompt.appId === NEW_APP)); + } + + // The backend is read through a ref, kept current after every render, so an inline + // `backend={{…}}` never reopens the socket or changes the actions' identity. + const backendRef = useRef(backend); + useEffect(() => { + backendRef.current = backend; + }); + + const reportActionError = useCallback( + (error: unknown) => setActionError({ message: (error as Error)?.message || "Something went wrong", cause: error }), + [], + ); + + // Live updates: a snapshot on every connect, then events. + useEffect(() => { + const followedAppId = currentAppId; + if (!followedAppId) return; + let closed = false; + const reportConnectionError = (error: unknown) => { + if (closed) return; + setConnectionError({ message: (error as Error)?.message || "Live updates failed", code: (error as { code?: string })?.code, cause: error }); + }; + // A failure of the session or the subscription ends live updates: nothing retries after it. + const endLiveUpdates = (error: unknown) => { + reportConnectionError(error); + if (!closed) setConnection("disconnected"); + }; + // A new reply ends the submission. A new user message is Base44's copy of a prompt, which + // settles the pending one. + const seenMessageIds = new Set(); + const handleIncomingMessages = (incomingMessages: Message[], settleOldestIfUnmatched: boolean) => { + const newMessages = incomingMessages.filter(message => message.id && !seenMessageIds.has(message.id)); + for (const message of incomingMessages) if (message.id) seenMessageIds.add(message.id); + if (newMessages.some(message => message.role !== "user")) setSubmittedTo(null); + const arrivedPrompts = newMessages.filter(message => message.role === "user").map(message => message.content ?? ""); + if (arrivedPrompts.length) { + setPendingPrompts(prompts => settlePendingPrompts(prompts, followedAppId, arrivedPrompts, settleOldestIfUnmatched)); + } + }; + // A snapshot replaces the conversation when it is the first, or follows a rewrite of main + // (undo, restore, sync); after a plain rejoin the messages before its window stay. + let nextSnapshotReplaces = true; + // Base44 can resend an earlier turn's status after the next turn has started: one older than + // the newest seen is stale. + let latestStatusDate = ""; + const applyBuildStatus = (buildStatus: StatusObject | null | undefined) => { + const statusDate = buildStatus?.last_updated_date ?? ""; + if (statusDate && statusDate < latestStatusDate) return; + if (statusDate) latestStatusDate = statusDate; + const state = buildStatus?.state; + setBuildState(state); + if (state === "processing" || state === "error") setSubmittedTo(null); + }; + + // The client asks for a session on the first connect and whenever one expires, and forwards + // no detail of a failed call, so the backend's own error is kept to report. + let lastSessionError: unknown; + const builderSession = createPlatformClient().builder.init({ + getSession: () => + backendRef.current.openLiveSession(followedAppId).then( + socketSession => { + lastSessionError = undefined; + return socketSession; + }, + (error: unknown) => { + lastSessionError = error; + throw error; + }, + ), + onError: error => { + if (error.code !== "session_unavailable" || !lastSessionError) return endLiveUpdates(error); + if (closed) return; + setConnectionError({ message: (lastSessionError as Error)?.message || error.message, code: error.code, cause: lastSessionError }); + setConnection("disconnected"); + }, + }); + builderSession.subscribe(followedAppId, { + onSnapshot: snapshot => { + // A snapshot holds old prompts too, so only an exact match settles one. + handleIncomingMessages(snapshot.messages, false); + const replaceMessages = nextSnapshotReplaces; + nextSnapshotReplaces = false; + setMessages(heldMessages => (replaceMessages ? snapshot.messages : mergeSnapshot(heldMessages, snapshot.messages))); + applyBuildStatus(snapshot.status); + // A declined call that failed tries again on the next snapshot, after a reconnect. + for (const declineKey of failedDeclines.current) declinedToolCalls.current.delete(declineKey); + failedDeclines.current.clear(); + setConnection("connected"); + }, + onEvent: event => { + if (event.type === "message.updated") { + handleIncomingMessages([event.data.message], true); + setMessages(heldMessages => mergeMessage(heldMessages, event.data.message)); + } + if (event.type === "message.removed") { + setMessages(heldMessages => heldMessages.filter(message => message.id !== event.data.message_id)); + } + if (event.type === "app.status_changed") applyBuildStatus(event.data.status); + if (event.type === "conversation.changed" && event.data.branch_id == null) nextSnapshotReplaces = true; + // A prompt sent while the AI is working waits in Base44's queue: Base44 has it, and it + // stays pending until it runs. + if (event.type === "queue.updated" && event.data.branch_id == null) setSubmittedTo(null); + }, + // Only snapshot_unavailable leaves the subscription running. + onError: error => ((error as { code?: string }).code === "snapshot_unavailable" ? reportConnectionError(error) : endLiveUpdates(error)), + }); + // A failed connect also reaches onError, which reports it. + builderSession.connect().catch(() => {}); + + return () => { + closed = true; + builderSession.close(); + }; + }, [currentAppId]); + + // Answers one tool call. Its id leaves the answered list again if the submit fails, so the + // question comes back. + const submitAnswer = useCallback( + (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => { + if (!currentAppId) return Promise.resolve(); + setActionError(null); + setSubmittedTo(currentAppId); + setAnsweredToolCallIds(ids => [...ids, toolCallId]); + const answer = { toolCallId, messageId, approve, extraUserInput }; + return backendRef.current.submitToolCallInput(currentAppId, answer).catch((error: unknown) => { + setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); + setSubmittedTo(null); + reportActionError(error); + }); + }, + [currentAppId, reportActionError], + ); + + const sortedMessages = useMemo(() => sortMessages(messages), [messages]); + const pendingForCurrentApp = useMemo( + () => pendingPrompts.filter(prompt => prompt.appId === currentAppId || (prompt.appId === NEW_APP && !currentAppId)), + [pendingPrompts, currentAppId], + ); + const items = useMemo( + () => [...toItems(sortedMessages, answeredToolCallIds, submitAnswer), ...pendingPromptItems(pendingForCurrentApp)], + [sortedMessages, answeredToolCallIds, submitAnswer, pendingForCurrentApp], + ); + const waitingForUser = useMemo(() => isWaitingForUser(sortedMessages, answeredToolCallIds), [sortedMessages, answeredToolCallIds]); + + // A kind this release doesn't know is declined once per tool call. If that fails, the step waits + // again, the error shows, and the next snapshot tries once more. + useEffect(() => { + const followedAppId = currentAppId; + if (!followedAppId) return; + for (const { toolCallId, messageId } of unknownKindToolCalls(sortedMessages)) { + const declineKey = `${followedAppId}:${toolCallId}`; + if (declinedToolCalls.current.has(declineKey)) continue; + declinedToolCalls.current.add(declineKey); + setAnsweredToolCallIds(ids => [...ids, toolCallId]); + backendRef.current.submitToolCallInput(followedAppId, { toolCallId, messageId, approve: false }).catch((error: unknown) => { + failedDeclines.current.add(declineKey); + setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); + reportActionError(error); + }); + } + }, [currentAppId, sortedMessages, reportActionError]); + + // An answered call stays hidden while Base44 still reports it waiting, and is forgotten once it + // doesn't: if Base44 parks it again, its question comes back. + useEffect(() => { + const stillWaiting = waitingToolCallIds(sortedMessages); + setAnsweredToolCallIds(ids => (ids.every(answeredId => stillWaiting.has(answeredId)) ? ids : ids.filter(answeredId => stillWaiting.has(answeredId)))); + }, [sortedMessages]); + + const submissionInFlight = (submittedTo === NEW_APP && !currentAppId) || (submittedTo !== null && submittedTo === currentAppId); + let status: ChatStatus = "ready"; + if (buildState === "error") status = "error"; + else if (waitingForUser) status = "waiting"; + else if (buildState === "processing") status = "building"; + else if (submissionInFlight) status = "submitted"; + + const addPendingPrompt = useCallback((content: string, targetAppId: string) => { + const pendingPrompt = { id: `pending-${++pendingPromptCount.current}`, content, createdAt: new Date().toISOString(), appId: targetAppId }; + setPendingPrompts(prompts => [...prompts, pendingPrompt]); + return pendingPrompt; + }, []); + + const send = useCallback( + (prompt: string) => { + if (!currentAppId) { + setActionError({ message: "There is no app to send to yet: create one first" }); + return Promise.resolve(); + } + setActionError(null); + setSubmittedTo(currentAppId); + const pendingPrompt = addPendingPrompt(prompt, currentAppId); + return backendRef.current.sendMessage(currentAppId, prompt).catch((error: unknown) => { + setSubmittedTo(null); + setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); + reportActionError(error); + }); + }, + [currentAppId, reportActionError, addPendingPrompt], + ); + + const create = useCallback( + async (prompt: string) => { + setActionError(null); + setSubmittedTo(NEW_APP); + const pendingPrompt = addPendingPrompt(prompt, NEW_APP); + try { + const app = await backendRef.current.createApp(prompt); + setCreatedAppId(app.id); + setSubmittedTo(app.id); + setPendingPrompts(prompts => prompts.map(other => (other === pendingPrompt ? { ...other, appId: app.id } : other))); + onAppCreated?.(app); + } catch (error) { + setSubmittedTo(null); + setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); + reportActionError(error); + } + }, + [onAppCreated, reportActionError, addPendingPrompt], + ); + + const clearError = useCallback(() => { + setActionError(null); + setConnectionError(null); + }, []); + + const error = actionError ?? connectionError ?? (buildState === "error" ? BUILD_FAILED_ERROR : null); + return { items, status, connection, error, send, create, clearError }; +} diff --git a/packages/platform/tests/package.test.mjs b/packages/platform/tests/package.test.mjs index 346dabe2..6d6bd1a5 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/client/index.js", "dist/client/index.d.ts", "dist/server/index.js", "dist/server/index.d.ts", "README.md"]) assert.ok(files.has(file), file); + for (const file of ["dist/client/index.js", "dist/client/index.d.ts", "dist/server/index.js", "dist/server/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); }); @@ -50,11 +50,41 @@ test("the server entrypoint resolves from the installed package", () => { `]); }); +test("the React modules that use hooks or context keep their \"use client\" directive", () => { + for (const file of ['dist/react/useBase44Chat.js']) { + assert.match(readFileSync(path.join(installed, file), "utf8"), /^"use client";/, file); + } +}); + +test("react is an optional peer of the /react entry, never a dependency of the client", () => { + assert.equal("react" in manifest.dependencies, false); + assert.deepEqual(manifest.peerDependencies, { react: "^18.0.0 || ^19.0.0" }); + assert.deepEqual(manifest.peerDependenciesMeta, { react: { optional: true } }); + run(process.execPath, ["--input-type=module", "--eval", ` + import assert from "node:assert/strict"; + import { useBase44Chat } from "@base44/platform/react"; + assert.equal(typeof useBase44Chat, "function"); + `]); +}); + const consumer = ` import { createPlatformServer, type PlatformServer } from "@base44/platform/server"; const server: PlatformServer = createPlatformServer({ serverUrl: "https://example.test", apiKey: "key" }); void server; +import { useBase44Chat, type Base44ChatBackend, type ChatQuestion } from "@base44/platform/react"; +const backend: Base44ChatBackend = { + async createApp(prompt) { return { id: "a".repeat(24), name: prompt }; }, + async openLiveSession() { return { session_token: "token", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }; }, + async sendMessage() {}, + async submitToolCallInput() {}, +}; +declare const question: ChatQuestion; +if (question.kind === "choice") void question.answer([["A"]]); +// @ts-expect-error A choice is answered, not approved. +if (question.kind === "choice") question.approve(); +void useBase44Chat; void backend; + import { createPlatformClient, type PlatformEvent } from "@base44/platform"; const platform = createPlatformClient(); platform.builder.init({ getSession: async () => (await fetch("/session")).json(), onError() {} }).subscribe("a".repeat(24), { diff --git a/packages/platform/tests/types/react.types.ts b/packages/platform/tests/types/react.types.ts new file mode 100644 index 00000000..5ba0015f --- /dev/null +++ b/packages/platform/tests/types/react.types.ts @@ -0,0 +1,72 @@ +import { useBase44Chat, type Base44Chat, type Base44ChatBackend, type ChatItem, type ChatQuestion } from "@base44/platform/react"; + +const backend: Base44ChatBackend = { + async createApp(prompt) { return { id: "a".repeat(24), name: prompt }; }, + async openLiveSession() { return { session_token: "token", socket_url: "https://example.test", socket_path: "/ws/socket.io/" }; }, + 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: Base44ChatBackend = { createApp: backend.createApp }; +void partial; + +declare const chat: Base44Chat; +const status: "ready" | "submitted" | "building" | "waiting" | "error" = chat.status; +void status; +const connection: "connecting" | "connected" | "disconnected" = chat.connection; +void connection; +const error: { message: string; code?: string; cause?: unknown } | null = chat.error; +void error; +// @ts-expect-error The queue is not part of this release; send while status is ready or error. +void chat.queue; +const sent: Promise = chat.send("prompt"); +void sent; +const created: Promise = chat.create("prompt"); +void created; +// @ts-expect-error Raw messages are not part of the contract; draw from items. +void chat.messages; +// @ts-expect-error Whether to send is the status's to say; there is no canSend. +void chat.canSend; +// @ts-expect-error Answers belong to the question, not to the chat. +void chat.approve; + +declare const item: ChatItem; +const createdAt: string = item.createdAt; +void createdAt; +// @ts-expect-error A question belongs to its step, not to the message. +void item.question; +const step = item.steps[0]; +const stepStatus: "running" | "waiting" | "done" | "stopped" | "error" = step.status; +void stepStatus; +const files: string[] = step.files; +void files; +void step.tool; + +declare const question: ChatQuestion; +void question.decline(); +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 "secrets": + void question.submit({ API_KEY: "value" }); + void question.fields[0].name; + break; + case "approval": + void question.approve(); + void question.reason; + break; +} +// @ts-expect-error Every kind must be handled; there is no fourth. +const exhaustive: never = question; +void exhaustive; +// @ts-expect-error A question names no tool call; its actions are already bound to one. +void question.toolCallId; + +// @ts-expect-error The calls are the backend; there is no server option. +void useBase44Chat({ appId: null, server: backend }); +// @ts-expect-error appId is required, as null when there is no app yet. +void useBase44Chat({ backend }); 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..96ffd0d2 --- /dev/null +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi } from "vitest"; +import type { Message } from "../../../src/client/modules/builder.events.generated.js"; +import type { ToolCall } from "../../../src/client/modules/builder.events.types.js"; +import { toQuestion, isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js"; + +const message = (id: string, created: string, extra: Partial = {}): Message => ({ 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("snapshots", () => { + const at = (i: number) => `2026-10-16T10:${String(i).padStart(2, "0")}:00Z`; + const range = (from: number, to: number, extra: Partial = {}) => + Array.from({ length: to - from }, (_, i) => message(`m${from + i}`, at(from + i), extra)); + + test("a full snapshot is the truth for its window, and the messages before it stay", () => { + const held = range(0, 60); + const snapshot = range(10, 10 + SNAPSHOT_SIZE, { content: "fresh" }).filter(m => m.id !== "m30"); + snapshot.push(message("m60", at(60))); + const merged = sortMessages(mergeSnapshot(held, snapshot)); + expect(merged.map(m => m.id)).toEqual([...range(0, 60).map(m => m.id).filter(id => id !== "m30"), "m60"]); + expect(merged.slice(0, 10).every(m => m.content === undefined)).toBe(true); + expect(merged.slice(10, 20).every(m => m.content === "fresh")).toBe(true); + }); + + test("a snapshot with room to spare holds the whole conversation", () => { + expect(mergeSnapshot(range(0, 60), range(55, 60)).map(m => m.id)).toEqual(["m55", "m56", "m57", "m58", "m59"]); + }); +}); + +describe("items", () => { + test("text, role, time, and each step's tool, files, label and status come straight from the message", () => { + const tools: ToolCall[] = [ + { id: "t1", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx", "src/index.css"] } }, + { id: "t2", name: "run", status: "running" }, + { id: "t3", name: "run", status: "error" }, + { id: "t4", name: "run", status: "stopped" }, + ]; + const [item] = toItems([message("m", "2026-10-14T10:00:00Z", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn()); + expect(item).toMatchObject({ id: "m", role: "user", text: "hi", createdAt: "2026-10-14T10:00:00Z" }); + expect(item.steps[0]).toEqual({ id: "t1", tool: "write_file", files: ["src/App.jsx", "src/index.css"], label: "write_file src/App.jsx src/index.css", status: "done" }); + expect(item.steps.map(s => [s.label, s.status])).toEqual([["write_file src/App.jsx src/index.css", "done"], ["run", "running"], ["run", "error"], ["run", "stopped"]]); + expect(item.steps.every(s => !("question" in s))).toBe(true); + }); + + test("an unknown role is rendered as the assistant, and missing fields as empty", () => { + const [item] = toItems([{ id: "m", role: "system" as Message["role"] }], [], vi.fn()); + expect(item).toEqual({ id: "m", role: "assistant", text: "", createdAt: "", steps: [], pending: false }); + const [step] = toItems([message("m", "1", { tool_calls: [{ id: "t" }] })], [], vi.fn())[0].steps; + expect(step).toEqual({ id: "t", tool: "", files: [], label: "", status: "done" }); + }); + + test("several waiting steps in the last message each carry their own question, bound to its own ids", async () => { + const respond = vi.fn(async () => {}); + const [item] = toItems( + [message("m", "1", { tool_calls: [waiting("t1", { waiting_on: { kind: "approval" } }), waiting("t2", { waiting_on: { kind: "choice" } }), { id: "t3", status: "success" }, waiting("t4", { waiting_on: { kind: "input" } })] })], + [], + respond, + ); + expect(item.steps.map(s => s.question?.kind)).toEqual(["approval", "choice", undefined, "secrets"]); + await item.steps[1].question!.decline(); + expect(respond).toHaveBeenLastCalledWith("t2", "m", false, undefined); + await (item.steps[0].question as { approve(): Promise }).approve(); + expect(respond).toHaveBeenLastCalledWith("t1", "m", true, undefined); + }); + + test("questions are answerable only in the last assistant message, the one that makes the chat wait", () => { + const sorted = [ + message("a1", "1", { tool_calls: [waiting("t1", { waiting_on: { kind: "approval" } })] }), + message("a2", "2", { tool_calls: [waiting("t2", { waiting_on: { kind: "approval" } }), waiting("t3", { waiting_on: { kind: "choice" } })] }), + message("u1", "3", { role: "user" }), + message("a3", "4", { tool_calls: [waiting("t4", { waiting_on: { kind: "choice" } }), waiting("t5", { waiting_on: { kind: "input" } })] }), + ]; + const items = toItems(sorted, [], vi.fn()); + expect(items.map(i => i.steps.map(s => [s.status, s.question?.kind]))).toEqual([ + [["waiting", undefined]], + [["waiting", undefined], ["waiting", undefined]], + [], + [["waiting", "choice"], ["waiting", "secrets"]], + ]); + }); + + test("a notice that expects no reply is skipped when finding the last assistant message", () => { + const sorted = [ + message("a1", "1", { tool_calls: [waiting("t1", { waiting_on: { kind: "approval" } })] }), + message("n1", "2", { additional_message_params: { skip_ai_response: true } }), + ]; + expect(toItems(sorted, [], vi.fn())[0].steps[0].question?.kind).toBe("approval"); + }); + + test("an answered step loses its question, even while the server still reports it waiting", () => { + const [item] = toItems([message("m", "1", { tool_calls: [waiting("t1", { waiting_on: { kind: "approval" } }), waiting("t2", { waiting_on: { kind: "approval" } })] })], ["t1"], vi.fn()); + expect(item.steps.map(s => [s.status, s.question?.kind])).toEqual([["waiting", undefined], ["waiting", "approval"]]); + }); + + test("a kind this release doesn't know has no question, and is listed for declining only on the last message", () => { + const old = message("m0", "0", { tool_calls: [waiting("t0", { waiting_on: { kind: "plan" as "approval" } })] }); + const last = message("m", "2", { tool_calls: [waiting("t1", { waiting_on: { kind: "plan" as "approval" } }), waiting("t2"), waiting("t3", { waiting_on: { kind: "choice" } })] }); + const sorted = [old, message("u", "1", { role: "user" }), last]; + const steps = toItems(sorted, [], vi.fn())[2].steps; + expect(steps.map(s => [s.status, s.question?.kind])).toEqual([["waiting", undefined], ["waiting", undefined], ["waiting", "choice"]]); + // A call with no kind comes only from old messages: it is left alone. + expect(unknownKindToolCalls(sorted)).toEqual([{ toolCallId: "t1", messageId: "m" }]); + expect(waitingToolCallIds(sorted)).toEqual(new Set(["t0", "t1", "t2", "t3"])); + }); + + test("the chat waits only on the last assistant message, as in Base44's builder", () => { + const sorted = [message("m1", "1", { tool_calls: [{ id: "t0", status: "running" }] }), message("m2", "2", { tool_calls: [waiting("t1"), waiting("t2")] })]; + expect(isWaitingForUser(sorted, [])).toBe(true); + expect(isWaitingForUser(sorted, ["t1"])).toBe(true); + expect(isWaitingForUser(sorted, ["t1", "t2"])).toBe(false); + expect(isWaitingForUser([message("m", "1")], [])).toBe(false); + // An old call left waiting in an earlier turn doesn't hold the chat. + expect(isWaitingForUser([message("m1", "1", { tool_calls: [waiting("t1")] }), message("u", "2", { role: "user" }), message("m2", "3")], [])).toBe(false); + // A notice that expects no reply is skipped. + expect(isWaitingForUser([message("m1", "1", { tool_calls: [waiting("t1")] }), message("n", "2", { additional_message_params: { skip_ai_response: true } })], [])).toBe(true); + }); +}); + +describe("pending prompts", () => { + const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId }); + + test("an arrival settles the pending prompt with its text, of its own app, ignoring outer spaces", () => { + const all = [pending("p1", "one"), pending("p2", "two"), pending("p3", "two", "b")]; + expect(settlePendingPrompts(all, "a", [" two "], false).map(p => p.id)).toEqual(["p1", "p3"]); + expect(settlePendingPrompts(all, "a", ["three"], false).map(p => p.id)).toEqual(["p1", "p2", "p3"]); + }); + + test("an arrival that matches none settles the oldest, when asked", () => { + expect(settlePendingPrompts([pending("p1", "one"), pending("p2", "two")], "a", ["One!"], true).map(p => p.id)).toEqual(["p2"]); + }); + + test("a pending prompt is a user item with no steps", () => { + expect(pendingPromptItems([{ id: "p1", content: "hi", createdAt: "t", appId: "a" }])).toEqual([ + { id: "p1", role: "user", text: "hi", createdAt: "t", steps: [], pending: true }, + ]); + }); +}); + + +describe("questions", () => { + test("choice: questions, options as strings or objects, multi, and answers keyed by position", async () => { + const reply = vi.fn(async () => {}); + const question = toQuestion( + 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("secrets: Base44's input kind, fields from secrets_schema, submitted as secrets", async () => { + const reply = vi.fn(async () => {}); + const question = toQuestion( + waiting("t", { waiting_on: { kind: "input" }, arguments: { secrets_schema: [{ secretName: "API_KEY", description: "From the dashboard" }, {}] } }), + reply, + ); + expect(question?.kind).toBe("secrets"); + if (question?.kind !== "secrets") 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" } }); + await question.decline(); + expect(reply).toHaveBeenLastCalledWith(false); + }); + + test("approval: the tool name, and the guard's reason or its summary", async () => { + const reply = vi.fn(async () => {}); + const withReason = toQuestion(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 = toQuestion(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 = toQuestion(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, or none, has no question", () => { + expect(toQuestion(waiting("t", { waiting_on: { kind: "plan" as "approval" } }), vi.fn())).toBeUndefined(); + expect(toQuestion(waiting("t"), vi.fn())).toBeUndefined(); + }); + + test("Base44's tool call statuses map to five step states", () => { + expect(stepStatus({ status: "running" })).toBe("running"); + expect(stepStatus({ status: "waiting_for_user_input" })).toBe("waiting"); + expect(stepStatus({ status: "success" })).toBe("done"); + expect(stepStatus({ status: "stopped" })).toBe("stopped"); + expect(stepStatus({ status: "error" })).toBe("error"); + 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..fa43e711 --- /dev/null +++ b/packages/platform/tests/unit/react/useBase44Chat.test.ts @@ -0,0 +1,716 @@ +// @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 { Message, QueueItem } from "../../../src/client/modules/builder.events.generated.js"; +import type { PlatformSnapshot } from "../../../src/client/modules/builder.events.types.js"; +import type { SocketSession } from "../../../src/client/modules/builder.types.js"; +import { useBase44Chat } from "../../../src/react/index.js"; +import type { Base44Chat, Base44ChatOptions, Base44ChatBackend } from "../../../src/react/index.js"; + +// The platform client is replaced by a recorder: the test plays snapshots, events and connection +// changes into the session the hook opened, and checks which sessions were opened and closed. +// Like the real client, connect asks getSession for a session and reports a failure to onError. +type SubOptions = { onSnapshot(s: PlatformSnapshot): unknown; onEvent(e: unknown): unknown; onError(e: unknown): void }; +type InitOptions = { getSession(): Promise; onError(e: { code: string; message: string }): void }; +type FakeSession = { subs: Record; init: InitOptions; closed: boolean; sessions: SocketSession[]; connect: () => Promise }; +const fake = vi.hoisted(() => ({ sessions: [] as FakeSession[] })); +vi.mock("../../../src/client/client.js", () => ({ + createPlatformClient: () => ({ + builder: { + init(init: InitOptions) { + const session: FakeSession & { subscribe(appId: string, opts: SubOptions): unknown; close(): void } = { + subs: {}, + init, + closed: false, + sessions: [], + async connect() { + try { + session.sessions.push(await init.getSession()); + } catch { + init.onError({ code: "session_unavailable", message: "Session unavailable" }); + throw new Error("session_unavailable"); + } + }, + subscribe(appId, opts) { session.subs[appId] = opts; return { appId, active: true, unsubscribe() {} }; }, + close() { session.closed = true; }, + }; + fake.sessions.push(session); + return session; + }, + }, + }), +})); +vi.mock("../../../src/client.js", () => ({ + Base44PlatformClient: class { + builder: { init(options: InitOptions): FakeSession }; + constructor(options: ClientOptions) { + fake.clients.push(options); + this.builder = { + init(init) { + const session: FakeSession & { subscribe(appId: string, opts: SubOptions): unknown; close(): void } = { + subs: {}, + init, + 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 = {}): Message => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); +const waiting = (id: string, kind: string = "approval") => ({ id, name: "tool", status: "waiting_for_user_input" as const, waiting_on: { kind: kind as "approval" } }); + +function makeBackend(): { [K in keyof Base44ChatBackend]: ReturnType & Base44ChatBackend[K] } { + return { + createApp: vi.fn(async (prompt: string) => ({ id: other, name: prompt })), + openLiveSession: vi.fn(async (): Promise => ({ session_token: "session-token", socket_url: "https://socket.test", socket_path: "/ws/socket.io/" })), + 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: Message[], state: "ready" | "processing" | "error" = "ready", extra: Partial = {}, appId = app) => + act(async () => { await session().subs[appId].onSnapshot({ room: `/apps/${appId}`, status: { state }, messages, ...extra }); }); +const event = async (e: unknown, appId = app) => act(async () => { await session().subs[appId].onEvent(e); }); +const statusChanged = (state: "ready" | "processing" | "error") => event({ type: "app.status_changed", appId: app, data: { status: { state } } }); +const updated = (m: Message) => event({ type: "message.updated", appId: app, data: { message: m } }); +const queueUpdated = (items: QueueItem[], branch_id: string | null = null) => event({ type: "queue.updated", appId: app, data: { items, is_paused: false, branch_id } }); +// A call the test resolves or rejects itself, to look at the chat while it is in flight. +function pending() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +beforeEach(() => { fake.sessions.length = 0; renders = 0; }); +afterEach(async () => { for (const root of roots.splice(0)) await act(async () => root.unmount()); }); + +describe("the contract", () => { + test("the hook returns exactly the documented fields", async () => { + await mount({ appId: app, backend: makeBackend() }); + expect(Object.keys(latest).sort()).toEqual(["clearError", "connection", "create", "error", "items", "send", "status"]); + }); +}); + +describe("connection", () => { + test("without an app nothing connects, and send needs an app", async () => { + const backend = makeBackend(); + await mount({ appId: null, backend }); + expect(backend.openLiveSession).not.toHaveBeenCalled(); + expect(latest).toMatchObject({ items: [], status: "ready", connection: "connecting", error: null }); + await act(async () => { await latest.send("hello"); }); + expect(backend.sendMessage).not.toHaveBeenCalled(); + expect(backend.createApp).not.toHaveBeenCalled(); + expect(latest.error?.message).toMatch(/create one first/); + expect(latest.status).toBe("ready"); + }); + + test("with an app it opens one live session from the backend's answer, then shows the snapshot", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + expect(backend.openLiveSession).toHaveBeenCalledWith(app); + expect(latest.connection).toBe("connecting"); + await snapshot([message("m2", "2", { content: "second" }), message("m1", "1", { role: "user", content: "first" })], "processing"); + expect(latest.connection).toBe("connected"); + expect(latest.status).toBe("building"); + expect(latest.items.map(i => [i.role, i.text, i.createdAt])).toEqual([["user", "first", "1"], ["assistant", "second", "2"]]); + }); + + test("the client gets Base44's session body from the backend, unchanged", async () => { + const backend = makeBackend(); + const body = { session_id: "s1", session_token: "t", socket_url: "https://socket.test", socket_path: "/live/socket.io/", expires_in: 3600 }; + backend.openLiveSession.mockResolvedValueOnce(body); + await mount({ appId: app, backend }); + expect(session().sessions).toEqual([body]); + }); + + test("the session renews through openLiveSession when the client asks for a new one", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + expect(backend.openLiveSession).toHaveBeenCalledTimes(1); + // An hour later Base44 expires it, and the client asks again. + const renewed = { session_token: "renewed-token", socket_url: "https://socket.test", socket_path: "/ws/socket.io/" }; + backend.openLiveSession.mockResolvedValueOnce(renewed); + expect(await session().init.getSession()).toEqual(renewed); + expect(backend.openLiveSession).toHaveBeenCalledTimes(2); + expect(backend.openLiveSession).toHaveBeenLastCalledWith(app); + expect(fake.sessions).toHaveLength(1); + }); + + test("connecting lasts until the first snapshot", async () => { + await mount({ appId: app, backend: makeBackend() }); + expect(latest.connection).toBe("connecting"); + await snapshot([]); + expect(latest.connection).toBe("connected"); + }); + + test("a connection error stays through the next action, and only clearError clears it", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([]); + await act(async () => { session().init.onError({ code: "session_revoked", message: "revoked" }); }); + await act(async () => { await latest.send("hello"); }); + expect(latest).toMatchObject({ connection: "disconnected", error: { code: "session_revoked" } }); + await act(async () => { latest.clearError(); }); + expect(latest.error).toBeNull(); + }); + + test("a session failure that a later session fixed is not reported for another error", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + backend.openLiveSession.mockRejectedValueOnce(new Error("Socket session failed: 503")); + await expect(session().init.getSession()).rejects.toThrow("503"); + await session().init.getSession(); + await act(async () => { session().init.onError({ code: "session_unavailable", message: "Session unavailable" }); }); + expect(latest.error).toMatchObject({ message: "Session unavailable", code: "session_unavailable" }); + }); + + test("a reconnect keeps the messages before the snapshot's window, and a rewrite replaces them", async () => { + const at = (i: number) => `2026-10-16T10:${String(i).padStart(2, "0")}:00Z`; + const range = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => message(`m${from + i}`, at(from + i))); + await mount({ appId: app, backend: makeBackend() }); + await snapshot(range(0, 50)); + for (const m of range(50, 60)) await updated(m); + expect(latest.items).toHaveLength(60); + // The socket drops and rejoins: Base44 sends only the newest 50. + await snapshot(range(10, 60)); + expect(latest.items.map(i => i.id)).toEqual(range(0, 60).map(m => m.id)); + // An undo rewrites main: the next snapshot is the whole truth. + await event({ type: "conversation.changed", appId: app, data: { branch_id: null } }); + await snapshot(range(5, 55)); + expect(latest.items.map(i => i.id)).toEqual(range(5, 55).map(m => m.id)); + // A branch's rewrite is not main's. + await event({ type: "conversation.changed", appId: app, data: { branch_id: "branch-1" } }); + await snapshot(range(6, 56)); + expect(latest.items[0].id).toBe("m5"); + }); + + test("events update, add and remove messages, and the app state drives the status", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1", { content: "draft" })], "processing"); + await updated(message("m1", "1", { content: "final" })); + await updated(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 statusChanged("ready"); + expect(latest.status).toBe("ready"); + }); + + test("a failed session, a connection error and a subscription error all land in error, with the socket's code", async () => { + const backend = makeBackend(); + const refused = new Error("Socket session failed: 403"); + backend.openLiveSession.mockRejectedValueOnce(refused); + const { render } = await mount({ appId: app, backend }); + // The client forwards no detail of a failed session, so the hook reports the backend's own error. + expect(latest.error).toEqual({ message: "Socket session failed: 403", code: "session_unavailable", cause: refused }); + expect(latest.connection).toBe("disconnected"); + await render({ appId: other, backend }); + expect(latest.error).toBeNull(); + expect(latest.connection).toBe("connecting"); + const revoked = { code: "access_revoked", message: "revoked" }; + await act(async () => { session().subs[other].onError(revoked); }); + expect(latest.error).toEqual({ message: "revoked", code: "access_revoked", cause: revoked }); + expect(latest.connection).toBe("disconnected"); + await act(async () => { session().init.onError({ code: "connection_failed", message: "failed" }); }); + expect(latest.error).toMatchObject({ code: "connection_failed" }); + await act(async () => { latest.clearError(); }); + expect(latest.error).toBeNull(); + expect(latest.connection).toBe("disconnected"); + }); + + test("retries that run out are disconnected, and a missing snapshot is only an error", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([]); + await act(async () => { session().subs[app].onError({ code: "snapshot_unavailable", message: "no snapshot" }); }); + expect(latest).toMatchObject({ connection: "connected", error: { code: "snapshot_unavailable" } }); + await act(async () => { session().init.onError({ code: "connection_failed", message: "gave up" }); }); + expect(latest).toMatchObject({ connection: "disconnected", error: { code: "connection_failed" } }); + }); + + test("switching apps closes the old session, resets everything, and opens a new one", async () => { + const backend = makeBackend(); + backend.sendMessage.mockRejectedValueOnce(new Error("Send message failed: 409")); + const { render } = await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { content: "old" })], "error"); + await act(async () => { await latest.send("x"); }); + const first = session(); + await render({ appId: other, backend }); + expect(first.closed).toBe(true); + expect(latest).toMatchObject({ items: [], status: "ready", connection: "connecting", error: null }); + expect(backend.openLiveSession).toHaveBeenLastCalledWith(other); + expect(fake.sessions).toHaveLength(2); + await render({ appId: null, backend }); + expect(session().closed).toBe(true); + expect(latest.status).toBe("ready"); + }); + + test("an error from the old session after a switch does not reach the new app", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: app, backend }); + const first = session(); + await render({ appId: other, backend }); + await act(async () => { first.init.onError({ code: "client_closed", message: "closed" }); }); + expect(latest.error).toBeNull(); + }); + + test("an inline backend object does not reopen the session, and the latest one is used", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: app, backend }); + const replacement = makeBackend(); + await render({ appId: app, backend: { ...replacement } }); + await render({ appId: app, backend: { ...replacement } }); + expect(fake.sessions).toHaveLength(1); + expect(backend.openLiveSession).toHaveBeenCalledTimes(1); + await act(async () => { await latest.send("hi"); }); + expect(replacement.sendMessage).toHaveBeenCalledWith(app, "hi"); + expect(backend.sendMessage).not.toHaveBeenCalled(); + await session().init.getSession(); + expect(replacement.openLiveSession).toHaveBeenCalledWith(app); + }); + + test("unmounting closes the session", async () => { + const { unmount } = await mount({ appId: app, backend: makeBackend() }); + await unmount(); + expect(session().closed).toBe(true); + }); +}); + +describe("status", () => { + test("a failed build is the error status, with an error that clearError leaves while Base44 reports it", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([], "error"); + expect(latest.status).toBe("error"); + expect(latest.error).toEqual({ message: "The build failed", code: "build_failed" }); + await act(async () => { latest.clearError(); }); + expect(latest.error).toMatchObject({ code: "build_failed" }); + await statusChanged("processing"); + expect(latest.status).toBe("building"); + expect(latest.error).toBeNull(); + await statusChanged("error"); + expect(latest.status).toBe("error"); + }); + + test("an earlier turn's status, resent after the next turn started, is ignored", async () => { + // Recorded from Base44 while a queue drained: Q4's ready arrived during Q6's turn. + const at = (state: string, turn: string, updated: string) => + event({ type: "app.status_changed", appId: app, data: { status: { state, turn_id: turn, last_updated_date: `2026-10-16T14:10:${updated}` } } }); + await mount({ appId: app, backend: makeBackend() }); + await snapshot([]); + await at("processing", "q4", "13.63"); + await at("ready", "q4", "24.64"); + await at("processing", "q6", "36.94"); + await at("ready", "q4", "24.64"); + expect(latest.status).toBe("building"); + await at("ready", "q5", "35.30"); + expect(latest.status).toBe("building"); + await at("ready", "q6", "54.79"); + expect(latest.status).toBe("ready"); + }); + + test("precedence: error over waiting over building", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })], "error"); + expect(latest.status).toBe("error"); + await statusChanged("processing"); + expect(latest.status).toBe("waiting"); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1"), status: "running" }] })); + expect(latest.status).toBe("building"); + }); + + test("a send is submitted from the call until Base44 reports work", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([]); + let done!: Promise; + await act(async () => { done = latest.send("make it blue"); }); + expect(latest.status).toBe("submitted"); + // Base44 echoes the prompt: that is not a reply. + await updated(message("u1", "1", { role: "user", content: "make it blue" })); + expect(latest.status).toBe("submitted"); + await act(async () => { call.resolve(); await done; }); + expect(latest.status).toBe("submitted"); + await statusChanged("processing"); + expect(latest.status).toBe("building"); + await statusChanged("ready"); + expect(latest.status).toBe("ready"); + }); + + test("a submitted send ends with a new reply, or with a queue change", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1")]); + await act(async () => { await latest.send("a"); }); + await updated(message("m1", "1", { content: "an edit of an old reply" })); + expect(latest.status).toBe("submitted"); + await updated(message("m2", "2", { content: "done" })); + expect(latest.status).toBe("ready"); + await act(async () => { await latest.send("b"); }); + await queueUpdated([{ id: "q1", content: "b" }]); + expect(latest.status).toBe("ready"); + }); + + test("a failed send sets error without changing the status, and a 409 is never lost", async () => { + const backend = makeBackend(); + const refused = new Error("Send message failed: 409"); + backend.sendMessage.mockRejectedValueOnce(refused); + await mount({ appId: app, backend }); + await snapshot([], "processing"); + await act(async () => { await latest.send("make it blue"); }); + expect(backend.sendMessage).toHaveBeenCalledWith(app, "make it blue"); + expect(latest.error).toEqual({ message: "Send message failed: 409", cause: refused }); + expect(latest.status).toBe("building"); + await statusChanged("ready"); + expect(latest.status).toBe("ready"); + }); + + test("a prompt sent mid-build leaves the status building", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([], "processing"); + await act(async () => { await latest.send("and a footer"); }); + expect(latest.status).toBe("building"); + await queueUpdated([{ id: "q1", content: "and a footer" }]); + expect(latest.status).toBe("building"); + }); +}); + +describe("pending prompts", () => { + const shown = () => latest.items.map(i => [i.text, i.pending]); + + test("a sent prompt shows at once, and Base44's own message takes its place", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { content: "hello" })]); + await act(async () => { void latest.send("make it blue"); }); + expect(shown()).toEqual([["hello", false], ["make it blue", true]]); + expect(latest.items[1]).toMatchObject({ role: "user", steps: [] }); + await updated(message("u1", "2", { role: "user", content: "make it blue" })); + expect(shown()).toEqual([["hello", false], ["make it blue", false]]); + await act(async () => { call.resolve(); }); + expect(shown()).toEqual([["hello", false], ["make it blue", false]]); + }); + + test("a prompt Base44 echoes with other text still settles, oldest first", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([]); + await act(async () => { await latest.send("first"); await latest.send("second"); }); + await updated(message("u1", "1", { role: "user", content: "first (edited)" })); + expect(shown()).toEqual([["first (edited)", false], ["second", true]]); + }); + + test("a prompt Base44 queues stays pending, in the order sent, until it runs", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([], "processing"); + await act(async () => { await latest.send("Q5"); }); + await queueUpdated([{ id: "q5", content: "Q5" }]); + await act(async () => { await latest.send("Q7"); }); + await queueUpdated([{ id: "q5", content: "Q5" }, { id: "q7", content: "Q7" }]); + expect(shown()).toEqual([["Q5", true], ["Q7", true]]); + await queueUpdated([{ id: "q7", content: "Q7" }]); + expect(shown()).toEqual([["Q5", true], ["Q7", true]]); + await updated(message("u5", "5", { role: "user", content: "Q5" })); + expect(shown()).toEqual([["Q5", false], ["Q7", true]]); + }); + + test("a failed send removes the prompt", async () => { + const backend = makeBackend(); + backend.sendMessage.mockRejectedValueOnce(new Error("Send message failed: 409")); + await mount({ appId: app, backend }); + await snapshot([]); + await act(async () => { await latest.send("x"); }); + expect(latest.items).toEqual([]); + expect(latest.error).toMatchObject({ message: "Send message failed: 409" }); + }); + + test("the first prompt shows while the app is created, and the new app's snapshot settles it", async () => { + const backend = makeBackend(); + const call = pending<{ id: string; name: string }>(); + backend.createApp.mockReturnValueOnce(call.promise); + await mount({ appId: null, backend }); + let done!: Promise; + await act(async () => { done = latest.create("A tiny habit app"); }); + expect(shown()).toEqual([["A tiny habit app", true]]); + await act(async () => { call.resolve({ id: other, name: "A tiny habit app" }); await done; }); + await settle(); + expect(shown()).toEqual([["A tiny habit app", true]]); + await snapshot([message("u1", "1", { role: "user", content: "A tiny habit app" })], "processing", {}, other); + expect(shown()).toEqual([["A tiny habit app", false]]); + }); + + test("a snapshot full of old prompts settles only an exact match, never the oldest", async () => { + await mount({ appId: app, backend: makeBackend() }); + await act(async () => { await latest.send("again"); }); + await snapshot([message("u0", "0", { role: "user", content: "older" })]); + expect(shown()).toEqual([["older", false], ["again", true]]); + }); + + test("switching apps drops the pending prompts", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: app, backend }); + await snapshot([]); + await act(async () => { await latest.send("x"); }); + await render({ appId: other, backend }); + expect(latest.items).toEqual([]); + }); +}); + + +describe("questions", () => { + test("every waiting step in a message is a question that answers exactly its own tool call", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + await snapshot([ + message("m1", "1", { tool_calls: [waiting("t1"), waiting("t2", "choice"), { id: "t3", name: "write_file", status: "success" }] }), + ]); + expect(latest.status).toBe("waiting"); + expect(latest.items[0].steps.map(s => s.question?.kind)).toEqual(["approval", "choice", undefined]); + const choice = latest.items[0].steps[1].question!; + if (choice.kind !== "choice") throw new Error("expected a choice"); + await act(async () => { await choice.answer([["A"]]); }); + expect(backend.submitToolCallInput).toHaveBeenCalledWith(app, { + toolCallId: "t2", messageId: "m1", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_labels: ["A"] }] }, + }); + expect(latest.items[0].steps.map(s => s.question?.kind)).toEqual(["approval", undefined, undefined]); + expect(latest.items[0].steps[1].status).toBe("waiting"); + expect(latest.status).toBe("waiting"); + const approval = latest.items[0].steps[0].question!; + if (approval.kind !== "approval") throw new Error("expected an approval"); + await act(async () => { await approval.decline(); }); + expect(backend.submitToolCallInput).toHaveBeenLastCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: false, extraUserInput: undefined }); + expect(latest.status).toBe("submitted"); + await statusChanged("processing"); + expect(latest.status).toBe("building"); + }); + + test("secrets are Base44's input kind, submitted as secrets", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1", "input")] })]); + const question = latest.items[0].steps[0].question!; + if (question.kind !== "secrets") throw new Error("expected secrets"); + await act(async () => { await question.submit({ API_KEY: "value" }); }); + expect(backend.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: true, extraUserInput: { secrets: { API_KEY: "value" } } }); + }); + + test("a reply the backend refuses reopens the question and reports the error", async () => { + const backend = makeBackend(); + backend.submitToolCallInput.mockRejectedValueOnce(new Error("Submit answer failed: 409")); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const question = latest.items[0].steps[0].question!; + if (question.kind !== "approval") throw new Error("expected an approval"); + await act(async () => { await question.approve(); }); + expect(latest.items[0].steps[0].question?.kind).toBe("approval"); + expect(latest.error).toMatchObject({ message: "Submit answer failed: 409" }); + expect(latest.status).toBe("waiting"); + }); + + test("a kind this release doesn't know is declined once, then shows stopped when Base44 says so", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1", "plan_review")] })]); + expect(backend.submitToolCallInput).toHaveBeenCalledTimes(1); + expect(backend.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: false }); + expect(latest.items[0].steps[0]).toMatchObject({ status: "waiting" }); + expect(latest.items[0].steps[0].question).toBeUndefined(); + expect(latest.status).toBe("ready"); + await updated(message("m1", "1", { content: "still", tool_calls: [waiting("t1", "plan_review")] })); + expect(backend.submitToolCallInput).toHaveBeenCalledTimes(1); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1", "plan_review"), status: "stopped" }] })); + expect(latest.items[0].steps[0].status).toBe("stopped"); + }); + + test("a failed decline shows the error and waits, and the next snapshot tries once more", async () => { + const backend = makeBackend(); + backend.submitToolCallInput.mockRejectedValueOnce(new Error("Submit answer failed: 500")); + await mount({ appId: app, backend }); + const parked = [message("m1", "1", { tool_calls: [waiting("t1", "plan_review")] })]; + await snapshot(parked); + expect(latest).toMatchObject({ status: "waiting", error: { message: "Submit answer failed: 500" } }); + await updated(message("m1", "1", { content: "still", tool_calls: [waiting("t1", "plan_review")] })); + expect(backend.submitToolCallInput).toHaveBeenCalledTimes(1); + // A reconnect's snapshot. + await snapshot(parked); + expect(backend.submitToolCallInput).toHaveBeenCalledTimes(2); + expect(latest.status).toBe("ready"); + }); + + test("an unknown kind left in an earlier turn, or with no kind, is never declined", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + await snapshot([ + message("m0", "0", { tool_calls: [waiting("t0", "plan_review")] }), + message("u1", "1", { role: "user" }), + message("m1", "2", { tool_calls: [{ id: "t1", name: "tool", status: "waiting_for_user_input" }] }), + ]); + expect(backend.submitToolCallInput).not.toHaveBeenCalled(); + expect(latest.status).toBe("waiting"); + }); + + test("a question left waiting in an earlier turn neither holds the chat nor can be answered", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1", "choice")] }), message("u1", "2", { role: "user" }), message("m2", "3", { content: "done" })]); + expect(latest.status).toBe("ready"); + expect(latest.items[0].steps[0]).toMatchObject({ status: "waiting" }); + expect(latest.items[0].steps[0].question).toBeUndefined(); + }); + + test("an answered call is forgotten once Base44 stops reporting it waiting, so it can ask again", async () => { + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + await act(async () => { await (latest.items[0].steps[0].question as { approve(): Promise }).approve(); }); + expect(latest.items[0].steps[0].question).toBeUndefined(); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1"), status: "running" }] })); + await updated(message("m1", "1", { tool_calls: [waiting("t1")] })); + expect(latest.items[0].steps[0].question?.kind).toBe("approval"); + }); + + test("the question's actions keep working after an unrelated message arrives", async () => { + const backend = makeBackend(); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const question = latest.items[0].steps[0].question!; + await updated(message("m2", "2", { content: "meanwhile" })); + await act(async () => { await question.decline(); }); + expect(backend.submitToolCallInput).toHaveBeenCalledWith(app, { toolCallId: "t1", messageId: "m1", approve: false, extraUserInput: undefined }); + }); +}); + +describe("creating", () => { + test("create is submitted through the switch to the new app, until its build shows", async () => { + const backend = makeBackend(); + const call = pending<{ id: string; name: string }>(); + backend.createApp.mockReturnValueOnce(call.promise); + let render!: (next: Base44ChatOptions) => Promise; + const onAppCreated = vi.fn((created: { id: string }) => { void render({ appId: created.id, backend, onAppCreated }); }); + ({ render } = await mount({ appId: null, backend, onAppCreated })); + let done!: Promise; + await act(async () => { done = latest.create("A tiny habit app"); }); + expect(latest.status).toBe("submitted"); + await act(async () => { call.resolve({ id: other, name: "A tiny habit app" }); await done; }); + await settle(); + expect(onAppCreated).toHaveBeenCalledWith({ id: other, name: "A tiny habit app" }); + expect(backend.openLiveSession).toHaveBeenCalledWith(other); + // The chat already watched the new app, so passing its id back reopens nothing. + expect(backend.openLiveSession).toHaveBeenCalledTimes(1); + expect(fake.sessions).toHaveLength(1); + expect(latest).toMatchObject({ status: "submitted", connection: "connecting" }); + await snapshot([message("u1", "1", { role: "user", content: "A tiny habit app" })], "processing", {}, other); + expect(latest.status).toBe("building"); + }); + + test("a partner that never passes the new id back still gets the new app's chat", async () => { + const backend = makeBackend(); + await mount({ appId: null, backend }); + await act(async () => { await latest.create("A tiny habit app"); }); + await settle(); + expect(backend.openLiveSession).toHaveBeenCalledWith(other); + expect(latest.status).toBe("submitted"); + await snapshot([], "processing", {}, other); + expect(latest).toMatchObject({ status: "building", connection: "connected" }); + await act(async () => { session().subs[other].onEvent({ type: "app.status_changed", appId: other, data: { status: { state: "ready" } } }); }); + await act(async () => { await latest.send("make it blue"); }); + expect(backend.sendMessage).toHaveBeenCalledWith(other, "make it blue"); + }); + + test("any change to appId forgets the created app, so null again is a new chat", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: null, backend }); + await act(async () => { await latest.create("x"); }); + await settle(); + const created = session(); + await render({ appId: app, backend }); + expect(created.closed).toBe(true); + expect(backend.openLiveSession).toHaveBeenLastCalledWith(app); + await render({ appId: null, backend }); + expect(session().closed).toBe(true); + expect(backend.openLiveSession).toHaveBeenCalledTimes(2); + expect(latest).toMatchObject({ items: [], status: "ready" }); + await act(async () => { await latest.send("x"); }); + expect(backend.sendMessage).not.toHaveBeenCalled(); + }); + + test("a set appId wins over the app create made, which is only reported", async () => { + const backend = makeBackend(); + const onAppCreated = vi.fn(); + await mount({ appId: app, backend, onAppCreated }); + await snapshot([]); + await act(async () => { await latest.create("another app"); }); + await settle(); + expect(onAppCreated).toHaveBeenCalledWith({ id: other, name: "another app" }); + expect(backend.openLiveSession).toHaveBeenCalledTimes(1); + expect(latest.status).toBe("ready"); + }); + + test("switching to an app this chat did not just create is not submitted", async () => { + const backend = makeBackend(); + const call = pending<{ id: string; name: string }>(); + backend.createApp.mockReturnValueOnce(call.promise); + const { render } = await mount({ appId: null, backend }); + await act(async () => { void latest.create("x"); }); + await render({ appId: app, backend }); + expect(latest.status).toBe("ready"); + }); + + test("a failed create sets the error and calls nobody", async () => { + const backend = makeBackend(); + backend.createApp.mockRejectedValueOnce(new Error("Create app failed: 402")); + const onAppCreated = vi.fn(); + await mount({ appId: null, backend, onAppCreated }); + await act(async () => { await latest.create("x"); }); + expect(onAppCreated).not.toHaveBeenCalled(); + expect(latest).toMatchObject({ status: "ready", error: { message: "Create app failed: 402" } }); + }); + + test("an inline backend keeps the actions and items the same across renders", async () => { + const { render } = await mount({ appId: app, backend: { ...makeBackend() } }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const { items, send, create } = latest; + await render({ appId: app, backend: { ...makeBackend() } }); + expect(latest.items).toBe(items); + expect(latest.send).toBe(send); + expect(latest.create).toBe(create); + }); + + test("actions and items keep their identity across renders that change nothing", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { content: "hello" })]); + const { items, send, create, clearError } = latest; + await render({ appId: app, backend }); + 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 163e9b78..7bb14cbe 100644 --- a/packages/platform/tsconfig.type-tests.json +++ b/packages/platform/tsconfig.type-tests.json @@ -6,7 +6,8 @@ "baseUrl": ".", "paths": { "@base44/platform": ["src/client/index.ts"], - "@base44/platform/server": ["src/server/index.ts"] + "@base44/platform/server": ["src/server/index.ts"], + "@base44/platform/react": ["src/react/index.ts"] } }, "include": ["src/**/*.ts", "tests/types/**/*.ts", "examples/**/*.ts"] diff --git a/packages/platform/typedoc.json b/packages/platform/typedoc.json index d3206d71..fc60bd84 100644 --- a/packages/platform/typedoc.json +++ b/packages/platform/typedoc.json @@ -2,7 +2,8 @@ "$schema": "https://typedoc.org/schema.json", "entryPoints": [ "./src/client/index.ts", - "./src/server/index.ts" + "./src/server/index.ts", + "./src/react/index.ts" ], "tsconfig": "./tsconfig.json", "out": "docs", From 9035fc7d0301f806f011a4e697174632f5d09967 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 18 Oct 2026 14:05:47 +0300 Subject: [PATCH 2/4] fix(platform/react): review fixes, and free-text answers From the review on #315: - An answer's submission ends once its call stops waiting, and any newer status ends a submission, so status no longer sticks at submitted when Base44 stayed processing while the question waited. - A send, answer or create that fails after the chat switched apps no longer touches the new app's error or status. - A pending prompt settles only on an exact text match, so another client's prompt to the same app never settles ours. - create while the chat follows a set appId drops its pending prompt, so it can't reappear when the new app is picked later. Choice answers now carry free text (answer(picked, texts?)), and send a single pick as selected_label and several as selected_labels, the way Base44's builder does; Base44 reads a design or palette pick only from selected_label. ChoiceEntry gains Base44's question type. Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/chat-items.ts | 37 +++++------ packages/platform/src/react/chat.types.ts | 9 ++- packages/platform/src/react/useBase44Chat.ts | 63 ++++++++++++++----- .../tests/unit/react/chat-items.test.ts | 21 +++++-- .../tests/unit/react/useBase44Chat.test.ts | 63 +++++++++++++++++-- 5 files changed, 146 insertions(+), 47 deletions(-) diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts index 22a2675b..31aec28a 100644 --- a/packages/platform/src/react/chat-items.ts +++ b/packages/platform/src/react/chat-items.ts @@ -127,20 +127,12 @@ export interface PendingPrompt { } /** @internal Prompts that arrived from Base44 as messages settle the pending ones with the same - * text. Base44's public send carries no client id, so text is the only link. With - * `settleOldestIfUnmatched`, an arrival that matches none settles the oldest, so a prompt Base44 - * changed on the way never stays pending. */ -export function settlePendingPrompts( - pendingPrompts: PendingPrompt[], - appId: string, - arrivedPrompts: string[], - settleOldestIfUnmatched: boolean, -): PendingPrompt[] { + * text. Base44's public send carries no client id, so text is the only link, and a prompt someone + * else sent to the same app never settles one of ours. */ +export function settlePendingPrompts(pendingPrompts: PendingPrompt[], appId: string, arrivedPrompts: string[]): PendingPrompt[] { let stillPending = pendingPrompts; for (const arrivedPrompt of arrivedPrompts) { - const pendingForApp = stillPending.filter(prompt => prompt.appId === appId); - const exactMatch = pendingForApp.find(prompt => prompt.content.trim() === arrivedPrompt.trim()); - const settled = exactMatch ?? (settleOldestIfUnmatched ? pendingForApp[0] : undefined); + const settled = stillPending.find(prompt => prompt.appId === appId && prompt.content.trim() === arrivedPrompt.trim()); if (settled) stillPending = stillPending.filter(prompt => prompt !== settled); } return stillPending; @@ -152,8 +144,9 @@ export function pendingPromptItems(pendingPrompts: PendingPrompt[]): ChatItem[] } /** @internal Base44's question formats, as one simple shape per kind, each bound to the call that - * answers it. Base44 keys choice answers by position, so `picked` follows `questions`. A kind this - * release doesn't know has no shape. */ + * answers it. Base44 keys choice answers by position, so `picked` and `texts` follow `questions`, + * and like Base44's builder a single pick goes in `selected_label`, several in `selected_labels`, + * and free text in `custom_text`. A kind this release doesn't know has no shape. */ export function toQuestion(toolCall: ToolCall, answerToolCall: AnswerToolCall): ChatQuestion | undefined { const kind = toolCall.waiting_on?.kind; const decline = () => answerToolCall(false); @@ -164,13 +157,21 @@ export function toQuestion(toolCall: ToolCall, answerToolCall: AnswerToolCall): questions: questions.map(question => ({ text: question.question ?? "", description: question.description, + type: question.type, options: (question.options ?? []).map(option => (typeof option === "string" ? option : option.label ?? "")), multi: !!question.multi_select, })), - answer: picked => { - const answers = picked - .map((labels, questionIndex) => ({ question_index: questionIndex, selected_labels: labels })) - .filter(answer => answer.selected_labels.length); + answer: (picked, texts = []) => { + const answers = questions.flatMap((question, questionIndex) => { + const labels = picked[questionIndex] ?? []; + const customText = texts[questionIndex]?.trim(); + if (!labels.length && !customText) return []; + const answer: Record = { question_index: questionIndex }; + if (question.multi_select && labels.length) answer.selected_labels = labels; + if (!question.multi_select && labels.length) answer.selected_label = labels[0]; + if (customText) answer.custom_text = customText; + return [answer]; + }); return answerToolCall(true, { answers }); }, decline, diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index a0681f7f..011bdb72 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -150,6 +150,8 @@ export interface ChoiceEntry { text: string; /** Supporting text for the question. */ description?: string; + /** Base44's type of question, such as `text_input` for one that takes only a free-text answer. */ + type?: string; /** The option labels to pick from. */ options: string[]; /** Whether more than one option can be picked. */ @@ -163,12 +165,13 @@ export interface ChoiceQuestion { /** The questions, in order. */ questions: ChoiceEntry[]; /** - * Answers with the picked labels. + * Answers with the picked labels, and free text where the user wrote some. * - * @param picked - One list of picked labels per question, in the order of `questions`. An empty list skips that question. + * @param picked - One list of picked labels per question, in the order of `questions`. + * @param texts - One free-text answer per question, in the same order. Use it for a `text_input` question, or as a note beside a pick. A question with no pick and no text is skipped. * @returns Promise resolving once the answer is sent. */ - answer(picked: string[][]): Promise; + answer(picked: string[][], texts?: string[]): Promise; /** * Declines the question. The AI continues without the answer. * diff --git a/packages/platform/src/react/useBase44Chat.ts b/packages/platform/src/react/useBase44Chat.ts index 6a2104aa..fb95a437 100644 --- a/packages/platform/src/react/useBase44Chat.ts +++ b/packages/platform/src/react/useBase44Chat.ts @@ -73,6 +73,8 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption // Tool calls declined for the user, and those whose decline failed and may try again. const declinedToolCalls = useRef(new Set()); const failedDeclines = useRef(new Set()); + // Tool calls answered from here whose submission ends when Base44 stops reporting them waiting. + const answersAwaitingBase44 = useRef(new Set()); // The app `create` made, shown while `appId` is null. Any change to `appId` forgets it. const [createdAppId, setCreatedAppId] = useState(null); @@ -97,7 +99,10 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption setConnectionError(null); setAnsweredToolCallIds([]); if (submittedTo !== currentAppId && submittedTo !== NEW_APP) setSubmittedTo(null); - setPendingPrompts(pendingPrompts.filter(prompt => prompt.appId === currentAppId || prompt.appId === NEW_APP)); + // Only the app `create` just handed over keeps its first prompt; any other is dropped, so it + // can't reappear when that app is picked later. + const handedOver = submittedTo !== null && submittedTo === currentAppId; + setPendingPrompts(pendingPrompts.filter(prompt => prompt.appId === NEW_APP || (handedOver && prompt.appId === currentAppId))); } // The backend is read through a ref, kept current after every render, so an inline @@ -106,6 +111,12 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption useEffect(() => { backendRef.current = backend; }); + // The app the chat follows now, so a call that finishes after a switch doesn't touch the new app. + const followedAppIdRef = useRef(currentAppId); + useEffect(() => { + followedAppIdRef.current = currentAppId; + }, [currentAppId]); + const stillFollowing = (targetAppId: string | null) => followedAppIdRef.current === targetAppId; const reportActionError = useCallback( (error: unknown) => setActionError({ message: (error as Error)?.message || "Something went wrong", cause: error }), @@ -129,13 +140,13 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption // A new reply ends the submission. A new user message is Base44's copy of a prompt, which // settles the pending one. const seenMessageIds = new Set(); - const handleIncomingMessages = (incomingMessages: Message[], settleOldestIfUnmatched: boolean) => { + const handleIncomingMessages = (incomingMessages: Message[]) => { const newMessages = incomingMessages.filter(message => message.id && !seenMessageIds.has(message.id)); for (const message of incomingMessages) if (message.id) seenMessageIds.add(message.id); if (newMessages.some(message => message.role !== "user")) setSubmittedTo(null); const arrivedPrompts = newMessages.filter(message => message.role === "user").map(message => message.content ?? ""); if (arrivedPrompts.length) { - setPendingPrompts(prompts => settlePendingPrompts(prompts, followedAppId, arrivedPrompts, settleOldestIfUnmatched)); + setPendingPrompts(prompts => settlePendingPrompts(prompts, followedAppId, arrivedPrompts)); } }; // A snapshot replaces the conversation when it is the first, or follows a rewrite of main @@ -144,13 +155,15 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption // Base44 can resend an earlier turn's status after the next turn has started: one older than // the newest seen is stale. let latestStatusDate = ""; + // A newer status, whatever its state, means Base44 has reacted, which ends the submission. const applyBuildStatus = (buildStatus: StatusObject | null | undefined) => { const statusDate = buildStatus?.last_updated_date ?? ""; if (statusDate && statusDate < latestStatusDate) return; + const isNewer = !!statusDate && statusDate > latestStatusDate; if (statusDate) latestStatusDate = statusDate; const state = buildStatus?.state; setBuildState(state); - if (state === "processing" || state === "error") setSubmittedTo(null); + if (isNewer || state === "processing" || state === "error") setSubmittedTo(null); }; // The client asks for a session on the first connect and whenever one expires, and forwards @@ -178,7 +191,7 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption builderSession.subscribe(followedAppId, { onSnapshot: snapshot => { // A snapshot holds old prompts too, so only an exact match settles one. - handleIncomingMessages(snapshot.messages, false); + handleIncomingMessages(snapshot.messages); const replaceMessages = nextSnapshotReplaces; nextSnapshotReplaces = false; setMessages(heldMessages => (replaceMessages ? snapshot.messages : mergeSnapshot(heldMessages, snapshot.messages))); @@ -190,7 +203,7 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption }, onEvent: event => { if (event.type === "message.updated") { - handleIncomingMessages([event.data.message], true); + handleIncomingMessages([event.data.message]); setMessages(heldMessages => mergeMessage(heldMessages, event.data.message)); } if (event.type === "message.removed") { @@ -219,11 +232,15 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption const submitAnswer = useCallback( (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => { if (!currentAppId) return Promise.resolve(); + const targetAppId = currentAppId; setActionError(null); - setSubmittedTo(currentAppId); + setSubmittedTo(targetAppId); setAnsweredToolCallIds(ids => [...ids, toolCallId]); + answersAwaitingBase44.current.add(toolCallId); const answer = { toolCallId, messageId, approve, extraUserInput }; - return backendRef.current.submitToolCallInput(currentAppId, answer).catch((error: unknown) => { + return backendRef.current.submitToolCallInput(targetAppId, answer).catch((error: unknown) => { + answersAwaitingBase44.current.delete(toolCallId); + if (!stillFollowing(targetAppId)) return; setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); setSubmittedTo(null); reportActionError(error); @@ -263,9 +280,17 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption // An answered call stays hidden while Base44 still reports it waiting, and is forgotten once it // doesn't: if Base44 parks it again, its question comes back. + // An answer's submission ends there too: Base44 has taken it once the call no longer waits. useEffect(() => { const stillWaiting = waitingToolCallIds(sortedMessages); setAnsweredToolCallIds(ids => (ids.every(answeredId => stillWaiting.has(answeredId)) ? ids : ids.filter(answeredId => stillWaiting.has(answeredId)))); + let answerTaken = false; + for (const toolCallId of answersAwaitingBase44.current) { + if (stillWaiting.has(toolCallId)) continue; + answersAwaitingBase44.current.delete(toolCallId); + answerTaken = true; + } + if (answerTaken) setSubmittedTo(target => (target === followedAppIdRef.current ? null : target)); }, [sortedMessages]); const submissionInFlight = (submittedTo === NEW_APP && !currentAppId) || (submittedTo !== null && submittedTo === currentAppId); @@ -287,12 +312,14 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption setActionError({ message: "There is no app to send to yet: create one first" }); return Promise.resolve(); } + const targetAppId = currentAppId; setActionError(null); - setSubmittedTo(currentAppId); - const pendingPrompt = addPendingPrompt(prompt, currentAppId); - return backendRef.current.sendMessage(currentAppId, prompt).catch((error: unknown) => { - setSubmittedTo(null); + setSubmittedTo(targetAppId); + const pendingPrompt = addPendingPrompt(prompt, targetAppId); + return backendRef.current.sendMessage(targetAppId, prompt).catch((error: unknown) => { setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); + if (!stillFollowing(targetAppId)) return; + setSubmittedTo(null); reportActionError(error); }); }, @@ -301,6 +328,8 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption const create = useCallback( async (prompt: string) => { + const startedFrom = followedAppIdRef.current; + const followsSetApp = appId !== null; setActionError(null); setSubmittedTo(NEW_APP); const pendingPrompt = addPendingPrompt(prompt, NEW_APP); @@ -308,15 +337,19 @@ export function useBase44Chat({ appId, backend, onAppCreated }: Base44ChatOption const app = await backendRef.current.createApp(prompt); setCreatedAppId(app.id); setSubmittedTo(app.id); - setPendingPrompts(prompts => prompts.map(other => (other === pendingPrompt ? { ...other, appId: app.id } : other))); + // With appId set, the chat doesn't follow the new app, so its first prompt has nowhere to + // show; the new app's own snapshot holds it once someone opens it. + if (followsSetApp) setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); + else setPendingPrompts(prompts => prompts.map(other => (other === pendingPrompt ? { ...other, appId: app.id } : other))); onAppCreated?.(app); } catch (error) { - setSubmittedTo(null); setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); + if (!stillFollowing(startedFrom)) return; + setSubmittedTo(null); reportActionError(error); } }, - [onAppCreated, reportActionError, addPendingPrompt], + [appId, onAppCreated, reportActionError, addPendingPrompt], ); const clearError = useCallback(() => { diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts index 96ffd0d2..6a7ad0cd 100644 --- a/packages/platform/tests/unit/react/chat-items.test.ts +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -137,8 +137,8 @@ describe("pending prompts", () => { expect(settlePendingPrompts(all, "a", ["three"], false).map(p => p.id)).toEqual(["p1", "p2", "p3"]); }); - test("an arrival that matches none settles the oldest, when asked", () => { - expect(settlePendingPrompts([pending("p1", "one"), pending("p2", "two")], "a", ["One!"], true).map(p => p.id)).toEqual(["p2"]); + test("an arrival that matches none, such as another client's prompt, settles nothing", () => { + expect(settlePendingPrompts([pending("p1", "one"), pending("p2", "two")], "a", ["someone else's"]).map(p => p.id)).toEqual(["p1", "p2"]); }); test("a pending prompt is a user item with no steps", () => { @@ -159,6 +159,7 @@ describe("questions", () => { questions: [ { question: "Which?", description: "Pick one", options: ["A", { label: "B" }, {}], multi_select: false }, { question: "Extras?", options: ["X", "Y"], multi_select: true }, + { question: "Name it", type: "text_input" }, ], }, }), @@ -167,18 +168,28 @@ describe("questions", () => { 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 }, + { text: "Which?", description: "Pick one", type: undefined, options: ["A", "B", ""], multi: false }, + { text: "Extras?", description: undefined, type: undefined, options: ["X", "Y"], multi: true }, + { text: "Name it", description: undefined, type: "text_input", options: [], multi: false }, ]); + // As in Base44's builder: one pick in selected_label, several in selected_labels. await question.answer([["A"], ["X", "Y"]]); expect(reply).toHaveBeenCalledWith(true, { answers: [ - { question_index: 0, selected_labels: ["A"] }, + { question_index: 0, selected_label: "A" }, { question_index: 1, selected_labels: ["X", "Y"] }, ], }); await question.answer([[], ["Y"]]); expect(reply).toHaveBeenLastCalledWith(true, { answers: [{ question_index: 1, selected_labels: ["Y"] }] }); + // Free text answers a text_input question, or rides beside a pick; blank text is no answer. + await question.answer([["B"], [], []], [" make it warmer ", "", "Sip"]); + expect(reply).toHaveBeenLastCalledWith(true, { + answers: [ + { question_index: 0, selected_label: "B", custom_text: "make it warmer" }, + { question_index: 2, custom_text: "Sip" }, + ], + }); await question.decline(); expect(reply).toHaveBeenLastCalledWith(false); }); diff --git a/packages/platform/tests/unit/react/useBase44Chat.test.ts b/packages/platform/tests/unit/react/useBase44Chat.test.ts index fa43e711..259afc9d 100644 --- a/packages/platform/tests/unit/react/useBase44Chat.test.ts +++ b/packages/platform/tests/unit/react/useBase44Chat.test.ts @@ -334,6 +334,47 @@ describe("status", () => { expect(latest.status).toBe("ready"); }); + test("an answer ends submitted once its call stops waiting, even when Base44 stayed processing", async () => { + // Recorded shape from the review: processing while the question waits, then ready. + await mount({ appId: app, backend: makeBackend() }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })], "processing"); + await act(async () => { await (latest.items[0].steps[0].question as { approve(): Promise }).approve(); }); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1"), status: "success" }] })); + await statusChanged("ready"); + expect(latest.status).toBe("ready"); + }); + + test("a newer status of any state ends submitted", async () => { + const at = (state: string, updated: string) => + event({ type: "app.status_changed", appId: app, data: { status: { state, last_updated_date: `2026-10-18T10:00:${updated}` } } }); + await mount({ appId: app, backend: makeBackend() }); + await snapshot([]); + await at("ready", "01"); + await act(async () => { await latest.send("hello"); }); + expect(latest.status).toBe("submitted"); + // The same status sent again is not Base44 reacting. + await at("ready", "01"); + expect(latest.status).toBe("submitted"); + await at("ready", "05"); + expect(latest.status).toBe("ready"); + }); + + test("a call that fails after a switch leaves the new app alone", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + const { render } = await mount({ appId: app, backend }); + await snapshot([]); + let done!: Promise; + await act(async () => { done = latest.send("to the first app"); }); + await render({ appId: other, backend }); + await snapshot([], "ready", {}, other); + await act(async () => { await latest.send("to the second app"); }); + await act(async () => { call.reject(new Error("Send message failed: 500")); await done; }); + expect(latest.error).toBeNull(); + expect(latest.status).toBe("submitted"); + }); + test("precedence: error over waiting over building", async () => { await mount({ appId: app, backend: makeBackend() }); await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })], "error"); @@ -419,12 +460,12 @@ describe("pending prompts", () => { expect(shown()).toEqual([["hello", false], ["make it blue", false]]); }); - test("a prompt Base44 echoes with other text still settles, oldest first", async () => { + test("a prompt someone else sends to the same app does not settle ours", async () => { await mount({ appId: app, backend: makeBackend() }); await snapshot([]); - await act(async () => { await latest.send("first"); await latest.send("second"); }); - await updated(message("u1", "1", { role: "user", content: "first (edited)" })); - expect(shown()).toEqual([["first (edited)", false], ["second", true]]); + await act(async () => { await latest.send("mine"); }); + await updated(message("u1", "1", { role: "user", content: "theirs" })); + expect(shown()).toEqual([["theirs", false], ["mine", true]]); }); test("a prompt Base44 queues stays pending, in the order sent, until it runs", async () => { @@ -473,6 +514,16 @@ describe("pending prompts", () => { expect(shown()).toEqual([["older", false], ["again", true]]); }); + test("create while following a set app leaves no hidden prompt behind", async () => { + const backend = makeBackend(); + const { render } = await mount({ appId: app, backend }); + await snapshot([]); + await act(async () => { await latest.create("Another app"); }); + expect(latest.items).toEqual([]); + await render({ appId: other, backend }); + expect(latest.items).toEqual([]); + }); + test("switching apps drops the pending prompts", async () => { const backend = makeBackend(); const { render } = await mount({ appId: app, backend }); @@ -489,7 +540,7 @@ describe("questions", () => { const backend = makeBackend(); await mount({ appId: app, backend }); await snapshot([ - message("m1", "1", { tool_calls: [waiting("t1"), waiting("t2", "choice"), { id: "t3", name: "write_file", status: "success" }] }), + message("m1", "1", { tool_calls: [waiting("t1"), { ...waiting("t2", "choice"), arguments: { questions: [{ question: "Which?", options: ["A", "B"] }] } }, { id: "t3", name: "write_file", status: "success" }] }), ]); expect(latest.status).toBe("waiting"); expect(latest.items[0].steps.map(s => s.question?.kind)).toEqual(["approval", "choice", undefined]); @@ -497,7 +548,7 @@ describe("questions", () => { if (choice.kind !== "choice") throw new Error("expected a choice"); await act(async () => { await choice.answer([["A"]]); }); expect(backend.submitToolCallInput).toHaveBeenCalledWith(app, { - toolCallId: "t2", messageId: "m1", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_labels: ["A"] }] }, + toolCallId: "t2", messageId: "m1", approve: true, extraUserInput: { answers: [{ question_index: 0, selected_label: "A" }] }, }); expect(latest.items[0].steps.map(s => s.question?.kind)).toEqual(["approval", undefined, undefined]); expect(latest.items[0].steps[1].status).toBe("waiting"); From c77c79d103386fa9d49be548db3339d5d823b39c Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 18 Oct 2026 14:21:25 +0300 Subject: [PATCH 3/4] feat(platform/react): approval details, answered choices and step summaries From the review on #315, the three fields partners need most: - ApprovalQuestion gains guard and details (summary, entityName, changeType, changedOps, functionName, method, inspectionFailed), so the user sees what they approve instead of a tool name alone. - ChatStep gains answered: what the user answered on a choice, next to each question, so the chat's history keeps it after the question closes. - ChatStep gains summary, Base44's description of the step, and label uses it, then the entity and record count, then the tool and its files. Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/chat-items.ts | 77 +++++++++++++++++-- packages/platform/src/react/chat.types.ts | 38 ++++++++- packages/platform/src/react/index.ts | 2 +- .../tests/unit/react/chat-items.test.ts | 46 +++++++++++ 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts index 31aec28a..f24f569e 100644 --- a/packages/platform/src/react/chat-items.ts +++ b/packages/platform/src/react/chat-items.ts @@ -1,6 +1,6 @@ -import type { Message, QuestionArguments, SetSecretsArguments } from "../client/modules/builder.events.generated.js"; +import type { Message, QuestionArguments, QuestionInput, SetSecretsArguments } from "../client/modules/builder.events.generated.js"; import type { ToolCall } from "../client/modules/builder.events.types.js"; -import type { ChatItem, ChatQuestion, ChatStep } from "./chat.types.js"; +import type { ApprovalDetails, ChatAnswer, ChatItem, ChatQuestion, ChatStep } from "./chat.types.js"; /** @internal Sends the answer to the one tool call a question belongs to. */ export type AnswerToolCall = (approve: boolean, extraUserInput?: Record) => Promise; @@ -66,14 +66,50 @@ export function toItems(sortedMessages: Message[], answeredToolCallIds: string[] })); } +// What a tool's display projection may carry; each tool declares only some of it. +type ToolDisplay = { file_paths?: string[]; summary?: string; entity_name?: string; record_count?: number }; + function toStep(toolCall: ToolCall, answerToolCall: AnswerToolCall | undefined): ChatStep { const toolName = toolCall.name ?? ""; - // Only file tools carry `display.file_paths`. - const display = "display" in toolCall ? (toolCall.display as { file_paths?: string[] } | null | undefined) : undefined; + const display = "display" in toolCall ? (toolCall.display as ToolDisplay | null | undefined) : undefined; const files = display?.file_paths ?? []; + const summary = display?.summary ?? (toolArguments(toolCall) as { summary?: string } | undefined)?.summary; const status = stepStatus(toolCall); const question = status === "waiting" && answerToolCall ? toQuestion(toolCall, answerToolCall) : undefined; - return { id: toolCall.id!, tool: toolName, files, label: [toolName, ...files].join(" "), status, ...(question && { question }) }; + const answered = answersOf(toolCall); + return { + id: toolCall.id!, + tool: toolName, + files, + label: stepLabel(toolName, files, summary, display), + status, + ...(summary && { summary }), + ...(question && { question }), + ...(answered && { answered }), + }; +} + +// Base44's own description of the step reads best; then the entity it touched; then the files. +function stepLabel(toolName: string, files: string[], summary: string | undefined, display: ToolDisplay | null | undefined): string { + if (summary) return summary; + const entityName = display?.entity_name; + if (entityName) { + const recordCount = display?.record_count; + return recordCount === undefined ? `${toolName} ${entityName}` : `${toolName} ${entityName} (${recordCount})`; + } + return [toolName, ...files].join(" "); +} + +// What the user answered on a choice, each answer next to the question it answers. +function answersOf(toolCall: ToolCall): ChatAnswer[] | undefined { + const userInput = "user_input" in toolCall ? (toolCall.user_input as QuestionInput | null | undefined) : undefined; + if (!userInput?.answers?.length) return undefined; + const questions = (toolArguments(toolCall) as QuestionArguments | undefined)?.questions ?? []; + return userInput.answers.map(answer => { + const labels = answer.selected_labels ?? (answer.selected_label ? [answer.selected_label] : []); + const question = questions[answer.question_index ?? -1]?.question ?? ""; + return { question, labels, ...(answer.custom_text && { text: answer.custom_text }) }; + }); } /** @internal Base44's tool call status, as a step's. */ @@ -187,14 +223,39 @@ export function toQuestion(toolCall: ToolCall, answerToolCall: AnswerToolCall): }; } if (kind === "approval") { - // Guards that declare a reason or a summary carry them; the others name only themselves. - const approval = toolCall.approval as { reason?: string | null; details?: { summary?: string | null } | null } | null | undefined; + // Guards that declare a reason or details carry them; the others name only themselves. + const approval = toolCall.approval as GuardApproval | null | undefined; const reason = approval?.reason ?? approval?.details?.summary ?? ""; - return { kind, action: toolCall.name ?? "", reason, approve: () => answerToolCall(true), decline }; + const guard = approval?.guard ?? ""; + return { kind, action: toolCall.name ?? "", reason, guard, details: approvalDetails(approval?.details), approve: () => answerToolCall(true), decline }; } return undefined; } +// Every detail a guard may publish, across Base44's guards. +type GuardApproval = { + guard?: string; + reason?: string | null; + details?: { + summary?: string; entity_name?: string; change_type?: string; changed_ops?: string[]; + function_name?: string; method?: string; inspection_failed?: boolean; + } | null; +}; + +function approvalDetails(details: GuardApproval["details"]): ApprovalDetails { + if (!details) return {}; + const mapped: ApprovalDetails = { + summary: details.summary, + entityName: details.entity_name, + changeType: details.change_type, + changedOps: details.changed_ops, + functionName: details.function_name, + method: details.method, + inspectionFailed: details.inspection_failed, + }; + return Object.fromEntries(Object.entries(mapped).filter(([, value]) => value !== undefined)) as ApprovalDetails; +} + // The waiting kind says which arguments a call carries; the tool's name only narrows the union. function toolArguments(toolCall: ToolCall): unknown { return "arguments" in toolCall ? toolCall.arguments : undefined; diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index 011bdb72..43c07170 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -117,8 +117,10 @@ export interface ChatStep { tool: string; /** The file paths the tool call touched. */ files: string[]; - /** The tool name and its files, ready to show. */ + /** A line ready to show. It's Base44's description of the step when there is one, then the entity it touched, then the tool name and its files. */ label: string; + /** Base44's description of what the step did, such as what a command or code run did. */ + summary?: string; /** * Where the tool call is. A `waiting` step waits on the user. A `stopped` step was declined, or the * turn was stopped. @@ -130,6 +132,18 @@ export interface ChatStep { * once answered, and is absent for a question kind this release doesn't know. */ question?: ChatQuestion; + /** What the user answered on a choice question, once answered. It stays after the question closes, so the chat's history shows it. */ + answered?: ChatAnswer[]; +} + +/** One answer to a choice question, next to the question it answers. */ +export interface ChatAnswer { + /** The question text. */ + question: string; + /** The labels the user picked. */ + labels: string[]; + /** The free text the user wrote, if any. */ + text?: string; } /** @@ -217,6 +231,10 @@ export interface ApprovalQuestion { action: string; /** Why the action needs approval, when Base44 gives a reason. Empty otherwise. */ reason: string; + /** The check that paused the action, such as `exec_tool_send_email` or `entity_rls_guard`. */ + guard: string; + /** What the action would do, as far as the check publishes it. Show it so the user knows what they approve. */ + details: ApprovalDetails; /** * Lets the action run. * @@ -231,6 +249,24 @@ export interface ApprovalQuestion { decline(): Promise; } +/** What an action that waits for approval would do. Each check publishes only some of these. */ +export interface ApprovalDetails { + /** The AI's summary of what the code does. */ + summary?: string; + /** The entity whose records or access rules change. */ + entityName?: string; + /** How the access rules change. */ + changeType?: string; + /** The access operations whose rules change. */ + changedOps?: string[]; + /** The backend function under test. */ + functionName?: string; + /** The entity method the code calls, such as `delete`. */ + method?: string; + /** Whether the check couldn't verify the function's source. */ + inspectionFailed?: boolean; +} + /** * What the AI is doing. When several hold at once, the first in this list wins. * diff --git a/packages/platform/src/react/index.ts b/packages/platform/src/react/index.ts index 1b5a4af1..0841fdaf 100644 --- a/packages/platform/src/react/index.ts +++ b/packages/platform/src/react/index.ts @@ -1,7 +1,7 @@ /** The `@base44/platform/react` entry: the AI chat for one app, as a React hook. React is an optional peer, loaded only here. */ export { useBase44Chat } from "./useBase44Chat.js"; export type { - ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatBackend, ChatConnection, ChatError, ChatItem, ChatQuestion, + ApprovalDetails, ApprovalQuestion, Base44App, Base44Chat, Base44ChatOptions, Base44ChatBackend, ChatAnswer, ChatConnection, ChatError, ChatItem, ChatQuestion, ChatStatus, ChatStep, ChoiceEntry, ChoiceQuestion, SecretField, SecretsQuestion, ToolCallAnswer, } from "./chat.types.js"; export type { SocketSession } from "../client/modules/builder.types.js"; diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts index 6a7ad0cd..e4c67a9a 100644 --- a/packages/platform/tests/unit/react/chat-items.test.ts +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -128,6 +128,42 @@ describe("items", () => { }); }); +describe("steps", () => { + const step = (toolCall: Record) => toItems([message("m", "1", { tool_calls: [toolCall as ToolCall] })], [], vi.fn())[0].steps[0]; + + test("a step's label is Base44's summary, then the entity it touched, then the tool and files", () => { + expect(step({ id: "t1", name: "run_shell_command", status: "success", display: { summary: "Installed the date library" } })) + .toMatchObject({ label: "Installed the date library", summary: "Installed the date library" }); + expect(step({ id: "t2", name: "update_entities", status: "success", arguments: { summary: "Marked three tasks done" }, display: { entity_name: "Task" } })) + .toMatchObject({ label: "Marked three tasks done", summary: "Marked three tasks done" }); + expect(step({ id: "t3", name: "delete_entities", status: "success", display: { entity_name: "Task", record_count: 3 } })) + .toMatchObject({ label: "delete_entities Task (3)" }); + const plain = step({ id: "t4", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }); + expect(plain.label).toBe("write_file src/App.jsx"); + expect("summary" in plain).toBe(false); + }); + + test("an answered choice keeps what the user answered, next to each question", () => { + const answered = step({ + id: "t1", + name: "ask_clarifying_questions", + status: "success", + arguments: { questions: [{ question: "Which colour?" }, { question: "Which pages?", multi_select: true }, { question: "Name it" }] }, + user_input: { answers: [ + { question_index: 0, selected_label: "Blue", custom_text: "a calm one" }, + { question_index: 1, selected_labels: ["Home", "About"] }, + { question_index: 2, custom_text: "Sip" }, + ] }, + }); + expect(answered.answered).toEqual([ + { question: "Which colour?", labels: ["Blue"], text: "a calm one" }, + { question: "Which pages?", labels: ["Home", "About"] }, + { question: "Name it", labels: [], text: "Sip" }, + ]); + expect(answered.question).toBeUndefined(); + }); +}); + describe("pending prompts", () => { const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId }); @@ -215,7 +251,17 @@ describe("questions", () => { expect(withReason).toMatchObject({ kind: "approval", action: "send_email", reason: "Sends mail" }); const withSummary = toQuestion(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 guarded = toQuestion(waiting("t", { + name: "update_entity_rules", + waiting_on: { kind: "approval" }, + approval: { guard: "entity_rls_guard", reason: "Opens Task to everyone", details: { entity_name: "Task", change_type: "loosen", changed_ops: ["read", "update"] } }, + } as Partial), reply); + expect(guarded).toMatchObject({ + guard: "entity_rls_guard", + details: { entityName: "Task", changeType: "loosen", changedOps: ["read", "update"] }, + }); const bare = toQuestion(waiting("t", { waiting_on: { kind: "approval" } }), reply); + expect(bare).toMatchObject({ guard: "", details: {} }); expect(bare).toMatchObject({ kind: "approval", action: "tool", reason: "" }); if (bare?.kind !== "approval") return; await bare.approve(); From 5ffd6d33891cb7ec1fddfb007177dc938917a266 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 18 Oct 2026 14:58:27 +0300 Subject: [PATCH 4/4] refactor(platform/react): no separate step summary; label already shows it Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/chat-items.ts | 1 - packages/platform/src/react/chat.types.ts | 2 -- packages/platform/tests/unit/react/chat-items.test.ts | 8 +++----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts index f24f569e..f827ec28 100644 --- a/packages/platform/src/react/chat-items.ts +++ b/packages/platform/src/react/chat-items.ts @@ -83,7 +83,6 @@ function toStep(toolCall: ToolCall, answerToolCall: AnswerToolCall | undefined): files, label: stepLabel(toolName, files, summary, display), status, - ...(summary && { summary }), ...(question && { question }), ...(answered && { answered }), }; diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index 43c07170..9c0eb515 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -119,8 +119,6 @@ export interface ChatStep { files: string[]; /** A line ready to show. It's Base44's description of the step when there is one, then the entity it touched, then the tool name and its files. */ label: string; - /** Base44's description of what the step did, such as what a command or code run did. */ - summary?: string; /** * Where the tool call is. A `waiting` step waits on the user. A `stopped` step was declined, or the * turn was stopped. diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts index e4c67a9a..49c40f1f 100644 --- a/packages/platform/tests/unit/react/chat-items.test.ts +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -133,14 +133,12 @@ describe("steps", () => { test("a step's label is Base44's summary, then the entity it touched, then the tool and files", () => { expect(step({ id: "t1", name: "run_shell_command", status: "success", display: { summary: "Installed the date library" } })) - .toMatchObject({ label: "Installed the date library", summary: "Installed the date library" }); + .toMatchObject({ label: "Installed the date library" }); expect(step({ id: "t2", name: "update_entities", status: "success", arguments: { summary: "Marked three tasks done" }, display: { entity_name: "Task" } })) - .toMatchObject({ label: "Marked three tasks done", summary: "Marked three tasks done" }); + .toMatchObject({ label: "Marked three tasks done" }); expect(step({ id: "t3", name: "delete_entities", status: "success", display: { entity_name: "Task", record_count: 3 } })) .toMatchObject({ label: "delete_entities Task (3)" }); - const plain = step({ id: "t4", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }); - expect(plain.label).toBe("write_file src/App.jsx"); - expect("summary" in plain).toBe(false); + expect(step({ id: "t4", name: "write_file", status: "success", display: { file_paths: ["src/App.jsx"] } }).label).toBe("write_file src/App.jsx"); }); test("an answered choice keeps what the user answered, next to each question", () => {