From 18357a2926c0ad2b8d327371804f17f68c6edcd8 Mon Sep 17 00:00:00 2001 From: Michael Magan Date: Mon, 9 Feb 2026 19:04:53 -0800 Subject: [PATCH 1/3] Migrate to @tambo-ai/react 1.0.0-rc.4 --- README.md | 2 +- package-lock.json | 520 +++++++++++------- package.json | 3 +- src/app/chat/page.tsx | 6 +- src/components/tambo/dictation-button.tsx | 4 +- src/components/tambo/mcp-components.tsx | 2 +- .../tambo/message-generation-stage.tsx | 25 +- src/components/tambo/message-input.tsx | 42 +- src/components/tambo/message-suggestions.tsx | 41 +- src/components/tambo/message-thread-full.tsx | 8 +- src/components/tambo/message.tsx | 418 ++++++-------- .../tambo/scrollable-message-container.tsx | 17 +- src/components/tambo/text-editor.tsx | 2 +- src/components/tambo/thread-container.tsx | 2 +- src/components/tambo/thread-content.tsx | 21 +- src/components/tambo/thread-history.tsx | 96 ++-- .../ui/interactable-canvas-details.tsx | 4 +- src/components/ui/interactable-tabs.tsx | 4 +- src/lib/tambo.ts | 55 +- src/lib/use-anonymous-user-key.ts | 21 + 20 files changed, 664 insertions(+), 629 deletions(-) create mode 100644 src/lib/use-anonymous-user-key.ts diff --git a/README.md b/README.md index f1175eb..f811353 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ export default function Chat() { >
- +
diff --git a/package-lock.json b/package-lock.json index 86716c4..34fc4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,7 @@ "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-tooltip": "1.2.8", "@tailwindcss/oxide": "^4.1.17", - "@tambo-ai/react": "^0.65.2", - "@tambo-ai/typescript-sdk": "^0.79.0", + "@tambo-ai/react": "^1.0.0-rc.4", "@tiptap/extension-document": "^3.12.1", "@tiptap/extension-mention": "^3.12.1", "@tiptap/extension-paragraph": "^3.12.1", @@ -57,6 +56,15 @@ "typescript": "5.9.3" } }, + "node_modules/@ag-ui/core": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.43.tgz", + "integrity": "sha512-/T7kKwPAhtriFFiv3YxZ0yAzMHoY8a8I5UKzhPzwW934h92c6RJ54N93yb9PAqdX3XjCPId0C5gIBHb6QbeR3Q==", + "dependencies": { + "rxjs": "7.8.1", + "zod": "^3.22.4" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -113,7 +121,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -320,9 +327,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -450,7 +457,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -661,7 +667,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -686,6 +691,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1261,11 +1278,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz", - "integrity": "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1273,13 +1291,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -3060,7 +3080,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -3135,10 +3156,55 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-community/standard-json": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@standard-community/standard-json/-/standard-json-0.3.5.tgz", + "integrity": "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==", + "license": "MIT", + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/json-schema": "^7.0.15", + "@valibot/to-json-schema": "^1.3.0", + "arktype": "^2.1.20", + "effect": "^3.16.8", + "quansync": "^0.2.11", + "sury": "^10.0.0", + "typebox": "^1.0.17", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -3468,41 +3534,42 @@ } }, "node_modules/@tambo-ai/react": { - "version": "0.65.2", - "resolved": "https://registry.npmjs.org/@tambo-ai/react/-/react-0.65.2.tgz", - "integrity": "sha512-V8YlVSCFGGdyCJSL3EmnPIaDgtASGz6ua52/HWPFrRObLLHeRLLUEJ0C4T524iRcgmYOa+MJJHfRM4nELZf3zg==", + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@tambo-ai/react/-/react-1.0.0-rc.4.tgz", + "integrity": "sha512-tvVhw9i2fujj/NWOOtfXLiT3XlXoA02SaLLBFDlKJl2KBMHKLQQpzU0LOPguLj+7hZKeoZsmYFIH9JosPg5IGg==", + "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", - "@tambo-ai/typescript-sdk": "^0.77.0", - "@tanstack/react-query": "^5.90.10", + "@ag-ui/core": "^0.0.43", + "@modelcontextprotocol/sdk": "^1.26.0", + "@standard-community/standard-json": "^0.3.5", + "@standard-schema/spec": "^1.1.0", + "@tambo-ai/typescript-sdk": "^0.92.0", + "@tanstack/react-query": "^5.90.16", "fast-equals": "^5.3.3", + "fast-json-patch": "^3.1.1", "partial-json": "^0.1.7", "react-fast-compare": "^3.2.2", "react-media-recorder": "^1.7.2", - "ts-essentials": "^10.1.1", "ts-node": "^10.9.2", - "use-debounce": "^10.0.6", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.24.6" - }, - "engines": { - "node": ">=22", - "npm": ">=11" + "type-fest": "^5.4.3", + "use-debounce": "^10.0.6" }, "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tambo-ai/react/node_modules/@tambo-ai/typescript-sdk": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@tambo-ai/typescript-sdk/-/typescript-sdk-0.77.0.tgz", - "integrity": "sha512-KNND3ULzpuEvBZSDOCuwE8Aw7oYC4tOQUEnN0n8vTLCl7yuK6BLtfUk3ClS5hqsBfK5nCURlNTZ5CRhyURFheA==", - "license": "Apache-2.0", - "bin": { - "tambo-ai-typescript-sdk": "bin/cli" + "react-dom": "^18.0.0 || ^19.0.0", + "zod": "^3.25.76 || ^4", + "zod-to-json-schema": "^3.25.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } } }, "node_modules/@tambo-ai/react/node_modules/ts-node": { @@ -3549,18 +3616,18 @@ } }, "node_modules/@tambo-ai/typescript-sdk": { - "version": "0.79.0", - "resolved": "https://registry.npmjs.org/@tambo-ai/typescript-sdk/-/typescript-sdk-0.79.0.tgz", - "integrity": "sha512-DMh/bLyMfnmPsJ7CjnE0YUi2sJu271yf2gwdQPqSac9aY3eIMZM3UJgYXAWGcN7lS5OIVPmDofXXtwXcftjKJA==", + "version": "0.92.0", + "resolved": "https://registry.npmjs.org/@tambo-ai/typescript-sdk/-/typescript-sdk-0.92.0.tgz", + "integrity": "sha512-nLJeV3S5Szca0X0y+A57JgVKTPuRjrGgSb5TI4l95E2enRB8wI4m/bUaZl89e7jajiB9cykPelPucz/AzXpnyQ==", "license": "Apache-2.0", "bin": { "tambo-ai-typescript-sdk": "bin/cli" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -3568,12 +3635,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -3776,7 +3843,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.12.1.tgz", "integrity": "sha512-LXuWF1Ow5aoynOBy9YMb89RBJNRzKa9Vy3s90Hve7wtMDV7PlXb5apiNWQsYe+CGXc5bvLYjMFDMbE6ahWcUyA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3787,9 +3853,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "license": "MIT" }, "node_modules/@tsconfig/node12": { @@ -4125,8 +4191,7 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -4144,13 +4209,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4169,7 +4236,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/ms": { "version": "2.1.0", @@ -4182,7 +4250,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -4191,7 +4258,6 @@ "version": "19.0.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.11.tgz", "integrity": "sha512-vrdxRZfo9ALXth6yPfV16PYTLZwsUWhVjjC+DkfE5t1suNSbBrWC9YqSuuxJZ8Ps6z1o2ycRpIqzZJIgklq4Tw==", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4200,7 +4266,6 @@ "version": "19.0.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4281,7 +4346,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -4663,7 +4727,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4963,12 +5026,12 @@ } }, "node_modules/automation-events": { - "version": "7.1.13", - "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.13.tgz", - "integrity": "sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==", + "version": "7.1.15", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.15.tgz", + "integrity": "sha512-NsHJlve3twcgs8IyP4iEYph7Fzpnh6klN7G5LahwvypakBjFbsiGHJxrqTmeHKREdu/Tx6oZboqNI0tD4MnFlA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" }, "engines": { @@ -5073,9 +5136,9 @@ } }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -5084,7 +5147,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -5120,15 +5183,15 @@ } }, "node_modules/broker-factory": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.10.tgz", - "integrity": "sha512-BzqK5GYFhvVFvO13uzPN0SCiOsOQuhMUbsGvTXDJMA2/N4GvIlFdxEuueE+60Zk841bBU5G3+fl2cqYEo0wgGg==", + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz", + "integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "fast-unique-numbers": "^9.0.24", + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", - "worker-factory": "^7.0.46" + "worker-factory": "^7.0.48" } }, "node_modules/browserslist": { @@ -5151,7 +5214,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5320,7 +5382,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -5505,9 +5566,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -5515,6 +5576,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cose-base": { @@ -5536,7 +5601,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5561,7 +5627,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -5977,7 +6042,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6277,9 +6341,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -6366,6 +6430,7 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.12" }, @@ -6582,7 +6647,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -6766,7 +6830,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7059,7 +7122,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7099,10 +7161,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -7135,38 +7200,38 @@ } }, "node_modules/extendable-media-recorder-wav-encoder": { - "version": "7.0.132", - "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.132.tgz", - "integrity": "sha512-i+DWP7eDBP+V/jVzmpVMET6XsQPTWcW3vmYZP8lGShnWx5vkwB+mdgK2kAwdu9i9r0IVMFVQETsO1nGYOW1cwg==", + "version": "7.0.136", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.136.tgz", + "integrity": "sha512-K4ZcMSbsTlI7gv92K+UY+czvk37PIPSWLqp5NF3pNDF9S6iffCbNJnl+k1zce02P6tmOAf+ZlzVRRjxSXNEoog==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "extendable-media-recorder-wav-encoder-broker": "^7.0.122", - "extendable-media-recorder-wav-encoder-worker": "^8.0.119", + "@babel/runtime": "^7.28.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.125", + "extendable-media-recorder-wav-encoder-worker": "^8.0.121", "tslib": "^2.8.1" } }, "node_modules/extendable-media-recorder-wav-encoder-broker": { - "version": "7.0.122", - "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.122.tgz", - "integrity": "sha512-vupumv22Zb7WsUJ/K16SYrRTs/aTPc+48Smgd0Cq16IzH8eR7BiScp7ciFva04uZlWZtCtnYz8uLGJWrN+rlyA==", + "version": "7.0.125", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.125.tgz", + "integrity": "sha512-HVmznJvyG+eFZJRYLd9h3OF0oNNIGEmEHAP4IQ0Y5gwxJcmrFhVmGB4hLi1GT/jNM8aSoCxIePVkCX+5tuGvpA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "broker-factory": "^3.1.10", - "extendable-media-recorder-wav-encoder-worker": "^8.0.119", + "@babel/runtime": "^7.28.6", + "broker-factory": "^3.1.13", + "extendable-media-recorder-wav-encoder-worker": "^8.0.121", "tslib": "^2.8.1" } }, "node_modules/extendable-media-recorder-wav-encoder-worker": { - "version": "8.0.119", - "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.119.tgz", - "integrity": "sha512-7RRdga3SvQ5k/KLuFPxZJl3wBe9vhanBVZD/2+STwHxfHkbbh3VLKv3rjtH7h4F2OYOEGCLLKKcfI5LFWiXA7g==", + "version": "8.0.121", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.121.tgz", + "integrity": "sha512-UBBgWkyE9fpCLDdrWdTZM56FkImAljpUuxr6+y9W6LHvY7XWhkZP+yO5uZUUquS5IpsBlY2uKOWpKwiLdo3FOg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", - "worker-factory": "^7.0.46" + "worker-factory": "^7.0.48" } }, "node_modules/fast-deep-equal": { @@ -7213,6 +7278,12 @@ "node": ">= 6" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -7226,12 +7297,12 @@ "dev": true }, "node_modules/fast-unique-numbers": { - "version": "9.0.24", - "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.24.tgz", - "integrity": "sha512-Dv0BYn4waOWse94j16rsZ5w/0zoaCa74O3q6IZjMqaXbtT92Q+Sb6pPk+phGzD8Xh+nueQmSRI3tSCaHKidzKw==", + "version": "9.0.26", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz", + "integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" }, "engines": { @@ -8005,6 +8076,15 @@ "node": ">=12.0.0" } }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -8046,9 +8126,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8075,7 +8155,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -8144,6 +8223,15 @@ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8690,6 +8778,12 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -9078,6 +9172,7 @@ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -9171,6 +9266,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -9518,7 +9614,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/media-encoder-host": { "version": "8.1.0", @@ -9631,7 +9728,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -10268,8 +10364,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -10677,7 +10772,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/own-keys": { "version": "1.0.1", @@ -10936,7 +11032,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10988,6 +11083,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-transform": "^1.0.0" } @@ -10997,6 +11093,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0" } @@ -11006,6 +11103,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -11017,6 +11115,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -11028,6 +11127,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -11040,6 +11140,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -11052,6 +11153,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -11062,6 +11164,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -11072,6 +11175,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", "license": "MIT", + "peer": true, "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", @@ -11083,6 +11187,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", "license": "MIT", + "peer": true, "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -11105,6 +11210,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.25.0" } @@ -11114,6 +11220,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -11137,6 +11244,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.3.tgz", "integrity": "sha512-wbqCR/RlRPRe41a4LFtmhKElzBEfBTdtAYWNIGHM6X2e24NN/MTNUKyXjjphfAfdQce37Kh/5yf765mLPYDe7Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", @@ -11150,6 +11258,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "license": "MIT", + "peer": true, "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" @@ -11165,6 +11274,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -11208,14 +11318,15 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -11227,6 +11338,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11354,7 +11482,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11364,7 +11491,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -11381,8 +11507,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "peer": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-markdown": { "version": "10.1.0", @@ -11426,7 +11551,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11599,8 +11723,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -11924,7 +12047,8 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/roughjs": { "version": "4.6.6", @@ -12006,6 +12130,15 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/rxjs-interop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/rxjs-interop/-/rxjs-interop-2.0.0.tgz", @@ -12090,31 +12223,35 @@ } }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -12124,6 +12261,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { @@ -12623,12 +12764,12 @@ "license": "MIT" }, "node_modules/subscribable-things": { - "version": "2.1.55", - "resolved": "https://registry.npmjs.org/subscribable-things/-/subscribable-things-2.1.55.tgz", - "integrity": "sha512-WBx7R/NJYPOGX+cRpruSTFOYsMWOKBx+4cRKf0IowVXFGNDb2dF+215rwKtwk4eAa+QAv4HyTjL8jj/9Ks05pw==", + "version": "2.1.57", + "resolved": "https://registry.npmjs.org/subscribable-things/-/subscribable-things-2.1.57.tgz", + "integrity": "sha512-Ebcu2SJUntGnfJlTKc5jIGcDbuev4Ys2bRstzl5DUyzjWTZV9ymONZ0x8kEiN8NtnDlVuFe40EB8t3XvH8SWkw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "rxjs-interop": "^2.0.0", "tslib": "^2.8.1" } @@ -12657,6 +12798,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -12744,7 +12897,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12825,20 +12977,6 @@ "node": ">=6.10" } }, - "node_modules/ts-essentials": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", - "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12868,6 +13006,21 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -12961,7 +13114,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12998,7 +13150,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ufo": { "version": "1.6.1", @@ -13035,7 +13188,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -13217,9 +13369,9 @@ } }, "node_modules/use-debounce": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", - "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", + "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", "license": "MIT", "engines": { "node": ">= 16.0.0" @@ -13404,7 +13556,8 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/web-namespaces": { "version": "2.0.1", @@ -13525,13 +13678,13 @@ } }, "node_modules/worker-factory": { - "version": "7.0.46", - "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.46.tgz", - "integrity": "sha512-Sr1hq2FMgNa04UVhYQacsw+i58BtMimzDb4+CqYphZ97OfefRpURu0UZ+JxMr/H36VVJBfuVkxTK7MytsanC3w==", + "version": "7.0.48", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz", + "integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "fast-unique-numbers": "^9.0.24", + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1" } }, @@ -13574,15 +13727,14 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 589a0b3..5ba5fe7 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,7 @@ "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-tooltip": "1.2.8", "@tailwindcss/oxide": "^4.1.17", - "@tambo-ai/react": "^0.65.2", - "@tambo-ai/typescript-sdk": "^0.79.0", + "@tambo-ai/react": "^1.0.0-rc.4", "@tiptap/extension-document": "^3.12.1", "@tiptap/extension-mention": "^3.12.1", "@tiptap/extension-paragraph": "^3.12.1", diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 6af947d..949f5c6 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -4,12 +4,14 @@ import { MessageThreadFull } from "@/components/tambo/message-thread-full"; import ComponentsCanvas from "@/components/ui/components-canvas"; import { InteractableCanvasDetails } from "@/components/ui/interactable-canvas-details"; import { InteractableTabs } from "@/components/ui/interactable-tabs"; +import { useAnonymousUserKey } from "@/lib/use-anonymous-user-key"; import { components, tools } from "@/lib/tambo"; import { TamboProvider } from "@tambo-ai/react"; import { TamboMcpProvider } from "@tambo-ai/react/mcp"; export default function Home() { const mcpServers = useMcpServers(); + const userKey = useAnonymousUserKey(); // You can customize default suggestions via MessageThreadFull internals @@ -18,6 +20,8 @@ export default function Home() {
- +
{/* Tabs interactable manages tabs state only */} diff --git a/src/components/tambo/dictation-button.tsx b/src/components/tambo/dictation-button.tsx index ce3d3e9..ffb4218 100644 --- a/src/components/tambo/dictation-button.tsx +++ b/src/components/tambo/dictation-button.tsx @@ -30,9 +30,9 @@ export default function DictationButton() { useEffect(() => { if (transcript && transcript !== lastProcessedTranscriptRef.current) { lastProcessedTranscriptRef.current = transcript; - setValue(value + " " + transcript); + setValue((prev) => prev + " " + transcript); } - }, [transcript, value, setValue]); + }, [transcript, setValue]); if (isTranscribing) { return ( diff --git a/src/components/tambo/mcp-components.tsx b/src/components/tambo/mcp-components.tsx index cc448a5..67f211d 100644 --- a/src/components/tambo/mcp-components.tsx +++ b/src/components/tambo/mcp-components.tsx @@ -288,7 +288,7 @@ function ResourceListContent({ <> {filteredResources.map((resourceEntry) => ( { onSelectResource(resourceEntry.resource.uri); diff --git a/src/components/tambo/message-generation-stage.tsx b/src/components/tambo/message-generation-stage.tsx index 8887cc8..b654485 100644 --- a/src/components/tambo/message-generation-stage.tsx +++ b/src/components/tambo/message-generation-stage.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { type GenerationStage, useTambo } from "@tambo-ai/react"; +import { useTambo } from "@tambo-ai/react"; import { Loader2Icon } from "lucide-react"; import * as React from "react"; @@ -20,30 +20,15 @@ export function MessageGenerationStage({ showLabel = true, ...props }: GenerationStageProps) { - const { thread, isIdle } = useTambo(); - const stage = thread?.generationStage; + const { isStreaming, isWaiting, isIdle } = useTambo(); - // Only render if we have a generation stage - if (!stage) { + if (isIdle) { return null; } - // Map stage names to more user-friendly labels - const stageLabels: Record = { - IDLE: "Idle", - CHOOSING_COMPONENT: "Choosing component", - FETCHING_CONTEXT: "Fetching context", - HYDRATING_COMPONENT: "Preparing component", - STREAMING_RESPONSE: "Generating response", - COMPLETE: "Complete", - ERROR: "Error", - CANCELLED: "Cancelled", - }; - - const label = - stageLabels[stage] || stage.charAt(0).toUpperCase() + stage.slice(1); + const label = isWaiting ? "Preparing response" : isStreaming ? "Generating response" : ""; - if (isIdle) { + if (!label) { return null; } diff --git a/src/components/tambo/message-input.tsx b/src/components/tambo/message-input.tsx index 2f6a20e..ae15946 100644 --- a/src/components/tambo/message-input.tsx +++ b/src/components/tambo/message-input.tsx @@ -13,7 +13,7 @@ import { import { cn } from "@/lib/utils"; import { useIsTamboTokenUpdating, - useTamboThread, + useTambo, useTamboThreadInput, type StagedImage, } from "@tambo-ai/react"; @@ -81,7 +81,6 @@ const messageInputVariants = cva("w-full", { * @property {function} handleSubmit - Function to handle form submission * @property {boolean} isPending - Whether a submission is in progress * @property {Error|null} error - Any error from the submission - * @property {string|undefined} contextKey - The thread context key * @property {Editor|null} editorRef - Reference to the TipTap editor instance * @property {string | null} submitError - Error from the submission * @property {function} setSubmitError - Function to set the submission error @@ -91,14 +90,10 @@ const messageInputVariants = cva("w-full", { interface MessageInputContextValue { value: string; setValue: (value: string) => void; - submit: (options: { - contextKey?: string; - streamResponse?: boolean; - }) => Promise; + submit: () => Promise<{ threadId: string | undefined } | void>; handleSubmit: (e: React.FormEvent) => Promise; isPending: boolean; error: Error | null; - contextKey?: string; editorRef: React.RefObject; submitError: string | null; setSubmitError: React.Dispatch>; @@ -135,8 +130,6 @@ const useMessageInputContext = () => { * Extends standard HTMLFormElement attributes. */ export interface MessageInputProps extends React.HTMLAttributes { - /** The context key identifying which thread to send messages to. */ - contextKey?: string; /** Optional styling variant for the input container. */ variant?: VariantProps["variant"]; /** Optional ref to forward to the TipTap editor instance. */ @@ -151,7 +144,7 @@ export interface MessageInputProps extends React.HTMLAttributes * @component MessageInput * @example * ```tsx - * + * * * * @@ -159,12 +152,11 @@ export interface MessageInputProps extends React.HTMLAttributes * ``` */ const MessageInput = React.forwardRef( - ({ children, className, contextKey, variant, ...props }, ref) => { + ({ children, className, variant, ...props }, ref) => { return ( @@ -181,7 +173,7 @@ MessageInput.displayName = "MessageInput"; const MessageInputInternal = React.forwardRef< HTMLFormElement, MessageInputProps ->(({ children, className, contextKey, variant, inputRef, ...props }, ref) => { +>(({ children, className, variant, inputRef, ...props }, ref) => { const { value, setValue, @@ -192,7 +184,7 @@ const MessageInputInternal = React.forwardRef< addImages, clearImages, } = useTamboThreadInput(); - const { cancel } = useTamboThread(); + const { cancelRun, isIdle: tamboIsIdle } = useTambo(); const [displayValue, setDisplayValue] = React.useState(""); const [submitError, setSubmitError] = React.useState(null); const [isSubmitting, setIsSubmitting] = React.useState(false); @@ -225,10 +217,7 @@ const MessageInputInternal = React.forwardRef< } try { - await submit({ - contextKey, - streamResponse: true, - }); + await submit(); setValue(""); // Images are cleared automatically by the TamboThreadInputProvider setTimeout(() => { @@ -243,8 +232,8 @@ const MessageInputInternal = React.forwardRef< : "Failed to send message. Please try again.", ); - // Cancel the thread to reset loading state - await cancel(); + // Cancel the run to reset loading state + await cancelRun(); } finally { setIsSubmitting(false); } @@ -252,11 +241,10 @@ const MessageInputInternal = React.forwardRef< [ value, submit, - contextKey, setValue, setDisplayValue, setSubmitError, - cancel, + cancelRun, isSubmitting, images, clearImages, @@ -334,7 +322,6 @@ const MessageInputInternal = React.forwardRef< handleSubmit, isPending: isPending ?? isSubmitting, error, - contextKey, editorRef: inputRef ?? editorRef, submitError, setSubmitError, @@ -349,7 +336,6 @@ const MessageInputInternal = React.forwardRef< isPending, isSubmitting, error, - contextKey, inputRef, editorRef, submitError, @@ -458,7 +444,7 @@ const MessageInputTextarea = ({ ...props }: MessageInputTextareaProps) => { const { value, setValue, handleSubmit, editorRef } = useMessageInputContext(); - const { isIdle } = useTamboThread(); + const { isIdle } = useTambo(); const isUpdatingToken = useIsTamboTokenUpdating(); return ( @@ -505,7 +491,7 @@ const MessageInputPlainTextarea = ({ ...props }: MessageInputPlainTextareaProps) => { const { value, setValue, handleSubmit } = useMessageInputContext(); - const { isIdle } = useTamboThread(); + const { isIdle } = useTambo(); const { addImage } = useTamboThreadInput(); const isUpdatingToken = useIsTamboTokenUpdating(); const isPending = !isIdle; @@ -602,13 +588,13 @@ const MessageInputSubmitButton = React.forwardRef< MessageInputSubmitButtonProps >(({ className, children, ...props }, ref) => { const { isPending } = useMessageInputContext(); - const { cancel } = useTamboThread(); + const { cancelRun } = useTambo(); const isUpdatingToken = useIsTamboTokenUpdating(); const handleCancel = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - await cancel(); + await cancelRun(); }; const buttonClasses = cn( diff --git a/src/components/tambo/message-suggestions.tsx b/src/components/tambo/message-suggestions.tsx index 5fabdb4..b1296e8 100644 --- a/src/components/tambo/message-suggestions.tsx +++ b/src/components/tambo/message-suggestions.tsx @@ -6,7 +6,7 @@ import { TooltipProvider, } from "@/components/tambo/suggestions-tooltip"; import { cn } from "@/lib/utils"; -import type { Suggestion, TamboThread } from "@tambo-ai/react"; +import type { Suggestion } from "@tambo-ai/react"; import { useTambo, useTamboSuggestions } from "@tambo-ai/react"; import { Loader2Icon } from "lucide-react"; import * as React from "react"; @@ -27,7 +27,9 @@ interface MessageSuggestionsContextValue { accept: (options: { suggestion: Suggestion }) => Promise; isGenerating: boolean; error: Error | null; - thread: TamboThread; + messages: { id: string; role: string }[]; + isIdle: boolean; + streamingStatus: string; isMac: boolean; } @@ -93,24 +95,25 @@ const MessageSuggestions = React.forwardRef< }, ref, ) => { - const { thread } = useTambo(); + const { messages, isIdle, streamingState } = useTambo(); const { suggestions: generatedSuggestions, selectedSuggestionId, accept, - generateResult: { isPending: isGenerating, error }, + isGenerating, + error, } = useTamboSuggestions({ maxSuggestions }); // Combine initial and generated suggestions, but only use initial ones when thread is empty const suggestions = React.useMemo(() => { // Only use pre-seeded suggestions if thread is empty - if (!thread?.messages?.length && initialSuggestions.length > 0) { + if (!messages?.length && initialSuggestions.length > 0) { return initialSuggestions.slice(0, maxSuggestions); } // Otherwise use generated suggestions return generatedSuggestions; }, [ - thread?.messages?.length, + messages?.length, generatedSuggestions, initialSuggestions, maxSuggestions, @@ -130,7 +133,9 @@ const MessageSuggestions = React.forwardRef< accept, isGenerating, error, - thread, + messages, + isIdle, + streamingStatus: streamingState.status, isMac, }), [ @@ -139,14 +144,16 @@ const MessageSuggestions = React.forwardRef< accept, isGenerating, error, - thread, + messages, + isIdle, + streamingState.status, isMac, ], ); // Find the last AI message - const lastAiMessage = thread?.messages - ? [...thread.messages].reverse().find((msg) => msg.role === "assistant") + const lastAiMessage = messages?.length + ? [...messages].reverse().find((msg) => msg.role === "assistant") : null; // When a new AI message appears, update the reference @@ -195,7 +202,7 @@ const MessageSuggestions = React.forwardRef< }, [suggestions, accept, isMac]); // If we have no messages yet and no initial suggestions, render nothing - if (!thread?.messages?.length && initialSuggestions.length === 0) { + if (!messages?.length && initialSuggestions.length === 0) { return null; } @@ -240,7 +247,7 @@ const MessageSuggestionsStatus = React.forwardRef< HTMLDivElement, MessageSuggestionsStatusProps >(({ className, ...props }, ref) => { - const { error, isGenerating, thread } = useMessageSuggestionsContext(); + const { error, isGenerating, isIdle, streamingStatus } = useMessageSuggestionsContext(); return (
@@ -280,13 +287,13 @@ MessageSuggestionsStatus.displayName = "MessageSuggestions.Status"; * Internal component to render generation stage content */ function GenerationStageContent({ - generationStage, + streamingStatus, isGenerating, }: { - generationStage?: string; + streamingStatus?: string; isGenerating: boolean; }) { - if (generationStage && generationStage !== "COMPLETE") { + if (streamingStatus && streamingStatus !== "idle") { return ; } if (isGenerating) { diff --git a/src/components/tambo/message-thread-full.tsx b/src/components/tambo/message-thread-full.tsx index 76f904e..eb85e27 100644 --- a/src/components/tambo/message-thread-full.tsx +++ b/src/components/tambo/message-thread-full.tsx @@ -41,8 +41,6 @@ import * as React from "react"; * Props for the MessageThreadFull component */ export interface MessageThreadFullProps extends React.HTMLAttributes { - /** Optional context key for the thread */ - contextKey?: string; /** * Controls the visual styling of messages in the thread. * Possible values include: "default", "compact", etc. @@ -58,12 +56,12 @@ export interface MessageThreadFullProps extends React.HTMLAttributes(({ className, contextKey, variant, ...props }, ref) => { +>(({ className, variant, ...props }, ref) => { const { containerRef, historyPosition } = useThreadContainerContext(); const mergedRef = useMergeRefs(ref, containerRef); const threadHistorySidebar = ( - + @@ -116,7 +114,7 @@ export const MessageThreadFull = React.forwardRef< {/* Message input */}
- + diff --git a/src/components/tambo/message.tsx b/src/components/tambo/message.tsx index e1e0d5f..7fc83c4 100644 --- a/src/components/tambo/message.tsx +++ b/src/components/tambo/message.tsx @@ -9,9 +9,13 @@ import { getSafeContent, } from "@/lib/thread-hooks"; import { cn } from "@/lib/utils"; -import type { TamboThreadMessage } from "@tambo-ai/react"; +import type { + TamboThreadMessage, + TamboToolUseContent, + TamboComponentContent, + Content, +} from "@tambo-ai/react"; import { useTambo } from "@tambo-ai/react"; -import type TamboAI from "@tambo-ai/typescript-sdk"; import { cva, type VariantProps } from "class-variance-authority"; import stringify from "json-stringify-pretty-compact"; import { Check, ChevronDown, ExternalLink, Loader2, X } from "lucide-react"; @@ -80,15 +84,46 @@ const useMessageContext = () => { }; /** - * Get the tool call request from the message, or the component tool call request + * Get tool_use content blocks from the message content array. + * In V1, tool calls are content blocks of type "tool_use" within message.content. * - * @param message - The message to get the tool call request from - * @returns The tool call request + * @param message - The message to get tool use blocks from + * @returns Array of TamboToolUseContent blocks */ -export function getToolCallRequest( +export function getToolUseBlocks( message: TamboThreadMessage, -): TamboAI.ToolCallRequest | undefined { - return message.toolCallRequest ?? message.component?.toolCallRequest; +): TamboToolUseContent[] { + return message.content.filter( + (c): c is TamboToolUseContent => c.type === "tool_use", + ); +} + +/** + * Get the first tool_use content block from a message (convenience helper). + * @param message - The message to get the tool use block from + * @returns The first TamboToolUseContent block or undefined + */ +export function getFirstToolUseBlock( + message: TamboThreadMessage, +): TamboToolUseContent | undefined { + return message.content.find( + (c): c is TamboToolUseContent => c.type === "tool_use", + ); +} + +/** + * Get rendered component content blocks from the message content array. + * In V1, rendered components are content blocks of type "component" within message.content. + * + * @param message - The message to get component blocks from + * @returns Array of TamboComponentContent blocks + */ +export function getComponentBlocks( + message: TamboThreadMessage, +): TamboComponentContent[] { + return message.content.filter( + (c): c is TamboComponentContent => c.type === "component", + ); } // --- Sub-Components --- @@ -135,10 +170,7 @@ const Message = React.forwardRef( [role, variant, isLoading, message], ); - // Don't render tool response messages as they're shown in tool call dropdowns - if (message.role === "tool") { - return null; - } + // In V1, there is no "tool" role. Tool results are content blocks within messages. return ( @@ -281,6 +313,7 @@ const MessageContent = React.forwardRef( ref, ) => { const { message, isLoading } = useMessageContext(); + const { thread } = useTambo(); const contentToRender = children ?? contentProp ?? message.content; const safeContent = React.useMemo( @@ -294,6 +327,16 @@ const MessageContent = React.forwardRef( const showLoading = isLoading && !hasContent; + // Show cancellation indicator on the last assistant message when the thread's last run was cancelled. + const isLastAssistantMessage = React.useMemo(() => { + if (message.role !== "assistant") return false; + const messages = thread?.thread.messages ?? []; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + return lastAssistant?.id === message.id; + }, [message.role, message.id, thread?.thread.messages]); + + const wasCancelled = isLastAssistantMessage && (thread?.thread.lastRunCancelled ?? false); + return (
( safeContent={safeContent} markdown={markdown} /> - {message.isCancelled && ( + {wasCancelled && ( cancelled )}
@@ -345,20 +388,15 @@ export interface ToolcallInfoProps extends Omit< } function getToolStatusMessage( - message: TamboThreadMessage, + toolUseBlock: TamboToolUseContent, isLoading: boolean | undefined, ) { - if (message.role !== "assistant" || !getToolCallRequest(message)) { - return null; + if (toolUseBlock.statusMessage) { + return toolUseBlock.statusMessage; } - - const toolCallMessage = isLoading - ? `Calling ${getToolCallRequest(message)?.toolName ?? "tool"}` - : `Called ${getToolCallRequest(message)?.toolName ?? "tool"}`; - const toolStatusMessage = isLoading - ? message.component?.statusMessage - : message.component?.completionStatusMessage; - return toolStatusMessage ?? toolCallMessage; + return isLoading + ? `Calling ${toolUseBlock.name ?? "tool"}` + : `Called ${toolUseBlock.name ?? "tool"}`; } /** @@ -391,103 +429,100 @@ const ToolcallInfo = React.forwardRef( ({ className, markdown = true, ...props }, ref) => { const [isExpanded, setIsExpanded] = useState(false); const { message, isLoading } = useMessageContext(); - const { thread } = useTambo(); + const { messages } = useTambo(); const toolDetailsId = React.useId(); - const associatedToolResponse = React.useMemo(() => { - if (!thread?.messages) return null; - const currentMessageIndex = thread.messages.findIndex( - (m: TamboThreadMessage) => m.id === message.id, - ); - if (currentMessageIndex === -1) return null; - for (let i = currentMessageIndex + 1; i < thread.messages.length; i++) { - const nextMessage = thread.messages[i]; - if (nextMessage.role === "tool") { - return nextMessage; - } - if ( - nextMessage.role === "assistant" && - getToolCallRequest(nextMessage) - ) { - break; + const toolUseBlocks = getToolUseBlocks(message); + + const associatedToolResults = React.useMemo(() => { + if (!messages || toolUseBlocks.length === 0) return []; + // Look through all messages for tool_result blocks matching our tool_use IDs + const toolUseIds = new Set(toolUseBlocks.map((t) => t.id)); + const results: { toolUseId: string; content: Content[] }[] = []; + for (const msg of messages) { + for (const block of msg.content) { + if (block.type === "tool_result" && toolUseIds.has(block.toolUseId)) { + results.push({ + toolUseId: block.toolUseId, + content: block.content ? (Array.isArray(block.content) ? block.content : [block.content]) : [], + }); + } } } - return null; - }, [message, thread?.messages]); + return results; + }, [messages, toolUseBlocks]); - if (message.role !== "assistant" || !getToolCallRequest(message)) { + if (message.role !== "assistant" || toolUseBlocks.length === 0) { return null; } - const toolCallRequest: TamboAI.ToolCallRequest | undefined = - getToolCallRequest(message); - const hasToolError = !!message.error; - - const toolStatusMessage = getToolStatusMessage(message, isLoading); - + // Render each tool_use block return (
-
- -
- - tool: {toolCallRequest?.toolName} - - - parameters:{"\n"} - {stringify(keyifyParameters(toolCallRequest?.parameters))} - - - {associatedToolResponse && ( -
- result: -
- {!associatedToolResponse.content ? ( - - Empty response - - ) : ( - formatToolResult(associatedToolResponse.content, markdown) + {toolUseBlocks.map((toolUseBlock) => { + const toolStatusMessage = getToolStatusMessage(toolUseBlock, isLoading && !toolUseBlock.hasCompleted); + const hasToolError = false; + const toolResult = associatedToolResults.find( + (r) => r.toolUseId === toolUseBlock.id, + ); + + return ( +
+ +
+ + tool: {toolUseBlock.name} + + + parameters:{"\n"} + {stringify(toolUseBlock.input)} + + {toolResult && toolResult.content.length > 0 && ( +
+ result: +
+ {formatToolResult(toolResult.content as TamboThreadMessage["content"], markdown)} +
+
+ )}
- )} -
-
+
+ ); + })}
); }, @@ -495,82 +530,6 @@ const ToolcallInfo = React.forwardRef( ToolcallInfo.displayName = "ToolcallInfo"; -/** - * Displays a message's child messages in a collapsible dropdown. - * Used for MCP sampling sub-threads. - * @component SamplingSubThread - */ -const SamplingSubThread = ({ - parentMessageId, - titleText = "finished additional work", -}: { - parentMessageId: string; - titleText?: string; -}) => { - const { thread } = useTambo(); - const [isExpanded, setIsExpanded] = useState(false); - const samplingDetailsId = React.useId(); - - const childMessages = React.useMemo(() => { - return thread?.messages?.filter( - (m: TamboThreadMessage) => m.parentMessageId === parentMessageId, - ); - }, [thread?.messages, parentMessageId]); - - if (!childMessages?.length) return null; - - return ( -
- -
-
-
- {childMessages?.map((m: TamboThreadMessage) => ( -
- - {getSafeContent(m.content)} - -
- ))} -
-
-
-
- ); -}; -SamplingSubThread.displayName = "SamplingSubThread"; - /** * Props for the ReasoningInfo component. * Extends standard HTMLDivElement attributes. @@ -696,13 +655,6 @@ const ReasoningInfo = React.forwardRef( ReasoningInfo.displayName = "ReasoningInfo"; -function keyifyParameters(parameters: TamboAI.ToolCallParameter[] | undefined) { - if (!parameters) return; - return Object.fromEntries( - parameters.map((p) => [p.parameterName, p.parameterValue]), - ); -} - /** * Internal component to render reasoning status text */ @@ -812,64 +764,33 @@ export type MessageRenderedComponentAreaProps = React.HTMLAttributes; /** - * Helper function to extract component type and props from rendered component + * Helper function to extract component type and props from a component content block. + * In V1, the component name and props are available directly on the TamboComponentContent + * block, so we no longer need to introspect the React element tree. */ -function extractComponentInfo(renderedComponent: React.ReactNode): { +function extractComponentInfo(componentBlock: TamboComponentContent | undefined): { componentType: string; componentProps: Record; } { - let componentType = "unknown"; - let componentProps: Record = {}; - - const wrapperElement = renderedComponent as React.ReactElement; - - if ( - React.isValidElement(wrapperElement) && - (wrapperElement as { props?: { children?: React.ReactElement } }).props - ?.children - ) { - const actualComponent = ( - wrapperElement as { props: { children: React.ReactElement } } - ).props.children as React.ReactElement; - - if (React.isValidElement(actualComponent)) { - const matchedComponent = components.find( - (comp) => comp.component === actualComponent.type, - ); - if (matchedComponent) { - componentType = matchedComponent.name; - } else if (typeof actualComponent.type === "function") { - const typeFunc = actualComponent.type as React.ComponentType & { - displayName?: string; - name?: string; - }; - const funcName = typeFunc.displayName || typeFunc.name || "unknown"; - componentType = funcName === "Graph" ? "Graph" : funcName; - } + if (!componentBlock) { + return { componentType: "unknown", componentProps: {} }; + } - if (actualComponent.props) { - // Normalize props for Graph so subsequent edits (title/type) - // via CanvasDetails work whether the component was added by - // button or drag-and-drop. - if (componentType === "Graph") { - const { data, title, showLegend, variant, size, className } = - actualComponent.props as Record; - componentProps = { - data, - title, - showLegend, - variant, - size, - className, - }; - } else { - componentProps = { ...actualComponent.props }; - } - } - } + const componentType = componentBlock.name ?? "unknown"; + const rawProps = (componentBlock.props ?? {}) as Record; + + // Normalize props for Graph so subsequent edits (title/type) + // via CanvasDetails work whether the component was added by + // button or drag-and-drop. + if (componentType === "Graph") { + const { data, title, showLegend, variant, size, className } = rawProps; + return { + componentType, + componentProps: { data, title, showLegend, variant, size, className }, + }; } - return { componentType, componentProps }; + return { componentType, componentProps: { ...rawProps } }; } /** @@ -891,13 +812,18 @@ const MessageRenderedComponentArea = React.forwardRef< MessageRenderedComponentAreaProps >(({ className, children, ...props }, ref) => { const { message, role } = useMessageContext(); + const { thread } = useTambo(); const [canvasExists, setCanvasExists] = React.useState(false); const { addComponent, activeCanvasId, createCanvas } = useCanvasStore(); + const componentBlocks = getComponentBlocks(message); + const firstComponentBlock = componentBlocks[0]; + const renderedComponent = firstComponentBlock?.renderedComponent; + // Extract component info once to check if it's draggable const { componentType, componentProps } = React.useMemo( - () => extractComponentInfo(message.renderedComponent), - [message.renderedComponent], + () => extractComponentInfo(firstComponentBlock), + [firstComponentBlock], ); const canDrag = isDraggableComponent(componentType); @@ -943,22 +869,20 @@ const MessageRenderedComponentArea = React.forwardRef< setCanvasExists(!!canvas); }; - // Check on mount checkCanvasExists(); - - // Set up resize listener window.addEventListener("resize", checkCanvasExists); - // Clean up return () => { window.removeEventListener("resize", checkCanvasExists); }; }, []); + const isCancelled = thread?.thread?.lastRunCancelled ?? false; + if ( - !message.renderedComponent || + !renderedComponent || role !== "assistant" || - message.isCancelled + isCancelled ) { return null; } @@ -980,7 +904,7 @@ const MessageRenderedComponentArea = React.forwardRef< new CustomEvent("tambo:showComponent", { detail: { messageId: message.id, - component: message.renderedComponent, + component: renderedComponent, }, }), ); @@ -1016,12 +940,12 @@ const MessageRenderedComponentArea = React.forwardRef< draggable={true} onDragStart={handleDragStart} > - {message.renderedComponent} + {renderedComponent}
) : (
- {message.renderedComponent} + {renderedComponent}
))}
diff --git a/src/components/tambo/scrollable-message-container.tsx b/src/components/tambo/scrollable-message-container.tsx index d7697f2..d9e59aa 100644 --- a/src/components/tambo/scrollable-message-container.tsx +++ b/src/components/tambo/scrollable-message-container.tsx @@ -29,7 +29,7 @@ export const ScrollableMessageContainer = React.forwardRef< ScrollableMessageContainerProps >(({ className, children, ...props }, ref) => { const scrollContainerRef = useRef(null); - const { thread } = useTambo(); + const { messages, isStreaming } = useTambo(); const [shouldAutoscroll, setShouldAutoscroll] = useState(true); const lastScrollTopRef = useRef(0); @@ -38,19 +38,14 @@ export const ScrollableMessageContainer = React.forwardRef< // Create a dependency that represents all content that should trigger autoscroll const messagesContent = React.useMemo(() => { - if (!thread.messages) return null; + if (!messages) return null; - return thread.messages.map((message) => ({ + return messages.map((message) => ({ id: message.id, content: message.content, - tool_calls: message.tool_calls, - component: message.component, reasoning: message.reasoning, - componentState: message.componentState, })); - }, [thread.messages]); - - const generationStage = thread?.generationStage ?? "IDLE"; + }, [messages]); // Handle scroll events to detect user scrolling const handleScroll = () => { @@ -84,7 +79,7 @@ export const ScrollableMessageContainer = React.forwardRef< } }; - if (generationStage === "STREAMING_RESPONSE") { + if (isStreaming) { // During streaming, scroll immediately requestAnimationFrame(scroll); } else { @@ -93,7 +88,7 @@ export const ScrollableMessageContainer = React.forwardRef< return () => clearTimeout(timeoutId); } } - }, [messagesContent, generationStage, shouldAutoscroll]); + }, [messagesContent, isStreaming, shouldAutoscroll]); return (
( onSelect: (item: ResourceItem) => { // When a mention is selected, add it as a context attachment // This will appear as a badge above the input - tamboContextAttachmentRef.current.addContextAttachment({ name: item.name }); + tamboContextAttachmentRef.current.addContextAttachment({ displayName: item.name, context: item.name }); }, renderLabel: ({ node, diff --git a/src/components/tambo/thread-container.tsx b/src/components/tambo/thread-container.tsx index 0dcec9d..70acf0c 100644 --- a/src/components/tambo/thread-container.tsx +++ b/src/components/tambo/thread-container.tsx @@ -44,7 +44,7 @@ export const ThreadContainer = React.forwardRef< ref={mergedRef} className={cn( // Base layout and styling - "flex flex-col bg-white overflow-hidden bg-background", + "flex flex-col overflow-hidden bg-background", "h-full", // Add smooth transitions for layout changes diff --git a/src/components/tambo/thread-content.tsx b/src/components/tambo/thread-content.tsx index c888209..b63d83c 100644 --- a/src/components/tambo/thread-content.tsx +++ b/src/components/tambo/thread-content.tsx @@ -75,17 +75,17 @@ export interface ThreadContentProps extends React.HTMLAttributes */ const ThreadContent = React.forwardRef( ({ children, className, variant, ...props }, ref) => { - const { thread, generationStage, isIdle } = useTambo(); + const { messages, isIdle, streamingState } = useTambo(); const isGenerating = !isIdle; const contextValue = React.useMemo( () => ({ - messages: thread?.messages ?? [], + messages, isGenerating, - generationStage, + generationStage: streamingState.status === "streaming" ? "STREAMING_RESPONSE" : streamingState.status === "waiting" ? "FETCHING_CONTEXT" : "COMPLETE", variant, }), - [thread?.messages, isGenerating, generationStage, variant], + [messages, isGenerating, streamingState.status, variant], ); return ( @@ -127,9 +127,16 @@ const ThreadContentMessages = React.forwardRef< >(({ className, ...props }, ref) => { const { messages, isGenerating, variant } = useThreadContentContext(); - const filteredMessages = messages.filter( - (message) => message.role !== "system" && !message.parentMessageId, - ); + const filteredMessages = messages.filter((message) => { + if (message.role === "system") return false; + if ( + message.content.length > 0 && + message.content.every((block) => block.type === "tool_result") + ) { + return false; + } + return true; + }); return (
Promise; - currentThread: TamboThread; - switchCurrentThread: (threadId: string) => void; + currentThreadId: string; + switchThread: (threadId: string) => void; startNewThread: () => void; searchQuery: string; setSearchQuery: React.Dispatch>; isCollapsed: boolean; setIsCollapsed: React.Dispatch>; onThreadChange?: () => void; - contextKey?: string; position?: "left" | "right"; - updateThreadName: (newName: string, threadId?: string) => Promise; - generateThreadName: (threadId: string) => Promise; + updateThreadName: (threadId: string, name: string) => Promise; } const ThreadHistoryContext = @@ -57,7 +56,6 @@ const useThreadHistoryContext = () => { * Root component that provides context for thread history */ interface ThreadHistoryProps extends React.HTMLAttributes { - contextKey?: string; onThreadChange?: () => void; children?: React.ReactNode; defaultCollapsed?: boolean; @@ -68,7 +66,6 @@ const ThreadHistory = React.forwardRef( ( { className, - contextKey, onThreadChange, defaultCollapsed = true, position = "left", @@ -86,15 +83,14 @@ const ThreadHistory = React.forwardRef( isLoading, error, refetch, - } = useTamboThreadList({ contextKey }); + } = useTamboThreadList(); const { - switchCurrentThread, + switchThread, startNewThread, - thread: currentThread, + currentThreadId, updateThreadName, - generateThreadName, - } = useTamboThread(); + } = useTambo(); // Update CSS variable when sidebar collapses/expands React.useEffect(() => { @@ -118,34 +114,30 @@ const ThreadHistory = React.forwardRef( isLoading, error, refetch, - currentThread, - switchCurrentThread, + currentThreadId, + switchThread, startNewThread, searchQuery, setSearchQuery, isCollapsed, setIsCollapsed, onThreadChange, - contextKey, position, updateThreadName, - generateThreadName, }), [ threads, isLoading, error, refetch, - currentThread, - switchCurrentThread, + currentThreadId, + switchThread, startNewThread, searchQuery, isCollapsed, onThreadChange, - contextKey, position, updateThreadName, - generateThreadName, ], ); @@ -249,7 +241,7 @@ const ThreadHistoryNewButton = React.forwardRef< if (e) e.stopPropagation(); try { - await startNewThread(); + startNewThread(); await refetch(); onThreadChange?.(); } catch (error) { @@ -374,15 +366,14 @@ const ThreadHistoryList = React.forwardRef< error, isCollapsed, searchQuery, - currentThread, - switchCurrentThread, + currentThreadId, + switchThread, onThreadChange, - updateThreadName, - generateThreadName, refetch, + updateThreadName, } = useThreadHistoryContext(); - const [editingThread, setEditingThread] = React.useState( + const [editingThread, setEditingThread] = React.useState( null, ); const [newName, setNewName] = React.useState(""); @@ -427,10 +418,10 @@ const ThreadHistoryList = React.forwardRef< // While collapsed we do not need the list, avoid extra work. if (isCollapsed) return []; - if (!threads?.items) return []; + if (!threads?.threads) return []; const query = searchQuery.toLowerCase(); - return threads.items.filter((thread: TamboThread) => { + return threads.threads.filter((thread: ThreadListItem) => { const nameMatches = thread.name?.toLowerCase().includes(query) ?? false; const idMatches = thread.id.toLowerCase().includes(query); @@ -442,38 +433,28 @@ const ThreadHistoryList = React.forwardRef< if (e) e.stopPropagation(); try { - switchCurrentThread(threadId); + switchThread(threadId); onThreadChange?.(); } catch (error) { console.error("Failed to switch thread:", error); } }; - const handleRename = (thread: TamboThread) => { + const handleRename = (thread: ThreadListItem) => { setEditingThread(thread); setNewName(thread.name ?? ""); }; - const handleGenerateName = async (thread: TamboThread) => { - try { - await generateThreadName(thread.id); - await refetch(); - } catch (error) { - console.error("Failed to generate name:", error); - } - }; - const handleNameSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!editingThread) return; - + if (!editingThread || !newName.trim()) return; try { - await updateThreadName(newName, editingThread.id); + await updateThreadName(editingThread.id, newName.trim()); await refetch(); - setEditingThread(null); } catch (error) { console.error("Failed to rename thread:", error); } + setEditingThread(null); }; // Content to show @@ -517,13 +498,13 @@ const ThreadHistoryList = React.forwardRef< } else { content = (
- {filteredThreads.map((thread: TamboThread) => ( + {filteredThreads.map((thread: ThreadListItem) => (
await handleSwitchThread(thread.id)} className={cn( "p-2 rounded-md hover:bg-backdrop cursor-pointer group flex items-center justify-between", - currentThread?.id === thread.id ? "bg-muted" : "", + currentThreadId === thread.id ? "bg-muted" : "", editingThread?.id === thread.id ? "bg-muted" : "", )} > @@ -571,7 +552,6 @@ const ThreadHistoryList = React.forwardRef<
))} @@ -603,11 +583,9 @@ ThreadHistoryList.displayName = "ThreadHistory.List"; const ThreadOptionsDropdown = ({ thread, onRename, - onGenerateName, }: { - thread: TamboThread; - onRename: (thread: TamboThread) => void; - onGenerateName: (thread: TamboThread) => void; + thread: ThreadListItem; + onRename: (thread: ThreadListItem) => void; }) => { return ( @@ -635,16 +613,6 @@ const ThreadOptionsDropdown = ({ Rename - { - e.stopPropagation(); - onGenerateName(thread); - }} - > - - Generate Name - diff --git a/src/components/ui/interactable-canvas-details.tsx b/src/components/ui/interactable-canvas-details.tsx index 365d871..bb1d1f0 100644 --- a/src/components/ui/interactable-canvas-details.tsx +++ b/src/components/ui/interactable-canvas-details.tsx @@ -1,7 +1,7 @@ "use client"; import { useCanvasStore } from "@/lib/canvas-storage"; -import { useTamboInteractable, withInteractable } from "@tambo-ai/react"; +import { useTamboInteractable, withTamboInteractable } from "@tambo-ai/react"; import { useCallback, useEffect, useRef } from "react"; import { z } from "zod"; @@ -192,7 +192,7 @@ function CanvasDetailsWrapper(props: CanvasDetailsProps) { ); } -export const InteractableCanvasDetails = withInteractable( +export const InteractableCanvasDetails = withTamboInteractable( CanvasDetailsWrapper, { componentName: "CanvasDetails", diff --git a/src/components/ui/interactable-tabs.tsx b/src/components/ui/interactable-tabs.tsx index 8970906..8905f78 100644 --- a/src/components/ui/interactable-tabs.tsx +++ b/src/components/ui/interactable-tabs.tsx @@ -2,7 +2,7 @@ import type { Canvas, CanvasComponent } from "@/lib/canvas-storage"; import { useCanvasStore } from "@/lib/canvas-storage"; -import { useTamboInteractable, withInteractable } from "@tambo-ai/react"; +import { useTamboInteractable, withTamboInteractable } from "@tambo-ai/react"; import { useCallback, useEffect, useRef } from "react"; import { z } from "zod"; @@ -164,7 +164,7 @@ function TabsWrapper(props: TabsProps) { return
; } -export const InteractableTabs = withInteractable(TabsWrapper, { +export const InteractableTabs = withTamboInteractable(TabsWrapper, { componentName: "Tabs", description: "Tabs-only interactable. Manages canvases (id, name) and activeCanvasId. Use CanvasDetails to edit charts for the selected tab.", diff --git a/src/lib/tambo.ts b/src/lib/tambo.ts index af1946f..8fd4d98 100644 --- a/src/lib/tambo.ts +++ b/src/lib/tambo.ts @@ -10,8 +10,7 @@ import { Graph, graphSchema } from "@/components/tambo/graph"; import { SelectForm, selectFormSchema } from "@/components/tambo/select-form"; -import type { TamboComponent } from "@tambo-ai/react"; -import { TamboTool } from "@tambo-ai/react"; +import type { TamboComponent, TamboTool } from "@tambo-ai/react"; import { z } from "zod"; import { getSalesData, @@ -34,53 +33,41 @@ export const tools: TamboTool[] = [ description: "Get monthly sales revenue and units data. Can filter by region (North, South, East, West) or category (Electronics, Clothing, Home)", tool: getSalesData, - toolSchema: z.function().args( - z - .object({ - region: z.string().optional(), - category: z.string().optional(), - }) - .optional(), - ), + inputSchema: z.object({ + region: z.string().optional(), + category: z.string().optional(), + }), + outputSchema: z.any(), }, { name: "getProducts", description: "Get top products with sales and revenue information. Can filter by category (Electronics, Furniture, Appliances)", tool: getProducts, - toolSchema: z.function().args( - z - .object({ - category: z.string().optional(), - }) - .optional(), - ), + inputSchema: z.object({ + category: z.string().optional(), + }), + outputSchema: z.any(), }, { name: "getUserData", description: "Get monthly user growth and activity data. Can filter by segment (Free, Premium, Enterprise)", tool: getUserData, - toolSchema: z.function().args( - z - .object({ - segment: z.string().optional(), - }) - .optional(), - ), + inputSchema: z.object({ + segment: z.string().optional(), + }), + outputSchema: z.any(), }, { name: "getKPIs", description: "Get key business performance indicators. Can filter by category (Financial, Growth, Quality, Retention, Marketing)", tool: getKPIs, - toolSchema: z.function().args( - z - .object({ - category: z.string().optional(), - }) - .optional(), - ), + inputSchema: z.object({ + category: z.string().optional(), + }), + outputSchema: z.any(), }, ]; @@ -96,14 +83,16 @@ export const components: TamboComponent[] = [ name: "Graph", description: "Use this when you want to display a chart. It supports bar, line, and pie charts. When you see data generally use this component. IMPORTANT: When asked to create a graph, always generate it first in the chat - do NOT add it directly to the canvas/dashboard. Let the user decide if they want to add it.", - component: Graph, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: Graph as any, propsSchema: graphSchema, }, { name: "SelectForm", description: "ALWAYS use this component instead of listing options as bullet points in text. Whenever you need to ask the user a question and would normally follow up with bullet points or numbered options, use this component instead. For yes/no or single-choice questions, use mode='single'. For questions where the user can select multiple options, use mode='multi' (default). Each group has a label (the question) and options (the choices). Examples: 'Would you like to continue?' with Yes/No options, or 'Which regions interest you?' with multiple region options.", - component: SelectForm, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: SelectForm as any, propsSchema: selectFormSchema, }, // Add more components here diff --git a/src/lib/use-anonymous-user-key.ts b/src/lib/use-anonymous-user-key.ts new file mode 100644 index 0000000..a1da28f --- /dev/null +++ b/src/lib/use-anonymous-user-key.ts @@ -0,0 +1,21 @@ +import { useState } from "react"; + +const STORAGE_KEY = "tambo-user-key"; + +function getOrCreateUserKey(): string { + if (typeof window === "undefined") { + return ""; + } + const existing = localStorage.getItem(STORAGE_KEY); + if (existing) { + return existing; + } + const newKey = crypto.randomUUID(); + localStorage.setItem(STORAGE_KEY, newKey); + return newKey; +} + +export function useAnonymousUserKey(): string { + const [userKey] = useState(getOrCreateUserKey); + return userKey; +} From 35b30c7a15fb50b8abdf684371fd492ec53c959e Mon Sep 17 00:00:00 2001 From: akhileshrangani4 Date: Tue, 10 Feb 2026 09:08:58 -0800 Subject: [PATCH 2/3] chore: update Next.js configuration to improve ESLint handling and stub optional peer dependencies - Added ESLint configuration to ignore during builds. - Updated Webpack configuration to stub optional peer dependencies from @standard-community/standard-json. --- next.config.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index e9ffa30..e23a120 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,20 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Run ESLint separately via `npm run lint` (avoids deprecated next lint) + eslint: { + ignoreDuringBuilds: true, + }, + // Stub optional peer deps from @standard-community/standard-json + webpack: (config) => { + config.resolve.alias = { + ...config.resolve.alias, + effect: false, + sury: false, + "@valibot/to-json-schema": false, + }; + return config; + }, }; export default nextConfig; From 7c8da1b35de975d6114518e1f05006e486b1e19f Mon Sep 17 00:00:00 2001 From: akhileshrangani4 Date: Tue, 10 Feb 2026 09:57:03 -0800 Subject: [PATCH 3/3] chore: sync latest components --- src/app/globals.css | 10 + src/components/tambo/dictation-button.tsx | 4 +- src/components/tambo/graph.tsx | 13 +- src/components/tambo/mcp-components.tsx | 159 ++- .../tambo/message-generation-stage.tsx | 7 +- src/components/tambo/message-input.tsx | 605 +++++++-- src/components/tambo/message-suggestions.tsx | 118 +- src/components/tambo/message-thread-full.tsx | 2 +- .../tambo/scrollable-message-container.tsx | 14 +- src/components/tambo/text-editor.tsx | 1200 +++++++++-------- src/components/tambo/thread-content.tsx | 9 +- src/components/tambo/thread-history.tsx | 64 +- 12 files changed, 1433 insertions(+), 772 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 6136a16..f1afe9b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -49,6 +49,16 @@ body { "Segoe UI Emoji"; } +/* TipTap editor placeholder - required for @tiptap/extension-placeholder to display */ +.tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + opacity: 0.5; + pointer-events: none; + height: 0; +} + @layer base { :root { --card: 0 0% 100%; diff --git a/src/components/tambo/dictation-button.tsx b/src/components/tambo/dictation-button.tsx index 5c74c0c..1c2e37e 100644 --- a/src/components/tambo/dictation-button.tsx +++ b/src/components/tambo/dictation-button.tsx @@ -1,4 +1,4 @@ -import { Tooltip } from "@/components/tambo/suggestions-tooltip"; +import { Tooltip } from "@/components/tambo/message-suggestions"; import { useTamboThreadInput, useTamboVoice } from "@tambo-ai/react"; import { Loader2Icon, Mic, Square } from "lucide-react"; import React, { useEffect, useRef } from "react"; @@ -15,7 +15,7 @@ export default function DictationButton() { transcript, transcriptionError, } = useTamboVoice(); - const { value, setValue } = useTamboThreadInput(); + const { setValue } = useTamboThreadInput(); const lastProcessedTranscriptRef = useRef(""); const handleStartRecording = () => { diff --git a/src/components/tambo/graph.tsx b/src/components/tambo/graph.tsx index 6910d41..0cc148b 100644 --- a/src/components/tambo/graph.tsx +++ b/src/components/tambo/graph.tsx @@ -196,15 +196,12 @@ export const Graph = React.forwardRef( { className, variant, size, data, title, showLegend = true, ...props }, ref, ) => { - // Use larger size for pie charts by default to give them more room - const effectiveSize = size ?? (data?.type === "pie" ? "lg" : "default"); - // If no data received yet, show loading if (!data) { return (
@@ -235,7 +232,7 @@ export const Graph = React.forwardRef( return (
@@ -260,7 +257,7 @@ export const Graph = React.forwardRef( return (
@@ -468,10 +465,10 @@ export const Graph = React.forwardRef( }; return ( - +
diff --git a/src/components/tambo/mcp-components.tsx b/src/components/tambo/mcp-components.tsx index 67f211d..dff03dc 100644 --- a/src/components/tambo/mcp-components.tsx +++ b/src/components/tambo/mcp-components.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipProvider, -} from "@/components/tambo/suggestions-tooltip"; +} from "@/components/tambo/message-suggestions"; import { cn } from "@/lib/utils"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { @@ -11,9 +11,66 @@ import { useTamboMcpPromptList, useTamboMcpResourceList, } from "@tambo-ai/react/mcp"; -import { AtSign, FileText, Search } from "lucide-react"; +import { AlertCircle, AtSign, FileText, Search } from "lucide-react"; import * as React from "react"; +/** + * Represents a single message content item from an MCP prompt. + */ +interface PromptMessageContent { + type?: string; + text?: string; +} + +/** + * Represents a single message from an MCP prompt. + */ +interface PromptMessage { + content?: PromptMessageContent; +} + +/** + * Validates that prompt data has a valid messages array structure. + * @param promptData - The prompt data to validate + * @returns true if the prompt data has valid messages, false otherwise + */ +function isValidPromptData( + promptData: unknown, +): promptData is { messages: PromptMessage[] } { + if (!promptData || typeof promptData !== "object") { + return false; + } + + const data = promptData as { messages?: unknown }; + if (!Array.isArray(data.messages)) { + return false; + } + + return true; +} + +/** + * Safely extracts text content from prompt messages. + * Handles malformed or missing content gracefully. + * @param messages - Array of prompt messages + * @returns Extracted text content joined by newlines + */ +function extractPromptText(messages: PromptMessage[]): string { + return messages + .map((msg) => { + // Safely access nested properties + if ( + msg?.content?.type === "text" && + typeof msg.content.text === "string" + ) { + return msg.content.text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + /** * Props for the McpPromptButton component. */ @@ -45,21 +102,32 @@ export const McpPromptButton = React.forwardRef< const [selectedPromptName, setSelectedPromptName] = React.useState< string | null >(null); - const { data: promptData } = useTamboMcpPrompt(selectedPromptName ?? ""); + const [promptError, setPromptError] = React.useState(null); + const { data: promptData, error: fetchError } = useTamboMcpPrompt( + selectedPromptName ?? "", + ); - // When prompt data is fetched, insert it into the input + // When prompt data is fetched, validate and insert it into the input React.useEffect(() => { - if (promptData && selectedPromptName) { - // Extract the text from the prompt messages - const promptText = promptData.messages - .map((msg) => { - if (msg.content.type === "text") { - return msg.content.text; - } - return ""; - }) - .filter(Boolean) - .join("\n"); + if (selectedPromptName && promptData) { + // Validate prompt data structure + if (!isValidPromptData(promptData)) { + setPromptError("Invalid prompt format received"); + setSelectedPromptName(null); + return; + } + + // Extract text with safe access + const promptText = extractPromptText(promptData.messages); + + if (!promptText) { + setPromptError("Prompt contains no text content"); + setSelectedPromptName(null); + return; + } + + // Clear any previous errors + setPromptError(null); // Insert the prompt text, appending to existing value if any const newValue = value ? `${value}\n\n${promptText}` : promptText; @@ -70,6 +138,22 @@ export const McpPromptButton = React.forwardRef< } }, [promptData, selectedPromptName, onInsertText, value]); + // Handle fetch errors + React.useEffect(() => { + if (fetchError) { + setPromptError("Failed to load prompt"); + setSelectedPromptName(null); + } + }, [fetchError]); + + // Clear error after a delay + React.useEffect(() => { + if (promptError) { + const timer = setTimeout(() => setPromptError(null), 3000); + return () => clearTimeout(timer); + } + }, [promptError]); + // Only show button if prompts are available (hide during loading and when no prompts) if (!promptList || promptList.length === 0) { return null; @@ -83,26 +167,36 @@ export const McpPromptButton = React.forwardRef< return ( void; filteredResources: ReturnType["data"]; isLoading: boolean; - onSelectResource: (uri: string) => void; + onSelectResource: (id: string, label: string) => void; } /** @@ -209,7 +303,7 @@ const ResourceCombobox: React.FC = ({ return ( ["data"]; searchQuery: string; - onSelectResource: (uri: string) => void; + onSelectResource: (id: string, label: string) => void; }) { if (isLoading) { return ( @@ -288,10 +382,13 @@ function ResourceListContent({ <> {filteredResources.map((resourceEntry) => ( { - onSelectResource(resourceEntry.resource.uri); + onSelectResource( + resourceEntry.resource.uri, + resourceEntry.resource.name || resourceEntry.resource.uri, + ); }} >
@@ -320,7 +417,7 @@ function ResourceListContent({ */ export interface McpResourceButtonProps extends React.ButtonHTMLAttributes { /** Callback to insert text into the input */ - onInsertText: (text: string) => void; + onInsertResource: (id: string, label: string) => void; /** Current input value */ value: string; /** Optional custom className */ @@ -342,8 +439,7 @@ export interface McpResourceButtonProps extends React.ButtonHTMLAttributes(({ className, onInsertText, value, ...props }, ref) => { +>(({ className, onInsertResource, value: _value, ...props }, ref) => { const { data: resourceList, isLoading } = useTamboMcpResourceList(); const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); @@ -368,10 +464,9 @@ export const McpResourceButton = React.forwardRef< }); }, [resourceList, searchQuery]); - const handleSelectResource = (resourceUri: string) => { - // Pass raw @resource string to caller; caller decides how to insert - const resourceRef = `@${resourceUri}`; - onInsertText(resourceRef); + const handleSelectResource = (id: string, label: string) => { + // Pass raw resource string to caller; caller decides how to insert + onInsertResource(id, label); setIsOpen(false); setSearchQuery(""); }; diff --git a/src/components/tambo/message-generation-stage.tsx b/src/components/tambo/message-generation-stage.tsx index b654485..9dd83e3 100644 --- a/src/components/tambo/message-generation-stage.tsx +++ b/src/components/tambo/message-generation-stage.tsx @@ -26,7 +26,12 @@ export function MessageGenerationStage({ return null; } - const label = isWaiting ? "Preparing response" : isStreaming ? "Generating response" : ""; + let label = ""; + if (isWaiting) { + label = "Preparing response"; + } else if (isStreaming) { + label = "Generating response"; + } if (!label) { return null; diff --git a/src/components/tambo/message-input.tsx b/src/components/tambo/message-input.tsx index ae15946..6dbb392 100644 --- a/src/components/tambo/message-input.tsx +++ b/src/components/tambo/message-input.tsx @@ -5,7 +5,7 @@ import { McpPromptButton, McpResourceButton, } from "@/components/tambo/mcp-components"; -import { McpConfigModal } from "@/components/tambo/mcp-config-modal"; +import { McpConfigModal } from "./mcp-config-modal"; import { Tooltip, TooltipProvider, @@ -17,28 +17,270 @@ import { useTamboThreadInput, type StagedImage, } from "@tambo-ai/react"; -import type { Editor } from "@tiptap/react"; -import { TextEditor, type ResourceItem } from "./text-editor"; import { useTamboElicitationContext, + useTamboMcpPrompt, + useTamboMcpPromptList, + useTamboMcpResourceList, type TamboElicitationRequest, type TamboElicitationResponse, } from "@tambo-ai/react/mcp"; import { cva, type VariantProps } from "class-variance-authority"; import { ArrowUp, + AtSign, + FileText, Image as ImageIcon, Paperclip, Square, X, } from "lucide-react"; -import dynamic from "next/dynamic"; -import Image from "next/image"; import * as React from "react"; +import { useDebounce } from "use-debounce"; +import { + getImageItems, + TextEditor, + type PromptItem, + type ResourceItem, + type TamboEditor, +} from "./text-editor"; -const DictationButton = dynamic(() => import("./dictation-button"), { - ssr: false, -}); +// Lazy load DictationButton for code splitting (framework-agnostic alternative to next/dynamic) + +const LazyDictationButton = React.lazy(() => import("./dictation-button")); + +/** + * Wrapper component that includes Suspense boundary for the lazy-loaded DictationButton. + * This ensures the component can be safely used without requiring consumers to add their own Suspense. + * Also handles SSR by only rendering on the client (DictationButton uses Web Audio APIs). + */ +const DictationButton = () => { + const [isMounted, setIsMounted] = React.useState(false); + + React.useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + + + + ); +}; + +/** + * Provider interface for searching resources (for "@" mentions). + * Empty query string "" should return all available resources. + */ +export interface ResourceProvider { + /** Search for resources matching the query */ + search(query: string): Promise; +} + +/** + * Provider interface for searching and fetching prompts (for "/" commands). + * Empty query string "" should return all available prompts. + */ +export interface PromptProvider { + /** Search for prompts matching the query */ + search(query: string): Promise; + /** Get the full prompt details including text by ID */ + get(id: string): Promise; +} + +/** + * Removes duplicate resource items based on ID. + */ +const dedupeResourceItems = (resourceItems: ResourceItem[]) => { + const seen = new Set(); + return resourceItems.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +}; + +/** + * Filters resource items by query string. + * Empty query returns all items. + */ +const filterResourceItems = ( + resourceItems: ResourceItem[], + query: string, +): ResourceItem[] => { + if (query === "") return resourceItems; + + const normalizedQuery = query.toLocaleLowerCase(); + return resourceItems.filter((item) => + item.name.toLocaleLowerCase().includes(normalizedQuery), + ); +}; + +/** + * Filters prompt items by query string. + * Empty query returns all items. + */ +const filterPromptItems = ( + promptItems: PromptItem[], + query: string, +): PromptItem[] => { + if (query === "") return promptItems; + + const normalizedQuery = query.toLocaleLowerCase(); + return promptItems.filter((item) => + item.name.toLocaleLowerCase().includes(normalizedQuery), + ); +}; + +const EXTERNAL_SEARCH_DEBOUNCE_MS = 200; + +/** + * Hook to get a combined resource list that merges MCP resources with an external provider. + * Returns the combined, filtered resource items. + * + * @param externalProvider - Optional external resource provider + * @param search - Search string to filter resources. For MCP servers, results are filtered locally. + * For registry dynamic sources, the search is passed to listResources(search). + */ +function useCombinedResourceList( + externalProvider: ResourceProvider | undefined, + search: string, +): ResourceItem[] { + const { data: mcpResources } = useTamboMcpResourceList(search); + const [debouncedSearch] = useDebounce(search, EXTERNAL_SEARCH_DEBOUNCE_MS); + + // Convert MCP resources to ResourceItems + const mcpItems: ResourceItem[] = React.useMemo( + () => + mcpResources + ? ( + mcpResources as { + resource: { uri: string; name?: string }; + }[] + ).map((entry) => ({ + // Use the full URI (already includes serverKey prefix from MCP hook) + // When inserted as @{id}, parseResourceReferences will strip serverKey before sending to backend + id: entry.resource.uri, + name: entry.resource.name ?? entry.resource.uri, + icon: React.createElement(AtSign, { className: "w-4 h-4" }), + componentData: { type: "mcp-resource", data: entry }, + })) + : [], + [mcpResources], + ); + + // Track external provider results with state + const [externalItems, setExternalItems] = React.useState([]); + + // Fetch external resources when search changes + React.useEffect(() => { + if (!externalProvider) { + setExternalItems([]); + return; + } + + let cancelled = false; + externalProvider + .search(debouncedSearch) + .then((items) => { + if (!cancelled) { + setExternalItems(items); + } + }) + .catch((error) => { + console.error("Failed to fetch external resources", error); + if (!cancelled) { + setExternalItems([]); + } + }); + + return () => { + cancelled = true; + }; + }, [externalProvider, debouncedSearch]); + + // Combine and dedupe - MCP resources are already filtered by the hook + // External items need to be filtered locally + const combined = React.useMemo(() => { + const filteredExternal = filterResourceItems(externalItems, search); + return dedupeResourceItems([...mcpItems, ...filteredExternal]); + }, [mcpItems, externalItems, search]); + + return combined; +} + +/** + * Hook to get a combined prompt list that merges MCP prompts with an external provider. + * Returns the combined, filtered prompt items. + * + * @param externalProvider - Optional external prompt provider + * @param search - Search string to filter prompts by name. MCP prompts are filtered via the hook. + */ +function useCombinedPromptList( + externalProvider: PromptProvider | undefined, + search: string, +): PromptItem[] { + // Pass search to MCP hook for filtering + const { data: mcpPrompts } = useTamboMcpPromptList(search); + const [debouncedSearch] = useDebounce(search, EXTERNAL_SEARCH_DEBOUNCE_MS); + + // Convert MCP prompts to PromptItems (mark with mcp-prompt: prefix for special handling) + const mcpItems: PromptItem[] = React.useMemo( + () => + mcpPrompts + ? (mcpPrompts as { prompt: { name: string } }[]).map((entry) => ({ + id: `mcp-prompt:${entry.prompt.name}`, + name: entry.prompt.name, + icon: React.createElement(FileText, { className: "w-4 h-4" }), + text: "", // Text will be fetched when selected via useTamboMcpPrompt + })) + : [], + [mcpPrompts], + ); + + // Track external provider results with state + const [externalItems, setExternalItems] = React.useState([]); + + // Fetch external prompts when search changes + React.useEffect(() => { + if (!externalProvider) { + setExternalItems([]); + return; + } + + let cancelled = false; + externalProvider + .search(debouncedSearch) + .then((items) => { + if (!cancelled) { + setExternalItems(items); + } + }) + .catch((error) => { + console.error("Failed to fetch external prompts", error); + if (!cancelled) { + setExternalItems([]); + } + }); + + return () => { + cancelled = true; + }; + }, [externalProvider, debouncedSearch]); + + // Combine - MCP prompts are already filtered by the hook + // External items need to be filtered locally + const combined = React.useMemo(() => { + const filteredExternal = filterPromptItems(externalItems, search); + return [...mcpItems, ...filteredExternal]; + }, [mcpItems, externalItems, search]); + + return combined; +} /** * CSS variants for the message input container @@ -81,22 +323,29 @@ const messageInputVariants = cva("w-full", { * @property {function} handleSubmit - Function to handle form submission * @property {boolean} isPending - Whether a submission is in progress * @property {Error|null} error - Any error from the submission - * @property {Editor|null} editorRef - Reference to the TipTap editor instance + * @property {TamboEditor|null} editorRef - Reference to the TamboEditor instance * @property {string | null} submitError - Error from the submission * @property {function} setSubmitError - Function to set the submission error + * @property {string | null} imageError - Error related to image uploads + * @property {function} setImageError - Function to set the image upload error * @property {TamboElicitationRequest | null} elicitation - Current elicitation request (read-only) * @property {function} resolveElicitation - Function to resolve the elicitation promise (automatically clears state) */ interface MessageInputContextValue { value: string; setValue: (value: string) => void; - submit: () => Promise<{ threadId: string | undefined } | void>; + submit: (options?: { + debug?: boolean; + toolChoice?: unknown; + }) => Promise<{ threadId: string | undefined }>; handleSubmit: (e: React.FormEvent) => Promise; isPending: boolean; error: Error | null; - editorRef: React.RefObject; + editorRef: React.RefObject; submitError: string | null; setSubmitError: React.Dispatch>; + imageError: string | null; + setImageError: React.Dispatch>; elicitation: TamboElicitationRequest | null; resolveElicitation: ((response: TamboElicitationResponse) => void) | null; } @@ -132,8 +381,8 @@ const useMessageInputContext = () => { export interface MessageInputProps extends React.HTMLAttributes { /** Optional styling variant for the input container. */ variant?: VariantProps["variant"]; - /** Optional ref to forward to the TipTap editor instance. */ - inputRef?: React.RefObject; + /** Optional ref to forward to the TamboEditor instance. */ + inputRef?: React.RefObject; /** The child elements to render within the form container. */ children?: React.ReactNode; } @@ -167,6 +416,30 @@ const MessageInput = React.forwardRef( ); MessageInput.displayName = "MessageInput"; +const STORAGE_KEY = "tambo.components.messageInput.draft"; + +const getStorageKey = (key: string) => `${STORAGE_KEY}.${key}`; + +const storeValueInSessionStorage = (key: string, value?: string) => { + const storageKey = getStorageKey(key); + if (value === undefined) { + sessionStorage.removeItem(storageKey); + return; + } + + sessionStorage.setItem(storageKey, JSON.stringify({ rawQuery: value })); +}; + +const getValueFromSessionStorage = (key: string): string => { + const storedValue = sessionStorage.getItem(getStorageKey(key)) ?? ""; + try { + const parsed = JSON.parse(storedValue); + return parsed.rawQuery ?? ""; + } catch { + return ""; + } +}; + /** * Internal MessageInput component that uses the TamboThreadInput context */ @@ -182,50 +455,69 @@ const MessageInputInternal = React.forwardRef< error, images, addImages, - clearImages, + removeImage, } = useTamboThreadInput(); - const { cancelRun, isIdle: tamboIsIdle } = useTambo(); + const { cancelRun, currentThreadId } = useTambo(); const [displayValue, setDisplayValue] = React.useState(""); const [submitError, setSubmitError] = React.useState(null); + const [imageError, setImageError] = React.useState(null); const [isSubmitting, setIsSubmitting] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false); - const editorRef = React.useRef(null); + const editorRef = React.useRef(null!); const dragCounter = React.useRef(0); + const submittingRef = React.useRef(false); // Use elicitation context (optional) const { elicitation, resolveElicitation } = useTamboElicitationContext(); React.useEffect(() => { + // On mount, load any stored draft value, but only if current value is empty + const storedValue = getValueFromSessionStorage(currentThreadId); + if (!storedValue) return; + setValue((value) => value ?? storedValue); + }, [setValue, currentThreadId]); + + React.useEffect(() => { + if (submittingRef.current) return; setDisplayValue(value); + storeValueInSessionStorage(currentThreadId, value); if (value && editorRef.current) { - editorRef.current.commands.focus(); + editorRef.current.focus(); } - }, [value]); + }, [value, currentThreadId]); const handleSubmit = React.useCallback( async (e: React.FormEvent) => { e.preventDefault(); if ((!value.trim() && images.length === 0) || isSubmitting) return; + // Clear any previous errors setSubmitError(null); + setImageError(null); setDisplayValue(""); + storeValueInSessionStorage(currentThreadId); + submittingRef.current = true; setIsSubmitting(true); - // Clear images in next tick for immediate UI feedback - if (images.length > 0) { - setTimeout(() => clearImages(), 0); - } + const imageIdsAtSubmitTime = images.map((image) => image.id); try { await submit(); setValue(""); - // Images are cleared automatically by the TamboThreadInputProvider + // Clear only the images that were staged when submission started so + // any images added while the request was in-flight are preserved. + if (imageIdsAtSubmitTime.length > 0) { + imageIdsAtSubmitTime.forEach((id) => removeImage(id)); + } + // Refocus the editor after a successful submission setTimeout(() => { - editorRef.current?.commands.focus(); + editorRef.current?.focus(); }, 0); } catch (error) { console.error("Failed to submit message:", error); setDisplayValue(value); + // On submit failure, also clear image error + setImageError(null); setSubmitError( error instanceof Error ? error.message @@ -235,6 +527,7 @@ const MessageInputInternal = React.forwardRef< // Cancel the run to reset loading state await cancelRun(); } finally { + submittingRef.current = false; setIsSubmitting(false); } }, @@ -247,7 +540,9 @@ const MessageInputInternal = React.forwardRef< cancelRun, isSubmitting, images, - clearImages, + removeImage, + editorRef, + currentThreadId, ], ); @@ -291,14 +586,25 @@ const MessageInputInternal = React.forwardRef< ); if (files.length > 0) { + const totalImages = images.length + files.length; + if (totalImages > MAX_IMAGES) { + setImageError(`Max ${MAX_IMAGES} uploads at a time`); + return; + } + setImageError(null); // Clear previous error try { await addImages(files); } catch (error) { console.error("Failed to add dropped images:", error); + setImageError( + error instanceof Error + ? error.message + : "Failed to add images. Please try again.", + ); } } }, - [addImages], + [addImages, images, setImageError], ); const handleElicitationResponse = React.useCallback( @@ -325,6 +631,8 @@ const MessageInputInternal = React.forwardRef< editorRef: inputRef ?? editorRef, submitError, setSubmitError, + imageError, + setImageError, elicitation, resolveElicitation, }), @@ -339,6 +647,8 @@ const MessageInputInternal = React.forwardRef< inputRef, editorRef, submitError, + imageError, + setImageError, elicitation, resolveElicitation, ], @@ -397,6 +707,9 @@ MessageInput.displayName = "MessageInput"; */ const IS_PASTED_IMAGE = Symbol.for("tambo-is-pasted-image"); +/** Maximum number of images that can be staged at once */ +const MAX_IMAGES = 10; + /** * Extend the File interface to include IS_PASTED_IMAGE symbol */ @@ -413,10 +726,12 @@ declare global { export interface MessageInputTextareaProps extends React.HTMLAttributes { /** Custom placeholder text. */ placeholder?: string; - /** Static mention items to include in @ suggestions. */ - staticMentionItems?: ResourceItem[]; - /** Async fetcher to load mention items (e.g., MCP resources). */ - mentionItemFetcher?: (query: string) => Promise; + /** Resource provider for @ mentions (optional - includes interactables by default) */ + resourceProvider?: ResourceProvider; + /** Prompt provider for / commands (optional) */ + promptProvider?: PromptProvider; + /** Callback when a resource is selected from @ mentions (optional) */ + onResourceSelect?: (item: ResourceItem) => void; } /** @@ -425,13 +740,23 @@ export interface MessageInputTextareaProps extends React.HTMLAttributes * { + * // Return custom resources + * return [{ id: "foo", name: "Foo" }]; + * } + * }} * /> * * ``` @@ -439,13 +764,105 @@ export interface MessageInputTextareaProps extends React.HTMLAttributes { - const { value, setValue, handleSubmit, editorRef } = useMessageInputContext(); + const { value, setValue, handleSubmit, editorRef, setImageError } = + useMessageInputContext(); const { isIdle } = useTambo(); + const { addImage, images } = useTamboThreadInput(); const isUpdatingToken = useIsTamboTokenUpdating(); + // Resource names are extracted from editor at submit time, no need to track in state + const setResourceNames = React.useCallback( + ( + _resourceNames: + | Record + | ((prev: Record) => Record), + ) => { + // No-op - we extract resource names directly from editor at submit time + }, + [], + ); + + // Track search state for resources (controlled by TextEditor) + const [resourceSearch, setResourceSearch] = React.useState(""); + + // Track search state for prompts (controlled by TextEditor) + const [promptSearch, setPromptSearch] = React.useState(""); + + // Get combined resource list (MCP + external provider), filtered by search + const resourceItems = useCombinedResourceList( + resourceProvider, + resourceSearch, + ); + + // Get combined prompt list (MCP + external provider), filtered by search + const promptItems = useCombinedPromptList(promptProvider, promptSearch); + + // State for MCP prompt fetching (since we can't call hooks inside get()) + const [selectedMcpPromptName, setSelectedMcpPromptName] = React.useState< + string | null + >(null); + const { data: selectedMcpPromptData } = useTamboMcpPrompt( + selectedMcpPromptName ?? "", + ); + + // Handle MCP prompt insertion when data is fetched + React.useEffect(() => { + if (selectedMcpPromptData && selectedMcpPromptName) { + const promptMessages = selectedMcpPromptData?.messages; + if (promptMessages) { + const promptText = promptMessages + .map((msg) => { + if (msg.content?.type === "text") { + return msg.content.text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); + + const editor = editorRef.current; + if (editor) { + editor.setContent(promptText); + setValue(promptText); + editor.focus("end"); + } + } + setSelectedMcpPromptName(null); + } + }, [selectedMcpPromptData, selectedMcpPromptName, editorRef, setValue]); + + // Handle prompt selection - check if it's an MCP prompt + const handlePromptSelect = React.useCallback((item: PromptItem) => { + if (item.id.startsWith("mcp-prompt:")) { + const promptName = item.id.replace("mcp-prompt:", ""); + setSelectedMcpPromptName(promptName); + } + }, []); + + // Handle image paste - mark as pasted and add to thread + const pendingImagesRef = React.useRef(0); + + const handleAddImage = React.useCallback( + async (file: File) => { + if (images.length + pendingImagesRef.current >= MAX_IMAGES) { + setImageError(`Max ${MAX_IMAGES} uploads at a time`); + return; + } + setImageError(null); + pendingImagesRef.current += 1; + try { + file[IS_PASTED_IMAGE] = true; + await addImage(file); + } finally { + pendingImagesRef.current -= 1; + } + }, + [addImage, images, setImageError], + ); return (
{})} + onPromptSelect={handlePromptSelect} />
); @@ -490,9 +913,10 @@ const MessageInputPlainTextarea = ({ placeholder = "What do you want to do?", ...props }: MessageInputPlainTextareaProps) => { - const { value, setValue, handleSubmit } = useMessageInputContext(); + const { value, setValue, handleSubmit, setImageError } = + useMessageInputContext(); const { isIdle } = useTambo(); - const { addImage } = useTamboThreadInput(); + const { addImage, images } = useTamboThreadInput(); const isUpdatingToken = useIsTamboTokenUpdating(); const isPending = !isIdle; const textareaRef = React.useRef(null); @@ -511,11 +935,7 @@ const MessageInputPlainTextarea = ({ }; const handlePaste = async (e: React.ClipboardEvent) => { - const items = Array.from(e.clipboardData.items); - const imageItems = items.filter((item) => item.type.startsWith("image/")); - - // Allow default paste if there is text, even when images exist - const hasText = e.clipboardData.getData("text/plain").length > 0; + const { imageItems, hasText } = getImageItems(e.clipboardData); if (imageItems.length === 0) { return; // Allow default text paste @@ -525,16 +945,20 @@ const MessageInputPlainTextarea = ({ e.preventDefault(); // Only prevent when image-only paste } + const totalImages = images.length + imageItems.length; + if (totalImages > MAX_IMAGES) { + setImageError(`Max ${MAX_IMAGES} uploads at a time`); + return; + } + setImageError(null); + for (const item of imageItems) { - const file = item.getAsFile(); - if (file) { - try { - // Mark this file as pasted so we can show "Image 1", "Image 2", etc. - file[IS_PASTED_IMAGE] = true; - await addImage(file); - } catch (error) { - console.error("Failed to add pasted image:", error); - } + try { + // Mark this image as pasted so we can show "Image 1", "Image 2", etc. + item[IS_PASTED_IMAGE] = true; + await addImage(item); + } catch (error) { + console.error("Failed to add pasted image:", error); } } }; @@ -588,9 +1012,14 @@ const MessageInputSubmitButton = React.forwardRef< MessageInputSubmitButtonProps >(({ className, children, ...props }, ref) => { const { isPending } = useMessageInputContext(); - const { cancelRun } = useTambo(); + const { cancelRun, isIdle } = useTambo(); const isUpdatingToken = useIsTamboTokenUpdating(); + // Show cancel button if either: + // 1. A mutation is in progress (isPending), OR + // 2. Thread is stuck in a processing state (e.g., after browser refresh during tool execution) + const showCancelButton = isPending || !isIdle; + const handleCancel = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -598,23 +1027,25 @@ const MessageInputSubmitButton = React.forwardRef< }; const buttonClasses = cn( - "w-10 h-10 bg-black/80 text-white rounded-lg hover:bg-black/70 disabled:opacity-50 flex items-center justify-center enabled:cursor-pointer", + "w-10 h-10 bg-foreground text-background rounded-lg hover:bg-foreground/90 disabled:opacity-50 flex items-center justify-center enabled:cursor-pointer", className, ); return ( - ))} -
+ !open && onClose()} + > + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => { + e.preventDefault(); + onClose(); + }} + > + {state.items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {state.items.map((item, index) => ( + + ))} +
+ )} +
+ ); -}); -ResourceItemList.displayName = "ResourceItemList"; +} /** - * Checks if a mention with the given label already exists in the editor. - * Used to prevent duplicate mentions when inserting via @ command or EditableHint. - * - * @param editor - The TipTap editor instance - * @param label - The mention label to check for - * @returns true if a mention with the given label exists, false otherwise + * Internal helper to check if a mention exists in a raw TipTap Editor. */ -export function hasExistingMention(editor: Editor, label: string): boolean { - let hasMention = false; +function checkMentionExists(editor: Editor, label: string): boolean { + if (!editor.state?.doc) return false; + let exists = false; editor.state.doc.descendants((node) => { if (node.type.name === "mention") { const mentionLabel = node.attrs.label as string; if (mentionLabel === label) { - hasMention = true; - return false; // Stop traversing + exists = true; + return false; } } return true; }); - return hasMention; + return exists; } /** - * Creates a popup handler for the resource item dropdown using tippy.js. + * Creates the resource mention configuration for TipTap Mention extension. + * The items() function triggers the search - actual items come from props via stateRef. */ -function createResourceItemPopup() { - let itemListComponent: ReactRenderer | undefined; - let tippyPopup: TippyInstance | undefined; - - return { - /** - * Called when the user starts typing "@" and resource items should appear. - * Creates the React component and tippy popup. - */ - onStart(props: { - items: ResourceItem[]; - command: (item: ResourceItem) => void; - editor: Editor; - clientRect?: (() => DOMRect | null) | null; - }) { - itemListComponent = new ReactRenderer(ResourceItemList, { - props: { items: props.items, command: props.command }, - editor: props.editor, - }); - - if (!props.clientRect) return; - - tippyPopup = tippy("body", { - getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), - appendTo: () => document.body, - content: itemListComponent.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - maxWidth: "24rem", - theme: "light-border", - })[0]; - }, - - /** - * Called when resource items change (user continues typing after "@"). - * Updates the resource item list and repositions the popup. - */ - onUpdate(props: { - items: ResourceItem[]; - command: (item: ResourceItem) => void; - clientRect?: (() => DOMRect | null) | null; - }) { - itemListComponent?.updateProps({ - items: props.items, - command: props.command, - }); - if (props.clientRect && tippyPopup) { - tippyPopup.setProps({ - getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), - }); - } - }, - - /** - * Handles keyboard events in the resource item dropdown. - * - Escape: closes the popup - * - Arrow keys/Enter: delegated to the resource item list component - */ - onKeyDown({ event }: { event: KeyboardEvent }) { - if (event.key === "Escape") { - tippyPopup?.hide(); - return true; - } - const handled = itemListComponent?.ref?.onKeyDown({ event }) ?? false; - if (handled) event.preventDefault(); - return handled; - }, - - /** - * Called when the resource item popup should be closed. - * Cleans up the React component and tippy popup. - */ - onExit() { - tippyPopup?.destroy(); - itemListComponent?.destroy(); - }, - }; -} - -/** - * Creates the resource item configuration for TipTap Mention extension. - * Filters resource items as user types and handles dropdown lifecycle. - * Supports both static arrays and async fetching. - */ -function createResourceItemConfig( - items: ResourceItemSource, - triggerChar: string, - onSelect?: (item: ResourceItem) => void, - isMenuOpenRef?: React.MutableRefObject, +function createResourceMentionConfig( + onSearchChange: (query: string) => void, + onSelect: (item: ResourceItem) => void, + stateRef: React.MutableRefObject>, ): Omit { return { - char: triggerChar, - items: async ({ query }) => { - return await resolveResourceItems(items, query); + char: "@", + items: ({ query }) => { + onSearchChange(query); + return []; }, - /** - * Returns handlers for managing the resource item popup lifecycle. - * Called once when the mention system initializes (when editor is created). - */ render: () => { - const popupHandlers = createResourceItemPopup(); - - /** - * Creates a wrapped command that checks for duplicates before inserting. - * Must be created inside onStart/onUpdate where editor is available. - */ const createWrapCommand = - (editor: Editor) => - (tiptapCommand: (attrs: { id: string; label: string }) => void) => + ( + editor: Editor, + tiptapCommand: (attrs: { id: string; label: string }) => void, + ) => (item: ResourceItem) => { - // Check if mention already exists in the editor - if (hasExistingMention(editor, item.name)) { - // Don't insert duplicate mention - return; - } - - // Insert the command into the editor (e.g., "@ComponentName") + if (checkMentionExists(editor, item.name)) return; tiptapCommand({ id: item.id, label: item.name }); - // Run custom logic (e.g., add context attachment, insert table, etc.) - onSelect?.(item); + onSelect(item); }; return { - /** - * Called when user starts typing the trigger character (e.g., "@"). - * Shows the resource item dropdown. - */ onStart: (props) => { - if (props.items.length === 0) { - if (isMenuOpenRef) isMenuOpenRef.current = false; - return; - } - if (isMenuOpenRef) isMenuOpenRef.current = true; - popupHandlers.onStart({ - items: props.items, - editor: props.editor, - clientRect: props.clientRect, - command: createWrapCommand(props.editor)(props.command), + stateRef.current.setState({ + isOpen: true, + selectedIndex: 0, + position: getPositionFromClientRect(props.clientRect), + command: createWrapCommand(props.editor, props.command), }); }, - - /** - * Called as user continues typing after the trigger (e.g., "@jo" -> "@john"). - * Updates the filtered resource items in the dropdown. - */ onUpdate: (props) => { - if (props.items.length === 0) { - popupHandlers.onExit(); - if (isMenuOpenRef) isMenuOpenRef.current = false; - return; - } - popupHandlers.onUpdate({ - items: props.items, - clientRect: props.clientRect, - command: createWrapCommand(props.editor)(props.command), + stateRef.current.setState({ + position: getPositionFromClientRect(props.clientRect), + command: createWrapCommand(props.editor, props.command), + selectedIndex: 0, }); }, - - /** - * Handles keyboard events in the resource item dropdown. - * - ArrowUp/ArrowDown: Navigate through resource items - * - Enter: Select current resource item - * - Escape: Close dropdown - */ - onKeyDown: popupHandlers.onKeyDown, - - /** - * Called when the resource item dropdown should close. - * Cleans up the popup and updates the menu state. - */ + onKeyDown: ({ event }) => { + const { state, setState } = stateRef.current; + if (!state.isOpen) return false; + + const handlers: Record boolean> = { + ArrowUp: () => { + if (state.items.length === 0) return false; + setState({ + selectedIndex: + (state.selectedIndex - 1 + state.items.length) % + state.items.length, + }); + return true; + }, + ArrowDown: () => { + if (state.items.length === 0) return false; + setState({ + selectedIndex: (state.selectedIndex + 1) % state.items.length, + }); + return true; + }, + Enter: () => { + const item = state.items[state.selectedIndex]; + if (item && state.command) { + state.command(item); + return true; + } + return false; + }, + Escape: () => { + setState({ isOpen: false }); + return true; + }, + }; + + const handler = handlers[event.key]; + if (handler) { + event.preventDefault(); + return handler(); + } + return false; + }, onExit: () => { - // Delay setting menu to closed to give our handleKeyDown a chance to see it was open - // This prevents the form from submitting when Enter is used to select an item - setTimeout(() => { - if (isMenuOpenRef) isMenuOpenRef.current = false; - }, 100); - popupHandlers.onExit(); + stateRef.current.setState({ isOpen: false }); }, }; }, @@ -432,132 +379,290 @@ function createResourceItemConfig( } /** - * Text editor component with command support (e.g., "@" mentions). + * Creates a custom TipTap extension for prompt commands using the Suggestion plugin. + * The items() function triggers the search - actual items come from props via stateRef. + */ +function createPromptCommandExtension( + onSearchChange: (query: string) => void, + onSelect: (item: PromptItem) => void, + stateRef: React.MutableRefObject>, +) { + return Extension.create({ + name: "promptCommand", + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: "/", + items: ({ query, editor }) => { + // Only show prompts when editor is empty (except for the "/" and query) + const editorValue = editor.getText().replace("/", "").trim(); + if (editorValue.length > 0) { + stateRef.current.setState({ isOpen: false }); + return []; + } + // Trigger search - actual items come from props via stateRef + onSearchChange(query); + return []; + }, + render: () => { + // Store command creator that captures editor context + let createCommand: ((item: PromptItem) => void) | null = null; + + return { + onStart: (props) => { + createCommand = (item: PromptItem) => { + props.editor.commands.deleteRange({ + from: props.range.from, + to: props.range.to, + }); + onSelect(item); + }; + stateRef.current.setState({ + isOpen: true, + selectedIndex: 0, + position: getPositionFromClientRect(props.clientRect), + command: createCommand, + }); + }, + onUpdate: (props) => { + createCommand = (item: PromptItem) => { + props.editor.commands.deleteRange({ + from: props.range.from, + to: props.range.to, + }); + onSelect(item); + }; + stateRef.current.setState({ + position: getPositionFromClientRect(props.clientRect), + command: createCommand, + selectedIndex: 0, + }); + }, + onKeyDown: ({ event }) => { + const { state, setState } = stateRef.current; + if (!state.isOpen) return false; + + const handlers: Record boolean> = { + ArrowUp: () => { + if (state.items.length === 0) return false; + setState({ + selectedIndex: + (state.selectedIndex - 1 + state.items.length) % + state.items.length, + }); + return true; + }, + ArrowDown: () => { + if (state.items.length === 0) return false; + setState({ + selectedIndex: + (state.selectedIndex + 1) % state.items.length, + }); + return true; + }, + Enter: () => { + const item = state.items[state.selectedIndex]; + if (item && state.command) { + state.command(item); + return true; + } + return false; + }, + Escape: () => { + setState({ isOpen: false }); + return true; + }, + }; + + const handler = handlers[event.key]; + if (handler) { + event.preventDefault(); + return handler(); + } + return false; + }, + onExit: () => { + stateRef.current.setState({ isOpen: false }); + }, + }; + }, + }), + ]; + }, + }); +} + +/** + * Custom text extraction that serializes mention nodes with their ID (resource URI). + */ +function getTextWithResourceURIs(editor: Editor | null): { + text: string; + resourceNames: Record; +} { + if (!editor?.state?.doc) return { text: "", resourceNames: {} }; + + let text = ""; + const resourceNames: Record = {}; + + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") { + const id = node.attrs.id ?? ""; + const label = node.attrs.label ?? ""; + text += `@${id}`; + if (label && id) { + resourceNames[id] = label; + } + } else if (node.type.name === "hardBreak") { + text += "\n"; + } else if (node.isText) { + text += node.text; + } + return true; + }); + + return { text, resourceNames }; +} + +/** + * Hook to create suggestion state with a ref for TipTap access. + */ +function useSuggestionState( + externalItems?: T[], +): [SuggestionState, React.MutableRefObject>] { + const [state, setStateInternal] = useState>({ + isOpen: false, + items: externalItems ?? [], + selectedIndex: 0, + position: null, + command: null, + }); + + const setState = React.useCallback((update: Partial>) => { + setStateInternal((prev) => ({ ...prev, ...update })); + }, []); + + const stateRef = React.useRef>({ state, setState }); + + // Keep ref in sync + React.useEffect(() => { + stateRef.current = { state, setState }; + }, [state, setState]); + + // Sync external items when provided + React.useEffect(() => { + if (externalItems !== undefined) { + setStateInternal((prev) => { + if (prev.items === externalItems) { + return prev; + } + + const previousMaxIndex = Math.max(prev.items.length - 1, 0); + const safePrevIndex = Math.min( + Math.max(prev.selectedIndex, 0), + previousMaxIndex, + ); + + const selectedItem = prev.items[safePrevIndex]; + const matchedIndex = selectedItem + ? externalItems.findIndex((item) => item.id === selectedItem.id) + : -1; + + const maxIndex = Math.max(externalItems.length - 1, 0); + const nextSelectedIndex = + matchedIndex >= 0 ? matchedIndex : Math.min(safePrevIndex, maxIndex); + + return { + ...prev, + items: externalItems, + selectedIndex: nextSelectedIndex, + }; + }); + } + }, [externalItems]); + + return [state, stateRef]; +} + +/** + * Text editor component with resource ("@") and prompt ("/") support. */ -export const TextEditor = React.forwardRef( +export const TextEditor = React.forwardRef( ( { value, onChange, + onResourceNamesChange, onKeyDown, placeholder = "What do you want to do?", disabled = false, className, - editorRef, - commands: providedCommands = [], onSubmit, - staticMentionItems = [], - mentionItemFetcher, + onAddImage, + onSearchResources, + resources, + onSearchPrompts, + prompts, + onResourceSelect, + onPromptSelect, }, ref, ) => { - // Use Tambo-specific hooks - must be called unconditionally per React rules - // These hooks will throw if not in Tambo context, which is expected when onSubmit is provided - const tamboThreadInput = useTamboThreadInput(); - const tamboContextAttachment = useTamboContextAttachment(); - const interactables = useCurrentInteractablesSnapshot(); - - // Ref to access the current interactables without capturing them in a closure - const interactablesRef = React.useRef(interactables); - useEffect(() => { - interactablesRef.current = interactables; - }, [interactables]); - - // Ref to access tamboContextAttachment without causing re-renders - const tamboContextAttachmentRef = React.useRef(tamboContextAttachment); - useEffect(() => { - tamboContextAttachmentRef.current = tamboContextAttachment; - }, [tamboContextAttachment]); - - const tamboCommands = React.useMemo((): CommandConfig[] => { - if (!onSubmit) return []; - - // Function to get the resource items for the @ mention dropdown - const getResourceItems = async ( - query: string, - ): Promise => { - // Get the current interactables via ref to get the current value - const interactableItems = interactablesRef.current.map((component) => ({ - id: component.id, - name: component.name, - icon: React.createElement(Cuboid, { className: "w-4 h-4" }), - componentData: component, - })); - - const filteredInteractables = filterResourceItems( - interactableItems, - query, - ); - const filteredStatic = filterResourceItems(staticMentionItems, query); - const fetchedItems = mentionItemFetcher - ? await mentionItemFetcher(query) - : []; - - return dedupeResourceItems([ - ...filteredInteractables, - ...filteredStatic, - ...fetchedItems, - ]); + // Suggestion states with refs for TipTap access + const [resourceState, resourceRef] = + useSuggestionState(resources); + const [promptState, promptRef] = useSuggestionState(prompts); + + // Consolidated ref for callbacks that TipTap needs to access + const callbacksRef = React.useRef({ + onSearchResources, + onResourceSelect, + onSearchPrompts, + onPromptSelect, + }); + + React.useEffect(() => { + callbacksRef.current = { + onSearchResources, + onResourceSelect, + onSearchPrompts, + onPromptSelect, }; + }, [onSearchResources, onResourceSelect, onSearchPrompts, onPromptSelect]); - // Create the @ command - return [ - { - triggerChar: "@", - items: getResourceItems, - onSelect: (item: ResourceItem) => { - // When a mention is selected, add it as a context attachment - // This will appear as a badge above the input - tamboContextAttachmentRef.current.addContextAttachment({ displayName: item.name, context: item.name }); - }, - renderLabel: ({ - node, - }: { - node: { attrs: Record }; - }) => `@${(node.attrs.label as string) ?? ""}`, - HTMLAttributes: { class: "mention" }, - }, - ]; - }, [ - mentionItemFetcher, - onSubmit, - staticMentionItems, - ]); + // Stable callbacks for TipTap + const stableSearchResources = React.useCallback( + (query: string) => callbacksRef.current.onSearchResources(query), + [], + ); - // Merge provided commands with Tambo-specific commands - const commands = React.useMemo( - () => [...providedCommands, ...tamboCommands], - [providedCommands, tamboCommands], + const stableSearchPrompts = React.useCallback( + (query: string) => callbacksRef.current.onSearchPrompts(query), + [], + ); + + const handleResourceSelect = React.useCallback( + (item: ResourceItem) => callbacksRef.current.onResourceSelect(item), + [], + ); + + const handlePromptSelect = React.useCallback( + (item: PromptItem) => callbacksRef.current.onPromptSelect(item), + [], ); - // Handle Enter key to submit message when onSubmit is provided const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent, editor: Editor) => { - // Handle Tambo-specific Enter key behavior - if (onSubmit && e.key === "Enter" && !e.shiftKey && value.trim()) { + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && value.trim()) { e.preventDefault(); void onSubmit(e as React.FormEvent); return; } - - // Delegate to provided onKeyDown handler - if (onKeyDown) { - onKeyDown(e, editor); - } + onKeyDown?.(e); }, [onSubmit, value, onKeyDown], ); - // Initialize menu open refs for each command - // Use a stable ref that persists across renders to track menu state - const stableMenuOpenRef = React.useRef(false); - const commandRefs = React.useMemo( - () => - // eslint-disable-next-line react-hooks/refs -- Passing refs to config objects is intentional - commands.map((cmd) => ({ - isMenuOpenRef: cmd.isMenuOpenRef ?? stableMenuOpenRef, - })), - [commands], - ); const editor = useEditor({ immediatelyRender: false, @@ -565,27 +670,41 @@ export const TextEditor = React.forwardRef( Document, Paragraph, Text, + HardBreak, Placeholder.configure({ placeholder }), - // eslint-disable-next-line react-hooks/refs -- Refs are passed to config, not read during render - ...commands.map((cmd, index) => { - const ref = commandRefs[index]; - return Mention.configure({ - HTMLAttributes: cmd.HTMLAttributes ?? {}, - suggestion: createResourceItemConfig( - cmd.items, - cmd.triggerChar, - cmd.onSelect, - ref.isMenuOpenRef, - ), - renderLabel: cmd.renderLabel, - renderText: ({ node }) => - `${cmd.triggerChar}${(node.attrs.label as string) ?? ""}`, - }); + Mention.configure({ + HTMLAttributes: { + class: + "mention resource inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground", + }, + /* eslint-disable react-hooks/refs */ + suggestion: createResourceMentionConfig( + stableSearchResources, + handleResourceSelect, + resourceRef, + ), + /* eslint-enable react-hooks/refs */ + renderLabel: ({ node }) => `@${(node.attrs.label as string) ?? ""}`, }), + /* eslint-disable react-hooks/refs */ + createPromptCommandExtension( + stableSearchPrompts, + handlePromptSelect, + promptRef, + ), + /* eslint-enable react-hooks/refs */ ], content: value, editable: !disabled, - onUpdate: ({ editor }) => onChange(editor.getText()), + onUpdate: ({ editor }) => { + const { text, resourceNames } = getTextWithResourceURIs(editor); + if (text !== value) { + onChange(text); + } + if (onResourceNamesChange) { + onResourceNamesChange((prev) => ({ ...prev, ...resourceNames })); + } + }, editorProps: { attributes: { class: cn( @@ -598,87 +717,133 @@ export const TextEditor = React.forwardRef( ), }, handlePaste: (_view, event) => { - if (!onSubmit) { - return false; - } - - const items = Array.from(event.clipboardData?.items ?? []); - const imageItems = items.filter((item) => - item.type.startsWith("image/"), - ); + const { imageItems, hasText } = getImageItems(event.clipboardData); - // If there are no images, let TipTap handle the paste normally - if (imageItems.length === 0) { - return false; - } - - const text = event.clipboardData?.getData("text/plain") ?? ""; - const hasText = text.length > 0; + if (imageItems.length === 0) return false; - // Only prevent default when it's an image-only paste so users can still - // paste mixed text + images and keep the text in the editor if (!hasText) { event.preventDefault(); } void (async () => { for (const item of imageItems) { - const file = item.getAsFile(); - if (!file) continue; try { - file[IS_PASTED_IMAGE] = true; - await tamboThreadInput.addImage(file); + await onAddImage(item); } catch (error) { console.error("Failed to add pasted image:", error); } } })(); - // For pure image pastes we've already prevented the default and - // signal that the event was handled. For mixed text+image pastes, - // return false so TipTap can still process the text payload. return !hasText; }, - handleKeyDown: (view, event) => { - // Check if any command menu is open - const anyMenuOpen = commandRefs.some( - (ref) => ref.isMenuOpenRef.current, - ); - - // Prevent Enter from submitting form when selecting from any resource item menu - if (event.key === "Enter" && !event.shiftKey && anyMenuOpen) { - // Prevent the DOM event from propagating - event.preventDefault(); - event.stopPropagation(); - // Return false to let the suggestion plugin handle the selection - return false; - } + handleKeyDown: (_view, event) => { + const anyMenuOpen = resourceState.isOpen || promptState.isOpen; + + if (anyMenuOpen) return false; - // Delegate to handleKeyDown (which handles both Tambo-specific and custom handlers) - if (editor) { + if (event.key === "Enter" && !event.shiftKey && editor) { const reactEvent = event as unknown as React.KeyboardEvent; - handleKeyDown(reactEvent, editor); + handleKeyDown(reactEvent); return reactEvent.defaultPrevented; } + return false; }, }, }); - // Sync external value changes and disabled state with editor + useImperativeHandle(ref, () => { + if (!editor) { + return { + focus: () => {}, + setContent: () => {}, + appendText: () => {}, + getTextWithResourceURIs: () => ({ text: "", resourceNames: {} }), + hasMention: () => false, + insertMention: () => {}, + setEditable: () => {}, + }; + } + + return { + focus: (position?: "start" | "end") => { + if (position) { + editor.commands.focus(position); + } else { + editor.commands.focus(); + } + }, + setContent: (content: string) => { + editor.commands.setContent(content); + }, + appendText: (text: string) => { + editor.chain().focus("end").insertContent(text).run(); + }, + getTextWithResourceURIs: () => getTextWithResourceURIs(editor), + hasMention: (id: string) => { + if (!editor.state?.doc) return false; + let exists = false; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") { + const mentionId = node.attrs.id as string; + if (mentionId === id) { + exists = true; + return false; + } + } + return true; + }); + return exists; + }, + insertMention: (id: string, label: string) => { + editor + .chain() + .focus() + .insertContent([ + { type: "mention", attrs: { id, label } }, + { type: "text", text: " " }, + ]) + .run(); + }, + setEditable: (editable: boolean) => { + editor.setEditable(editable); + }, + }; + }, [editor]); + + const lastSyncedValueRef = React.useRef(value); + React.useEffect(() => { if (!editor) return; - if (value !== editor.getText()) { + + const { text: currentText } = getTextWithResourceURIs(editor); + + if (value !== currentText && value !== lastSyncedValueRef.current) { editor.commands.setContent(value); + lastSyncedValueRef.current = value; + } else if (value === currentText) { + lastSyncedValueRef.current = value; } + editor.setEditable(!disabled); - if (editorRef) { - editorRef.current = editor; - } - }, [editor, value, disabled, editorRef]); + }, [editor, value, disabled]); return ( -
+
+ resourceRef.current.setState({ isOpen: false })} + defaultIcon={} + emptyMessage="No results found" + monoSecondary + /> + promptRef.current.setState({ isOpen: false })} + defaultIcon={} + emptyMessage="No prompts found" + />
); @@ -686,20 +851,3 @@ export const TextEditor = React.forwardRef( ); TextEditor.displayName = "TextEditor"; - -/** - * Symbol for marking pasted images. - * Using Symbol.for to create a global symbol that can be accessed across modules. - * @internal - */ -const IS_PASTED_IMAGE = Symbol.for("tambo-is-pasted-image"); - -/** - * Extend the File interface to include the IS_PASTED_IMAGE property. - * This is a type-safe way to mark pasted images without using a broad index signature. - */ -declare global { - interface File { - [IS_PASTED_IMAGE]?: boolean; - } -} diff --git a/src/components/tambo/thread-content.tsx b/src/components/tambo/thread-content.tsx index b63d83c..0b2eabe 100644 --- a/src/components/tambo/thread-content.tsx +++ b/src/components/tambo/thread-content.tsx @@ -24,7 +24,6 @@ import * as React from "react"; interface ThreadContentContextValue { messages: TamboThreadMessage[]; isGenerating: boolean; - generationStage?: string; variant?: VariantProps["variant"]; } @@ -75,17 +74,16 @@ export interface ThreadContentProps extends React.HTMLAttributes */ const ThreadContent = React.forwardRef( ({ children, className, variant, ...props }, ref) => { - const { messages, isIdle, streamingState } = useTambo(); + const { messages, isIdle } = useTambo(); const isGenerating = !isIdle; const contextValue = React.useMemo( () => ({ messages, isGenerating, - generationStage: streamingState.status === "streaming" ? "STREAMING_RESPONSE" : streamingState.status === "waiting" ? "FETCHING_CONTEXT" : "COMPLETE", variant, }), - [messages, isGenerating, streamingState.status, variant], + [messages, isGenerating, variant], ); return ( @@ -129,6 +127,9 @@ const ThreadContentMessages = React.forwardRef< const filteredMessages = messages.filter((message) => { if (message.role === "system") return false; + // Hide messages that only contain tool_result content blocks. + // These are consumed by ToolcallInfo on the preceding tool_use message + // and shouldn't render as standalone message bubbles. if ( message.content.length > 0 && message.content.every((block) => block.type === "tool_result") diff --git a/src/components/tambo/thread-history.tsx b/src/components/tambo/thread-history.tsx index ceb6293..215cd11 100644 --- a/src/components/tambo/thread-history.tsx +++ b/src/components/tambo/thread-history.tsx @@ -3,10 +3,10 @@ import { cn } from "@/lib/utils"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { + type ThreadListResponse, useTambo, useTamboThreadList, } from "@tambo-ai/react"; -import type { ThreadListResponse } from "@tambo-ai/react"; import { ArrowLeftToLine, ArrowRightToLine, @@ -17,6 +17,7 @@ import { } from "lucide-react"; import React, { useMemo } from "react"; +/** Thread item from the thread list API */ type ThreadListItem = ThreadListResponse["threads"][number]; /** @@ -29,14 +30,13 @@ interface ThreadHistoryContextValue { refetch: () => Promise; currentThreadId: string; switchThread: (threadId: string) => void; - startNewThread: () => void; + startNewThread: () => string; searchQuery: string; setSearchQuery: React.Dispatch>; isCollapsed: boolean; setIsCollapsed: React.Dispatch>; onThreadChange?: () => void; position?: "left" | "right"; - updateThreadName: (threadId: string, name: string) => Promise; } const ThreadHistoryContext = @@ -78,19 +78,9 @@ const ThreadHistory = React.forwardRef( const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); const [shouldFocusSearch, setShouldFocusSearch] = React.useState(false); - const { - data: threads, - isLoading, - error, - refetch, - } = useTamboThreadList(); + const { data: threads, isLoading, error, refetch } = useTamboThreadList(); - const { - switchThread, - startNewThread, - currentThreadId, - updateThreadName, - } = useTambo(); + const { switchThread, startNewThread, currentThreadId } = useTambo(); // Update CSS variable when sidebar collapses/expands React.useEffect(() => { @@ -123,7 +113,6 @@ const ThreadHistory = React.forwardRef( setIsCollapsed, onThreadChange, position, - updateThreadName, }), [ threads, @@ -137,14 +126,11 @@ const ThreadHistory = React.forwardRef( isCollapsed, onThreadChange, position, - updateThreadName, ], ); return ( - +
( - null, - ); + const [hasMounted, setHasMounted] = React.useState(false); + const [editingThread, setEditingThread] = + React.useState(null); const [newName, setNewName] = React.useState(""); const inputRef = React.useRef(null); + // Prevent hydration mismatch: treat as loading until mounted on the client + React.useEffect(() => { + setHasMounted(true); + }, []); + // Handle click outside name editing input React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -422,10 +411,8 @@ const ThreadHistoryList = React.forwardRef< const query = searchQuery.toLowerCase(); return threads.threads.filter((thread: ThreadListItem) => { - const nameMatches = thread.name?.toLowerCase().includes(query) ?? false; const idMatches = thread.id.toLowerCase().includes(query); - - return idMatches ? true : nameMatches; + return idMatches; }); }, [isCollapsed, threads, searchQuery]); @@ -442,24 +429,20 @@ const ThreadHistoryList = React.forwardRef< const handleRename = (thread: ThreadListItem) => { setEditingThread(thread); - setNewName(thread.name ?? ""); + setNewName(`Thread ${thread.id.substring(0, 8)}`); }; const handleNameSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!editingThread || !newName.trim()) return; - try { - await updateThreadName(editingThread.id, newName.trim()); - await refetch(); - } catch (error) { - console.error("Failed to rename thread:", error); - } + if (!editingThread) return; + + // Thread renaming is not supported in V1 API setEditingThread(null); }; // Content to show let content; - if (isLoading) { + if (!hasMounted || isLoading) { content = (
- {thread.name ?? `Thread ${thread.id.substring(0, 8)}`} + {`Thread ${thread.id.substring(0, 8)}`}

{new Date(thread.createdAt).toLocaleString(undefined, { @@ -549,10 +532,7 @@ const ThreadHistoryList = React.forwardRef< )}

- +
))}