feat: add experimental Chat UI (#17650)

Builds on https://github.com/coder/coder/pull/17570

Frontend portion of https://github.com/coder/coder/tree/chat originally
authored by @kylecarbs

Additional changes:
- Addresses linter complaints
- Brings `ChatToolInvocation` argument definitions in line with those
defined in `codersdk/toolsdk`
- Ensures chat-related features are not shown unless
`ExperimentAgenticChat` is enabled.

Co-authored-by: Kyle Carberry <kyle@carberry.com>
This commit is contained in:
Cian Johnston
2025-05-13 17:24:10 +01:00
committed by GitHub
parent 8f64d49b22
commit a1c03b6c5f
14 changed files with 3381 additions and 6 deletions

View File

@ -35,6 +35,8 @@
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis"
},
"dependencies": {
"@ai-sdk/provider-utils": "2.2.6",
"@ai-sdk/react": "1.2.6",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@emotion/cache": "11.14.0",
@ -111,6 +113,7 @@
"react-virtualized-auto-sizer": "1.0.24",
"react-window": "1.8.11",
"recharts": "2.15.0",
"rehype-raw": "7.0.0",
"remark-gfm": "4.0.0",
"resize-observer-polyfill": "1.5.1",
"rollup-plugin-visualizer": "5.14.0",

216
site/pnpm-lock.yaml generated
View File

@ -16,6 +16,12 @@ importers:
.:
dependencies:
'@ai-sdk/provider-utils':
specifier: 2.2.6
version: 2.2.6(zod@3.24.3)
'@ai-sdk/react':
specifier: 1.2.6
version: 1.2.6(react@18.3.1)(zod@3.24.3)
'@emoji-mart/data':
specifier: 1.2.1
version: 1.2.1
@ -244,6 +250,9 @@ importers:
recharts:
specifier: 2.15.0
version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rehype-raw:
specifier: 7.0.0
version: 7.0.0
remark-gfm:
specifier: 4.0.0
version: 4.0.0
@ -489,6 +498,42 @@ packages:
'@adobe/css-tools@4.4.1':
resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==, tarball: https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz}
'@ai-sdk/provider-utils@2.2.4':
resolution: {integrity: sha512-13sEGBxB6kgaMPGOgCLYibF6r8iv8mgjhuToFrOTU09bBxbFQd8ZoARarCfJN6VomCUbUvMKwjTBLb1vQnN+WA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.4.tgz}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider-utils@2.2.6':
resolution: {integrity: sha512-sUlZ7Gnq84DCGWMQRIK8XVbkzIBnvPR1diV4v6JwPgpn5armnLI/j+rqn62MpLrU5ZCQZlDKl/Lw6ed3ulYqaA==, tarball: https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.6.tgz}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider@1.1.0':
resolution: {integrity: sha512-0M+qjp+clUD0R1E5eWQFhxEvWLNaOtGQRUaBn8CUABnSKredagq92hUS9VjOzGsTm37xLfpaxl97AVtbeOsHew==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.0.tgz}
engines: {node: '>=18'}
'@ai-sdk/provider@1.1.2':
resolution: {integrity: sha512-ITdgNilJZwLKR7X5TnUr1BsQW6UTX5yFp0h66Nfx8XjBYkWD9W3yugr50GOz3CnE9m/U/Cd5OyEbTMI0rgi6ZQ==, tarball: https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.2.tgz}
engines: {node: '>=18'}
'@ai-sdk/react@1.2.6':
resolution: {integrity: sha512-5BFChNbcYtcY9MBStcDev7WZRHf0NpTrk8yfSoedWctB3jfWkFd1HECBvdc8w3mUQshF2MumLHtAhRO7IFtGGQ==, tarball: https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.6.tgz}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.23.8
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/ui-utils@1.2.5':
resolution: {integrity: sha512-XDgqnJcaCkDez7qolvk+PDbs/ceJvgkNkxkOlc9uDWqxfDJxtvCZ+14MP/1qr4IBwGIgKVHzMDYDXvqVhSWLzg==, tarball: https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.5.tgz}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==, tarball: https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz}
engines: {node: '>=10'}
@ -3942,18 +3987,33 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz}
engines: {node: '>= 0.4'}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==, tarball: https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz}
hast-util-parse-selector@2.2.5:
resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==, tarball: https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==, tarball: https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz}
hast-util-to-jsx-runtime@2.3.2:
resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz}
hast-util-to-parse5@8.0.0:
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==, tarball: https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz}
hastscript@6.0.0:
resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==, tarball: https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz}
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==, tarball: https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz}
@ -3976,6 +4036,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, tarball: https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==, tarball: https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz}
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==, tarball: https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz}
engines: {node: '>= 0.8'}
@ -4480,6 +4543,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, tarball: https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz}
@ -5236,6 +5302,9 @@ packages:
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==, tarball: https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz}
property-information@7.0.0:
resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz}
protobufjs@7.4.0:
resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz}
engines: {node: '>=12.0.0'}
@ -5492,6 +5561,9 @@ packages:
resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==, tarball: https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz}
engines: {node: '>= 0.4'}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz}
remark-gfm@4.0.0:
resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz}
@ -5599,6 +5671,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, tarball: https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz}
secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==, tarball: https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz}
semver@7.6.2:
resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==, tarball: https://registry.npmjs.org/semver/-/semver-7.6.2.tgz}
engines: {node: '>=10'}
@ -5840,6 +5915,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz}
engines: {node: '>= 0.4'}
swr@2.3.3:
resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==, tarball: https://registry.npmjs.org/swr/-/swr-2.3.3.tgz}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, tarball: https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz}
@ -5877,6 +5957,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, tarball: https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==, tarball: https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz}
engines: {node: '>=18'}
tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz}
@ -6163,6 +6247,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmjs.org/vary/-/vary-1.1.2.tgz}
engines: {node: '>= 0.8'}
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, tarball: https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz}
vfile-message@4.0.2:
resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz}
@ -6274,6 +6361,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, tarball: https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, tarball: https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, tarball: https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz}
engines: {node: '>=12'}
@ -6405,6 +6495,11 @@ packages:
yup@1.6.1:
resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==, tarball: https://registry.npmjs.org/yup/-/yup-1.6.1.tgz}
zod-to-json-schema@3.24.5:
resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==, tarball: https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz}
peerDependencies:
zod: ^3.24.1
zod-validation-error@3.4.0:
resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==, tarball: https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz}
engines: {node: '>=18.0.0'}
@ -6424,6 +6519,45 @@ snapshots:
'@adobe/css-tools@4.4.1': {}
'@ai-sdk/provider-utils@2.2.4(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.0
nanoid: 3.3.8
secure-json-parse: 2.7.0
zod: 3.24.3
'@ai-sdk/provider-utils@2.2.6(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.2
nanoid: 3.3.8
secure-json-parse: 2.7.0
zod: 3.24.3
'@ai-sdk/provider@1.1.0':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@1.1.2':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.2.6(react@18.3.1)(zod@3.24.3)':
dependencies:
'@ai-sdk/provider-utils': 2.2.4(zod@3.24.3)
'@ai-sdk/ui-utils': 1.2.5(zod@3.24.3)
react: 18.3.1
swr: 2.3.3(react@18.3.1)
throttleit: 2.1.0
optionalDependencies:
zod: 3.24.3
'@ai-sdk/ui-utils@1.2.5(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.0
'@ai-sdk/provider-utils': 2.2.4(zod@3.24.3)
zod: 3.24.3
zod-to-json-schema: 3.24.5(zod@3.24.3)
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@ -10183,8 +10317,39 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
devlop: 1.1.0
hastscript: 9.0.1
property-information: 7.0.0
vfile: 6.0.3
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-parse-selector@2.2.5: {}
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw@9.1.0:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
'@ungap/structured-clone': 1.3.0
hast-util-from-parse5: 8.0.3
hast-util-to-parse5: 8.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
parse5: 7.1.2
unist-util-position: 5.0.0
unist-util-visit: 5.0.0
vfile: 6.0.3
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.2:
dependencies:
'@types/estree': 1.0.6
@ -10205,6 +10370,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.0:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 6.5.0
space-separated-tokens: 2.0.2
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@ -10217,6 +10392,14 @@ snapshots:
property-information: 5.6.0
space-separated-tokens: 1.1.5
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.0.0
space-separated-tokens: 2.0.2
headers-polyfill@4.0.3: {}
highlight.js@10.7.3: {}
@ -10235,6 +10418,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
@ -10962,6 +11147,8 @@ snapshots:
json-schema-traverse@0.4.1:
optional: true
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1:
optional: true
@ -11986,6 +12173,8 @@ snapshots:
property-information@6.5.0: {}
property-information@7.0.0: {}
protobufjs@7.4.0:
dependencies:
'@protobufjs/aspromise': 1.1.2
@ -12303,6 +12492,12 @@ snapshots:
define-properties: 1.2.1
set-function-name: 2.0.1
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
remark-gfm@4.0.0:
dependencies:
'@types/mdast': 4.0.3
@ -12442,6 +12637,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
secure-json-parse@2.7.0: {}
semver@7.6.2: {}
send@0.19.0:
@ -12695,6 +12892,12 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swr@2.3.3(react@18.3.1):
dependencies:
dequal: 2.0.3
react: 18.3.1
use-sync-external-store: 1.4.0(react@18.3.1)
symbol-tree@3.2.4: {}
tailwind-merge@2.6.0: {}
@ -12753,6 +12956,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
throttleit@2.1.0: {}
tiny-case@1.0.3: {}
tiny-invariant@1.3.3: {}
@ -13043,6 +13248,11 @@ snapshots:
vary@1.1.2: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
vfile: 6.0.3
vfile-message@4.0.2:
dependencies:
'@types/unist': 3.0.3
@ -13139,6 +13349,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-namespaces@2.0.1: {}
webidl-conversions@7.0.0: {}
webpack-sources@3.2.3: {}
@ -13253,6 +13465,10 @@ snapshots:
toposort: 2.0.2
type-fest: 2.19.0
zod-to-json-schema@3.24.5(zod@3.24.3):
dependencies:
zod: 3.24.3
zod-validation-error@3.4.0(zod@3.24.3):
dependencies:
zod: 3.24.3

View File

@ -827,6 +827,13 @@ class ApiMethods {
return response.data;
};
getDeploymentLLMs = async (): Promise<TypesGen.LanguageModelConfig> => {
const response = await this.axios.get<TypesGen.LanguageModelConfig>(
"/api/v2/deployment/llms",
);
return response.data;
};
getOrganizationIdpSyncClaimFieldValues = async (
organization: string,
field: string,
@ -2489,6 +2496,23 @@ class ApiMethods {
markAllInboxNotificationsAsRead = async () => {
await this.axios.put<void>("/api/v2/notifications/inbox/mark-all-as-read");
};
createChat = async () => {
const res = await this.axios.post<TypesGen.Chat>("/api/v2/chats");
return res.data;
};
getChats = async () => {
const res = await this.axios.get<TypesGen.Chat[]>("/api/v2/chats");
return res.data;
};
getChatMessages = async (chatId: string) => {
const res = await this.axios.get<TypesGen.ChatMessage[]>(
`/api/v2/chats/${chatId}/messages`,
);
return res.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,

View File

@ -0,0 +1,25 @@
import { API } from "api/api";
import type { QueryClient } from "react-query";
export const createChat = (queryClient: QueryClient) => {
return {
mutationFn: API.createChat,
onSuccess: async () => {
await queryClient.invalidateQueries(["chats"]);
},
};
};
export const getChats = () => {
return {
queryKey: ["chats"],
queryFn: API.getChats,
};
};
export const getChatMessages = (chatID: string) => {
return {
queryKey: ["chatMessages", chatID],
queryFn: () => API.getChatMessages(chatID),
};
};

View File

@ -36,3 +36,10 @@ export const deploymentIdpSyncFieldValues = (field: string) => {
queryFn: () => API.getDeploymentIdpSyncFieldValues(field),
};
};
export const deploymentLanguageModels = () => {
return {
queryKey: ["deployment", "llms"],
queryFn: API.getDeploymentLLMs,
};
};

View File

@ -0,0 +1,16 @@
import { experiments } from "api/queries/experiments";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { useQuery } from "react-query";
interface AgenticChat {
readonly enabled: boolean;
}
export const useAgenticChat = (): AgenticChat => {
const { metadata } = useEmbeddedMetadata();
const enabledExperimentsQuery = useQuery(experiments(metadata.experiments));
return {
enabled: enabledExperimentsQuery.data?.includes("agentic-chat") ?? false,
};
};

View File

@ -4,6 +4,7 @@ import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { useAgenticChat } from "contexts/useAgenticChat";
import { useWebpushNotifications } from "contexts/useWebpushNotifications";
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
import type { FC } from "react";
@ -45,8 +46,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewAuditLog,
proxyContextValue,
}) => {
const { subscribed, enabled, loading, subscribe, unsubscribe } =
useWebpushNotifications();
const webPush = useWebpushNotifications();
return (
<div className="border-0 border-b border-solid h-[72px] flex items-center leading-none px-6">
@ -76,13 +76,21 @@ export const NavbarView: FC<NavbarViewProps> = ({
/>
</div>
{enabled ? (
subscribed ? (
<Button variant="outline" disabled={loading} onClick={unsubscribe}>
{webPush.enabled ? (
webPush.subscribed ? (
<Button
variant="outline"
disabled={webPush.loading}
onClick={webPush.unsubscribe}
>
Disable WebPush
</Button>
) : (
<Button variant="outline" disabled={loading} onClick={subscribe}>
<Button
variant="outline"
disabled={webPush.loading}
onClick={webPush.subscribe}
>
Enable WebPush
</Button>
)
@ -132,6 +140,7 @@ interface NavItemsProps {
const NavItems: FC<NavItemsProps> = ({ className }) => {
const location = useLocation();
const agenticChat = useAgenticChat();
return (
<nav className={cn("flex items-center gap-4 h-full", className)}>
@ -154,6 +163,16 @@ const NavItems: FC<NavItemsProps> = ({ className }) => {
>
Templates
</NavLink>
{agenticChat.enabled ? (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, isActive ? linkStyles.active : "");
}}
to="/chat"
>
Chat
</NavLink>
) : null}
</nav>
);
};

View File

@ -0,0 +1,164 @@
import { useTheme } from "@emotion/react";
import SendIcon from "@mui/icons-material/Send";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { createChat } from "api/queries/chats";
import type { Chat } from "api/typesGenerated";
import { Margins } from "components/Margins/Margins";
import { useAuthenticated } from "hooks";
import { type FC, type FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { LanguageModelSelector } from "./LanguageModelSelector";
export interface ChatLandingLocationState {
chat: Chat;
message: string;
}
const ChatLanding: FC = () => {
const { user } = useAuthenticated();
const theme = useTheme();
const [input, setInput] = useState("");
const navigate = useNavigate();
const queryClient = useQueryClient();
const createChatMutation = useMutation(createChat(queryClient));
return (
<Margins>
<div
css={{
display: "flex",
flexDirection: "column",
marginTop: theme.spacing(24),
alignItems: "center",
paddingBottom: theme.spacing(4),
}}
>
{/* Initial Welcome Message Area */}
<div
css={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: theme.spacing(1),
padding: theme.spacing(1),
width: "100%",
maxWidth: "700px",
marginBottom: theme.spacing(4),
}}
>
<h1
css={{
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.h4.fontWeight,
lineHeight: theme.typography.h4.lineHeight,
marginBottom: theme.spacing(1),
textAlign: "center",
}}
>
Good evening, {user?.name.split(" ")[0]}
</h1>
<p
css={{
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
lineHeight: theme.typography.h6.lineHeight,
color: theme.palette.text.secondary,
textAlign: "center",
margin: 0,
maxWidth: "500px",
marginInline: "auto",
}}
>
How can I help you today?
</p>
</div>
{/* Input Form and Suggestions - Always Visible */}
<div css={{ width: "100%", maxWidth: "700px", marginTop: "auto" }}>
<Stack
direction="row"
spacing={2}
justifyContent="center"
sx={{ mb: 2 }}
>
<Button
variant="outlined"
onClick={() => setInput("Help me work on issue #...")}
>
Work on Issue
</Button>
<Button
variant="outlined"
onClick={() => setInput("Help me build a template for...")}
>
Build a Template
</Button>
<Button
variant="outlined"
onClick={() => setInput("Help me start a new project using...")}
>
Start a Project
</Button>
</Stack>
<LanguageModelSelector />
<Paper
component="form"
onSubmit={async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setInput("");
const chat = await createChatMutation.mutateAsync();
navigate(`/chat/${chat.id}`, {
state: {
chat,
message: input,
},
});
}}
elevation={2}
css={{
padding: "16px",
display: "flex",
alignItems: "center",
width: "100%",
borderRadius: "12px",
border: `1px solid ${theme.palette.divider}`,
}}
>
<TextField
value={input}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
}}
placeholder="Ask Coder..."
required
fullWidth
variant="outlined"
multiline
maxRows={5}
css={{
marginRight: theme.spacing(1),
"& .MuiOutlinedInput-root": {
borderRadius: "8px",
padding: "10px 14px",
},
}}
autoFocus
/>
<IconButton type="submit" color="primary" disabled={!input.trim()}>
<SendIcon />
</IconButton>
</Paper>
</div>
</div>
</Margins>
);
};
export default ChatLanding;

View File

@ -0,0 +1,246 @@
import { useTheme } from "@emotion/react";
import AddIcon from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Paper from "@mui/material/Paper";
import { createChat, getChats } from "api/queries/chats";
import { deploymentLanguageModels } from "api/queries/deployment";
import type { LanguageModelConfig } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { useAgenticChat } from "contexts/useAgenticChat";
import {
type FC,
type PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, Outlet, useNavigate, useParams } from "react-router-dom";
export interface ChatContext {
selectedModel: string;
modelConfig: LanguageModelConfig;
setSelectedModel: (model: string) => void;
}
export const useChatContext = (): ChatContext => {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChatContext must be used within a ChatProvider");
}
return context;
};
export const ChatContext = createContext<ChatContext | undefined>(undefined);
const SELECTED_MODEL_KEY = "coder_chat_selected_model";
const ChatProvider: FC<PropsWithChildren> = ({ children }) => {
const [selectedModel, setSelectedModel] = useState<string>(() => {
const savedModel = localStorage.getItem(SELECTED_MODEL_KEY);
return savedModel || "";
});
const modelConfigQuery = useQuery(deploymentLanguageModels());
useEffect(() => {
if (!modelConfigQuery.data) {
return;
}
if (selectedModel === "") {
const firstModel = modelConfigQuery.data.models[0]?.id; // Handle empty models array
if (firstModel) {
setSelectedModel(firstModel);
localStorage.setItem(SELECTED_MODEL_KEY, firstModel);
}
}
}, [modelConfigQuery.data, selectedModel]);
if (modelConfigQuery.error) {
return <ErrorAlert error={modelConfigQuery.error} />;
}
if (!modelConfigQuery.data) {
return <Loader fullscreen />;
}
const handleSetSelectedModel = (model: string) => {
setSelectedModel(model);
localStorage.setItem(SELECTED_MODEL_KEY, model);
};
return (
<ChatContext.Provider
value={{
selectedModel,
modelConfig: modelConfigQuery.data,
setSelectedModel: handleSetSelectedModel,
}}
>
{children}
</ChatContext.Provider>
);
};
export const ChatLayout: FC = () => {
const agenticChat = useAgenticChat();
const queryClient = useQueryClient();
const { data: chats, isLoading: chatsLoading } = useQuery(getChats());
const createChatMutation = useMutation(createChat(queryClient));
const theme = useTheme();
const navigate = useNavigate();
const { chatID } = useParams<{ chatID?: string }>();
const handleNewChat = () => {
navigate("/chat");
};
if (!agenticChat.enabled) {
return (
<Margins>
<div
css={{
display: "flex",
flexDirection: "column",
marginTop: "24px",
alignItems: "center",
paddingBottom: "16px",
}}
>
<h1>Agentic Chat is not enabled</h1>
<p>
Agentic Chat is an experimental feature and is not enabled by
default. Please contact your administrator for more information.
</p>
</div>
</Margins>
);
}
return (
// Outermost container: controls height and prevents page scroll
<div
css={{
display: "flex",
height: "calc(100vh - 164px)", // Assuming header height is 64px
overflow: "hidden",
}}
>
{/* Sidebar Container (using Paper for background/border) */}
<Paper
elevation={1}
square // Removes border-radius
css={{
width: 260,
flexShrink: 0,
borderRight: `1px solid ${theme.palette.divider}`,
display: "flex",
flexDirection: "column",
height: "100%", // Take full height of the parent flex container
backgroundColor: theme.palette.background.paper,
}}
>
{/* Sidebar Header */}
<div
css={{
padding: theme.spacing(1.5, 2),
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderBottom: `1px solid ${theme.palette.divider}`,
flexShrink: 0,
}}
>
{/* Replaced Typography with div + styling */}
<div
css={{
fontWeight: 600,
fontSize: theme.typography.subtitle1.fontSize,
lineHeight: theme.typography.subtitle1.lineHeight,
}}
>
Chats
</div>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon fontSize="small" />}
onClick={handleNewChat}
disabled={createChatMutation.isLoading}
css={{
lineHeight: 1.5,
padding: theme.spacing(0.5, 1.5),
}}
>
New Chat
</Button>
</div>
{/* Sidebar Scrollable List Area */}
<div css={{ overflowY: "auto", flexGrow: 1 }}>
{chatsLoading ? (
<Loader />
) : chats && chats.length > 0 ? (
<List dense>
{chats.map((chat) => (
<ListItem key={chat.id} disablePadding>
<ListItemButton
component={Link}
to={`/chat/${chat.id}`}
selected={chatID === chat.id}
css={{
padding: theme.spacing(1, 2),
}}
>
<ListItemText
primary={chat.title || `Chat ${chat.id}`}
primaryTypographyProps={{
noWrap: true,
variant: "body2",
style: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
) : (
// Replaced Typography with div + styling
<div
css={{
padding: theme.spacing(2),
textAlign: "center",
fontSize: theme.typography.body2.fontSize,
color: theme.palette.text.secondary,
}}
>
No chats yet. Start a new one!
</div>
)}
</div>
</Paper>
{/* Main Content Area Container */}
<div
css={{
flexGrow: 1, // Takes remaining width
height: "100%", // Takes full height of parent
overflow: "hidden", // Prevents this container from scrolling
display: "flex",
flexDirection: "column", // Stacks ChatProvider/Outlet
position: "relative", // Context for potential absolute children
backgroundColor: theme.palette.background.default, // Ensure background consistency
}}
>
<ChatProvider>
{/* Outlet renders ChatMessages, which should have its own internal scroll */}
<Outlet />
</ChatProvider>
</div>
</div>
);
};

View File

@ -0,0 +1,491 @@
import { type Message, useChat } from "@ai-sdk/react";
import { type Theme, keyframes, useTheme } from "@emotion/react";
import SendIcon from "@mui/icons-material/Send";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import { getChatMessages } from "api/queries/chats";
import type { ChatMessage, CreateChatMessageRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import {
type FC,
type KeyboardEvent,
memo,
useCallback,
useEffect,
useRef,
} from "react";
import ReactMarkdown from "react-markdown";
import { useQuery } from "react-query";
import { useLocation, useParams } from "react-router-dom";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import type { ChatLandingLocationState } from "./ChatLanding";
import { useChatContext } from "./ChatLayout";
import { ChatToolInvocation } from "./ChatToolInvocation";
import { LanguageModelSelector } from "./LanguageModelSelector";
const fadeIn = keyframes`
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const renderReasoning = (reasoning: string, theme: Theme) => (
<div
css={{
marginTop: theme.spacing(1),
marginLeft: theme.spacing(2),
borderLeft: `2px solid ${theme.palette.grey[400]}`,
paddingLeft: theme.spacing(1.5),
fontStyle: "italic",
color: theme.palette.text.secondary,
animation: `${fadeIn} 0.3s ease-out`,
fontSize: "0.875em",
}}
>
<div
css={{
color: theme.palette.grey[700],
fontWeight: 500,
marginBottom: theme.spacing(0.5),
}}
>
💭 Reasoning:
</div>
<div
css={{
whiteSpace: "pre-wrap",
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(1.5),
borderRadius: "6px",
fontSize: "0.95em",
lineHeight: 1.5,
}}
>
{reasoning}
</div>
</div>
);
interface MessageBubbleProps {
message: Message;
}
const MessageBubble: FC<MessageBubbleProps> = memo(({ message }) => {
const theme = useTheme();
const isUser = message.role === "user";
return (
<div
css={{
display: "flex",
justifyContent: isUser ? "flex-end" : "flex-start",
maxWidth: "80%",
marginLeft: isUser ? "auto" : 0,
animation: `${fadeIn} 0.3s ease-out`,
}}
>
<Paper
elevation={isUser ? 1 : 0}
variant={isUser ? "elevation" : "outlined"}
css={{
padding: theme.spacing(1.25, 1.75),
fontSize: "0.925rem",
lineHeight: 1.5,
backgroundColor: isUser
? theme.palette.grey[900]
: theme.palette.background.paper,
borderColor: !isUser ? theme.palette.divider : undefined,
color: isUser ? theme.palette.grey[50] : theme.palette.text.primary,
borderRadius: "16px",
borderBottomRightRadius: isUser ? "4px" : "16px",
borderBottomLeftRadius: isUser ? "16px" : "4px",
width: "auto",
maxWidth: "100%",
"& img": {
maxWidth: "100%",
maxHeight: "400px",
height: "auto",
borderRadius: "8px",
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
"& p": {
margin: theme.spacing(1, 0),
"&:first-of-type": {
marginTop: 0,
},
"&:last-of-type": {
marginBottom: 0,
},
},
"& ul, & ol": {
margin: theme.spacing(1.5, 0),
paddingLeft: theme.spacing(3),
},
"& li": {
margin: theme.spacing(0.5, 0),
},
"& code:not(pre > code)": {
backgroundColor: isUser
? theme.palette.grey[700]
: theme.palette.action.hover,
color: isUser ? theme.palette.grey[50] : theme.palette.text.primary,
padding: theme.spacing(0.25, 0.75),
borderRadius: "4px",
fontSize: "0.875em",
fontFamily: "monospace",
},
"& pre": {
backgroundColor: isUser
? theme.palette.common.black
: theme.palette.grey[100],
color: isUser
? theme.palette.grey[100]
: theme.palette.text.primary,
padding: theme.spacing(1.5),
borderRadius: "8px",
overflowX: "auto",
margin: theme.spacing(1.5, 0),
width: "100%",
"& code": {
backgroundColor: "transparent",
padding: 0,
fontSize: "0.875em",
fontFamily: "monospace",
color: "inherit",
},
},
"& a": {
color: isUser
? theme.palette.grey[100]
: theme.palette.primary.main,
textDecoration: "underline",
fontWeight: 500,
"&:hover": {
textDecoration: "none",
color: isUser
? theme.palette.grey[300]
: theme.palette.primary.dark,
},
},
}}
>
{message.role === "assistant" && message.parts ? (
<div>
{message.parts.map((part) => {
switch (part.type) {
case "text":
return (
<ReactMarkdown
key={message.id}
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
css={{
"& pre": {
backgroundColor: theme.palette.background.default,
},
}}
>
{part.text}
</ReactMarkdown>
);
case "tool-invocation":
return (
<div key={message.id}>
<ChatToolInvocation
toolInvocation={
part.toolInvocation as ChatToolInvocation
}
/>
</div>
);
case "reasoning":
return (
<div key={message.id}>
{renderReasoning(part.reasoning, theme)}
</div>
);
default:
return null;
}
})}
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
>
{message.content}
</ReactMarkdown>
)}
</Paper>
</div>
);
});
interface ChatViewProps {
messages: Message[];
input: string;
handleInputChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
>;
handleSubmit: (e?: React.FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
chatID: string;
}
const ChatView: FC<ChatViewProps> = ({
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
}) => {
const theme = useTheme();
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatContext = useChatContext();
useEffect(() => {
const timer = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
});
}, 50);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
};
return (
<div
css={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: theme.palette.background.default,
}}
>
<div
css={{
flexGrow: 1,
overflowY: "auto",
padding: theme.spacing(3),
}}
>
<div
css={{
maxWidth: "900px",
width: "100%",
margin: "0 auto",
display: "flex",
flexDirection: "column",
gap: theme.spacing(3),
}}
>
{messages.map((message) => (
<MessageBubble key={`message-${message.id}`} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</div>
<div
css={{
width: "100%",
maxWidth: "900px",
margin: "0 auto",
padding: theme.spacing(2, 3, 2, 3),
backgroundColor: theme.palette.background.default,
borderTop: `1px solid ${theme.palette.divider}`,
flexShrink: 0,
}}
>
<Paper
component="form"
onSubmit={handleSubmit}
elevation={0}
variant="outlined"
css={{
padding: theme.spacing(0.5, 0.5, 0.5, 1.5),
display: "flex",
alignItems: "flex-start",
width: "100%",
borderRadius: "12px",
backgroundColor: theme.palette.background.paper,
transition: "border-color 0.2s ease",
"&:focus-within": {
borderColor: theme.palette.primary.main,
},
}}
>
<div
css={{
marginRight: theme.spacing(1),
alignSelf: "flex-end",
marginBottom: theme.spacing(0.5),
}}
>
<LanguageModelSelector />
</div>
<TextField
inputRef={inputRef}
value={input}
disabled={isLoading || chatContext.selectedModel === ""}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Ask Coder..."
fullWidth
variant="standard"
multiline
maxRows={5}
InputProps={{ disableUnderline: true }}
css={{
alignSelf: "center",
padding: theme.spacing(0.75, 0),
fontSize: "0.9rem",
}}
autoFocus
/>
<IconButton
type="submit"
color="primary"
disabled={
!input.trim() || isLoading || chatContext.selectedModel === ""
}
css={{
alignSelf: "flex-end",
marginBottom: theme.spacing(0.5),
transition: "transform 0.2s ease, background-color 0.2s ease",
"&:not(:disabled):hover": {
transform: "scale(1.1)",
backgroundColor: theme.palette.action.hover,
},
}}
>
<SendIcon />
</IconButton>
</Paper>
</div>
</div>
);
};
export const ChatMessages: FC = () => {
const { chatID } = useParams();
if (!chatID) {
throw new Error("Chat ID is required in URL path /chat/:chatID");
}
const { state } = useLocation();
const transferredState = state as ChatLandingLocationState | undefined;
const messagesQuery = useQuery<ChatMessage[], Error>(getChatMessages(chatID));
const chatContext = useChatContext();
const {
messages,
input,
handleInputChange,
handleSubmit: originalHandleSubmit,
isLoading,
setInput,
setMessages,
} = useChat({
id: chatID,
api: `/api/v2/chats/${chatID}/messages`,
experimental_prepareRequestBody: (options): CreateChatMessageRequest => {
const userMessages = options.messages.filter(
(message) => message.role === "user",
);
const mostRecentUserMessage = userMessages.at(-1);
return {
model: chatContext.selectedModel,
message: mostRecentUserMessage,
thinking: false,
};
},
initialInput: transferredState?.message,
initialMessages: messagesQuery.data as Message[] | undefined,
});
// Update messages from query data when it loads
useEffect(() => {
if (messagesQuery.data && messages.length === 0) {
setMessages(messagesQuery.data as Message[]);
}
}, [messagesQuery.data, messages.length, setMessages]);
const handleSubmitCallback = useCallback(
(e?: React.FormEvent<HTMLFormElement>) => {
if (e) e.preventDefault();
if (!input.trim()) return;
originalHandleSubmit();
setInput(""); // Clear input after submit
},
[input, originalHandleSubmit, setInput],
);
// Clear input and potentially submit on initial load with message
useEffect(() => {
if (transferredState?.message && input === transferredState.message) {
// Prevent submitting if messages already exist (e.g., browser back/forward)
if (messages.length === (messagesQuery.data?.length ?? 0)) {
handleSubmitCallback(); // Use the correct callback name
}
// Clear the state to prevent re-submission on subsequent renders/navigation
window.history.replaceState({}, document.title);
}
}, [
transferredState?.message,
input,
handleSubmitCallback,
messages.length,
messagesQuery.data?.length,
]); // Use the correct callback name
useEffect(() => {
if (transferredState?.message) {
// Logic potentially related to transferredState can go here if needed,
}
}, [transferredState?.message]);
if (messagesQuery.error) {
return <ErrorAlert error={messagesQuery.error} />;
}
if (messagesQuery.isLoading && messages.length === 0) {
return <Loader fullscreen />;
}
return (
<ChatView
key={chatID}
chatID={chatID}
messages={messages}
input={input}
handleInputChange={handleInputChange}
handleSubmit={handleSubmitCallback}
isLoading={isLoading}
/>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,872 @@
import type { ToolCall, ToolResult } from "@ai-sdk/provider-utils";
import { useTheme } from "@emotion/react";
import ArticleIcon from "@mui/icons-material/Article";
import BuildIcon from "@mui/icons-material/Build";
import CheckCircle from "@mui/icons-material/CheckCircle";
import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@mui/icons-material/Delete";
import ErrorIcon from "@mui/icons-material/Error";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import PersonIcon from "@mui/icons-material/Person";
import SettingsIcon from "@mui/icons-material/Settings";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { InfoIcon } from "lucide-react";
import type React from "react";
import { type FC, memo, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { TabLink, Tabs, TabsList } from "../../components/Tabs/Tabs";
interface ChatToolInvocationProps {
toolInvocation: ChatToolInvocation;
}
export const ChatToolInvocation: FC<ChatToolInvocationProps> = ({
toolInvocation,
}) => {
const theme = useTheme();
const friendlyName = useMemo(() => {
return toolInvocation.toolName
.replace("coder_", "")
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}, [toolInvocation.toolName]);
const hasError = useMemo(() => {
if (toolInvocation.state !== "result") {
return false;
}
return (
typeof toolInvocation.result === "object" &&
toolInvocation.result !== null &&
"error" in toolInvocation.result
);
}, [toolInvocation]);
const statusColor = useMemo(() => {
if (toolInvocation.state !== "result") {
return theme.palette.info.main;
}
return hasError ? theme.palette.error.main : theme.palette.success.main;
}, [toolInvocation, hasError, theme]);
const tooltipContent = useMemo(() => {
return (
<SyntaxHighlighter
language="json"
style={dracula}
css={{
maxHeight: 300,
overflow: "auto",
fontSize: 14,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(1),
scrollbarWidth: "thin",
scrollbarColor: "auto",
}}
>
{JSON.stringify(toolInvocation, null, 2)}
</SyntaxHighlighter>
);
}, [toolInvocation, theme.shape.borderRadius, theme.spacing]);
return (
<div
css={{
marginTop: theme.spacing(1),
marginBottom: theme.spacing(2),
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.75),
width: "fit-content",
}}
>
<div
css={{ display: "flex", alignItems: "center", gap: theme.spacing(1) }}
>
{toolInvocation.state !== "result" && (
<CircularProgress
size={16}
css={{
color: statusColor,
}}
/>
)}
{toolInvocation.state === "result" ? (
hasError ? (
<ErrorIcon sx={{ color: statusColor, fontSize: 16 }} />
) : (
<CheckCircle sx={{ color: statusColor, fontSize: 16 }} />
)
) : null}
<div
css={{
fontSize: "0.9rem",
fontWeight: 500,
color: theme.palette.text.primary,
}}
>
{friendlyName}
</div>
<Tooltip title={tooltipContent}>
<InfoIcon size={12} color={theme.palette.text.disabled} />
</Tooltip>
</div>
{toolInvocation.state === "result" ? (
<ChatToolInvocationResultPreview toolInvocation={toolInvocation} />
) : (
<ChatToolInvocationCallPreview toolInvocation={toolInvocation} />
)}
</div>
);
};
const ChatToolInvocationCallPreview: FC<{
toolInvocation: Extract<
ChatToolInvocation,
{ state: "call" | "partial-call" }
>;
}> = memo(({ toolInvocation }) => {
const theme = useTheme();
let content: React.ReactNode;
switch (toolInvocation.toolName) {
case "coder_upload_tar_file":
content = (
<FilePreview
files={toolInvocation.args?.files || {}}
prefix="Uploading files:"
/>
);
break;
}
if (!content) {
return null;
}
return <div css={{ paddingLeft: theme.spacing(3) }}>{content}</div>;
});
const ChatToolInvocationResultPreview: FC<{
toolInvocation: Extract<ChatToolInvocation, { state: "result" }>;
}> = memo(({ toolInvocation }) => {
const theme = useTheme();
if (!toolInvocation.result) {
return null;
}
if (
typeof toolInvocation.result === "object" &&
"error" in toolInvocation.result
) {
return null;
}
let content: React.ReactNode;
switch (toolInvocation.toolName) {
case "coder_get_workspace":
case "coder_create_workspace":
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
}}
>
{toolInvocation.result.template_icon && (
<img
src={toolInvocation.result.template_icon || "/icon/code.svg"}
alt={toolInvocation.result.template_display_name || "Template"}
css={{
width: 32,
height: 32,
borderRadius: theme.shape.borderRadius / 2,
objectFit: "contain",
}}
/>
)}
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>
{toolInvocation.result.name}
</div>
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{toolInvocation.result.template_display_name}
</div>
</div>
</div>
);
break;
case "coder_list_workspaces":
content = (
<div
css={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(1.5),
}}
>
{toolInvocation.result.map((workspace) => (
<div
key={workspace.id}
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
}}
>
{workspace.template_icon && (
<img
src={workspace.template_icon || "/icon/code.svg"}
alt={workspace.template_display_name || "Template"}
css={{
width: 32,
height: 32,
borderRadius: theme.shape.borderRadius / 2,
objectFit: "contain",
}}
/>
)}
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>
{workspace.name}
</div>
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{workspace.template_display_name}
</div>
</div>
</div>
))}
</div>
);
break;
case "coder_list_templates": {
const templates = toolInvocation.result;
content = (
<div
css={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(1.5),
}}
>
{templates.map((template) => (
<div
key={template.id}
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
}}
>
<CodeIcon sx={{ width: 32, height: 32 }} />
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>
{template.name}
</div>
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 200,
}}
title={template.description}
>
{template.description}
</div>
</div>
</div>
))}
{templates.length === 0 && <div>No templates found.</div>}
</div>
);
break;
}
case "coder_template_version_parameters": {
const params = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<SettingsIcon fontSize="small" />
{params.length > 0
? `${params.length} parameter(s)`
: "No parameters"}
</div>
);
break;
}
case "coder_get_authenticated_user": {
const user = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
}}
>
<Avatar src={user.avatar_url}>
<PersonIcon />
</Avatar>
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>
{user.username}
</div>
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{user.email}
</div>
</div>
</div>
);
break;
}
case "coder_create_workspace_build": {
const build = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<BuildIcon fontSize="small" />
Build #{build.build_number} ({build.transition}) status:{" "}
{build.status}
</div>
);
break;
}
case "coder_create_template_version": {
const version = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
}}
>
<CodeIcon fontSize="small" />
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>{version.name}</div>
{version.message && (
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{version.message}
</div>
)}
</div>
</div>
);
break;
}
case "coder_get_workspace_agent_logs":
case "coder_get_workspace_build_logs":
case "coder_get_template_version_logs": {
const logs = toolInvocation.result;
const totalLines = logs.length;
const maxLinesToShow = 5;
const lastLogs = logs.slice(-maxLinesToShow);
const hiddenLines = totalLines - lastLogs.length;
const totalLinesText = `${totalLines} log line${totalLines !== 1 ? "s" : ""}`;
const hiddenLinesText =
hiddenLines > 0
? `... hiding ${hiddenLines} more line${hiddenLines !== 1 ? "s" : ""} ...`
: null;
const logsToShow = hiddenLinesText
? [hiddenLinesText, ...lastLogs]
: lastLogs;
content = (
<div
css={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.5),
}}
>
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<ArticleIcon fontSize="small" />
Retrieved {totalLinesText}.
</div>
{logsToShow.length > 0 && (
<SyntaxHighlighter
language="log"
style={dracula}
customStyle={{
fontSize: "0.8rem",
padding: theme.spacing(1),
margin: 0,
maxHeight: 150,
overflowY: "auto",
scrollbarWidth: "thin",
scrollbarColor: "auto",
}}
showLineNumbers={false}
lineNumberStyle={{ display: "none" }}
>
{logsToShow.join("\n")}
</SyntaxHighlighter>
)}
</div>
);
break;
}
case "coder_update_template_active_version":
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<SettingsIcon fontSize="small" />
{toolInvocation.result}
</div>
);
break;
case "coder_upload_tar_file":
content = (
<FilePreview files={toolInvocation.args.files} prefix={"Uploaded!"} />
);
break;
case "coder_create_template": {
const template = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
}}
>
<img
src={template.icon || "/icon/code.svg"}
alt={template.display_name || "Template"}
css={{
width: 32,
height: 32,
borderRadius: theme.shape.borderRadius / 2,
objectFit: "contain",
}}
/>
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>
{template.name}
</div>
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{template.display_name}
</div>
</div>
</div>
);
break;
}
case "coder_delete_template":
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<DeleteIcon fontSize="small" />
{toolInvocation.result}
</div>
);
break;
case "coder_get_template_version": {
const version = toolInvocation.result;
content = (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
}}
>
<CodeIcon fontSize="small" />
<div>
<div css={{ fontWeight: 500, lineHeight: 1.4 }}>{version.name}</div>
{version.message && (
<div
css={{
fontSize: "0.875rem",
color: theme.palette.text.secondary,
lineHeight: 1.4,
}}
>
{version.message}
</div>
)}
</div>
</div>
);
break;
}
case "coder_download_tar_file": {
const files = toolInvocation.result;
content = <FilePreview files={files} prefix="Files:" />;
break;
}
// Add default case or handle other tools if necessary
}
return (
<div
css={{
paddingLeft: theme.spacing(3),
}}
>
{content}
</div>
);
});
// New component to preview files with tabs
const FilePreview: FC<{ files: Record<string, string>; prefix?: string }> =
memo(({ files, prefix }) => {
const theme = useTheme();
const [selectedTab, setSelectedTab] = useState(0);
const fileEntries = useMemo(() => Object.entries(files), [files]);
if (fileEntries.length === 0) {
return null;
}
const handleTabChange = (index: number) => {
setSelectedTab(index);
};
const getLanguage = (filename: string): string => {
if (filename.includes("Dockerfile")) {
return "dockerfile";
}
const extension = filename.split(".").pop()?.toLowerCase();
switch (extension) {
case "tf":
return "hcl";
case "json":
return "json";
case "yaml":
case "yml":
return "yaml";
case "js":
case "jsx":
return "javascript";
case "ts":
case "tsx":
return "typescript";
case "py":
return "python";
case "go":
return "go";
case "rb":
return "ruby";
case "java":
return "java";
case "sh":
return "bash";
case "md":
return "markdown";
default:
return "plaintext";
}
};
// Get filename and content based on the selectedTab index
const [selectedFilename, selectedContent] = fileEntries[selectedTab] ?? [
"",
"",
];
return (
<div
css={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(1),
width: "100%",
maxWidth: 400,
}}
>
{prefix && (
<div
css={{
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
fontSize: "0.875rem",
color: theme.palette.text.secondary,
}}
>
<FileUploadIcon fontSize="small" />
{prefix}
</div>
)}
{/* Use custom Tabs component with active prop */}
<Tabs active={selectedFilename} className="flex-shrink-0">
<TabsList>
{fileEntries.map(([filename], index) => (
<TabLink
key={filename}
value={filename} // This matches the 'active' prop on Tabs
to="" // Dummy link, not navigating
css={{ whiteSpace: "nowrap" }} // Prevent wrapping
onClick={(e) => {
e.preventDefault(); // Prevent any potential default link behavior
handleTabChange(index);
}}
>
{filename}
</TabLink>
))}
</TabsList>
</Tabs>
<SyntaxHighlighter
language={getLanguage(selectedFilename)}
style={vscDarkPlus}
customStyle={{
fontSize: "0.8rem",
padding: theme.spacing(1),
margin: 0,
maxHeight: 200,
overflowY: "auto",
scrollbarWidth: "thin",
scrollbarColor: "auto",
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
}}
showLineNumbers={false}
lineNumberStyle={{ display: "none" }}
>
{selectedContent}
</SyntaxHighlighter>
</div>
);
});
// TODO: generate these from codersdk/toolsdk.go.
export type ChatToolInvocation =
| ToolInvocation<
"coder_get_workspace",
{
workspace_id: string;
},
TypesGen.Workspace
>
| ToolInvocation<
"coder_create_workspace",
{
user: string;
template_version_id: string;
name: string;
rich_parameters: Record<string, string>;
},
TypesGen.Workspace
>
| ToolInvocation<
"coder_list_workspaces",
{
owner: string;
},
Pick<
TypesGen.Workspace,
| "id"
| "name"
| "template_id"
| "template_name"
| "template_display_name"
| "template_icon"
| "template_active_version_id"
| "outdated"
>[]
>
| ToolInvocation<
"coder_list_templates",
Record<string, never>,
Pick<
TypesGen.Template,
| "id"
| "name"
| "description"
| "active_version_id"
| "active_user_count"
>[]
>
| ToolInvocation<
"coder_template_version_parameters",
{
template_version_id: string;
},
TypesGen.TemplateVersionParameter[]
>
| ToolInvocation<
"coder_get_authenticated_user",
Record<string, never>,
TypesGen.User
>
| ToolInvocation<
"coder_create_workspace_build",
{
workspace_id: string;
template_version_id?: string;
transition: "start" | "stop" | "delete";
},
TypesGen.WorkspaceBuild
>
| ToolInvocation<
"coder_create_template_version",
{
template_id?: string;
file_id: string;
},
TypesGen.TemplateVersion
>
| ToolInvocation<
"coder_get_workspace_agent_logs",
{
workspace_agent_id: string;
},
string[]
>
| ToolInvocation<
"coder_get_workspace_build_logs",
{
workspace_build_id: string;
},
string[]
>
| ToolInvocation<
"coder_get_template_version_logs",
{
template_version_id: string;
},
string[]
>
| ToolInvocation<
"coder_get_template_version",
{
template_version_id: string;
},
TypesGen.TemplateVersion
>
| ToolInvocation<
"coder_download_tar_file",
{
file_id: string;
},
Record<string, string>
>
| ToolInvocation<
"coder_update_template_active_version",
{
template_id: string;
template_version_id: string;
},
string
>
| ToolInvocation<
"coder_upload_tar_file",
{
files: Record<string, string>;
},
TypesGen.UploadResponse
>
| ToolInvocation<
"coder_create_template",
{
name: string;
},
TypesGen.Template
>
| ToolInvocation<
"coder_delete_template",
{
template_id: string;
},
string
>;
type ToolInvocation<N extends string, A, R> =
| ({
state: "partial-call";
step?: number;
} & ToolCall<N, A>)
| ({
state: "call";
step?: number;
} & ToolCall<N, A>)
| ({
state: "result";
step?: number;
} & ToolResult<
N,
A,
| R
| {
error: string;
}
>);

View File

@ -0,0 +1,73 @@
import { useTheme } from "@emotion/react";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import { deploymentLanguageModels } from "api/queries/deployment";
import type { LanguageModel } from "api/typesGenerated"; // Assuming types live here based on project structure
import { Loader } from "components/Loader/Loader";
import type { FC } from "react";
import { useQuery } from "react-query";
import { useChatContext } from "./ChatLayout";
export const LanguageModelSelector: FC = () => {
const theme = useTheme();
const { setSelectedModel, modelConfig, selectedModel } = useChatContext();
const {
data: languageModelConfig,
isLoading,
error,
} = useQuery(deploymentLanguageModels());
if (isLoading) {
return <Loader size={14} />;
}
if (error || !languageModelConfig) {
console.error("Failed to load language models:", error);
return (
<div css={{ color: theme.palette.error.main }}>Error loading models.</div>
);
}
const models = Array.from(languageModelConfig.models).toSorted((a, b) => {
// Sort by provider first, then by display name
const compareProvider = a.provider.localeCompare(b.provider);
if (compareProvider !== 0) {
return compareProvider;
}
return a.display_name.localeCompare(b.display_name);
});
if (models.length === 0) {
return (
<div css={{ color: theme.palette.text.disabled }}>
No language models available.
</div>
);
}
return (
<FormControl fullWidth size="small">
<InputLabel id="model-select-label">Model</InputLabel>
<Select
labelId="model-select-label"
value={selectedModel}
label="Model"
onChange={(e) => setSelectedModel(e.target.value)}
disabled={isLoading || models.length === 0}
>
{!selectedModel && (
<MenuItem value="" disabled>
Select a model...
</MenuItem>
)}
{models.map((model: LanguageModel) => (
<MenuItem key={model.id} value={model.id}>
{model.display_name} ({model.provider})
</MenuItem>
))}
</Select>
</FormControl>
);
};

View File

@ -1,4 +1,6 @@
import { GlobalErrorBoundary } from "components/ErrorBoundary/GlobalErrorBoundary";
import { ChatLayout } from "pages/ChatPage/ChatLayout";
import { ChatMessages } from "pages/ChatPage/ChatMessages";
import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController";
import { Suspense, lazy } from "react";
import {
@ -31,6 +33,7 @@ const NotFoundPage = lazy(() => import("./pages/404Page/404Page"));
const DeploymentSettingsLayout = lazy(
() => import("./modules/management/DeploymentSettingsLayout"),
);
const ChatLanding = lazy(() => import("./pages/ChatPage/ChatLanding"));
const DeploymentConfigProvider = lazy(
() => import("./modules/management/DeploymentConfigProvider"),
);
@ -422,6 +425,11 @@ export const router = createBrowserRouter(
<Route path="/audit" element={<AuditPage />} />
<Route path="/chat" element={<ChatLayout />}>
<Route index element={<ChatLanding />} />
<Route path=":chatID" element={<ChatMessages />} />
</Route>
<Route path="/organizations" element={<OrganizationSettingsLayout />}>
<Route path="new" element={<CreateOrganizationPage />} />