From 57679a606941daceffb15cbcaccf89446bb1eba2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 21:46:50 +0000 Subject: [PATCH 1/7] fix(client): use jsx primitive in remix JSX compat to preserve children createElement(type, props) merges variadic rest children onto props; with no rest args that becomes [] and wipes props.children, producing a blank UI. Co-authored-by: Kent C. Dodds --- client/remix-component-compat/runtime.ts | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 client/remix-component-compat/runtime.ts diff --git a/client/remix-component-compat/runtime.ts b/client/remix-component-compat/runtime.ts new file mode 100644 index 0000000..e97fe94 --- /dev/null +++ b/client/remix-component-compat/runtime.ts @@ -0,0 +1,58 @@ +import { css, on } from 'remix/component' +import { jsx as remixJsx } from 'remix/component/jsx-runtime' + +type CompatEventMap = Record< + string, + (event: Event, signal: AbortSignal) => void | Promise +> + +type CSSProps = Record + +type CompatProps = Record & { + css?: CSSProps + mix?: unknown + on?: CompatEventMap +} + +function normalizeMix(value: unknown) { + if (value == null) return [] + return Array.isArray(value) ? [...value] : [value] +} + +function normalizeProps(props: unknown) { + if (!props || typeof props !== 'object') { + return props as Record | undefined + } + + const input = props as CompatProps + const { css: cssProp, mix: mixProp, on: onProp, ...rest } = input + const mix = normalizeMix(mixProp) + + if (cssProp) { + mix.push(css(cssProp as never)) + } + + if (onProp) { + for (const [eventName, listener] of Object.entries(onProp)) { + mix.push(on(eventName as never, listener as never)) + } + } + + if (mix.length > 0) { + return { + ...rest, + mix, + } + } + + return rest +} + +export function jsx(type: string | Function, props: unknown, key?: string) { + const normalizedProps = normalizeProps(props) ?? {} + // Not createElement: its `...children` rest overwrites `props.children` when only (type, props) is passed. + return remixJsx(type as never, normalizedProps as never, key as never) +} + +export const jsxs = jsx +export const jsxDEV = jsx From 63e1a1a3c14507ccb695a2098a96dec709dfbdf8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Mar 2026 22:09:14 +0000 Subject: [PATCH 2/7] feat(remix): upgrade to alpha.4 runtime APIs Co-authored-by: Kent C. Dodds --- bun.lock | 52 +- client/app.tsx | 345 ++- client/client-router.tsx | 4 +- client/counter.tsx | 77 +- client/double-check.ts | 58 +- client/editable-text.tsx | 309 ++- client/remix-component-compat/runtime.ts | 58 - client/routes/account.tsx | 147 +- client/routes/chat.tsx | 2956 ++++++++++------------ client/routes/home.tsx | 119 +- client/routes/login.tsx | 634 +++-- client/routes/oauth-authorize.tsx | 736 +++--- client/routes/oauth-callback.tsx | 126 +- client/routes/reset-password.tsx | 456 ++-- mock-servers/ai/db-tables.ts | 21 +- mock-servers/resend/db-tables.ts | 21 +- package.json | 2 +- server/handlers/account.ts | 2 +- server/handlers/auth-page.ts | 2 +- server/handlers/auth.ts | 2 +- server/handlers/chat-threads.ts | 6 +- server/handlers/chat.ts | 2 +- server/handlers/health.ts | 2 +- server/handlers/home.ts | 2 +- server/handlers/logout.ts | 2 +- server/handlers/password-reset.ts | 4 +- server/handlers/session.ts | 2 +- server/router.ts | 43 +- types/tsconfig-client.json | 8 +- worker/d1-data-table-adapter.ts | 724 +----- worker/d1-sqlite-compiler.ts | 938 +++++++ worker/db.ts | 47 +- 32 files changed, 3916 insertions(+), 3991 deletions(-) delete mode 100644 client/remix-component-compat/runtime.ts create mode 100644 worker/d1-sqlite-compiler.ts diff --git a/bun.lock b/bun.lock index df77071..23adbc4 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "@modelcontextprotocol/sdk": "1.26.0", "agents": "^0.7.6", "get-port": "^7.1.0", - "remix": "3.0.0-alpha.3", + "remix": "3.0.0-alpha.4", "workers-ai-provider": "^3.1.2", "zod": "^4.3.6", }, @@ -306,35 +306,45 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@remix-run/async-context-middleware": ["@remix-run/async-context-middleware@0.1.3", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0" } }, "sha512-z2hTnQspCWsKka1QxAu5qf+IRbMYwTBmwL28zCk8nIPcYxG19rDbBeRmfQZ9hxHes72XVDu3Fd3ArLTy8bivdw=="], + "@remix-run/async-context-middleware": ["@remix-run/async-context-middleware@0.2.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0" } }, "sha512-ebnPM2OFM/BO3GSoRKs2i5+lgY/fIunzpL8/RhuEuP9a04cQYXMbEzbdae+qBHTgtsVdPrqeGr/lwlF5FNnQZA=="], - "@remix-run/component": ["@remix-run/component@0.5.0", "", { "dependencies": { "@remix-run/interaction": "^0.5.0" } }, "sha512-xRLOcgwWKZxFdj63bWi3/snC2uxkm978B49EGEv1/G43iBFksYjS4ADanfYxREvQjMlaCqSUH0ZTZsJbGsz3PQ=="], + "@remix-run/auth": ["@remix-run/auth@0.1.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/session": "^0.4.1" } }, "sha512-TFsK40jymT7iZyGWOjoqlFFmILdDXy3jYBoI1mohjdqrp8SVWbm7htiSbdd+j8DGBUEbxMTvKcaVG0bPA1M8VA=="], - "@remix-run/compression-middleware": ["@remix-run/compression-middleware@0.1.3", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0", "@remix-run/mime": "^0.4.0", "@remix-run/response": "^0.3.2" } }, "sha512-pKsKtIzW/yjCbftSQRYfvnKKdq65Nu+YEhbQooiXtmd1Ub4inHSrzeA/vca3sQkBnBr/eRA9uujxIszRe+6LAg=="], + "@remix-run/auth-middleware": ["@remix-run/auth-middleware@0.1.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/session": "^0.4.1" } }, "sha512-QZlI4MyU8VTVI6WB4T4MWWqtbYrIUiiBG3mgdJUXjEYMBBoxVAkT/6rT3YErmhX+SVARP2zC46qzujrbYcQLCQ=="], + + "@remix-run/component": ["@remix-run/component@0.6.0", "", { "dependencies": { "@types/dom-navigation": "^1.0.7" } }, "sha512-hmIUdPnBtONeOH8bbLuOVGSgFD/MAUNvpH0Xwgo27GByBIjQhOm25Tt9R04HQslDhHAzwNVnWeQkr3j9eyXYhQ=="], + + "@remix-run/compression-middleware": ["@remix-run/compression-middleware@0.1.4", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/mime": "^0.4.0", "@remix-run/response": "^0.3.2" } }, "sha512-vE+d4a04Tq9fO5KBmNtMD5GKJEt6U++B1yufEdBMNe5ROBQkpPWSuKlhkRcZyoV8SPEI2HbkKMtQ8QXfGJR11g=="], "@remix-run/cookie": ["@remix-run/cookie@0.5.1", "", { "dependencies": { "@remix-run/headers": "^0.19.0" } }, "sha512-gbeZfVd1AKRlFj3IJWcIcR6zqVGz2XGJhR+mcqYiWnYt6KM8oUGtc82dsc4qZnWWA1f0nM4/He9wrU4GjB0pag=="], - "@remix-run/data-schema": ["@remix-run/data-schema@0.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" } }, "sha512-PzpYP9P19cb8bS7Y9+MSxyAWGy0n13sx1lYOMoKI+iEx7pdB2ZLaiidwcn1l6AeR6gjVIpuzyM6/UONG3bzboA=="], + "@remix-run/cop-middleware": ["@remix-run/cop-middleware@0.1.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0" } }, "sha512-5rMNrBswTJJLPsu1nUfXubgVf7XBTRfPNfd/4QQHaXRdhXVWVxU9HbNJrOjd7iNbBWuZeaJxSdDepbQLnuBkTg=="], + + "@remix-run/cors-middleware": ["@remix-run/cors-middleware@0.1.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/headers": "^0.19.0" } }, "sha512-x5nyxDhWEOnznqetDhtj5WYoHrKpbiZ8K6BCGQ2+DFjLYHoyWCayciUNrqOVSIWc5XdV6KlKHcg1TyQvW/cS4w=="], + + "@remix-run/csrf-middleware": ["@remix-run/csrf-middleware@0.1.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/session": "^0.4.1" } }, "sha512-EZAV9UZrx3qdYu3ov9NgFVJchw6kOLYI2uRB+e3WBVW1phruHKc42EpdJIZ6fU0J9Of0Lfo4X4VTM7O1/u1cWw=="], - "@remix-run/data-table": ["@remix-run/data-table@0.1.0", "", { "dependencies": { "@remix-run/data-schema": "^0.1.0" } }, "sha512-u70INxiF9pThV2LcP7y37G7eq16OXWrA+HXS8qNplQgJJd1zhwZrODOtfF9U+PKLiWQkBj+bEeh7QhOlvGsVDg=="], + "@remix-run/data-schema": ["@remix-run/data-schema@0.2.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" } }, "sha512-bXgdWcWZS6TkbVO9Piwn035iw0LczfSTFrCW4Vd1k57x/4j3eWZwfHBr3lXdw5whefacs3m3pVZ8RUiu8NtBeA=="], - "@remix-run/data-table-mysql": ["@remix-run/data-table-mysql@0.1.0", "", { "dependencies": { "@remix-run/data-table": "^0.1.0" }, "peerDependencies": { "mysql2": "^3.15.3" }, "optionalPeers": ["mysql2"] }, "sha512-lzghxTYZDHODNisIJWkq4IkcGsb1pUrp9WGtlNEVWrXWC6aArOiojR3PW4arFwNG5ddKJPRfwot+ySc2YZy5RQ=="], + "@remix-run/data-table": ["@remix-run/data-table@0.2.0", "", {}, "sha512-6QbdlKER0F0rX3XkTHC7ZPBgu/dyGvvgrKRbraBD9iGHTE7AED1emo3/YWsDJUeFzy7INNJaUIpNm9jNcgL8Kg=="], - "@remix-run/data-table-postgres": ["@remix-run/data-table-postgres@0.1.0", "", { "dependencies": { "@remix-run/data-table": "^0.1.0" }, "peerDependencies": { "pg": "^8.16.3" }, "optionalPeers": ["pg"] }, "sha512-mqzARY5tOFVLjFAArryuLQ93M8IdjVnLKnD1VQyJHFbHQF7Zbr8+exv7Hp2hZ1TIGAhZiepGjjC8Re631yJ1Jw=="], + "@remix-run/data-table-mysql": ["@remix-run/data-table-mysql@0.2.0", "", { "dependencies": { "@remix-run/data-table": "^0.2.0" }, "peerDependencies": { "mysql2": "^3.15.3" }, "optionalPeers": ["mysql2"] }, "sha512-voTcYUjG12PWlrWIYSqoa+C3WK9Q6TVKPrqnvGKY9s6EVBil9e+glaP2JOM8BbzL0hGuKPifdbrDGfGpGitdQw=="], - "@remix-run/data-table-sqlite": ["@remix-run/data-table-sqlite@0.1.0", "", { "dependencies": { "@remix-run/data-table": "^0.1.0" }, "peerDependencies": { "better-sqlite3": "^12.4.1" }, "optionalPeers": ["better-sqlite3"] }, "sha512-hFtmz9haMr3p/aFWL5D1zpJsgAnNdLVCF6HvXBmtK4m3NdLmM1eNhblpB/SfPGNEODsmx4Jvcaof57dfaLuKgA=="], + "@remix-run/data-table-postgres": ["@remix-run/data-table-postgres@0.2.0", "", { "dependencies": { "@remix-run/data-table": "^0.2.0" }, "peerDependencies": { "pg": "^8.16.3" }, "optionalPeers": ["pg"] }, "sha512-03v+zpWL4lMJr34PZ76PWFpjIa4K6Uq3NLBqyvN4ojPpm6J7TGsFwPy6sInRm3HjoS4gPSCEmmQ7dUovWa58EA=="], + + "@remix-run/data-table-sqlite": ["@remix-run/data-table-sqlite@0.2.0", "", { "dependencies": { "@remix-run/data-table": "^0.2.0" }, "peerDependencies": { "better-sqlite3": "^12.4.1" }, "optionalPeers": ["better-sqlite3"] }, "sha512-TAQ0u8YnmO3WdnuZULPhc+fgZbKZZPutXiPLf8c9NDhXSC41FlcvfQHTKUxBPVhqdi9mhGiblHHNp6BsVu11dg=="], "@remix-run/fetch-proxy": ["@remix-run/fetch-proxy@0.7.1", "", { "dependencies": { "@remix-run/headers": "^0.19.0" } }, "sha512-rPLfOpAaCXtm1dLI45uIPKERNbXbrh0P9AJc1sliz8pWd/McaFYjdr5KzB4QrFSfPvEt/Wmy6F2521qB1kK0ug=="], - "@remix-run/fetch-router": ["@remix-run/fetch-router@0.17.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.19.0", "@remix-run/session": "^0.4.1" } }, "sha512-3FeJGrTqrKKCvZdQWijbCXTEHKcdttkLFbI2ogfpZ+iDYSNZ9036wgDXuuoZqg6d+D0E8Unhk5ZwrLKDCd/hOw=="], + "@remix-run/fetch-router": ["@remix-run/fetch-router@0.18.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.20.0" } }, "sha512-9Z4JgLH9/jD8jiVvAY9LZR+VoZxPJOQ7pENTBJoSo91PZOkPXfCxQWhPhAwYCP8z+/0FV4ZWSg1DmPPoU2f0UQ=="], "@remix-run/file-storage": ["@remix-run/file-storage@0.13.3", "", { "dependencies": { "@remix-run/fs": "^0.4.2", "@remix-run/lazy-file": "^5.0.2" } }, "sha512-HBDz9RRsFRvI6EoeasklxH/NleGy0QZBXBcA4gQBW8ueucop21TQI4wvGlhZmXcnJ3nP4RkhdF2Gff2/HD5eiA=="], "@remix-run/file-storage-s3": ["@remix-run/file-storage-s3@0.1.0", "", { "dependencies": { "@remix-run/file-storage": "^0.13.3", "aws4fetch": "^1.0.20" } }, "sha512-r80An7nSFidK/0xn9O9/HxfUcgxVpM4kprnTGr6pGhKdgbaTCEtA+U5ETYGfeedFxhDcT+7ue+4Fv/VxeIvFwQ=="], - "@remix-run/form-data-middleware": ["@remix-run/form-data-middleware@0.1.4", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0", "@remix-run/form-data-parser": "^0.15.0" } }, "sha512-WZfP1U6lDoipkfjcd0V39HJeTPMTX2WyaPcOBTbBHS0kapIZiHYm6RpGLhE8U58652i3TBh/zzvAczJIbFV2AA=="], + "@remix-run/form-data-middleware": ["@remix-run/form-data-middleware@0.2.0", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/form-data-parser": "^0.16.0" } }, "sha512-oaBcYvyP/U2GrDdFyQUh2pNCshvKwPFBoUup1Bz3NHnMzym4OkZJgaBi4o7HIJC33QudnqI7Mp7TW1pRg0CvIA=="], - "@remix-run/form-data-parser": ["@remix-run/form-data-parser@0.15.0", "", { "dependencies": { "@remix-run/multipart-parser": "^0.14.2" } }, "sha512-sQP4r9218TWmow6Nt252VjKE674dRi4Z8WTnWUxJJG8I/qNfnGZubZ8LgyE0dR9z1gfaEpkd19MfYMLiOTOkJQ=="], + "@remix-run/form-data-parser": ["@remix-run/form-data-parser@0.16.0", "", { "dependencies": { "@remix-run/multipart-parser": "^0.15.0" } }, "sha512-k4QCgCyPURpqe+9Rual1GBJ8Ab6ri82Clfh5ooxq03jKQ1TyQZT5xh+XP0R05+Bqg5bzAZl7r2BpmDEvfoRTWw=="], "@remix-run/fs": ["@remix-run/fs@0.4.2", "", { "dependencies": { "@remix-run/lazy-file": "^5.0.2", "@remix-run/mime": "^0.4.0" } }, "sha512-z3W2L+iUwgZ7i0S379SYQ8veOe2Weqs+JajmyTCqSVzbmMUniH3qQ6SAYr3FjbrKtLLWHN3SpK4XtFv57VzbLA=="], @@ -342,33 +352,31 @@ "@remix-run/html-template": ["@remix-run/html-template@0.3.0", "", {}, "sha512-aAMx68udtIk0fmCpCXHYscVeCDsRVEmEgh4XvtusPr3vkHu3jn4gx5oAxgsPXPdDmmD/d75SYyI0m/F+aLz5iQ=="], - "@remix-run/interaction": ["@remix-run/interaction@0.5.0", "", {}, "sha512-Z2ja9/7TfMHt/wzWq425GI2xj6QxW4E3OHZ8In81uytZKIuWaI6Pn3v8qyMrInwnBEaLcfcbeQVCiExgHU8D4A=="], - "@remix-run/lazy-file": ["@remix-run/lazy-file@5.0.2", "", { "dependencies": { "@remix-run/mime": "^0.4.0" } }, "sha512-52Bo5dTV+EDwrUMS3mjeR+Sly85aHeN3fnNTeaflqzlCMWJwr2pX+y6/3mTDtRdxmTWF1MGQAoeayzfPb4zZJg=="], - "@remix-run/logger-middleware": ["@remix-run/logger-middleware@0.1.3", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0" } }, "sha512-6gxz1XC2lJYQS3Oz1pZzxpuoLowwd2PSpimMaQnkk0fZ7hHYxx7uV+FSs2Z3fue6kYvZ+IxSUe8Wy52V2r4LxA=="], + "@remix-run/logger-middleware": ["@remix-run/logger-middleware@0.1.4", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0" } }, "sha512-gFDyYn8o5ddjQoEbrc8CK6PPq3lzrOX6BCqlvM+QVTMJ5/2aHgfMbQdXnWtbeiWJJrpODpABrSNfjLkHjaK4og=="], - "@remix-run/method-override-middleware": ["@remix-run/method-override-middleware@0.1.4", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0" } }, "sha512-gYFsdY0eIStTpsqGnF/22YracUmS8cZlef6KsBKOVf1nOI9wwwbRrj/DWLMQsWt22YSBMuPYZW5NLKEmXvJRZw=="], + "@remix-run/method-override-middleware": ["@remix-run/method-override-middleware@0.1.5", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0" } }, "sha512-HYQG6YR4l0rYMJ0MXth9SWfeSCwnqHIrWgMQpZHKS/MT7roX7EAfknrl9vzjQWsVfkwl6RrSD30SrLPONj3tsg=="], "@remix-run/mime": ["@remix-run/mime@0.4.0", "", {}, "sha512-O6TcTL6CtuX82Q8BHqAere5O+0hYcrzSgY9whsDOBuqbW753Rczprs2jYw3qCDSo0kLxykW4ys3qgZcdgZ+chw=="], - "@remix-run/multipart-parser": ["@remix-run/multipart-parser@0.14.2", "", { "dependencies": { "@remix-run/headers": "^0.19.0" } }, "sha512-yDq9ql4Xz92bRG/Sgl4cg2dRlxxC6A40XBy/oyDhy76hJtTQvgyzx9sfPXYPxcfL1BtqljC+sYHE0PvjmQhSfw=="], + "@remix-run/multipart-parser": ["@remix-run/multipart-parser@0.15.0", "", { "dependencies": { "@remix-run/headers": "^0.19.0" } }, "sha512-/Ugo6k2bN7gh7Ybyhe7R/NrkD075fHrEfVf17P+NNC9rlWHBQCOSyJ4V8n4wtoG8umeImagU/AuHuUuTwzvHww=="], "@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.13.0", "", {}, "sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA=="], "@remix-run/response": ["@remix-run/response@0.3.2", "", { "dependencies": { "@remix-run/headers": "^0.19.0", "@remix-run/html-template": "^0.3.0", "@remix-run/mime": "^0.4.0" } }, "sha512-GkFqVq5E7Do6rMKTBjgoNyJlrsLrqYg+TDlCDrXoZ3v8O2RlSI14+bCF8lGQHy15DWX2pizVj6R0e6NmjcuLuA=="], - "@remix-run/route-pattern": ["@remix-run/route-pattern@0.19.0", "", {}, "sha512-RXKaIJ2Lx01uyZc0iw+yLzowFCa1/NuB8jN7QTo4QUe2CaUGtvPGdhgrTUp75lyNNCSJIrM9SaAJ6c1pjZdmoA=="], + "@remix-run/route-pattern": ["@remix-run/route-pattern@0.20.0", "", {}, "sha512-TEdJ5eFn40St26oyaRYGI1FWeXDvlEzkbvollM8Xit0qIuDFyFG03Okvvfc1s8KgR9sYULDPjxJIzk6xvIRR9A=="], "@remix-run/session": ["@remix-run/session@0.4.1", "", {}, "sha512-Bm6aKYgutb/raHZ3laloz8g/Qu7f3CeK3o4gUVDMxtEiAdWCzJamwHoTpGOc5+g1Kuy7z85v4M6nGrF06MFDSg=="], - "@remix-run/session-middleware": ["@remix-run/session-middleware@0.1.4", "", { "dependencies": { "@remix-run/cookie": "^0.5.1", "@remix-run/fetch-router": "^0.17.0", "@remix-run/session": "^0.4.1" } }, "sha512-qqLmf7mG88h+Ge8pWiJMO8+t9nfQMuO/Zx2W68IwB7Cpt+b6PDpB++i3dd/KLlsjJ43XPMoT2ydmo+eQMgBX3g=="], + "@remix-run/session-middleware": ["@remix-run/session-middleware@0.2.0", "", { "dependencies": { "@remix-run/cookie": "^0.5.1", "@remix-run/fetch-router": "^0.18.0", "@remix-run/session": "^0.4.1" } }, "sha512-kRAr0inELyXeOnUhlaM/eKIe9x1RZsOGbglb7Keb1+Lf6KxWxNMwv1ymmHBfae2ossjQTN/QUaABwQgX/uk3DQ=="], "@remix-run/session-storage-memcache": ["@remix-run/session-storage-memcache@0.1.0", "", { "dependencies": { "@remix-run/session": "^0.4.1" } }, "sha512-k853rpHncdTJUwdk0hqd+gZ2OONZLNdOUJBKdJB+MehxrVv1TtacDnA+Xs3kh+IVwUrsTmBhED+GHSUocMATUg=="], "@remix-run/session-storage-redis": ["@remix-run/session-storage-redis@0.1.0", "", { "dependencies": { "@remix-run/session": "^0.4.1" }, "peerDependencies": { "redis": "^5.10.0" }, "optionalPeers": ["redis"] }, "sha512-MovUS1E98wDHP8zsESJGm3ySB7iiOhd+3usxyXXM2sbF9gIe6r1bdAXXirGIoC8AEq1v8IiFE5u5ipo7PX0UHQ=="], - "@remix-run/static-middleware": ["@remix-run/static-middleware@0.4.4", "", { "dependencies": { "@remix-run/fetch-router": "^0.17.0", "@remix-run/fs": "^0.4.2", "@remix-run/html-template": "^0.3.0", "@remix-run/mime": "^0.4.0", "@remix-run/response": "^0.3.2" } }, "sha512-aL5ngFG57uPXTEDaH0uP/cKDpYkLMTtmPjK+SR1ugS654ORk8WTD4Ajf56QekMykCvCnO6PkgFAruUyKkwDNMg=="], + "@remix-run/static-middleware": ["@remix-run/static-middleware@0.4.5", "", { "dependencies": { "@remix-run/fetch-router": "^0.18.0", "@remix-run/fs": "^0.4.2", "@remix-run/html-template": "^0.3.0", "@remix-run/mime": "^0.4.0", "@remix-run/response": "^0.3.2" } }, "sha512-jxEbrQMDWcUgmv/2NSv4pahvaW3I4jxqZUMDQ7/VfkcgeJKLFk15vgp4BJ5vLDKKMvwVcgM0Ccbw4Y3Ev2zemA=="], "@remix-run/tar-parser": ["@remix-run/tar-parser@0.7.0", "", {}, "sha512-PW8JxEUzaGcnqxC5hBI8L9lK/Qz3oad6IGKZ+NExI3L7urVJUux+yCBrsme79DMBgS6hL+lgd/5LPFA5fSwF9A=="], @@ -396,6 +404,8 @@ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/dom-navigation": ["@types/dom-navigation@1.0.7", "", {}, "sha512-Di4W+i2faYquHUnyWUg3bBQp5pTNvjDDA7mIYfD/1WlLgan6sKkeVjGbdL78K0CuNEk5Pfc/c0rfelwkz10mnQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -952,7 +962,7 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "remix": ["remix@3.0.0-alpha.3", "", { "dependencies": { "@remix-run/async-context-middleware": "^0.1.3", "@remix-run/component": "^0.5.0", "@remix-run/compression-middleware": "^0.1.3", "@remix-run/cookie": "^0.5.1", "@remix-run/data-schema": "^0.1.0", "@remix-run/data-table": "^0.1.0", "@remix-run/data-table-mysql": "^0.1.0", "@remix-run/data-table-postgres": "^0.1.0", "@remix-run/data-table-sqlite": "^0.1.0", "@remix-run/fetch-proxy": "^0.7.1", "@remix-run/fetch-router": "^0.17.0", "@remix-run/file-storage": "^0.13.3", "@remix-run/file-storage-s3": "^0.1.0", "@remix-run/form-data-middleware": "^0.1.4", "@remix-run/form-data-parser": "^0.15.0", "@remix-run/fs": "^0.4.2", "@remix-run/headers": "^0.19.0", "@remix-run/html-template": "^0.3.0", "@remix-run/interaction": "^0.5.0", "@remix-run/lazy-file": "^5.0.2", "@remix-run/logger-middleware": "^0.1.3", "@remix-run/method-override-middleware": "^0.1.4", "@remix-run/mime": "^0.4.0", "@remix-run/multipart-parser": "^0.14.2", "@remix-run/node-fetch-server": "^0.13.0", "@remix-run/response": "^0.3.2", "@remix-run/route-pattern": "^0.19.0", "@remix-run/session": "^0.4.1", "@remix-run/session-middleware": "^0.1.4", "@remix-run/session-storage-memcache": "^0.1.0", "@remix-run/session-storage-redis": "^0.1.0", "@remix-run/static-middleware": "^0.4.4", "@remix-run/tar-parser": "^0.7.0" } }, "sha512-RIctAYR7OW3oYzAGclLhgltrRtKviIdnCVwoLcPDicOjV4I2mJ9AEi8YXl2+hGPupzNNEUcrDtoICd7xNuMptg=="], + "remix": ["remix@3.0.0-alpha.4", "", { "dependencies": { "@remix-run/async-context-middleware": "^0.2.0", "@remix-run/auth": "^0.1.0", "@remix-run/auth-middleware": "^0.1.0", "@remix-run/component": "^0.6.0", "@remix-run/compression-middleware": "^0.1.4", "@remix-run/cookie": "^0.5.1", "@remix-run/cop-middleware": "^0.1.0", "@remix-run/cors-middleware": "^0.1.0", "@remix-run/csrf-middleware": "^0.1.0", "@remix-run/data-schema": "^0.2.0", "@remix-run/data-table": "^0.2.0", "@remix-run/data-table-mysql": "^0.2.0", "@remix-run/data-table-postgres": "^0.2.0", "@remix-run/data-table-sqlite": "^0.2.0", "@remix-run/fetch-proxy": "^0.7.1", "@remix-run/fetch-router": "^0.18.0", "@remix-run/file-storage": "^0.13.3", "@remix-run/file-storage-s3": "^0.1.0", "@remix-run/form-data-middleware": "^0.2.0", "@remix-run/form-data-parser": "^0.16.0", "@remix-run/fs": "^0.4.2", "@remix-run/headers": "^0.19.0", "@remix-run/html-template": "^0.3.0", "@remix-run/lazy-file": "^5.0.2", "@remix-run/logger-middleware": "^0.1.4", "@remix-run/method-override-middleware": "^0.1.5", "@remix-run/mime": "^0.4.0", "@remix-run/multipart-parser": "^0.15.0", "@remix-run/node-fetch-server": "^0.13.0", "@remix-run/response": "^0.3.2", "@remix-run/route-pattern": "^0.20.0", "@remix-run/session": "^0.4.1", "@remix-run/session-middleware": "^0.2.0", "@remix-run/session-storage-memcache": "^0.1.0", "@remix-run/session-storage-redis": "^0.1.0", "@remix-run/static-middleware": "^0.4.5", "@remix-run/tar-parser": "^0.7.0" } }, "sha512-fvYHPm8QbrbL09wmmddrnknyntB+fl4bLmpECpK32cOoJW3pMoMvESBB1qRAQe+yWgTexVjp5G3BGOmmtYOX0A=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], diff --git a/client/app.tsx b/client/app.tsx index cf4d925..525bcec 100644 --- a/client/app.tsx +++ b/client/app.tsx @@ -1,199 +1,184 @@ -import { type Handle } from 'remix/component' -import { clientRoutes } from './routes/index.tsx' -import { - getPathname, - listenToRouterNavigation, - Router, -} from './client-router.tsx' -import { - fetchSessionInfo, - type SessionInfo, - type SessionStatus, -} from './session.ts' -import { buildAuthLink } from './auth-links.ts' -import { colors, mq, spacing, typography } from './styles/tokens.ts' - +import { css, type Handle } from 'remix/component'; +import { clientRoutes } from './routes/index.tsx'; +import { getPathname, listenToRouterNavigation, Router, } from './client-router.tsx'; +import { fetchSessionInfo, type SessionInfo, type SessionStatus, } from './session.ts'; +import { buildAuthLink } from './auth-links.ts'; +import { colors, mq, spacing, typography } from './styles/tokens.ts'; export function App(handle: Handle) { - let session: SessionInfo | null = null - let sessionStatus: SessionStatus = 'idle' - let sessionRefreshInFlight = false - let sessionRefreshQueued = false - let currentPathname = getPathname() - - function queueSessionRefresh() { - sessionRefreshQueued = true - if (sessionRefreshInFlight) return - - // Preserve current nav state during refreshes after first load. - if (sessionStatus === 'idle') { - sessionStatus = 'loading' - handle.update() - } - - sessionRefreshQueued = false - sessionRefreshInFlight = true - handle.queueTask(async (signal) => { - const nextSession = await fetchSessionInfo(signal) - sessionRefreshInFlight = false - if (signal.aborted) return - session = nextSession - sessionStatus = 'ready' - handle.update() - if (sessionRefreshQueued) { - queueSessionRefresh() - } - }) - if (sessionStatus !== 'loading') { - handle.update() - } - } - - handle.queueTask(() => { - queueSessionRefresh() - }) - listenToRouterNavigation(handle, () => { - currentPathname = getPathname() - queueSessionRefresh() - handle.update() - }) - - const navLinkCss = { - color: colors.primaryText, - fontWeight: typography.fontWeight.medium, - textDecoration: 'none', - '&:hover': { - textDecoration: 'underline', - }, - } - - const navHomeLinkCss = { - ...navLinkCss, - display: 'flex', - alignItems: 'center', - lineHeight: 0, - '&:hover': { - textDecoration: 'none', - opacity: 0.85, - }, - } - - const logOutButtonCss = { - padding: `${spacing.xs} ${spacing.md}`, - borderRadius: '999px', - border: `1px solid ${colors.border}`, - backgroundColor: 'transparent', - color: colors.text, - fontWeight: typography.fontWeight.medium, - cursor: 'pointer', - } - - return () => { - const isChatLayout = currentPathname.startsWith('/chat') - const sessionEmail = session?.email ?? '' - const isSessionReady = sessionStatus === 'ready' - const isLoggedIn = isSessionReady && Boolean(sessionEmail) - const showAuthLinks = isSessionReady && !isLoggedIn - const oauthRedirectTo = - typeof window !== 'undefined' && currentPathname === '/oauth/authorize' - ? `${currentPathname}${window.location.search}` - : null - const loginHref = buildAuthLink('/login', oauthRedirectTo) - const signupHref = buildAuthLink('/signup', oauthRedirectTo) - - return ( -
-
); + }; } diff --git a/client/client-router.tsx b/client/client-router.tsx index 813fdf4..ca013c7 100644 --- a/client/client-router.tsx +++ b/client/client-router.tsx @@ -1,4 +1,4 @@ -import { type Handle } from 'remix/component' +import { addEventListeners, type Handle } from 'remix/component' type RouterSetup = { routes: Record @@ -251,7 +251,7 @@ function ensureRouter() { export function listenToRouterNavigation(handle: Handle, listener: () => void) { ensureRouter() - handle.on(routerEvents, { + addEventListeners(routerEvents, handle.signal, { navigate: () => listener(), }) } diff --git a/client/counter.tsx b/client/counter.tsx index f7fcea9..66e99af 100644 --- a/client/counter.tsx +++ b/client/counter.tsx @@ -1,49 +1,36 @@ -import { type Handle } from 'remix/component' -import { - colors, - radius, - spacing, - transitions, - typography, -} from './styles/tokens.ts' - +import { css, type Handle, on } from 'remix/component'; +import { colors, radius, spacing, transitions, typography, } from './styles/tokens.ts'; type CounterSetup = { - initial?: number -} - + initial?: number; +}; export function Counter(handle: Handle, setup: CounterSetup = {}) { - let count = setup.initial ?? 0 - - function increment() { - count += 1 - handle.update() - } - - return () => ( - - ) + ); } diff --git a/client/double-check.ts b/client/double-check.ts index e4bf7c0..55eb990 100644 --- a/client/double-check.ts +++ b/client/double-check.ts @@ -1,26 +1,11 @@ -import { type Handle } from 'remix/component' +import { on, type Handle } from 'remix/component' -type BlurHandler = (event: FocusEvent) => void -type ClickHandler = (event: MouseEvent) => void type ButtonLikeProps = { - on?: { - blur?: BlurHandler - click?: ClickHandler - } + mix?: Array [key: string]: unknown } -function callAll( - ...handlers: Array<((event: Event) => void) | undefined> -) { - return (event: Event) => { - for (const handler of handlers) { - handler?.(event) - } - } -} - export function createDoubleCheck(handle: Handle) { let doubleCheck = false @@ -39,29 +24,28 @@ export function createDoubleCheck(handle: Handle) { }, getButtonProps(props?: Props): Props { const buttonProps = props ?? ({} as Props) - - const onBlur: BlurHandler = () => { - setDoubleCheck(false) - } - - const onClick: ClickHandler = (event) => { - if (!doubleCheck) { - event.preventDefault() - setDoubleCheck(true) - return - } - - buttonProps.on?.click?.(event) - setDoubleCheck(false) - } + const mix = [...(buttonProps.mix ?? [])] + + mix.push( + on('blur', () => { + setDoubleCheck(false) + }), + ) + + mix.push( + on('click', (event) => { + if (!doubleCheck) { + event.preventDefault() + setDoubleCheck(true) + return + } + setDoubleCheck(false) + }), + ) return { ...buttonProps, - on: { - ...buttonProps.on, - blur: callAll(onBlur, buttonProps.on?.blur), - click: onClick, - }, + mix, } }, } diff --git a/client/editable-text.tsx b/client/editable-text.tsx index 468baf3..a85ed28 100644 --- a/client/editable-text.tsx +++ b/client/editable-text.tsx @@ -1,171 +1,148 @@ -import { type Handle } from 'remix/component' - +import { css, type Handle, on } from 'remix/component'; type EditableTextProps = { - id: string - ariaLabel: string - value: string - emptyText?: string - buttonCss?: Record - inputCss?: Record - onSave: (value: string) => Promise | boolean -} - + id: string; + ariaLabel: string; + value: string; + emptyText?: string; + buttonCss?: Record; + inputCss?: Record; + onSave: (value: string) => Promise | boolean; +}; const inheritTextStyles = { - fontSize: 'inherit', - fontStyle: 'inherit', - fontWeight: 'inherit', - fontFamily: 'inherit', - textAlign: 'inherit', - lineHeight: 'inherit', - color: 'inherit', -} as const - + fontSize: 'inherit', + fontStyle: 'inherit', + fontWeight: 'inherit', + fontFamily: 'inherit', + textAlign: 'inherit', + lineHeight: 'inherit', + color: 'inherit', +} as const; export function EditableText(handle: Handle) { - let isEditing = false - let draftValue = '' - let isSaving = false - - function focusInput(inputId: string) { - void handle.queueTask(async () => { - const input = document.getElementById(inputId) - if (!(input instanceof HTMLInputElement)) return - input.focus() - input.select() - }) - } - - function focusButton(buttonId: string) { - void handle.queueTask(async () => { - const button = document.getElementById(buttonId) - if (!(button instanceof HTMLButtonElement)) return - button.focus() - }) - } - - return (props: EditableTextProps) => { - const buttonId = `${props.id}-button` - - function startEditing() { - if (isSaving) return - draftValue = props.value - isEditing = true - handle.update() - focusInput(props.id) - } - - function cancelEditing() { - if (isSaving) return - draftValue = props.value - isEditing = false - handle.update() - focusButton(buttonId) - } - - async function submitEditing(event: SubmitEvent) { - event.preventDefault() - if (isSaving) return - const nextValue = draftValue.trim() - if (!nextValue) return - - isSaving = true - handle.update() - let didSave = false - try { - didSave = await props.onSave(nextValue) - } catch (error) { - isSaving = false - handle.update() - throw error - } - isSaving = false - if (!didSave) { - handle.update() - return - } - - isEditing = false - handle.update() - focusButton(buttonId) - } - - function handleDraftInput(event: Event) { - if (!(event.currentTarget instanceof HTMLInputElement)) return - draftValue = event.currentTarget.value - handle.update() - } - - function handleDraftKeyDown(event: KeyboardEvent) { - if (!(event.currentTarget instanceof HTMLInputElement)) return - if (event.key === 'Escape') { - event.preventDefault() - cancelEditing() - return - } - if (event.key === 'Enter') { - event.preventDefault() - event.currentTarget.form?.requestSubmit() - } - } - - if (isEditing) { - return ( -
- -
- ) - } - - return ( - - ) - } + ); + }; } diff --git a/client/remix-component-compat/runtime.ts b/client/remix-component-compat/runtime.ts deleted file mode 100644 index e97fe94..0000000 --- a/client/remix-component-compat/runtime.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { css, on } from 'remix/component' -import { jsx as remixJsx } from 'remix/component/jsx-runtime' - -type CompatEventMap = Record< - string, - (event: Event, signal: AbortSignal) => void | Promise -> - -type CSSProps = Record - -type CompatProps = Record & { - css?: CSSProps - mix?: unknown - on?: CompatEventMap -} - -function normalizeMix(value: unknown) { - if (value == null) return [] - return Array.isArray(value) ? [...value] : [value] -} - -function normalizeProps(props: unknown) { - if (!props || typeof props !== 'object') { - return props as Record | undefined - } - - const input = props as CompatProps - const { css: cssProp, mix: mixProp, on: onProp, ...rest } = input - const mix = normalizeMix(mixProp) - - if (cssProp) { - mix.push(css(cssProp as never)) - } - - if (onProp) { - for (const [eventName, listener] of Object.entries(onProp)) { - mix.push(on(eventName as never, listener as never)) - } - } - - if (mix.length > 0) { - return { - ...rest, - mix, - } - } - - return rest -} - -export function jsx(type: string | Function, props: unknown, key?: string) { - const normalizedProps = normalizeProps(props) ?? {} - // Not createElement: its `...children` rest overwrites `props.children` when only (type, props) is passed. - return remixJsx(type as never, normalizedProps as never, key as never) -} - -export const jsxs = jsx -export const jsxDEV = jsx diff --git a/client/routes/account.tsx b/client/routes/account.tsx index 3c706ff..04faf2c 100644 --- a/client/routes/account.tsx +++ b/client/routes/account.tsx @@ -1,82 +1,81 @@ -import { type Handle } from 'remix/component' -import { colors, spacing, typography } from '#client/styles/tokens.ts' - -type AccountStatus = 'idle' | 'loading' | 'ready' | 'error' - +import { css, type Handle } from 'remix/component'; +import { colors, spacing, typography } from '#client/styles/tokens.ts'; +type AccountStatus = 'idle' | 'loading' | 'ready' | 'error'; export function AccountRoute(handle: Handle) { - let status: AccountStatus = 'loading' - let email = '' - let message: string | null = null - - async function loadAccount(signal: AbortSignal) { - try { - const response = await fetch('/session', { - headers: { Accept: 'application/json' }, - credentials: 'include', - signal, - }) - if (signal.aborted) return - const payload = await response.json().catch(() => null) - const sessionEmail = - response.ok && - payload?.ok && - typeof payload?.session?.email === 'string' - ? payload.session.email.trim() - : '' - if (!sessionEmail) { - window.location.assign('/login') - return - } - email = sessionEmail - status = 'ready' - message = null - handle.update() - } catch { - if (signal.aborted) return - status = 'error' - message = 'Unable to load your account.' - handle.update() - } - } - - return () => { - if (status === 'loading') { - handle.queueTask(loadAccount) - } - - return ( -
-
-

+ let status: AccountStatus = 'loading'; + let email = ''; + let message: string | null = null; + async function loadAccount(signal: AbortSignal) { + try { + const response = await fetch('/session', { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }); + if (signal.aborted) + return; + const payload = await response.json().catch(() => null); + const sessionEmail = response.ok && + payload?.ok && + typeof payload?.session?.email === 'string' + ? payload.session.email.trim() + : ''; + if (!sessionEmail) { + window.location.assign('/login'); + return; + } + email = sessionEmail; + status = 'ready'; + message = null; + handle.update(); + } + catch { + if (signal.aborted) + return; + status = 'error'; + message = 'Unable to load your account.'; + handle.update(); + } + } + return () => { + if (status === 'loading') { + handle.queueTask(loadAccount); + } + return (
+
+

{email ? `Welcome, ${email}` : 'Welcome'}

-

+

You are signed in to epicflare.

- {status === 'loading' ? ( -

Loading your account…

- ) : null} - {message ? ( -

+ {status === 'loading' ? (

Loading your account…

) : null} + {message ? (

{message} -

- ) : null} -
- ) - } +

) : null} +

); + }; } diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index 9a5ab1a..7c44e44 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -1,1700 +1,1442 @@ -import { type Handle } from 'remix/component' -import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts' -import { navigate, routerEvents } from '#client/client-router.tsx' -import { createDoubleCheck } from '#client/double-check.ts' -import { EditableText } from '#client/editable-text.tsx' -import { - createInfiniteList, - type InfiniteListSnapshot, -} from '#client/infinite-list.ts' -import { - captureScrollAnchor, - getScrollFades, - isScrolledNearEdge, - restoreScrollAnchorAfterPrepend, - scrollToEdge, -} from '#client/scroll-container.ts' -import { createSpinDelay } from '#client/spin-delay.ts' -import { - breakpoints, - colors, - mq, - radius, - shadows, - spacing, - transitions, - typography, -} from '#client/styles/tokens.ts' -import { - type ChatThreadLookupResponse, - type ChatThreadListResponse, - type ChatThreadSummary, - type ChatThreadUpdateResponse, -} from '#shared/chat.ts' - -type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error' - +import { addEventListeners, css, type Handle, on } from 'remix/component'; +import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts'; +import { navigate, routerEvents } from '#client/client-router.tsx'; +import { createDoubleCheck } from '#client/double-check.ts'; +import { EditableText } from '#client/editable-text.tsx'; +import { createInfiniteList, type InfiniteListSnapshot, } from '#client/infinite-list.ts'; +import { captureScrollAnchor, getScrollFades, isScrolledNearEdge, restoreScrollAnchorAfterPrepend, scrollToEdge, } from '#client/scroll-container.ts'; +import { createSpinDelay } from '#client/spin-delay.ts'; +import { breakpoints, colors, mq, radius, shadows, spacing, transitions, typography, } from '#client/styles/tokens.ts'; +import { type ChatThreadLookupResponse, type ChatThreadListResponse, type ChatThreadSummary, type ChatThreadUpdateResponse, } from '#shared/chat.ts'; +type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error'; function getSelectedThreadIdFromLocation() { - if (typeof window === 'undefined') return null - const prefix = '/chat/' - if (!window.location.pathname.startsWith(prefix)) return null - const threadId = window.location.pathname.slice(prefix.length).trim() - return threadId || null + if (typeof window === 'undefined') + return null; + const prefix = '/chat/'; + if (!window.location.pathname.startsWith(prefix)) + return null; + const threadId = window.location.pathname.slice(prefix.length).trim(); + return threadId || null; } - function buildThreadHref(threadId: string) { - return `/chat/${threadId}` + return `/chat/${threadId}`; } - function isMobileViewport() { - return ( - typeof window !== 'undefined' && - window.matchMedia(`(max-width: ${breakpoints.tablet})`).matches - ) + return (typeof window !== 'undefined' && + window.matchMedia(`(max-width: ${breakpoints.tablet})`).matches); } - -const MESSAGES_SCROLL_CONTAINER_ID = 'chat-messages-scroll-container' -const THREAD_LIST_SCROLL_CONTAINER_ID = 'chat-thread-list-scroll-container' -const MESSAGES_SCROLL_THRESHOLD_PX = 96 -const THREAD_LIST_SCROLL_THRESHOLD_PX = 96 -const MESSAGE_SCROLL_FADE_HEIGHT = '2.5rem' -const THREADS_PAGE_LIMIT = 40 - +const MESSAGES_SCROLL_CONTAINER_ID = 'chat-messages-scroll-container'; +const THREAD_LIST_SCROLL_CONTAINER_ID = 'chat-thread-list-scroll-container'; +const MESSAGES_SCROLL_THRESHOLD_PX = 96; +const THREAD_LIST_SCROLL_THRESHOLD_PX = 96; +const MESSAGE_SCROLL_FADE_HEIGHT = '2.5rem'; +const THREADS_PAGE_LIMIT = 40; function truncatePreview(text: string) { - const normalized = text.trim() - if (!normalized) return '' - return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized + const normalized = text.trim(); + if (!normalized) + return ''; + return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized; } - function createInitialSnapshot(): ChatClientSnapshot { - return { - messages: [], - totalMessageCount: 0, - streamingText: '', - isStreaming: false, - hasOlderMessages: false, - isLoadingMessages: false, - isLoadingOlderMessages: false, - error: null, - connected: false, - } + return { + messages: [], + totalMessageCount: 0, + streamingText: '', + isStreaming: false, + hasOlderMessages: false, + isLoadingMessages: false, + isLoadingOlderMessages: false, + error: null, + connected: false, + }; } - -function buildThreadPreviewFromMessages( - messages: ChatClientSnapshot['messages'], -) { - const lastMessage = messages.at(-1) - if (!lastMessage) return null - const text = lastMessage.parts - .filter( - ( - part, - ): part is Extract< - (typeof lastMessage.parts)[number], - { type: 'text'; text: string } - > => part.type === 'text' && typeof part.text === 'string', - ) - .map((part) => part.text) - .join('\n') - .trim() - return text ? truncatePreview(text) : null +function buildThreadPreviewFromMessages(messages: ChatClientSnapshot['messages']) { + const lastMessage = messages.at(-1); + if (!lastMessage) + return null; + const text = lastMessage.parts + .filter((part): part is Extract<(typeof lastMessage.parts)[number], { + type: 'text'; + text: string; + }> => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join('\n') + .trim(); + return text ? truncatePreview(text) : null; } - async function fetchThreads(input?: { - cursor?: string | null - signal?: AbortSignal - search?: string + cursor?: string | null; + signal?: AbortSignal; + search?: string; }) { - const url = new URL('/chat-threads', window.location.href) - if (input?.cursor) { - url.searchParams.set('cursor', input.cursor) - } - url.searchParams.set('limit', String(THREADS_PAGE_LIMIT)) - const search = input?.search?.trim() - if (search) { - url.searchParams.set('q', search) - } - const response = await fetch(url.toString(), { - credentials: 'include', - headers: { Accept: 'application/json' }, - signal: input?.signal, - }) - const payload = (await response.json().catch(() => null)) as - | (ChatThreadListResponse & { - error?: string - }) - | { ok?: false; error?: string } - | null - if ( - !response.ok || - !payload?.ok || - !('threads' in payload) || - !Array.isArray(payload.threads) || - typeof payload.totalCount !== 'number' || - typeof payload.hasMore !== 'boolean' - ) { - throw new Error(payload?.error || 'Unable to load threads.') - } - return { - items: payload.threads, - hasMore: payload.hasMore, - nextCursor: payload.nextCursor, - totalCount: payload.totalCount, - } + const url = new URL('/chat-threads', window.location.href); + if (input?.cursor) { + url.searchParams.set('cursor', input.cursor); + } + url.searchParams.set('limit', String(THREADS_PAGE_LIMIT)); + const search = input?.search?.trim(); + if (search) { + url.searchParams.set('q', search); + } + const response = await fetch(url.toString(), { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal: input?.signal, + }); + const payload = (await response.json().catch(() => null)) as (ChatThreadListResponse & { + error?: string; + }) | { + ok?: false; + error?: string; + } | null; + if (!response.ok || + !payload?.ok || + !('threads' in payload) || + !Array.isArray(payload.threads) || + typeof payload.totalCount !== 'number' || + typeof payload.hasMore !== 'boolean') { + throw new Error(payload?.error || 'Unable to load threads.'); + } + return { + items: payload.threads, + hasMore: payload.hasMore, + nextCursor: payload.nextCursor, + totalCount: payload.totalCount, + }; } - async function fetchThreadById(threadId: string, signal?: AbortSignal) { - const url = new URL('/chat-threads', window.location.href) - url.searchParams.set('threadId', threadId) - const response = await fetch(url.toString(), { - credentials: 'include', - headers: { Accept: 'application/json' }, - signal, - }) - const payload = (await response.json().catch(() => null)) as - | (ChatThreadLookupResponse & { - error?: string - }) - | { ok?: false; error?: string } - | null - if ( - !response.ok || - !payload?.ok || - !('thread' in payload) || - !payload.thread - ) { - throw new Error(payload?.error || 'Unable to load the selected thread.') - } - return payload.thread + const url = new URL('/chat-threads', window.location.href); + url.searchParams.set('threadId', threadId); + const response = await fetch(url.toString(), { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal, + }); + const payload = (await response.json().catch(() => null)) as (ChatThreadLookupResponse & { + error?: string; + }) | { + ok?: false; + error?: string; + } | null; + if (!response.ok || + !payload?.ok || + !('thread' in payload) || + !payload.thread) { + throw new Error(payload?.error || 'Unable to load the selected thread.'); + } + return payload.thread; } - async function createThread() { - const response = await fetch('/chat-threads', { - method: 'POST', - credentials: 'include', - }) - const payload = (await response.json().catch(() => null)) as { - ok?: boolean - thread?: ChatThreadSummary - error?: string - } | null - if (!response.ok || !payload?.ok || !payload.thread) { - throw new Error(payload?.error || 'Unable to create thread.') - } - return payload.thread + const response = await fetch('/chat-threads', { + method: 'POST', + credentials: 'include', + }); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + thread?: ChatThreadSummary; + error?: string; + } | null; + if (!response.ok || !payload?.ok || !payload.thread) { + throw new Error(payload?.error || 'Unable to create thread.'); + } + return payload.thread; } - async function deleteThread(threadId: string) { - const response = await fetch('/chat-threads/delete', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ threadId }), - }) - const payload = (await response.json().catch(() => null)) as { - ok?: boolean - error?: string - } | null - if (!response.ok || !payload?.ok) { - throw new Error(payload?.error || 'Unable to delete thread.') - } + const response = await fetch('/chat-threads/delete', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId }), + }); + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + } | null; + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to delete thread.'); + } } - async function updateThreadTitle(threadId: string, title: string) { - const response = await fetch('/chat-threads/update', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ threadId, title }), - }) - const payload = (await response.json().catch(() => null)) as - | (ChatThreadUpdateResponse & { error?: string }) - | { ok?: false; error?: string } - | null - if ( - !response.ok || - !payload?.ok || - !('thread' in payload) || - !payload.thread - ) { - throw new Error(payload?.error || 'Unable to update thread title.') - } - return payload.thread + const response = await fetch('/chat-threads/update', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId, title }), + }); + const payload = (await response.json().catch(() => null)) as (ChatThreadUpdateResponse & { + error?: string; + }) | { + ok?: false; + error?: string; + } | null; + if (!response.ok || + !payload?.ok || + !('thread' in payload) || + !payload.thread) { + throw new Error(payload?.error || 'Unable to update thread title.'); + } + return payload.thread; } - -function renderMessageParts( - parts: Array<{ - type: string - text?: string - state?: string - input?: unknown - output?: unknown - errorText?: string - }>, -) { - return parts.map((part, index) => { - if (part.type === 'text') { - return ( -

+function renderMessageParts(parts: Array<{ + type: string; + text?: string; + state?: string; + input?: unknown; + output?: unknown; + errorText?: string; +}>) { + return parts.map((part, index) => { + if (part.type === 'text') { + return (

{part.text} -

- ) - } - - if (part.type.startsWith('tool-')) { - return ( -
+

); + } + if (part.type.startsWith('tool-')) { + return (
{part.type.replace(/^tool-/, '')} - State: {part.state} - {part.input !== undefined ? ( - + State: {part.state} + {part.input !== undefined ? ( Input: {JSON.stringify(part.input)} - - ) : null} - {part.output !== undefined ? ( - + ) : null} + {part.output !== undefined ? ( Output: {JSON.stringify(part.output)} - - ) : null} - {part.errorText ? ( - {part.errorText} - ) : null} -
- ) - } - - return null - }) + ) : null} + {part.errorText ? ({part.errorText}) : null} +
); + } + return null; + }); } - function renderPaperAirplaneIcon() { - return ( - - ) + return (); } - function renderBackIcon() { - return ( - - ) + return (); } - function renderTrashIcon() { - return ( - - ) + return (); } - -const SEND_BUTTON_SIZE_REM = 2.5 -const SEND_BUTTON_INSET_REM = 0.375 -const INPUT_MIN_HEIGHT_REM = SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2 -const INPUT_MIN_HEIGHT_PX = INPUT_MIN_HEIGHT_REM * 16 -const INPUT_MIN_HEIGHT = `${INPUT_MIN_HEIGHT_REM}rem` -const INPUT_RIGHT_PADDING = `${SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2}rem` -const SEND_BUTTON_SIZE = `${SEND_BUTTON_SIZE_REM}rem` -const SEND_BUTTON_INSET = `${SEND_BUTTON_INSET_REM}rem` -const CHAT_PANEL_HEIGHT = 'calc(100vh - 7rem)' +const SEND_BUTTON_SIZE_REM = 2.5; +const SEND_BUTTON_INSET_REM = 0.375; +const INPUT_MIN_HEIGHT_REM = SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2; +const INPUT_MIN_HEIGHT_PX = INPUT_MIN_HEIGHT_REM * 16; +const INPUT_MIN_HEIGHT = `${INPUT_MIN_HEIGHT_REM}rem`; +const INPUT_RIGHT_PADDING = `${SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2}rem`; +const SEND_BUTTON_SIZE = `${SEND_BUTTON_SIZE_REM}rem`; +const SEND_BUTTON_INSET = `${SEND_BUTTON_INSET_REM}rem`; +const CHAT_PANEL_HEIGHT = 'calc(100vh - 7rem)'; /** * The outer border should follow the button's contour plus its inset from the edge. * radius = button radius + inset */ -const SEND_BUTTON_RADIUS = `${SEND_BUTTON_SIZE_REM / 2 + SEND_BUTTON_INSET_REM}rem` - +const SEND_BUTTON_RADIUS = `${SEND_BUTTON_SIZE_REM / 2 + SEND_BUTTON_INSET_REM}rem`; function resizeMessageInput(target: EventTarget | null) { - if (!(target instanceof HTMLTextAreaElement)) return - target.style.height = INPUT_MIN_HEIGHT - const height = Math.max(target.scrollHeight, INPUT_MIN_HEIGHT_PX) - target.style.height = `${height}px` + if (!(target instanceof HTMLTextAreaElement)) + return; + target.style.height = INPUT_MIN_HEIGHT; + const height = Math.max(target.scrollHeight, INPUT_MIN_HEIGHT_PX); + target.style.height = `${height}px`; } - export function ChatRoute(handle: Handle) { - let threadListSnapshot: InfiniteListSnapshot = { - items: [], - hasMore: false, - totalCount: 0, - error: null, - isLoadingInitial: false, - isLoadingMore: false, - } - let threadStatus: ThreadStatus = 'loading' - let threadError: string | null = null - let threadListCursor: string | null = null - let activeThreadId: string | null = null - let threadSearch = '' - let chatSnapshot = createInitialSnapshot() - let activeClient: ChatClient | null = null - let actionError: string | null = null - let syncInFlight = false - let shouldAutoScrollMessages = true - let showMessageScrollFadeTop = false - let showMessageScrollFadeBottom = false - let showThreadListScrollFadeTop = false - let showThreadListScrollFadeBottom = false - const disconnectedIndicator = createSpinDelay(handle, { ssr: false }) - const deleteThreadChecks = new Map< - string, - ReturnType - >() - const threadList = createInfiniteList({ - mergeDirection: 'append', - getKey: (thread) => thread.id, - onSnapshot(snapshot) { - threadListSnapshot = snapshot - if (deleteThreadChecks.size) { - const activeThreadIds = new Set( - snapshot.items.map((thread) => thread.id), - ) - for (const threadId of deleteThreadChecks.keys()) { - if (!activeThreadIds.has(threadId)) { - deleteThreadChecks.delete(threadId) - } - } - } - threadError = snapshot.error - if (snapshot.isLoadingInitial) { - threadStatus = 'loading' - } else if (snapshot.error) { - threadStatus = 'error' - } else { - threadStatus = 'ready' - } - update() - }, - }) - - function update() { - handle.update() - } - - function setThreadState( - nextStatus: ThreadStatus, - nextError: string | null = null, - ) { - threadStatus = nextStatus - threadError = nextError - update() - } - - function resetChatSnapshot() { - chatSnapshot = createInitialSnapshot() - } - - function getThreads() { - return threadListSnapshot.items - } - - function updateThreadListFromSnapshot( - updater: (threads: Array) => Array, - ) { - threadList.updateItems(updater) - } - - function updateLocalThreadSummary( - threadId: string, - snapshot: ChatClientSnapshot, - ) { - updateThreadListFromSnapshot((threads) => { - const threadIndex = threads.findIndex((thread) => thread.id === threadId) - if (threadIndex === -1) return threads - const existingThread = threads[threadIndex] - if (!existingThread) return threads - const nextThread: ChatThreadSummary = { - ...existingThread, - messageCount: snapshot.totalMessageCount, - lastMessagePreview: buildThreadPreviewFromMessages(snapshot.messages), - } - if (threadSearch.trim()) { - return threads.map((thread) => - thread.id === threadId ? nextThread : thread, - ) - } - const remainingThreads = threads.filter( - (thread) => thread.id !== threadId, - ) - return [nextThread, ...remainingThreads] - }) - } - - function syncDisconnectedIndicator() { - disconnectedIndicator.setLoading( - Boolean(activeThreadId) && !chatSnapshot.connected, - ) - } - - function setMessageScrollFades( - nextTopVisible: boolean, - nextBottomVisible: boolean, - ) { - if ( - showMessageScrollFadeTop === nextTopVisible && - showMessageScrollFadeBottom === nextBottomVisible - ) { - return - } - - showMessageScrollFadeTop = nextTopVisible - showMessageScrollFadeBottom = nextBottomVisible - update() - } - - function syncMessageScrollFades(target?: HTMLDivElement | null) { - const container = - target ?? - (() => { - const element = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID) - return element instanceof HTMLDivElement ? element : null - })() - const fades = getScrollFades(container) - setMessageScrollFades(fades.top, fades.bottom) - } - - function scheduleMessageScrollFadeSync() { - void handle.queueTask(async () => { - syncMessageScrollFades() - }) - } - - function setThreadListScrollFades( - nextTopVisible: boolean, - nextBottomVisible: boolean, - ) { - if ( - showThreadListScrollFadeTop === nextTopVisible && - showThreadListScrollFadeBottom === nextBottomVisible - ) { - return - } - - showThreadListScrollFadeTop = nextTopVisible - showThreadListScrollFadeBottom = nextBottomVisible - update() - } - - function syncThreadListScrollFades(target?: HTMLDivElement | null) { - const container = - target ?? - (() => { - const element = document.getElementById(THREAD_LIST_SCROLL_CONTAINER_ID) - return element instanceof HTMLDivElement ? element : null - })() - const fades = getScrollFades(container) - setThreadListScrollFades(fades.top, fades.bottom) - } - - function scheduleThreadListScrollFadeSync() { - void handle.queueTask(async () => { - syncThreadListScrollFades() - }) - } - - function scheduleScrollToBottom(force = false) { - void handle.queueTask(async () => { - const container = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID) - if (!(container instanceof HTMLDivElement)) { - setMessageScrollFades(false, false) - return - } - if ( - !force && - !shouldAutoScrollMessages && - !isScrolledNearEdge(container, { - edge: 'bottom', - thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX, - }) - ) { - syncMessageScrollFades(container) - return - } - scrollToEdge(container, 'bottom') - shouldAutoScrollMessages = true - syncMessageScrollFades(container) - }) - } - - function handleMessagesScroll(event: Event) { - if (!(event.currentTarget instanceof HTMLDivElement)) return - shouldAutoScrollMessages = isScrolledNearEdge(event.currentTarget, { - edge: 'bottom', - thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX, - }) - syncMessageScrollFades(event.currentTarget) - if ( - chatSnapshot.hasOlderMessages && - !chatSnapshot.isLoadingOlderMessages && - isScrolledNearEdge(event.currentTarget, { - edge: 'top', - thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX, - }) - ) { - const scrollAnchor = captureScrollAnchor(event.currentTarget) - void handle.queueTask(async (signal) => { - const didLoad = await activeClient?.loadOlderMessages(signal) - if (!didLoad) return - void handle.queueTask(async () => { - const container = document.getElementById( - MESSAGES_SCROLL_CONTAINER_ID, - ) - if (!(container instanceof HTMLDivElement)) return - restoreScrollAnchorAfterPrepend(container, scrollAnchor) - syncMessageScrollFades(container) - }) - }) - } - } - - function handleThreadListScroll(event: Event) { - if (!(event.currentTarget instanceof HTMLDivElement)) return - syncThreadListScrollFades(event.currentTarget) - if ( - threadListSnapshot.hasMore && - !threadListSnapshot.isLoadingMore && - isScrolledNearEdge(event.currentTarget, { - edge: 'bottom', - thresholdPx: THREAD_LIST_SCROLL_THRESHOLD_PX, - }) - ) { - void handle.queueTask(async (signal) => { - await loadMoreThreads(signal) - }) - } - } - - function handleThreadSearchInput(event: Event) { - if (!(event.currentTarget instanceof HTMLInputElement)) return - threadSearch = event.currentTarget.value - update() - void handle.queueTask(async (signal) => { - await refreshThreads(signal) - }) - } - - function handleComposerKeyDown(event: KeyboardEvent) { - if (!(event.currentTarget instanceof HTMLTextAreaElement)) return - if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return - event.preventDefault() - event.currentTarget.form?.requestSubmit() - } - - async function connectThread(threadId: string) { - if (activeThreadId === threadId && activeClient) return - - activeClient?.close() - shouldAutoScrollMessages = true - activeClient = new ChatClient({ - threadId, - onSnapshot(snapshot) { - if (activeThreadId !== threadId) return - chatSnapshot = snapshot - updateLocalThreadSummary(threadId, snapshot) - syncDisconnectedIndicator() - update() - scheduleMessageScrollFadeSync() - scheduleThreadListScrollFadeSync() - scheduleScrollToBottom() - }, - }) - activeThreadId = threadId - resetChatSnapshot() - syncDisconnectedIndicator() - setMessageScrollFades(false, false) - update() - - try { - await activeClient.initialize() - } catch (error) { - chatSnapshot = { - ...createInitialSnapshot(), - error: - error instanceof Error - ? error.message - : 'Unable to connect to the selected thread.', - } - syncDisconnectedIndicator() - update() - } - } - - async function syncActiveThreadFromLocation() { - if (threadStatus !== 'ready' || syncInFlight) return - syncInFlight = true - try { - const locationThreadId = getSelectedThreadIdFromLocation() - const threads = getThreads() - if (threads.length === 0) { - activeClient?.close() - activeClient = null - activeThreadId = null - resetChatSnapshot() - disconnectedIndicator.reset() - setMessageScrollFades(false, false) - update() - if (locationThreadId) { - navigate('/chat') - } - return - } - - if ( - locationThreadId && - !threads.some((thread) => thread.id === locationThreadId) - ) { - try { - const selectedThread = await fetchThreadById(locationThreadId) - updateThreadListFromSnapshot((currentThreads) => [ - selectedThread, - ...currentThreads, - ]) - } catch { - // Ignore missing selections and fall back to the first loaded thread. - } - } - - const selectedThread = - locationThreadId && - getThreads().find((thread) => thread.id === locationThreadId) - ? locationThreadId - : null - const resolvedThreadId = selectedThread ?? getThreads()[0]?.id ?? null - if (!resolvedThreadId) return - - if (locationThreadId !== resolvedThreadId) { - if (locationThreadId || !isMobileViewport()) { - navigate(buildThreadHref(resolvedThreadId)) - } else { - activeClient?.close() - activeClient = null - activeThreadId = null - resetChatSnapshot() - disconnectedIndicator.reset() - setMessageScrollFades(false, false) - update() - } - return - } - - await connectThread(resolvedThreadId) - } finally { - syncInFlight = false - } - } - - async function loadMoreThreads(signal?: AbortSignal) { - if (!threadListCursor) return false - let nextCursor: string | null = null - const didLoad = await threadList.loadMore(async ({ signal }) => { - const page = await fetchThreads({ - cursor: threadListCursor, - search: threadSearch, - signal, - }) - nextCursor = page.nextCursor ?? null - return { - items: page.items, - hasMore: page.hasMore, - totalCount: page.totalCount, - } - }, signal) - if (didLoad) { - threadListCursor = nextCursor - } - return didLoad - } - - async function refreshThreads(signal?: AbortSignal) { - try { - threadListCursor = null - let nextCursor: string | null = null - const didLoad = await threadList.loadInitial(async ({ signal }) => { - const page = await fetchThreads({ search: threadSearch, signal }) - nextCursor = page.nextCursor ?? null - return { - items: page.items, - hasMore: page.hasMore, - totalCount: page.totalCount, - } - }, signal) - if (!didLoad) return - threadListCursor = nextCursor - setThreadState('ready') - scheduleThreadListScrollFadeSync() - await syncActiveThreadFromLocation() - } catch (error) { - if (signal?.aborted) return - setThreadState( - 'error', - error instanceof Error ? error.message : 'Unable to load threads.', - ) - } - } - - handle.on(routerEvents, { - navigate: () => { - void handle.queueTask(async () => { - await syncActiveThreadFromLocation() - }) - }, - }) - - async function createAndSelectThread() { - const thread = await createThread() - navigate(buildThreadHref(thread.id)) - await refreshThreads() - await connectThread(thread.id) - return thread - } - - async function handleCreateThread() { - actionError = null - update() - try { - await createAndSelectThread() - await activeClient?.waitUntilConnected() - } catch (error) { - actionError = - error instanceof Error ? error.message : 'Unable to create thread.' - update() - } - } - - async function handleDeleteThread(threadId: string) { - actionError = null - update() - try { - await deleteThread(threadId) - deleteThreadChecks.delete(threadId) - if (activeThreadId === threadId) { - activeClient?.close() - activeClient = null - activeThreadId = null - resetChatSnapshot() - disconnectedIndicator.reset() - } - await refreshThreads() - scheduleThreadListScrollFadeSync() - const nextThread = getThreads()[0] - if (nextThread) { - navigate(buildThreadHref(nextThread.id)) - await connectThread(nextThread.id) - } else { - navigate('/chat') - } - } catch (error) { - actionError = - error instanceof Error ? error.message : 'Unable to delete thread.' - update() - } - } - - async function handleRenameThread(threadId: string, title: string) { - actionError = null - update() - try { - const updatedThread = await updateThreadTitle(threadId, title) - updateThreadListFromSnapshot((threads) => - threads.map((thread) => - thread.id === updatedThread.id ? updatedThread : thread, - ), - ) - update() - return true - } catch (error) { - actionError = - error instanceof Error - ? error.message - : 'Unable to update thread title.' - update() - return false - } - } - - async function handleSubmit(event: SubmitEvent) { - event.preventDefault() - actionError = null - if (!(event.currentTarget instanceof HTMLFormElement)) return - const form = event.currentTarget - const formData = new FormData(form) - const text = String(formData.get('message') ?? '').trim() - if (!text) return - - try { - let client = activeClient - - if (!client) { - await createAndSelectThread() - client = activeClient - } - - if (!client) { - throw new Error('Unable to start a chat thread.') - } - - await client.waitUntilConnected() - client.sendMessage(text) - form.reset() - const messageInput = form.elements.namedItem('message') - resizeMessageInput( - messageInput instanceof HTMLTextAreaElement ? messageInput : null, - ) - } catch (error) { - actionError = - error instanceof Error ? error.message : 'Unable to send message.' - update() - } - } - - return () => { - if (threadStatus === 'loading') { - handle.queueTask(refreshThreads) - } - - const threads = getThreads() - const activeThread = activeThreadId - ? (threads.find((thread) => thread.id === activeThreadId) ?? null) - : null - const showEmptyStateComposer = - !activeThread && threads.length === 0 && threadStatus !== 'error' - const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation()) - - return ( -
- {actionError ? ( -

{actionError}

- ) : null} - -
- -
- {activeThread ? ( - <> -
- +
+ {activeThread ? (<> +
+ {renderBackIcon()} -
- -

- - handleRenameThread(activeThread.id, value) - } - buttonCss={{ - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} - /> +
+ +

+ handleRenameThread(activeThread.id, value)} buttonCss={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }}/>

-
-
- {chatSnapshot.isLoadingOlderMessages ? ( -

+

+
("scroll", handleMessagesScroll) + ]}> + {chatSnapshot.isLoadingOlderMessages ? (

Loading earlier messages... -

- ) : null} - {chatSnapshot.isLoadingMessages ? ( -

+

) : null} + {chatSnapshot.isLoadingMessages ? (

Loading messages... -

- ) : null} - {chatSnapshot.messages.map((message) => ( -
- +

) : null} + {chatSnapshot.messages.map((message) => (
+ {message.role === 'user' ? 'You' : 'Assistant'} -
- {renderMessageParts( - message.parts as Array<{ - type: string - text?: string - state?: string - input?: unknown - output?: unknown - errorText?: string - }>, - )} +
+ {renderMessageParts(message.parts as Array<{ + type: string; + text?: string; + state?: string; + input?: unknown; + output?: unknown; + errorText?: string; + }>)}
-
- ))} - {chatSnapshot.isStreaming || chatSnapshot.streamingText ? ( -
- Assistant -

+

))} + {chatSnapshot.isStreaming || chatSnapshot.streamingText ? (
+ Assistant +

{chatSnapshot.streamingText || 'Thinking…'}

-
- ) : null} +
) : null}
- {showMessageScrollFadeTop ? ( -