From 532ceb5595e160fa041b03af0067239ec392c393 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 18 Dec 2025 01:15:37 +0800 Subject: [PATCH] feat: integrate @assistant-ui/react library with new chat components --- bun.lock | 80 +++- web/package.json | 2 + .../AssistantChat/ComposerButtons.tsx | 122 +++++ .../AssistantChat/HappyComposer.tsx | 428 ++++++++++++++++++ .../components/AssistantChat/HappyThread.tsx | 46 ++ .../components/AssistantChat/StatusBar.tsx | 139 ++++++ web/src/components/AssistantChat/context.tsx | 31 ++ .../messages/AssistantMessage.tsx | 55 +++ .../AssistantChat/messages/SystemMessage.tsx | 17 + .../AssistantChat/messages/ToolMessage.tsx | 225 +++++++++ .../AssistantChat/messages/UserMessage.tsx | 73 +++ web/src/components/ChatInput/Autocomplete.tsx | 2 +- web/src/components/SessionChat.tsx | 198 ++++---- web/src/lib/assistant-runtime.ts | 169 +++++++ web/src/main.tsx | 1 - 15 files changed, 1495 insertions(+), 93 deletions(-) create mode 100644 web/src/components/AssistantChat/ComposerButtons.tsx create mode 100644 web/src/components/AssistantChat/HappyComposer.tsx create mode 100644 web/src/components/AssistantChat/HappyThread.tsx create mode 100644 web/src/components/AssistantChat/StatusBar.tsx create mode 100644 web/src/components/AssistantChat/context.tsx create mode 100644 web/src/components/AssistantChat/messages/AssistantMessage.tsx create mode 100644 web/src/components/AssistantChat/messages/SystemMessage.tsx create mode 100644 web/src/components/AssistantChat/messages/ToolMessage.tsx create mode 100644 web/src/components/AssistantChat/messages/UserMessage.tsx create mode 100644 web/src/lib/assistant-runtime.ts diff --git a/bun.lock b/bun.lock index 8a06e2de..fef52c5a 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,8 @@ "name": "hapi-web", "version": "0.1.0", "dependencies": { + "@assistant-ui/react": "^0.11.51", + "@assistant-ui/react-markdown": "^0.11.8", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", @@ -100,6 +102,12 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@assistant-ui/react": ["@assistant-ui/react@0.11.51", "", { "dependencies": { "@assistant-ui/tap": "^0.3.4", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "@standard-schema/spec": "^1.0.0", "assistant-cloud": "^0.1.11", "assistant-stream": "^0.2.45", "nanoid": "5.1.6", "react-textarea-autosize": "^8.5.9", "zod": "^4.1.13", "zustand": "^5.0.9" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8u0tmQBIio+fERgiUGqXTf+cm1MHp6i9V879or7DeyRwRnlwBS0Ew0OCFiR8ZqoJHn9ma0NBH2bXjHNkILnSew=="], + + "@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.11.8", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@types/hast": "^3.0.4", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.11.50", "@types/react": "*", "react": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Us7yD9xUGozmmmDuWn+lCp4bylA2sINWyGE6RXr0onmAlwXcOUaw1ZgofKscJXkw6DmC0jrLj7Bdsw8O4IUJhw=="], + + "@assistant-ui/tap": ["@assistant-ui/tap@0.3.4", "", { "peerDependencies": { "react": "*" }, "optionalPeers": ["react"] }, "sha512-62Fz1y4EJA7Hk+aStjNS9VTciZ/TUf9SKdv5RfGF65A1Y/REOBzxBDL4ZCeGHCX2dxmwESg5/Kehky+57og3EA=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -228,6 +236,14 @@ "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@grammyjs/types": ["@grammyjs/types@3.22.2", "", {}, "sha512-uu7DX2ezhnBPozL3bXHmwhLvaFsh59E4QyviNH4Cij7EdVekYrs6mCzeXsa2pDk30l3uXo7DBahlZLzTPtpYZg=="], "@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="], @@ -324,9 +340,11 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], @@ -338,11 +356,15 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -356,6 +378,12 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/plugin-alias": ["@rollup/plugin-alias@6.0.0", "", { "peerDependencies": { "rollup": ">=4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g=="], @@ -420,6 +448,8 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], @@ -534,6 +564,10 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "assistant-cloud": ["assistant-cloud@0.1.11", "", { "dependencies": { "assistant-stream": "^0.2.45" } }, "sha512-1ZS7ccwiuy1NxNzIbHKubdCEcqe914YXVYxAR9N3M4csiLdrtUC1/U7KgLXy5Sig2Jp951L/wnN2fLzl302+sA=="], + + "assistant-stream": ["assistant-stream@0.2.45", "", { "dependencies": { "@types/json-schema": "^7.0.15", "nanoid": "5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-gfCwkPcGpfCpwWDzKrgzR5TvKP/ppc+MqotFHPCufbYK8nMft1+GyWGhQp6XXzQR2dC7T/IrqmtEGlGSs8uobA=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -624,6 +658,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], @@ -1668,8 +1704,14 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@assistant-ui/react/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "@assistant-ui/react/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1700,9 +1742,29 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -1712,6 +1774,8 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "assistant-stream/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -1812,6 +1876,16 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/web/package.json b/web/package.json index 4c3eda65..742cb057 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@assistant-ui/react": "^0.11.51", + "@assistant-ui/react-markdown": "^0.11.8", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx new file mode 100644 index 00000000..4a742edb --- /dev/null +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -0,0 +1,122 @@ +import { ComposerPrimitive } from '@assistant-ui/react' + +function SettingsIcon() { + return ( + + + + + ) +} + +function AbortIcon(props: { spinning: boolean }) { + if (props.spinning) { + return ( + + + + + ) + } + + return ( + + + + ) +} + +function SendIcon() { + return ( + + + + + ) +} + +export function ComposerButtons(props: { + canSend: boolean + controlsDisabled: boolean + showSettingsButton: boolean + onSettingsToggle: () => void + showAbortButton: boolean + abortDisabled: boolean + isAborting: boolean + onAbort: () => void +}) { + return ( +
+
+ {props.showSettingsButton ? ( + + ) : null} + + {props.showAbortButton ? ( + + ) : null} +
+ + + + +
+ ) +} + diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx new file mode 100644 index 00000000..afdea1a4 --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -0,0 +1,428 @@ +import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' +import { + type ChangeEvent as ReactChangeEvent, + type KeyboardEvent as ReactKeyboardEvent, + type SyntheticEvent as ReactSyntheticEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import type { AgentState, ModelMode, PermissionMode } from '@/types/api' +import type { Suggestion } from '@/hooks/useActiveSuggestions' +import { useActiveWord } from '@/hooks/useActiveWord' +import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' +import { applySuggestion } from '@/utils/applySuggestion' +import { getTelegramWebApp } from '@/hooks/useTelegram' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' +import { Autocomplete } from '@/components/ChatInput/Autocomplete' +import { StatusBar } from '@/components/AssistantChat/StatusBar' +import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' + +export interface TextInputState { + text: string + selection: { start: number; end: number } +} + +const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const +const PERMISSION_MODE_LABELS: Record = { + default: 'Default', + acceptEdits: 'Accept Edits', + plan: 'Plan Mode', + bypassPermissions: 'Bypass All' +} + +const MODEL_MODES = ['default', 'sonnet', 'opus'] as const +const MODEL_MODE_LABELS: Record = { + default: 'Default', + sonnet: 'Sonnet', + opus: 'Opus' +} + +const defaultSuggestionHandler = async (): Promise => [] + +export function HappyComposer(props: { + disabled?: boolean + permissionMode?: PermissionMode + modelMode?: ModelMode + active?: boolean + thinking?: boolean + agentState?: AgentState | null + contextSize?: number + onPermissionModeChange?: (mode: PermissionMode) => void + onModelModeChange?: (mode: ModelMode) => void + autocompletePrefixes?: string[] + autocompleteSuggestions?: (query: string) => Promise +}) { + const { + disabled = false, + permissionMode = 'default', + modelMode = 'default', + active = true, + thinking = false, + agentState, + contextSize, + onPermissionModeChange, + onModelModeChange, + autocompletePrefixes = ['@', '/'], + autocompleteSuggestions = defaultSuggestionHandler + } = props + + const api = useAssistantApi() + const composerText = useAssistantState(({ composer }) => composer.text) + const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning) + const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled) + + const controlsDisabled = disabled || !active || threadIsDisabled + const trimmed = composerText.trim() + const hasText = trimmed.length > 0 + const canSend = hasText && !controlsDisabled && !threadIsRunning + + const [inputState, setInputState] = useState({ + text: '', + selection: { start: 0, end: 0 } + }) + const [showSettings, setShowSettings] = useState(false) + const [isAborting, setIsAborting] = useState(false) + + const textareaRef = useRef(null) + + useEffect(() => { + setInputState((prev) => { + if (prev.text === composerText) return prev + return { ...prev, text: composerText } + }) + }, [composerText]) + + const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) + const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( + activeWord, + autocompleteSuggestions, + { clampSelection: true, wrapAround: true } + ) + + const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => { + const tg = getTelegramWebApp() + if (type === 'light') { + tg?.HapticFeedback?.impactOccurred('light') + } else if (type === 'success') { + tg?.HapticFeedback?.notificationOccurred('success') + } else { + tg?.HapticFeedback?.notificationOccurred('error') + } + }, []) + + const handleSuggestionSelect = useCallback((index: number) => { + const suggestion = suggestions[index] + if (!suggestion || !textareaRef.current) return + + const result = applySuggestion( + inputState.text, + inputState.selection, + suggestion.text, + autocompletePrefixes, + true + ) + + api.composer().setText(result.text) + setInputState({ + text: result.text, + selection: { start: result.cursorPosition, end: result.cursorPosition } + }) + + setTimeout(() => { + const el = textareaRef.current + if (!el) return + el.setSelectionRange(result.cursorPosition, result.cursorPosition) + try { + el.focus({ preventScroll: true }) + } catch { + el.focus() + } + }, 0) + + haptic('light') + }, [api, suggestions, inputState, autocompletePrefixes, haptic]) + + const abortDisabled = controlsDisabled || isAborting || !threadIsRunning + + useEffect(() => { + if (!isAborting) return + if (threadIsRunning) return + setIsAborting(false) + }, [isAborting, threadIsRunning]) + + const handleAbort = useCallback(() => { + if (abortDisabled) return + haptic('error') + setIsAborting(true) + api.thread().cancelRun() + }, [abortDisabled, api, haptic]) + + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { + const key = e.key + + // Avoid intercepting IME composition keystrokes (Enter, arrows, etc.) + if (e.nativeEvent.isComposing) { + return + } + + if (suggestions.length > 0) { + if (key === 'ArrowUp') { + e.preventDefault() + moveUp() + return + } + if (key === 'ArrowDown') { + e.preventDefault() + moveDown() + return + } + if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) { + e.preventDefault() + const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0 + handleSuggestionSelect(indexToSelect) + return + } + if (key === 'Escape') { + e.preventDefault() + clearSuggestions() + return + } + } + + if (key === 'Escape' && threadIsRunning) { + e.preventDefault() + handleAbort() + return + } + + if (key === 'Tab' && e.shiftKey && onPermissionModeChange) { + e.preventDefault() + const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number]) + const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length + onPermissionModeChange(PERMISSION_MODES[nextIndex]) + haptic('light') + } + }, [ + suggestions, + selectedIndex, + moveUp, + moveDown, + clearSuggestions, + handleSuggestionSelect, + threadIsRunning, + handleAbort, + onPermissionModeChange, + permissionMode, + haptic + ]) + + useEffect(() => { + const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) { + e.preventDefault() + const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) + const nextIndex = (currentIndex + 1) % MODEL_MODES.length + onModelModeChange(MODEL_MODES[nextIndex]) + haptic('light') + } + } + + window.addEventListener('keydown', handleGlobalKeyDown) + return () => window.removeEventListener('keydown', handleGlobalKeyDown) + }, [modelMode, onModelModeChange, haptic]) + + const handleChange = useCallback((e: ReactChangeEvent) => { + const selection = { + start: e.target.selectionStart, + end: e.target.selectionEnd + } + setInputState({ text: e.target.value, selection }) + }, []) + + const handleSelect = useCallback((e: ReactSyntheticEvent) => { + const target = e.target as HTMLTextAreaElement + setInputState(prev => ({ + ...prev, + selection: { start: target.selectionStart, end: target.selectionEnd } + })) + }, []) + + const handleSettingsToggle = useCallback(() => { + haptic('light') + setShowSettings(prev => !prev) + }, [haptic]) + + const handlePermissionChange = useCallback((mode: PermissionMode) => { + if (!onPermissionModeChange || controlsDisabled) return + onPermissionModeChange(mode) + setShowSettings(false) + haptic('light') + }, [onPermissionModeChange, controlsDisabled, haptic]) + + const handleModelChange = useCallback((mode: ModelMode) => { + if (!onModelModeChange || controlsDisabled) return + onModelModeChange(mode) + setShowSettings(false) + haptic('light') + }, [onModelModeChange, controlsDisabled, haptic]) + + const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange) + const showAbortButton = true + + const overlays = useMemo(() => { + if (showSettings) { + return ( +
+ +
+
+ Permission Mode +
+ {PERMISSION_MODES.map((mode) => ( + + ))} +
+ +
+ +
+
+ Model +
+ {MODEL_MODES.map((mode) => ( + + ))} +
+ +
+ ) + } + + if (suggestions.length > 0) { + return ( +
+ + handleSuggestionSelect(index)} + /> + +
+ ) + } + + return null + }, [ + showSettings, + suggestions, + selectedIndex, + controlsDisabled, + permissionMode, + modelMode, + handlePermissionChange, + handleModelChange, + handleSuggestionSelect + ]) + + return ( +
+
+ + {overlays} + + + +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx new file mode 100644 index 00000000..ab02449b --- /dev/null +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' +import { ThreadPrimitive } from '@assistant-ui/react' +import type { ApiClient } from '@/api/client' +import type { SessionMetadataSummary } from '@/types/api' +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage' +import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage' +import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage' + +const THREAD_MESSAGE_COMPONENTS = { + UserMessage: HappyUserMessage, + AssistantMessage: HappyAssistantMessage, + SystemMessage: HappySystemMessage +} as const + +export function HappyThread(props: { + api: ApiClient + sessionId: string + metadata: SessionMetadataSummary | null + disabled: boolean + onRefresh: () => void + onRetryMessage?: (localId: string) => void + header?: ReactNode +}) { + return ( + + + +
+ {props.header} +
+ +
+
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx new file mode 100644 index 00000000..fbb54547 --- /dev/null +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import type { AgentState, PermissionMode } from '@/types/api' + +const PERMISSION_MODE_LABELS: Record = { + default: 'Default', + acceptEdits: 'Accept Edits', + plan: 'Plan Mode', + bypassPermissions: 'Bypass All' +} + +// Max context size for percentage calculation +const MAX_CONTEXT_SIZE = 190000 + +// Vibing messages for thinking state +const VIBING_MESSAGES = [ + "Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing", + "Calculating", "Cerebrating", "Channelling", "Churning", "Clauding", "Coalescing", + "Cogitating", "Computing", "Combobulating", "Concocting", "Conjuring", "Considering", + "Contemplating", "Cooking", "Crafting", "Creating", "Crunching", "Deciphering", + "Deliberating", "Determining", "Discombobulating", "Divining", "Doing", "Effecting", + "Elucidating", "Enchanting", "Envisioning", "Finagling", "Flibbertigibbeting", + "Forging", "Forming", "Frolicking", "Generating", "Germinating", "Hatching", + "Herding", "Honking", "Ideating", "Imagining", "Incubating", "Inferring", + "Manifesting", "Marinating", "Meandering", "Moseying", "Mulling", "Mustering", + "Musing", "Noodling", "Percolating", "Perusing", "Philosophising", "Pontificating", + "Pondering", "Processing", "Puttering", "Puzzling", "Reticulating", "Ruminating", + "Scheming", "Schlepping", "Shimmying", "Simmering", "Smooshing", "Spelunking", + "Spinning", "Stewing", "Sussing", "Synthesizing", "Thinking", "Tinkering", + "Transmuting", "Unfurling", "Unravelling", "Vibing", "Wandering", "Whirring", + "Wibbling", "Wizarding", "Working", "Wrangling" +] + +function getConnectionStatus( + active: boolean, + thinking: boolean, + agentState: AgentState | null | undefined +): { text: string; color: string; dotColor: string; isPulsing: boolean } { + const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0 + + if (!active) { + return { + text: 'offline', + color: 'text-[#999]', + dotColor: 'bg-[#999]', + isPulsing: false + } + } + + if (hasPermissions) { + return { + text: 'permission required', + color: 'text-[#FF9500]', + dotColor: 'bg-[#FF9500]', + isPulsing: true + } + } + + if (thinking) { + const vibingMessage = VIBING_MESSAGES[Math.floor(Math.random() * VIBING_MESSAGES.length)].toLowerCase() + '…' + return { + text: vibingMessage, + color: 'text-[#007AFF]', + dotColor: 'bg-[#007AFF]', + isPulsing: true + } + } + + return { + text: 'online', + color: 'text-[#34C759]', + dotColor: 'bg-[#34C759]', + isPulsing: false + } +} + +function getContextWarning(contextSize: number): { text: string; color: string } | null { + const percentageUsed = (contextSize / MAX_CONTEXT_SIZE) * 100 + const percentageRemaining = 100 - percentageUsed + + if (percentageRemaining <= 5) { + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-red-500' } + } else if (percentageRemaining <= 10) { + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-amber-500' } + } else { + return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-[var(--app-hint)]' } + } +} + +export function StatusBar(props: { + active: boolean + thinking: boolean + agentState: AgentState | null | undefined + contextSize?: number + permissionMode?: PermissionMode +}) { + const connectionStatus = useMemo( + () => getConnectionStatus(props.active, props.thinking, props.agentState), + [props.active, props.thinking, props.agentState] + ) + + const contextWarning = useMemo( + () => props.contextSize !== undefined ? getContextWarning(props.contextSize) : null, + [props.contextSize] + ) + + const permissionMode = props.permissionMode + + return ( +
+
+
+ + + {connectionStatus.text} + +
+ {contextWarning ? ( + + {contextWarning.text} + + ) : null} +
+ + {(permissionMode && permissionMode !== 'default') ? ( + + {PERMISSION_MODE_LABELS[permissionMode]} + + ) : null} +
+ ) +} + diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx new file mode 100644 index 00000000..e6d2b78c --- /dev/null +++ b/web/src/components/AssistantChat/context.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' +import type { ApiClient } from '@/api/client' +import type { SessionMetadataSummary } from '@/types/api' + +export type HappyChatContextValue = { + api: ApiClient + sessionId: string + metadata: SessionMetadataSummary | null + disabled: boolean + onRefresh: () => void + onRetryMessage?: (localId: string) => void +} + +const HappyChatContext = createContext(null) + +export function HappyChatProvider(props: { value: HappyChatContextValue; children: ReactNode }) { + return ( + + {props.children} + + ) +} + +export function useHappyChatContext(): HappyChatContextValue { + const ctx = useContext(HappyChatContext) + if (!ctx) { + throw new Error('HappyChatContext is missing') + } + return ctx +} diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx new file mode 100644 index 00000000..028f10c4 --- /dev/null +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -0,0 +1,55 @@ +import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' +import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown' +import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' +import remarkGfm from 'remark-gfm' +import { CodeBlock } from '@/components/CodeBlock' +import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' + +function MarkdownSyntaxHighlighter(props: SyntaxHighlighterProps) { + return ( + + ) +} + +const MARKDOWN_PLUGINS = [remarkGfm] +const MARKDOWN_COMPONENTS = { + SyntaxHighlighter: MarkdownSyntaxHighlighter +} as const + +function MarkdownText() { + return ( + + ) +} + +const TOOL_COMPONENTS = { + Fallback: HappyToolMessage +} as const + +const MESSAGE_PART_COMPONENTS = { + Text: MarkdownText, + tools: TOOL_COMPONENTS +} as const + +export function HappyAssistantMessage() { + const toolOnly = useAssistantState(({ message }) => { + if (message.role !== 'assistant') return false + const parts = message.content + return parts.length > 0 && parts.every((part) => part.type === 'tool-call') + }) + const rootClass = toolOnly ? 'py-1' : 'px-1' + + return ( + + + + ) +} diff --git a/web/src/components/AssistantChat/messages/SystemMessage.tsx b/web/src/components/AssistantChat/messages/SystemMessage.tsx new file mode 100644 index 00000000..18164dcb --- /dev/null +++ b/web/src/components/AssistantChat/messages/SystemMessage.tsx @@ -0,0 +1,17 @@ +import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' + +export function HappySystemMessage() { + const message = useAssistantState(({ message }) => message) + if (message.role !== 'system') return null + + const text = message.content[0]?.type === 'text' ? message.content[0].text : '' + + return ( + +
+ {text} +
+
+ ) +} + diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx new file mode 100644 index 00000000..f004fae0 --- /dev/null +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -0,0 +1,225 @@ +import type { ToolCallMessagePartProps } from '@assistant-ui/react' +import type { ChatBlock } from '@/chat/types' +import type { AgentEvent, ToolCallBlock } from '@/chat/types' +import type { MessageStatus } from '@/types/api' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { LazyRainbowText } from '@/components/LazyRainbowText' +import { ToolCard } from '@/components/ToolCard/ToolCard' +import { useHappyChatContext } from '@/components/AssistantChat/context' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function isToolCallBlock(value: unknown): value is ToolCallBlock { + if (!isObject(value)) return false + if (value.kind !== 'tool-call') return false + if (typeof value.id !== 'string') return false + if (!isObject(value.tool)) return false + return true +} + +function ErrorIcon() { + return ( + + + + + + ) +} + +function MessageStatusIndicator(props: { + status?: MessageStatus + onRetry?: () => void +}) { + if (props.status !== 'failed') { + return null + } + + return ( + + + + + {props.onRetry ? ( + + ) : null} + + ) +} + +function formatUnixTimestamp(value: number): string { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString() +} + +function renderEventLabel(event: AgentEvent): string { + if (event.type === 'switch') { + const mode = event.mode === 'local' ? 'local' : 'remote' + return `🔄 Switched to ${mode}` + } + if (event.type === 'title-changed') { + const title = typeof event.title === 'string' ? event.title : '' + return title ? `Title changed to "${title}"` : 'Title changed' + } + if (event.type === 'permission-mode-changed') { + const modeValue = (event as Record).mode + const mode = typeof modeValue === 'string' ? modeValue : 'default' + return `🔐 Permission mode: ${mode}` + } + if (event.type === 'limit-reached') { + const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null + return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' + } + if (event.type === 'message') { + return typeof event.message === 'string' ? event.message : 'Message' + } + try { + return JSON.stringify(event) + } catch { + return String(event.type) + } +} + +function HappyNestedBlockList(props: { + blocks: ChatBlock[] +}) { + const ctx = useHappyChatContext() + + return ( +
+ {props.blocks.map((block) => { + if (block.kind === 'user-text') { + const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm' + const status = block.status + const canRetry = status === 'failed' && typeof block.localId === 'string' && Boolean(ctx.onRetryMessage) + const onRetry = canRetry ? () => ctx.onRetryMessage!(block.localId!) : undefined + + return ( +
+
+
+ +
+ {status ? ( +
+ +
+ ) : null} +
+
+ ) + } + + if (block.kind === 'agent-text') { + return ( +
+ +
+ ) + } + + if (block.kind === 'agent-event') { + return ( +
+
+ {renderEventLabel(block.event)} +
+
+ ) + } + + if (block.kind === 'tool-call') { + const isTask = block.tool.name === 'Task' + + return ( +
+ + {block.children.length > 0 ? ( + isTask ? ( +
+ + Task details ({block.children.length}) + +
+ +
+
+ ) : ( +
+ +
+ ) + ) : null} +
+ ) + } + + return null + })} +
+ ) +} + +export function HappyToolMessage(props: ToolCallMessagePartProps) { + const ctx = useHappyChatContext() + const artifact = props.artifact + + if (!isToolCallBlock(artifact)) { + return ( +
+
+ Tool call: {props.toolName} +
+
+ ) + } + + const block = artifact + const isTask = block.tool.name === 'Task' + + return ( +
+ + {block.children.length > 0 ? ( + isTask ? ( +
+ + Task details ({block.children.length}) + +
+ +
+
+ ) : ( +
+ +
+ ) + ) : null} +
+ ) +} diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx new file mode 100644 index 00000000..d3e8d284 --- /dev/null +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -0,0 +1,73 @@ +import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' +import type { MessageStatus } from '@/types/api' +import { LazyRainbowText } from '@/components/LazyRainbowText' +import { useHappyChatContext } from '@/components/AssistantChat/context' +import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' + +function ErrorIcon() { + return ( + + + + + + ) +} + +function MessageStatusIndicator(props: { + status?: MessageStatus + onRetry?: () => void +}) { + if (props.status !== 'failed') { + return null + } + + return ( + + + + + {props.onRetry ? ( + + ) : null} + + ) +} + +export function HappyUserMessage() { + const ctx = useHappyChatContext() + const message = useAssistantState(({ message }) => message) + + if (message.role !== 'user') return null + + const text = message.content.find((part) => part.type === 'text')?.text ?? '' + const custom = message.metadata.custom as Partial | undefined + const status = custom?.status + const localId = custom?.localId + const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) + const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined + + const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm' + + return ( + +
+
+ +
+ {status ? ( +
+ +
+ ) : null} +
+
+ ) +} + diff --git a/web/src/components/ChatInput/Autocomplete.tsx b/web/src/components/ChatInput/Autocomplete.tsx index 8448824f..eb226464 100644 --- a/web/src/components/ChatInput/Autocomplete.tsx +++ b/web/src/components/ChatInput/Autocomplete.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import type { Suggestion } from '@/hooks/useActiveSuggestions' interface AutocompleteProps { - suggestions: Suggestion[] + suggestions: readonly Suggestion[] selectedIndex: number onSelect: (index: number) => void } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 05a77981..19d69866 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,15 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' import type { NormalizedMessage } from '@/chat/types' import { normalizeDecryptedMessage } from '@/chat/normalize' import { reduceChatBlocks } from '@/chat/reducer' import { Button } from '@/components/ui/button' +import { HappyComposer } from '@/components/AssistantChat/HappyComposer' +import { HappyThread } from '@/components/AssistantChat/HappyThread' +import { useHappyRuntime } from '@/lib/assistant-runtime' import { SessionHeader } from '@/components/SessionHeader' import { MessageBubble } from '@/components/MessageBubble' -import { ChatBlockList } from '@/components/ChatBlockList' -import { ChatInput } from '@/components/ChatInput' -import { useScrollToBottom } from '@/hooks/useScrollToBottom' import { getTelegramWebApp } from '@/hooks/useTelegram' export function SessionChat(props: { @@ -61,7 +62,6 @@ export function SessionChat(props: { const [debugViewMode, setDebugViewMode] = useState<'reduced' | 'raw'>('reduced') const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced' - const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode]) // Permission mode change handler const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { @@ -93,6 +93,98 @@ export function SessionChat(props: { props.onRefresh() }, [props.api, props.session.id, props.onRefresh]) + const runtime = useHappyRuntime({ + session: props.session, + blocks: viewMode === 'raw' ? [] : reduced.blocks, + isSending: props.isSending, + onSendMessage: props.onSend, + onAbort: handleAbort + }) + + const threadHeader = useMemo(() => { + if (props.isLoadingMessages) { + return ( +
+ Loading… +
+ ) + } + + return ( + <> + {props.messagesWarning ? ( +
+ {props.messagesWarning} +
+ ) : null} + + {import.meta.env.DEV ? ( +
+ + +
+ ) : null} + + {props.hasMoreMessages ? ( +
+ +
+ ) : null} + + {import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? ( +
+ Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`). +
+ ) : null} + + {viewMode === 'raw' ? ( +
+ {props.messages.map((m) => ( + props.onRetryMessage!(m.localId!) + : undefined + } + /> + ))} +
+ ) : null} + + ) + }, [ + props.isLoadingMessages, + props.messagesWarning, + props.hasMoreMessages, + props.isLoadingMoreMessages, + props.onLoadMore, + props.messages, + props.messages.length, + props.onRetryMessage, + viewMode, + normalizedMessages.length + ]) + return (
) : null} -
-
- {props.messagesWarning ? ( -
- {props.messagesWarning} -
- ) : null} - - {import.meta.env.DEV ? ( -
- - -
- ) : null} - - {props.hasMoreMessages ? ( -
- -
- ) : null} - - {props.isLoadingMessages ? ( -
Loading…
- ) : ( - <> - {import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? ( -
- Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`). -
- ) : null} - - {viewMode === 'raw' ? ( -
- {props.messages.map((m) => ( - props.onRetryMessage!(m.localId!) - : undefined - } - /> - ))} -
- ) : ( - - )} - - )} -
- -
- +
+ + +
-
+
) } diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts new file mode 100644 index 00000000..9af2ab2c --- /dev/null +++ b/web/src/lib/assistant-runtime.ts @@ -0,0 +1,169 @@ +import { useMemo } from 'react' +import type { AppendMessage, ExternalStoreAdapter, ThreadMessageLike } from '@assistant-ui/react' +import { useExternalStoreRuntime } from '@assistant-ui/react' +import type { ChatBlock } from '@/chat/types' +import type { AgentEvent, ToolCallBlock } from '@/chat/types' +import type { MessageStatus as HappyMessageStatus, Session } from '@/types/api' + +function safeStringify(value: unknown): string { + if (typeof value === 'string') return value + try { + const stringified = JSON.stringify(value, null, 2) + return typeof stringified === 'string' ? stringified : String(value) + } catch { + return String(value) + } +} + +function formatUnixTimestamp(value: number): string { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString() +} + +function renderEventLabel(event: AgentEvent): string { + if (event.type === 'switch') { + const mode = event.mode === 'local' ? 'local' : 'remote' + return `🔄 Switched to ${mode}` + } + if (event.type === 'title-changed') { + const title = typeof event.title === 'string' ? event.title : '' + return title ? `Title changed to "${title}"` : 'Title changed' + } + if (event.type === 'permission-mode-changed') { + const modeValue = (event as Record).mode + const mode = typeof modeValue === 'string' ? modeValue : 'default' + return `🔐 Permission mode: ${mode}` + } + if (event.type === 'limit-reached') { + const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null + return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' + } + if (event.type === 'message') { + return typeof event.message === 'string' ? event.message : 'Message' + } + try { + return JSON.stringify(event) + } catch { + return String(event.type) + } +} + +export type HappyChatMessageMetadata = { + kind: 'user' | 'assistant' | 'tool' | 'event' + status?: HappyMessageStatus + localId?: string | null + originalText?: string + toolCallId?: string + event?: AgentEvent +} + +function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { + if (block.kind === 'user-text') { + const messageId = `user:${block.id}` + return { + role: 'user', + id: messageId, + createdAt: new Date(block.createdAt), + content: [{ type: 'text', text: block.text }], + metadata: { + custom: { + kind: 'user', + status: block.status, + localId: block.localId, + originalText: block.originalText + } satisfies HappyChatMessageMetadata + } + } + } + + if (block.kind === 'agent-text') { + const messageId = `assistant:${block.id}` + return { + role: 'assistant', + id: messageId, + createdAt: new Date(block.createdAt), + content: [{ type: 'text', text: block.text }], + metadata: { + custom: { kind: 'assistant' } satisfies HappyChatMessageMetadata + } + } + } + + if (block.kind === 'agent-event') { + const messageId = `event:${block.id}` + return { + role: 'system', + id: messageId, + createdAt: new Date(block.createdAt), + content: renderEventLabel(block.event), + metadata: { + custom: { kind: 'event', event: block.event } satisfies HappyChatMessageMetadata + } + } + } + + const toolBlock: ToolCallBlock = block + const messageId = `tool:${toolBlock.id}` + const inputText = safeStringify(toolBlock.tool.input) + + return { + role: 'assistant', + id: messageId, + createdAt: new Date(toolBlock.createdAt), + content: [{ + type: 'tool-call', + toolCallId: toolBlock.id, + toolName: toolBlock.tool.name, + argsText: inputText, + result: toolBlock.tool.result, + isError: toolBlock.tool.state === 'error', + artifact: toolBlock + }], + metadata: { + custom: { kind: 'tool', toolCallId: toolBlock.id } satisfies HappyChatMessageMetadata + } + } +} + +function getTextFromAppendMessage(message: AppendMessage): string | null { + if (message.role !== 'user') return null + + const parts = message.content + const text = parts + .filter((part): part is { type: 'text'; text: string } => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n') + .trim() + + return text.length > 0 ? text : null +} + +export function useHappyRuntime(props: { + session: Session + blocks: readonly ChatBlock[] + isSending: boolean + onSendMessage: (text: string) => void + onAbort: () => Promise +}) { + const adapter = useMemo(() => { + return { + isDisabled: !props.session.active || props.isSending, + isRunning: props.session.thinking, + messages: props.blocks, + onNew: async (message: AppendMessage) => { + const text = getTextFromAppendMessage(message) + if (!text) return + props.onSendMessage(text) + }, + onCancel: async () => { + await props.onAbort() + }, + convertMessage: (block: ChatBlock) => toThreadMessageLike(block), + unstable_capabilities: { copy: true } + } satisfies ExternalStoreAdapter + }, [props.session.active, props.session.thinking, props.isSending, props.blocks, props.onSendMessage, props.onAbort]) + + return useExternalStoreRuntime(adapter) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 4e05f2ad..208d3e5d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -8,4 +8,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render( ) -