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 (
+
+
+
+
+
+
+
+ )
+}
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(
)
-