feat: add experimental agents support (#22290)

feat: add AI chat system with agent tools and chat UI

Introduce the chatd subsystem and Agents UI for AI-powered chat
within Coder workspaces.

- Add chatd package with chat loop, message compaction, prompt
  management, and LLM provider integration (OpenAI, Anthropic)
- Add agent tools: create workspace, list/read templates, read/write/
  edit files, execute commands
- Add chat API endpoints with streaming, message editing, and
  durable reconnection
- Add database schema and migrations for chats, chat messages, chat
  providers, and chat model configs
- Add RBAC policies and dbauthz enforcement for chat resources
- Add Agents UI pages with conversation timeline, queued messages
  list, diff viewer, and model configuration panel
- Add comprehensive test coverage including coderd integration tests,
  chatd unit tests, and Storybook stories
- Gate feature behind experiments flag

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
Co-authored-by: Jeremy Ruppel <jeremy@coder.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle Carberry
2026-02-27 16:50:56 +00:00
committed by GitHub
co-authored by Cian Johnston Danielle Maywood Jeremy Ruppel Claude Sonnet 4.6
parent 67da4e8b56
commit edee917d88
201 changed files with 44828 additions and 1859 deletions
+16 -1
View File
@@ -1042,7 +1042,22 @@ const fillParameters = async (
case "number":
{
const parameterField = parameterLabel.locator("input");
await parameterField.fill(buildParameter.value);
// Dynamic parameters can hydrate after initial render and
// overwrite an early fill. Re-apply until the desired value
// is stable.
for (let attempt = 0; attempt < 3; attempt++) {
await parameterField.fill(buildParameter.value);
try {
await expect(parameterField).toHaveValue(buildParameter.value, {
timeout: 1000,
});
break;
} catch (error) {
if (attempt === 2) {
throw error;
}
}
}
}
break;
default:
+2
View File
@@ -38,6 +38,8 @@ module.exports = {
moduleNameMapper: {
"\\.css$": "<rootDir>/src/testHelpers/styleMock.ts",
"^@fontsource": "<rootDir>/src/testHelpers/styleMock.ts",
"^@pierre/diffs/react$":
"<rootDir>/src/testHelpers/pierreDiffsReactMock.tsx",
},
},
],
+3
View File
@@ -50,6 +50,7 @@
"@mui/material": "5.18.0",
"@mui/system": "5.18.0",
"@mui/x-tree-view": "7.29.10",
"@pierre/diffs": "1.0.11",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
@@ -91,6 +92,7 @@
"lodash": "4.17.21",
"lucide-react": "0.555.0",
"monaco-editor": "0.55.1",
"motion": "12.34.1",
"pretty-bytes": "6.1.1",
"react": "19.2.2",
"react-color": "2.19.3",
@@ -110,6 +112,7 @@
"resize-observer-polyfill": "1.5.1",
"semver": "7.7.3",
"sonner": "2.0.7",
"streamdown": "2.2.0",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"tzdata": "1.0.46",
+381
View File
@@ -64,6 +64,9 @@ importers:
'@mui/x-tree-view':
specifier: 7.29.10
version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@pierre/diffs':
specifier: 1.0.11
version: 1.0.11(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-avatar':
specifier: 1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
@@ -187,6 +190,9 @@ importers:
monaco-editor:
specifier: 0.55.1
version: 0.55.1
motion:
specifier: 12.34.1
version: 12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
pretty-bytes:
specifier: 6.1.1
version: 6.1.1
@@ -244,6 +250,9 @@ importers:
sonner:
specifier: 2.0.7
version: 2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
streamdown:
specifier: 2.2.0
version: 2.2.0(react@19.2.2)
tailwind-merge:
specifier: 2.6.0
version: 2.6.0
@@ -1477,6 +1486,12 @@ packages:
cpu: [x64]
os: [win32]
'@pierre/diffs@1.0.11':
resolution: {integrity: sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.11.tgz}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
engines: {node: '>=14'}
@@ -2141,6 +2156,30 @@ packages:
cpu: [x64]
os: [win32]
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==, tarball: https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==, tarball: https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==, tarball: https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==, tarball: https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==, tarball: https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz}
'@shikijs/transformers@3.22.0':
resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==, tarball: https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.22.0.tgz}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==, tarball: https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, tarball: https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, tarball: https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz}
@@ -3436,6 +3475,10 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==, tarball: https://registry.npmjs.org/diff/-/diff-4.0.2.tgz}
engines: {node: '>=0.3.1'}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==, tarball: https://registry.npmjs.org/diff/-/diff-8.0.3.tgz}
engines: {node: '>=0.3.1'}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==, tarball: https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz}
@@ -3761,6 +3804,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==, tarball: https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz}
framer-motion@12.34.1:
resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==, tarball: https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.1.tgz}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==, tarball: https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz}
engines: {node: '>= 0.6'}
@@ -3890,18 +3947,39 @@ 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-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==, tarball: https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==, tarball: https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==, tarball: https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==, tarball: https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.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}
@@ -3928,6 +4006,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'}
@@ -4541,6 +4622,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz}
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==, tarball: https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz}
lucide-react@0.555.0:
resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==, tarball: https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz}
peerDependencies:
@@ -4575,6 +4659,11 @@ packages:
engines: {node: '>= 18'}
hasBin: true
marked@17.0.2:
resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==, tarball: https://registry.npmjs.org/marked/-/marked-17.0.2.tgz}
engines: {node: '>= 20'}
hasBin: true
material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==, tarball: https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz}
@@ -4788,6 +4877,26 @@ packages:
moo-color@1.0.3:
resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==, tarball: https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz}
motion-dom@12.34.1:
resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==, tarball: https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.1.tgz}
motion-utils@12.29.2:
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==, tarball: https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz}
motion@12.34.1:
resolution: {integrity: sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA==, tarball: https://registry.npmjs.org/motion/-/motion-12.34.1.tgz}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmjs.org/ms/-/ms-2.0.0.tgz}
@@ -4889,6 +4998,12 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, tarball: https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz}
engines: {node: '>=6'}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==, tarball: https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz}
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==, tarball: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz}
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==, tarball: https://registry.npmjs.org/open/-/open-10.2.0.tgz}
engines: {node: '>=18'}
@@ -5379,10 +5494,28 @@ packages:
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, tarball: https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==, tarball: https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==, tarball: https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==, tarball: https://registry.npmjs.org/regex/-/regex-6.1.0.tgz}
regexp.prototype.flags@1.5.1:
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-harden@1.1.7:
resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==, tarball: https://registry.npmjs.org/rehype-harden/-/rehype-harden-1.1.7.tgz}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==, tarball: https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==, tarball: https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==, tarball: https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz}
@@ -5395,6 +5528,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==, tarball: https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz}
remend@1.2.0:
resolution: {integrity: sha512-NbKrdWweTRuByPYErzQCNpNtsR9M1QQ0hK2UzmnmlSaEqHnkQ5Korlyi8KpdbOJ0rImJfRy4EAY0uDxYnL9Plw==, tarball: https://registry.npmjs.org/remend/-/remend-1.2.0.tgz}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, tarball: https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz}
engines: {node: '>=0.10.0'}
@@ -5533,6 +5669,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz}
engines: {node: '>=8'}
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==, tarball: https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, tarball: https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz}
engines: {node: '>= 0.4'}
@@ -5655,6 +5794,11 @@ packages:
prettier:
optional: true
streamdown@2.2.0:
resolution: {integrity: sha512-Y51o1I/sjpAy4Yn7j7R4TbUl9gcUZ7BTrHS+68IhrUBoYpNQZ28z06vww1MBFu4mSwvgF8xQIxIH2b9S9IHDyQ==, tarball: https://registry.npmjs.org/streamdown/-/streamdown-2.2.0.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==, tarball: https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz}
@@ -5747,6 +5891,9 @@ packages:
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz}
tailwind-merge@3.4.1:
resolution: {integrity: sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==, tarball: https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz}
peerDependencies:
@@ -6070,6 +6217,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.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==, tarball: https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz}
@@ -6211,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'}
@@ -7468,6 +7621,18 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.14.0':
optional: true
'@pierre/diffs@1.0.11(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/transformers': 3.22.0
diff: 8.0.3
hast-util-to-html: 9.0.5
lru_map: 0.4.1
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
shiki: 3.22.0
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -8088,6 +8253,44 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.53.3':
optional: true
'@shikijs/core@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
'@shikijs/engine-oniguruma@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/themes@3.22.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/transformers@3.22.0':
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/types@3.22.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sinclair/typebox@0.27.8': {}
'@sinonjs/commons@3.0.0':
@@ -9414,6 +9617,8 @@ snapshots:
diff@4.0.2:
optional: true
diff@8.0.3: {}
dlv@1.1.3: {}
doctrine@3.0.0:
@@ -9840,6 +10045,16 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2):
dependencies:
motion-dom: 12.34.1
motion-utils: 12.29.2
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.4.0
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
fresh@0.5.2: {}
front-matter@4.0.2:
@@ -9978,8 +10193,59 @@ 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.1.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.1
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
parse5: 7.3.0
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-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -10000,6 +10266,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
devlop: 1.1.0
property-information: 7.1.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
@@ -10012,6 +10288,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.1.0
space-separated-tokens: 2.0.2
headers-polyfill@4.0.3: {}
highlight.js@10.7.3: {}
@@ -10034,6 +10318,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
http-errors@2.0.0:
dependencies:
depd: 2.0.0
@@ -10891,6 +11177,8 @@ snapshots:
dependencies:
yallist: 3.1.1
lru_map@0.4.1: {}
lucide-react@0.555.0(react@19.2.2):
dependencies:
react: 19.2.2
@@ -10918,6 +11206,8 @@ snapshots:
marked@14.0.0: {}
marked@17.0.2: {}
material-colors@1.2.6: {}
math-intrinsics@1.1.0: {}
@@ -11324,6 +11614,21 @@ snapshots:
dependencies:
color-name: 1.1.4
motion-dom@12.34.1:
dependencies:
motion-utils: 12.29.2
motion-utils@12.29.2: {}
motion@12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2):
dependencies:
framer-motion: 12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.4.0
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
ms@2.0.0: {}
ms@2.1.3: {}
@@ -11420,6 +11725,14 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.4:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
open@10.2.0:
dependencies:
default-browser: 5.5.0
@@ -11976,12 +12289,37 @@ snapshots:
regenerator-runtime@0.14.1: {}
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
regexp.prototype.flags@1.5.1:
dependencies:
call-bind: 1.0.7
define-properties: 1.2.1
set-function-name: 2.0.1
rehype-harden@1.1.7:
dependencies:
unist-util-visit: 5.0.0
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-raw: 9.1.0
vfile: 6.0.3
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -12016,6 +12354,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
remend@1.2.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -12175,6 +12515,17 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.22.0:
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/engine-oniguruma': 3.22.0
'@shikijs/langs': 3.22.0
'@shikijs/themes': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -12299,6 +12650,27 @@ snapshots:
- react-dom
- utf-8-validate
streamdown@2.2.0(react@19.2.2):
dependencies:
clsx: 2.1.1
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
marked: 17.0.2
react: 19.2.2
rehype-harden: 1.1.7
rehype-raw: 7.0.0
rehype-sanitize: 6.0.0
remark-gfm: 4.0.1
remark-parse: 11.0.0
remark-rehype: 11.1.2
remend: 1.2.0
tailwind-merge: 3.4.1
unified: 11.0.5
unist-util-visit: 5.0.0
unist-util-visit-parents: 6.0.1
transitivePeerDependencies:
- supports-color
strict-event-emitter@0.5.1: {}
string-length@4.0.2:
@@ -12389,6 +12761,8 @@ snapshots:
tailwind-merge@2.6.0: {}
tailwind-merge@3.4.1: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.18(yaml@2.7.0)):
dependencies:
tailwindcss: 3.4.18(yaml@2.7.0)
@@ -12707,6 +13081,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.3:
dependencies:
'@types/unist': 3.0.3
@@ -12823,6 +13202,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-namespaces@2.0.1: {}
webidl-conversions@7.0.0: {}
webidl-conversions@8.0.0: {}
+11 -8
View File
@@ -12,6 +12,7 @@ import { QueryClient, QueryClientProvider } from "react-query";
import { RouterProvider } from "react-router";
import { Toaster } from "./components/Toaster/Toaster";
import { AuthProvider } from "./contexts/auth/AuthProvider";
import { DiffsWorkerPoolProvider } from "./contexts/DiffsWorkerPoolProvider";
import { ThemeProvider } from "./contexts/ThemeProvider";
import { router } from "./router";
@@ -52,14 +53,16 @@ export const AppProviders: FC<AppProvidersProps> = ({
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<TooltipProvider delayDuration={100}>
{children}
<Toaster />
</TooltipProvider>
</ThemeProvider>
</AuthProvider>
<DiffsWorkerPoolProvider>
<AuthProvider>
<ThemeProvider>
<TooltipProvider delayDuration={100}>
{children}
<Toaster />
</TooltipProvider>
</ThemeProvider>
</AuthProvider>
</DiffsWorkerPoolProvider>
{showDevtools && <ReactQueryDevtools initialIsOpen={showDevtools} />}
</QueryClientProvider>
);
+46
View File
@@ -280,4 +280,50 @@ describe("api.ts", () => {
});
});
});
describe("chat configuration endpoints", () => {
it.each<[string, () => Promise<unknown>, unknown]>([
[
"/api/experimental/chats/models",
() => API.getChatModels(),
{
providers: [],
},
],
[
"/api/experimental/chats/providers",
() => API.getChatProviderConfigs(),
[],
],
[
"/api/experimental/chats/model-configs",
() => API.getChatModelConfigs(),
[],
],
])("returns response data for %s", async (path, request, responseData) => {
vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
data: responseData,
});
const result = await request();
expect(axiosInstance.get).toHaveBeenCalledWith(path);
expect(result).toStrictEqual(responseData);
});
it.each<[string, () => Promise<unknown>]>([
["/api/experimental/chats/models", () => API.getChatModels()],
["/api/experimental/chats/providers", () => API.getChatProviderConfigs()],
[
"/api/experimental/chats/model-configs",
() => API.getChatModelConfigs(),
],
])("rethrows axios errors for %s", async (path, request) => {
const expectedError = new Error("request failed");
vi.spyOn(axiosInstance, "get").mockRejectedValueOnce(expectedError);
await expect(request()).rejects.toBe(expectedError);
expect(axiosInstance.get).toHaveBeenCalledWith(path);
});
});
});
+220
View File
@@ -138,6 +138,20 @@ export const watchWorkspace = (
});
};
export const watchChat = (
chatId: string,
): OneWayWebSocket<TypesGen.ServerSentEvent> => {
return new OneWayWebSocket({
apiRoute: `/api/experimental/chats/${chatId}/stream`,
});
};
export const watchChats = (): OneWayWebSocket<TypesGen.ServerSentEvent> => {
return new OneWayWebSocket({
apiRoute: "/api/experimental/chats/watch",
});
};
export const watchAgentContainers = (
agentId: string,
): OneWayWebSocket<TypesGen.WorkspaceAgentListContainersResponse> => {
@@ -324,6 +338,29 @@ export type GetTemplatesQuery = Readonly<{
readonly q: string;
}>;
interface ChatGitChangeResponse extends TypesGen.ChatGitChange {
readonly patch?: string;
readonly diff_patch?: string;
readonly unified_diff?: string;
readonly diffs_url?: string;
readonly diff_url?: string;
readonly diffs_link?: string;
}
export type ChatDiffStatusResponse = Readonly<
{
chat_id: string;
url?: string;
pull_request_state?: string;
changes_requested: boolean;
additions: number;
deletions: number;
changed_files: number;
refreshed_at?: string;
stale_at?: string;
} & Record<string, unknown>
>;
function normalizeGetTemplatesOptions(
options: GetTemplatesOptions | GetTemplatesQuery = {},
): Record<string, string> {
@@ -357,6 +394,9 @@ export type DeploymentConfig = Readonly<{
options: TypesGen.SerpentOption[];
}>;
const chatProviderConfigsPath = "/api/experimental/chats/providers";
const chatModelConfigsPath = "/api/experimental/chats/model-configs";
type Claims = {
license_expires: number;
// nbf is a standard JWT claim for "not before" - the license valid from date
@@ -2865,6 +2905,186 @@ class ApiMethods {
return response.data;
};
// Chat API methods
getChats = async (): Promise<TypesGen.Chat[]> => {
const response = await this.axios.get<TypesGen.Chat[]>(
"/api/experimental/chats",
);
return response.data;
};
getChat = async (chatId: string): Promise<TypesGen.ChatWithMessages> => {
const response = await this.axios.get<TypesGen.ChatWithMessages>(
`/api/experimental/chats/${chatId}`,
);
return response.data;
};
createChat = async (
req: TypesGen.CreateChatRequest,
): Promise<TypesGen.Chat> => {
const response = await this.axios.post<TypesGen.Chat>(
"/api/experimental/chats",
req,
);
return response.data;
};
deleteChat = async (chatId: string): Promise<void> => {
await this.axios.delete(`/api/experimental/chats/${chatId}`);
};
createChatMessage = async (
chatId: string,
req: TypesGen.CreateChatMessageRequest,
): Promise<TypesGen.CreateChatMessageResponse> => {
const response = await this.axios.post<TypesGen.CreateChatMessageResponse>(
`/api/experimental/chats/${chatId}/messages`,
req,
);
return response.data;
};
editChatMessage = async (
chatId: string,
messageId: number,
req: TypesGen.EditChatMessageRequest,
): Promise<TypesGen.ChatMessage> => {
const response = await this.axios.patch<TypesGen.ChatMessage>(
`/api/experimental/chats/${chatId}/messages/${messageId}`,
req,
);
return response.data;
};
interruptChat = async (chatId: string): Promise<TypesGen.Chat> => {
const response = await this.axios.post<TypesGen.Chat>(
`/api/experimental/chats/${chatId}/interrupt`,
);
return response.data;
};
deleteChatQueuedMessage = async (
chatId: string,
queuedMessageId: number,
): Promise<void> => {
await this.axios.delete(
`/api/experimental/chats/${chatId}/queue/${queuedMessageId}`,
);
};
promoteChatQueuedMessage = async (
chatId: string,
queuedMessageId: number,
): Promise<TypesGen.ChatMessage> => {
const response = await this.axios.post<TypesGen.ChatMessage>(
`/api/experimental/chats/${chatId}/queue/${queuedMessageId}/promote`,
);
return response.data;
};
getChatGitChanges = async (
chatId: string,
): Promise<ChatGitChangeResponse[]> => {
const response = await this.axios.get<ChatGitChangeResponse[]>(
`/api/experimental/chats/${chatId}/git-changes`,
);
return response.data;
};
getChatDiffStatus = async (
chatId: string,
): Promise<ChatDiffStatusResponse> => {
const response = await this.axios.get<ChatDiffStatusResponse>(
`/api/experimental/chats/${chatId}/diff-status`,
);
return response.data;
};
getChatDiffContents = async (
chatId: string,
): Promise<TypesGen.ChatDiffContents> => {
const response = await this.axios.get<TypesGen.ChatDiffContents>(
`/api/experimental/chats/${chatId}/diff`,
);
return response.data;
};
getChatModels = async (): Promise<TypesGen.ChatModelsResponse> => {
const response = await this.axios.get<TypesGen.ChatModelsResponse>(
"/api/experimental/chats/models",
);
return response.data;
};
getChatProviderConfigs = async (): Promise<TypesGen.ChatProviderConfig[]> => {
const response = await this.axios.get<TypesGen.ChatProviderConfig[]>(
chatProviderConfigsPath,
);
return response.data;
};
createChatProviderConfig = async (
req: TypesGen.CreateChatProviderConfigRequest,
): Promise<TypesGen.ChatProviderConfig> => {
const response = await this.axios.post<TypesGen.ChatProviderConfig>(
chatProviderConfigsPath,
req,
);
return response.data;
};
updateChatProviderConfig = async (
providerConfigId: string,
req: TypesGen.UpdateChatProviderConfigRequest,
): Promise<TypesGen.ChatProviderConfig> => {
const response = await this.axios.patch<TypesGen.ChatProviderConfig>(
`${chatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`,
req,
);
return response.data;
};
deleteChatProviderConfig = async (
providerConfigId: string,
): Promise<void> => {
await this.axios.delete(
`${chatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`,
);
};
getChatModelConfigs = async (): Promise<TypesGen.ChatModelConfig[]> => {
const response =
await this.axios.get<TypesGen.ChatModelConfig[]>(chatModelConfigsPath);
return response.data;
};
createChatModelConfig = async (
req: TypesGen.CreateChatModelConfigRequest,
): Promise<TypesGen.ChatModelConfig> => {
const response = await this.axios.post<TypesGen.ChatModelConfig>(
chatModelConfigsPath,
req,
);
return response.data;
};
updateChatModelConfig = async (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
): Promise<TypesGen.ChatModelConfig> => {
const response = await this.axios.patch<TypesGen.ChatModelConfig>(
`${chatModelConfigsPath}/${encodeURIComponent(modelConfigId)}`,
req,
);
return response.data;
};
deleteChatModelConfig = async (modelConfigId: string): Promise<void> => {
await this.axios.delete(
`${chatModelConfigsPath}/${encodeURIComponent(modelConfigId)}`,
);
};
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
+188
View File
@@ -0,0 +1,188 @@
import { API, type ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { QueryClient } from "react-query";
export const chatsKey = ["chats"] as const;
export const chatKey = (chatId: string) => ["chats", chatId] as const;
export const chats = () => ({
queryKey: chatsKey,
queryFn: () => API.getChats(),
});
export const chat = (chatId: string) => ({
queryKey: chatKey(chatId),
queryFn: () => API.getChat(chatId),
});
export const createChat = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatRequest) => API.createChat(req),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: chatsKey });
},
});
export const deleteChat = (queryClient: QueryClient) => ({
mutationFn: (chatId: string) => API.deleteChat(chatId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: chatsKey });
},
});
export const createChatMessage = (
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (req: TypesGen.CreateChatMessageRequest) =>
API.createChatMessage(chatId, req),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: chatsKey });
},
});
type EditChatMessageMutationArgs = {
messageId: number;
req: TypesGen.EditChatMessageRequest;
};
export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
mutationFn: ({ messageId, req }: EditChatMessageMutationArgs) =>
API.editChatMessage(chatId, messageId, req),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: chatsKey }),
queryClient.invalidateQueries({ queryKey: chatKey(chatId) }),
]);
},
});
export const interruptChat = (queryClient: QueryClient, chatId: string) => ({
mutationFn: () => API.interruptChat(chatId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: chatsKey });
},
});
export const deleteChatQueuedMessage = (
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (queuedMessageId: number) =>
API.deleteChatQueuedMessage(chatId, queuedMessageId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: chatKey(chatId) });
},
});
export const promoteChatQueuedMessage = (
queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (queuedMessageId: number) =>
API.promoteChatQueuedMessage(chatId, queuedMessageId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: chatsKey }),
queryClient.invalidateQueries({ queryKey: chatKey(chatId) }),
]);
},
});
export const chatDiffStatusKey = (chatId: string) =>
["chats", chatId, "diff-status"] as const;
export const chatDiffStatus = (chatId: string) => ({
queryKey: chatDiffStatusKey(chatId),
queryFn: (): Promise<ChatDiffStatusResponse> => API.getChatDiffStatus(chatId),
});
export const chatDiffContentsKey = (chatId: string) =>
["chats", chatId, "diff-contents"] as const;
export const chatDiffContents = (chatId: string) => ({
queryKey: chatDiffContentsKey(chatId),
queryFn: () => API.getChatDiffContents(chatId),
});
export const chatModelsKey = ["chat-models"] as const;
export const chatModels = () => ({
queryKey: chatModelsKey,
queryFn: (): Promise<TypesGen.ChatModelsResponse> => API.getChatModels(),
});
export const chatProviderConfigsKey = ["chat-provider-configs"] as const;
export const chatProviderConfigs = () => ({
queryKey: chatProviderConfigsKey,
queryFn: (): Promise<TypesGen.ChatProviderConfig[]> =>
API.getChatProviderConfigs(),
});
export const chatModelConfigsKey = ["chat-model-configs"] as const;
export const chatModelConfigs = () => ({
queryKey: chatModelConfigsKey,
queryFn: (): Promise<TypesGen.ChatModelConfig[]> => API.getChatModelConfigs(),
});
const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }),
queryClient.invalidateQueries({ queryKey: chatModelConfigsKey }),
queryClient.invalidateQueries({ queryKey: chatModelsKey }),
]);
};
export const createChatProviderConfig = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatProviderConfigRequest) =>
API.createChatProviderConfig(req),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
type UpdateChatProviderConfigMutationArgs = {
providerConfigId: string;
req: TypesGen.UpdateChatProviderConfigRequest;
};
export const updateChatProviderConfig = (queryClient: QueryClient) => ({
mutationFn: ({
providerConfigId,
req,
}: UpdateChatProviderConfigMutationArgs) =>
API.updateChatProviderConfig(providerConfigId, req),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
export const createChatModelConfig = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatModelConfigRequest) =>
API.createChatModelConfig(req),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
type UpdateChatModelConfigMutationArgs = {
modelConfigId: string;
req: TypesGen.UpdateChatModelConfigRequest;
};
export const updateChatModelConfig = (queryClient: QueryClient) => ({
mutationFn: ({ modelConfigId, req }: UpdateChatModelConfigMutationArgs) =>
API.updateChatModelConfig(modelConfigId, req),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
export const deleteChatModelConfig = (queryClient: QueryClient) => ({
mutationFn: (modelConfigId: string) =>
API.deleteChatModelConfig(modelConfigId),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
+10
View File
@@ -38,6 +38,16 @@ export const workspaceByOwnerAndNameKey = (
name: string,
) => ["workspace", ownerUsername, name, "settings"];
export const workspaceByIdKey = (workspaceId: string) =>
["workspace", workspaceId] as const;
export const workspaceById = (workspaceId: string) => {
return {
queryKey: workspaceByIdKey(workspaceId),
queryFn: () => API.getWorkspace(workspaceId),
};
};
export const workspaceByOwnerAndName = (owner: string, name: string) => {
return {
queryKey: workspaceByOwnerAndNameKey(owner, name),
+6
View File
@@ -41,6 +41,12 @@ export const RBACResourceActions: Partial<
read: "read boundary usage statistics",
update: "upsert boundary usage statistics",
},
chat: {
create: "create a new chat",
delete: "delete a chat",
read: "read chat messages and metadata",
update: "update chat title or settings",
},
connection_log: {
read: "read connection logs",
update: "upsert connection log entries",
+628
View File
@@ -194,6 +194,11 @@ export type APIKeyScope =
| "boundary_usage:delete"
| "boundary_usage:read"
| "boundary_usage:update"
| "chat:*"
| "chat:create"
| "chat:delete"
| "chat:read"
| "chat:update"
| "coder:all"
| "coder:apikeys.manage_self"
| "coder:application_connect"
@@ -398,6 +403,11 @@ export const APIKeyScopes: APIKeyScope[] = [
"boundary_usage:delete",
"boundary_usage:read",
"boundary_usage:update",
"chat:*",
"chat:create",
"chat:delete",
"chat:read",
"chat:update",
"coder:all",
"coder:apikeys.manage_self",
"coder:application_connect",
@@ -1036,6 +1046,521 @@ export interface ChangePasswordWithOneTimePasscodeRequest {
readonly one_time_passcode: string;
}
// From codersdk/chats.go
/**
* Chat represents a chat session with an AI agent.
*/
export interface Chat {
readonly id: string;
readonly owner_id: string;
readonly workspace_id?: string;
readonly workspace_agent_id?: string;
readonly parent_chat_id?: string;
readonly root_chat_id?: string;
readonly last_model_config_id: string;
readonly title: string;
readonly status: ChatStatus;
readonly diff_status?: ChatDiffStatus;
readonly created_at: string;
readonly updated_at: string;
}
// From codersdk/chats.go
/**
* ChatDiffContents represents the resolved diff text for a chat.
*/
export interface ChatDiffContents {
readonly chat_id: string;
readonly provider?: string;
readonly remote_origin?: string;
readonly branch?: string;
readonly pull_request_url?: string;
readonly diff?: string;
}
// From codersdk/chats.go
/**
* ChatDiffStatus represents cached diff status for a chat. The URL
* may point to a pull request or a branch page depending on whether
* a PR has been opened.
*/
export interface ChatDiffStatus {
readonly chat_id: string;
readonly url?: string;
readonly pull_request_state?: string;
readonly changes_requested: boolean;
readonly additions: number;
readonly deletions: number;
readonly changed_files: number;
readonly refreshed_at?: string;
readonly stale_at?: string;
}
// From codersdk/chats.go
/**
* ChatGitChange represents a git file change detected during a chat session.
*/
export interface ChatGitChange {
readonly id: string;
readonly chat_id: string;
readonly file_path: string;
readonly change_type: string; // added, modified, deleted, renamed
readonly old_path?: string;
readonly diff_summary?: string;
readonly detected_at: string;
}
// From codersdk/chats.go
/**
* ChatInputPart is a single user input part for creating a chat.
*/
export interface ChatInputPart {
readonly type: ChatInputPartType;
readonly text?: string;
}
// From codersdk/chats.go
export type ChatInputPartType = "text";
export const ChatInputPartTypes: ChatInputPartType[] = ["text"];
// From codersdk/chats.go
/**
* ChatMessage represents a single message in a chat.
*/
export interface ChatMessage {
readonly id: number;
readonly chat_id: string;
readonly model_config_id?: string;
readonly created_at: string;
readonly role: string;
readonly content?: readonly ChatMessagePart[];
readonly usage?: ChatMessageUsage;
}
// From codersdk/chats.go
/**
* ChatMessagePart is a structured chunk of a chat message.
*/
export interface ChatMessagePart {
readonly type: ChatMessagePartType;
readonly text?: string;
readonly signature?: string;
readonly tool_call_id?: string;
readonly tool_name?: string;
readonly args?: Record<string, string>;
readonly args_delta?: string;
readonly result?: Record<string, string>;
readonly result_delta?: string;
readonly is_error?: boolean;
readonly source_id?: string;
readonly url?: string;
readonly title?: string;
readonly media_type?: string;
readonly data?: string;
}
// From codersdk/chats.go
export type ChatMessagePartType =
| "file"
| "reasoning"
| "source"
| "text"
| "tool-call"
| "tool-result";
export const ChatMessagePartTypes: ChatMessagePartType[] = [
"file",
"reasoning",
"source",
"text",
"tool-call",
"tool-result",
];
// From codersdk/chats.go
/**
* ChatMessageUsage contains token usage information for a chat message.
*/
export interface ChatMessageUsage {
readonly input_tokens?: number;
readonly output_tokens?: number;
readonly total_tokens?: number;
readonly reasoning_tokens?: number;
readonly cache_creation_tokens?: number;
readonly cache_read_tokens?: number;
readonly context_limit?: number;
}
// From codersdk/chats.go
/**
* ChatModel represents a model in the chat model catalog.
*/
export interface ChatModel {
readonly id: string;
readonly provider: string;
readonly model: string;
readonly display_name: string;
}
// From codersdk/chats.go
/**
* ChatModelAnthropicProviderOptions configures Anthropic provider behavior.
*/
export interface ChatModelAnthropicProviderOptions {
readonly send_reasoning?: boolean;
readonly thinking?: ChatModelAnthropicThinkingOptions;
readonly effort?: string;
readonly disable_parallel_tool_use?: boolean;
}
// From codersdk/chats.go
/**
* ChatModelAnthropicThinkingOptions configures Anthropic thinking budget.
*/
export interface ChatModelAnthropicThinkingOptions {
readonly budget_tokens?: number;
}
// From codersdk/chats.go
/**
* ChatModelCallConfig configures per-call model behavior defaults.
*/
export interface ChatModelCallConfig {
readonly max_output_tokens?: number;
readonly temperature?: number;
readonly top_p?: number;
readonly top_k?: number;
readonly presence_penalty?: number;
readonly frequency_penalty?: number;
readonly provider_options?: ChatModelProviderOptions;
}
// From codersdk/chats.go
/**
* ChatModelConfig is an admin-managed model configuration.
*/
export interface ChatModelConfig {
readonly id: string;
readonly provider: string;
readonly model: string;
readonly display_name: string;
readonly enabled: boolean;
readonly is_default: boolean;
readonly context_limit: number;
readonly compression_threshold: number;
readonly model_config?: ChatModelCallConfig;
readonly created_at: string;
readonly updated_at: string;
}
// From codersdk/chats.go
/**
* ChatModelGoogleProviderOptions configures Google provider behavior.
*/
export interface ChatModelGoogleProviderOptions {
readonly thinking_config?: ChatModelGoogleThinkingConfig;
readonly cached_content?: string;
readonly safety_settings?: readonly ChatModelGoogleSafetySetting[];
readonly threshold?: string;
}
// From codersdk/chats.go
/**
* ChatModelGoogleSafetySetting configures Google safety filtering.
*/
export interface ChatModelGoogleSafetySetting {
readonly category?: string;
readonly threshold?: string;
}
// From codersdk/chats.go
/**
* ChatModelGoogleThinkingConfig configures Google thinking behavior.
*/
export interface ChatModelGoogleThinkingConfig {
readonly thinking_budget?: number;
readonly include_thoughts?: boolean;
}
// From codersdk/chats.go
/**
* ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior.
*/
export interface ChatModelOpenAICompatProviderOptions {
readonly user?: string;
readonly reasoning_effort?: string;
}
// From codersdk/chats.go
/**
* ChatModelOpenAIProviderOptions configures OpenAI provider behavior.
*/
export interface ChatModelOpenAIProviderOptions {
readonly include?: readonly string[];
readonly instructions?: string;
readonly logit_bias?: Record<string, number>;
readonly log_probs?: boolean;
readonly top_log_probs?: number;
readonly max_tool_calls?: number;
readonly parallel_tool_calls?: boolean;
readonly user?: string;
readonly reasoning_effort?: string;
readonly reasoning_summary?: string;
readonly max_completion_tokens?: number;
readonly text_verbosity?: string;
// empty interface{} type, falling back to unknown
readonly prediction?: Record<string, unknown>;
readonly store?: boolean;
// empty interface{} type, falling back to unknown
readonly metadata?: Record<string, unknown>;
readonly prompt_cache_key?: string;
readonly safety_identifier?: string;
readonly service_tier?: string;
readonly structured_outputs?: boolean;
readonly strict_json_schema?: boolean;
}
// From codersdk/chats.go
/**
* ChatModelOpenRouterProvider configures OpenRouter routing preferences.
*/
export interface ChatModelOpenRouterProvider {
readonly order?: readonly string[];
readonly allow_fallbacks?: boolean;
readonly require_parameters?: boolean;
readonly data_collection?: string;
readonly only?: readonly string[];
readonly ignore?: readonly string[];
readonly quantizations?: readonly string[];
readonly sort?: string;
}
// From codersdk/chats.go
/**
* ChatModelOpenRouterProviderOptions configures OpenRouter provider behavior.
*/
export interface ChatModelOpenRouterProviderOptions {
readonly reasoning?: ChatModelOpenRouterReasoningOptions;
// empty interface{} type, falling back to unknown
readonly extra_body?: Record<string, unknown>;
readonly include_usage?: boolean;
readonly logit_bias?: Record<string, number>;
readonly log_probs?: boolean;
readonly parallel_tool_calls?: boolean;
readonly user?: string;
readonly provider?: ChatModelOpenRouterProvider;
}
// From codersdk/chats.go
/**
* ChatModelOpenRouterReasoningOptions configures OpenRouter reasoning behavior.
*/
export interface ChatModelOpenRouterReasoningOptions {
readonly enabled?: boolean;
readonly exclude?: boolean;
readonly max_tokens?: number;
readonly effort?: string;
}
// From codersdk/chats.go
/**
* ChatModelProvider represents provider availability and model results.
*/
export interface ChatModelProvider {
readonly provider: string;
readonly available: boolean;
readonly unavailable_reason?: ChatModelProviderUnavailableReason;
readonly models: readonly ChatModel[];
}
// From codersdk/chats.go
/**
* ChatModelProviderOptions contains typed provider-specific options.
*
* Note: Azure models use the `openai` options shape.
* Note: Bedrock models use the `anthropic` options shape.
*/
export interface ChatModelProviderOptions {
readonly openai?: ChatModelOpenAIProviderOptions;
readonly anthropic?: ChatModelAnthropicProviderOptions;
readonly google?: ChatModelGoogleProviderOptions;
readonly openaicompat?: ChatModelOpenAICompatProviderOptions;
readonly openrouter?: ChatModelOpenRouterProviderOptions;
readonly vercel?: ChatModelVercelProviderOptions;
}
// From codersdk/chats.go
export type ChatModelProviderUnavailableReason =
| "fetch_failed"
| "missing_api_key";
export const ChatModelProviderUnavailableReasons: ChatModelProviderUnavailableReason[] =
["fetch_failed", "missing_api_key"];
// From codersdk/chats.go
/**
* ChatModelVercelGatewayProviderOptions configures Vercel routing behavior.
*/
export interface ChatModelVercelGatewayProviderOptions {
readonly order?: readonly string[];
readonly models?: readonly string[];
}
// From codersdk/chats.go
/**
* ChatModelVercelProviderOptions configures Vercel provider behavior.
*/
export interface ChatModelVercelProviderOptions {
readonly reasoning?: ChatModelVercelReasoningOptions;
readonly providerOptions?: ChatModelVercelGatewayProviderOptions;
readonly user?: string;
readonly logit_bias?: Record<string, number>;
readonly logprobs?: boolean;
readonly top_logprobs?: number;
readonly parallel_tool_calls?: boolean;
// empty interface{} type, falling back to unknown
readonly extra_body?: Record<string, unknown>;
}
// From codersdk/chats.go
/**
* ChatModelVercelReasoningOptions configures Vercel reasoning behavior.
*/
export interface ChatModelVercelReasoningOptions {
readonly enabled?: boolean;
readonly max_tokens?: number;
readonly effort?: string;
readonly exclude?: boolean;
}
// From codersdk/chats.go
/**
* ChatModelsResponse is the catalog returned from chat model discovery.
*/
export interface ChatModelsResponse {
readonly providers: readonly ChatModelProvider[];
}
// From codersdk/chats.go
/**
* ChatProviderConfig is an admin-managed provider configuration.
*/
export interface ChatProviderConfig {
readonly id: string;
readonly provider: string;
readonly display_name: string;
readonly enabled: boolean;
readonly has_api_key: boolean;
readonly base_url?: string;
readonly source: ChatProviderConfigSource;
readonly created_at?: string;
readonly updated_at?: string;
}
// From codersdk/chats.go
export type ChatProviderConfigSource = "database" | "env_preset" | "supported";
export const ChatProviderConfigSources: ChatProviderConfigSource[] = [
"database",
"env_preset",
"supported",
];
// From codersdk/chats.go
/**
* ChatQueuedMessage represents a queued message waiting to be processed.
*/
export interface ChatQueuedMessage {
readonly id: number;
readonly chat_id: string;
readonly content: readonly ChatMessagePart[];
readonly created_at: string;
}
// From codersdk/chats.go
export type ChatStatus =
| "completed"
| "error"
| "paused"
| "pending"
| "running"
| "waiting";
export const ChatStatuses: ChatStatus[] = [
"completed",
"error",
"paused",
"pending",
"running",
"waiting",
];
// From codersdk/chats.go
/**
* ChatStreamError represents an error event in the stream.
*/
export interface ChatStreamError {
readonly message: string;
}
// From codersdk/chats.go
/**
* ChatStreamEvent represents a real-time update for chat streaming.
*/
export interface ChatStreamEvent {
readonly type: ChatStreamEventType;
readonly chat_id: string;
readonly message?: ChatMessage;
readonly message_part?: ChatStreamMessagePart;
readonly status?: ChatStreamStatus;
readonly error?: ChatStreamError;
readonly queued_messages?: readonly ChatQueuedMessage[];
}
// From codersdk/chats.go
export type ChatStreamEventType =
| "error"
| "message"
| "message_part"
| "queue_update"
| "status";
export const ChatStreamEventTypes: ChatStreamEventType[] = [
"error",
"message",
"message_part",
"queue_update",
"status",
];
// From codersdk/chats.go
/**
* ChatStreamMessagePart is a streamed message part update.
*/
export interface ChatStreamMessagePart {
readonly role?: string;
readonly part: ChatMessagePart;
}
// From codersdk/chats.go
/**
* ChatStreamStatus represents an updated chat status.
*/
export interface ChatStreamStatus {
readonly status: ChatStatus;
}
// From codersdk/chats.go
/**
* ChatWithMessages is a chat along with its messages.
*/
export interface ChatWithMessages {
readonly chat: Chat;
readonly messages: readonly ChatMessage[];
readonly queued_messages: readonly ChatQueuedMessage[];
}
// From codersdk/client.go
/**
* CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry
@@ -1166,6 +1691,62 @@ export interface ConvertLoginRequest {
readonly password: string;
}
// From codersdk/chats.go
/**
* CreateChatMessageRequest is the request to add a message to a chat.
*/
export interface CreateChatMessageRequest {
readonly content: readonly ChatInputPart[];
readonly model_config_id?: string;
}
// From codersdk/chats.go
/**
* CreateChatMessageResponse is the response from adding a message to a chat.
*/
export interface CreateChatMessageResponse {
readonly message?: ChatMessage;
readonly queued_message?: ChatQueuedMessage;
readonly queued: boolean;
}
// From codersdk/chats.go
/**
* CreateChatModelConfigRequest creates a chat model config.
*/
export interface CreateChatModelConfigRequest {
readonly provider: string;
readonly model: string;
readonly display_name?: string;
readonly enabled?: boolean;
readonly is_default?: boolean;
readonly context_limit?: number;
readonly compression_threshold?: number;
readonly model_config?: ChatModelCallConfig;
}
// From codersdk/chats.go
/**
* CreateChatProviderConfigRequest creates a chat provider config.
*/
export interface CreateChatProviderConfigRequest {
readonly provider: string;
readonly display_name?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
}
// From codersdk/chats.go
/**
* CreateChatRequest is the request to create a new chat.
*/
export interface CreateChatRequest {
readonly content: readonly ChatInputPart[];
readonly workspace_id?: string;
readonly model_config_id?: string;
}
// From codersdk/users.go
export interface CreateFirstUserRequest {
readonly email: string;
@@ -1809,6 +2390,7 @@ export interface DeploymentValues {
readonly support?: SupportConfig;
readonly enable_authz_recording?: boolean;
readonly external_auth?: SerpentStruct<ExternalAuthConfig[]>;
readonly external_auth_github_default_provider_enable?: boolean;
readonly config_ssh?: SSHConfig;
readonly wgtunnel_host?: string;
readonly disable_owner_workspace_exec?: boolean;
@@ -1887,6 +2469,14 @@ export interface DynamicParametersResponse {
readonly parameters: readonly PreviewParameter[];
}
// From codersdk/chats.go
/**
* EditChatMessageRequest is the request to edit a user message in a chat.
*/
export interface EditChatMessageRequest {
readonly content: readonly ChatInputPart[];
}
// From codersdk/externalauth.go
export type EnhancedExternalAuthProvider =
| "azure-devops"
@@ -1933,6 +2523,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning";
// From codersdk/deployment.go
export type Experiment =
| "agents"
| "auto-fill-parameters"
| "example"
| "mcp-server-http"
@@ -1942,6 +2533,7 @@ export type Experiment =
| "workspace-usage";
export const Experiments: Experiment[] = [
"agents",
"auto-fill-parameters",
"example",
"mcp-server-http",
@@ -4094,6 +4686,7 @@ export type RBACResource =
| "assign_role"
| "audit_log"
| "boundary_usage"
| "chat"
| "connection_log"
| "crypto_key"
| "debug_info"
@@ -4139,6 +4732,7 @@ export const RBACResources: RBACResource[] = [
"assign_role",
"audit_log",
"boundary_usage",
"chat",
"connection_log",
"crypto_key",
"debug_info",
@@ -5591,6 +6185,40 @@ export interface UpdateAppearanceConfig {
readonly announcement_banners: readonly BannerConfig[];
}
// From codersdk/chats.go
/**
* UpdateChatModelConfigRequest updates a chat model config.
*/
export interface UpdateChatModelConfigRequest {
readonly provider?: string;
readonly model?: string;
readonly display_name?: string;
readonly enabled?: boolean;
readonly is_default?: boolean;
readonly context_limit?: number;
readonly compression_threshold?: number;
readonly model_config?: ChatModelCallConfig;
}
// From codersdk/chats.go
/**
* UpdateChatProviderConfigRequest updates a chat provider config.
*/
export interface UpdateChatProviderConfigRequest {
readonly display_name?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
}
// From codersdk/chats.go
/**
* UpdateChatRequest is the request to update a chat.
*/
export interface UpdateChatRequest {
readonly title: string;
}
// From codersdk/updatecheck.go
/**
* UpdateCheckResponse contains information on the latest release of Coder.
+1 -1
View File
@@ -33,7 +33,7 @@ const DialogOverlay: React.FC<
const dialogVariants = cva(
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6
border border-solid bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%]
translate-x-[-50%] translate-y-[-50%] outline-none
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
+4 -1
View File
@@ -406,7 +406,10 @@ const markdownStyles: Interpolation<Theme> = (theme: Theme) => ({
},
"& .prismjs": {
background: theme.palette.background.paper,
background:
theme.palette.mode === "dark"
? colors.zinc[950]
: theme.palette.background.paper,
borderRadius: 8,
padding: "16px 24px",
overflowX: "auto",
+17 -5
View File
@@ -5,18 +5,30 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "utils/cn";
export const ScrollArea: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root>
> = ({ className, children, ...props }) => {
interface ScrollAreaProps
extends React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root> {
scrollBarClassName?: string;
viewportClassName?: string;
}
export const ScrollArea: React.FC<ScrollAreaProps> = ({
className,
scrollBarClassName,
viewportClassName,
children,
...props
}) => {
return (
<ScrollAreaPrimitive.Root
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className="z-10" />
<ScrollBar className={cn("z-10", scrollBarClassName)} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
+1 -1
View File
@@ -39,7 +39,7 @@ export const SelectTrigger: React.FC<SelectTriggerProps> = ({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
<ChevronDownIcon className="size-icon-sm cursor-pointer text-content-secondary transition-colors hover:text-content-primary group-hover:text-content-primary" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Conversation, ConversationItem } from "./conversation";
import { Message, MessageContent } from "./message";
import { Shimmer } from "./shimmer";
import { Thinking } from "./thinking";
const meta: Meta<typeof Conversation> = {
title: "components/ai-elements/Conversation",
component: Conversation,
decorators: [
(Story) => (
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Conversation>;
export const ConversationWithMessages: Story = {
render: () => {
const userItemProps = { role: "user" as const };
const assistantItemProps = { role: "assistant" as const };
return (
<Conversation>
<ConversationItem {...userItemProps}>
<Message className="my-2 w-full max-w-none">
<MessageContent className="rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm">
Check why `git fetch` is failing in this workspace.
</MessageContent>
</Message>
</ConversationItem>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
<Thinking>
Inspecting auth state and recent command output before
suggesting a fix.
</Thinking>
<div className="text-sm text-content-primary">
The remote command failed because external auth needs to be
refreshed.
</div>
</div>
</MessageContent>
</Message>
</ConversationItem>
</Conversation>
);
},
};
export const LoadingState: Story = {
render: () => {
const assistantItemProps = { role: "assistant" as const };
return (
<Conversation>
<ConversationItem {...assistantItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<Shimmer as="span" className="text-sm">
Thinking...
</Shimmer>
</MessageContent>
</Message>
</ConversationItem>
</Conversation>
);
},
};
@@ -0,0 +1,42 @@
import type { ComponentPropsWithRef } from "react";
import { cn } from "utils/cn";
type ConversationProps = ComponentPropsWithRef<"div">;
export const Conversation = ({
className,
ref,
...props
}: ConversationProps) => {
return (
<div
ref={ref}
className={cn("flex flex-col gap-5", className)}
{...props}
/>
);
};
type ConversationItemProps = Omit<ComponentPropsWithRef<"div">, "role"> & {
role: "user" | "assistant";
};
export const ConversationItem = ({
className,
role,
ref,
...props
}: ConversationItemProps) => {
return (
<div
ref={ref}
data-role={role}
className={cn(
"group flex w-full items-start gap-3",
role === "user" && "justify-end",
className,
)}
{...props}
/>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { ConversationItem } from "./conversation";
export { Message, MessageContent } from "./message";
export type { ModelSelectorOption } from "./model-selector";
export { ModelSelector } from "./model-selector";
export { Response } from "./response";
export { Shimmer } from "./shimmer";
export { Tool } from "./tool";
@@ -0,0 +1,29 @@
import type { ComponentPropsWithRef } from "react";
import { cn } from "utils/cn";
type MessageProps = ComponentPropsWithRef<"div">;
export const Message = ({ className, ref, ...props }: MessageProps) => {
return (
<div ref={ref} className={cn("max-w-full min-w-0", className)} {...props} />
);
};
type MessageContentProps = ComponentPropsWithRef<"div">;
export const MessageContent = ({
className,
ref,
...props
}: MessageContentProps) => {
return (
<div
ref={ref}
className={cn(
"whitespace-pre-wrap break-words text-sm leading-relaxed text-content-primary",
className,
)}
{...props}
/>
);
};
@@ -0,0 +1,153 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { ModelSelector, type ModelSelectorOption } from "./model-selector";
const openAIModels: ModelSelectorOption[] = [
{
id: "openai/gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
contextLimit: 128_000,
},
{
id: "openai/gpt-4o-mini",
provider: "openai",
model: "gpt-4o-mini",
displayName: "GPT-4o Mini",
contextLimit: 128_000,
},
{
id: "openai/o3-mini",
provider: "openai",
model: "o3-mini",
displayName: "o3-mini",
contextLimit: 200_000,
},
];
const anthropicModels: ModelSelectorOption[] = [
{
id: "anthropic/claude-sonnet-4",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
displayName: "Claude Sonnet 4",
contextLimit: 200_000,
},
{
id: "anthropic/claude-haiku-3.5",
provider: "anthropic",
model: "claude-3-5-haiku-20241022",
displayName: "Claude 3.5 Haiku",
contextLimit: 200_000,
},
];
const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels];
const meta: Meta<typeof ModelSelector> = {
title: "components/ai-elements/ModelSelector",
component: ModelSelector,
decorators: [
(Story) => (
<div className="w-72 rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
args: {
options: openAIModels,
value: "",
onValueChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ModelSelector>;
// ---------------------------------------------------------------------------
// Single provider stories
// ---------------------------------------------------------------------------
export const Default: Story = {};
export const WithSelectedValue: Story = {
args: {
value: "openai/gpt-4o",
},
};
export const CustomPlaceholder: Story = {
args: {
placeholder: "Choose a model…",
},
};
export const Disabled: Story = {
args: {
disabled: true,
value: "openai/gpt-4o",
},
};
// ---------------------------------------------------------------------------
// Multiple providers (grouped)
// ---------------------------------------------------------------------------
export const MultipleProviders: Story = {
args: {
options: allModels,
value: "anthropic/claude-sonnet-4",
},
};
export const MultipleProvidersWithCustomLabel: Story = {
args: {
options: allModels,
value: "",
formatProviderLabel: (provider: string) => {
const labels: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
};
return labels[provider] ?? provider;
},
},
};
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
export const NoOptions: Story = {
args: {
options: [],
value: "",
},
};
// ---------------------------------------------------------------------------
// Play function – selection interaction
// ---------------------------------------------------------------------------
export const SelectsModel: Story = {
args: {
options: openAIModels,
value: "",
onValueChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Open the popover by clicking the trigger.
const trigger = canvas.getByRole("combobox");
await userEvent.click(trigger);
// The dropdown should appear with model options.
const listbox = await within(document.body).findByRole("listbox");
const option = within(listbox).getByText("GPT-4o Mini");
await userEvent.click(option);
expect(args.onValueChange).toHaveBeenCalledWith("openai/gpt-4o-mini");
},
};
@@ -0,0 +1,164 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { type FC, useMemo } from "react";
import { cn } from "utils/cn";
export interface ModelSelectorOption {
id: string;
provider: string;
model: string;
displayName: string;
contextLimit?: number;
}
interface ModelSelectorProps {
options: readonly ModelSelectorOption[];
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
emptyMessage?: string;
formatProviderLabel?: (provider: string) => string;
className?: string;
dropdownSide?: "top" | "bottom" | "left" | "right";
dropdownAlign?: "start" | "center" | "end";
contentClassName?: string;
}
const defaultFormatProviderLabel = (provider: string): string => {
const normalized = provider.trim().toLowerCase();
if (!normalized) {
return "Unknown";
}
return `${normalized[0].toUpperCase()}${normalized.slice(1)}`;
};
const formatContextLimit = (tokens: number): string => {
if (tokens >= 1_000_000) {
const m = tokens / 1_000_000;
return `${Number.isInteger(m) ? m : m.toFixed(1)}M context window`;
}
const k = Math.round(tokens / 1_000);
return `${k}K context window`;
};
export const ModelSelector: FC<ModelSelectorProps> = ({
options,
value,
onValueChange,
disabled = false,
placeholder = "Select model",
emptyMessage = "No models found.",
formatProviderLabel = defaultFormatProviderLabel,
className,
dropdownSide = "bottom",
dropdownAlign = "start",
contentClassName,
}) => {
const selectedModel = useMemo(
() => options.find((option) => option.id === value),
[options, value],
);
const optionsByProvider = useMemo(() => {
const grouped = new Map<string, ModelSelectorOption[]>();
for (const option of options) {
const providerOptions = grouped.get(option.provider);
if (providerOptions) {
providerOptions.push(option);
continue;
}
grouped.set(option.provider, [option]);
}
return Array.from(grouped.entries());
}, [options]);
const showProviderHeading = optionsByProvider.length > 1;
const isDisabled = disabled || options.length === 0;
return (
<Select value={value} onValueChange={onValueChange} disabled={isDisabled}>
<SelectTrigger
className={cn(
"h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary [&>svg]:transition-colors [&>svg]:hover:text-content-primary focus:ring-0 focus-visible:ring-0",
className,
)}
>
<SelectValue placeholder={placeholder}>
{selectedModel?.displayName ?? placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent
side={dropdownSide}
align={dropdownAlign}
className={cn("[&_[role=option]]:text-xs", contentClassName)}
>
<TooltipProvider delayDuration={300}>
{optionsByProvider.map(([provider, providerOptions]) => {
const providerLabel = formatProviderLabel(provider);
return (
<SelectGroup key={provider}>
{showProviderHeading && (
<SelectLabel>{providerLabel}</SelectLabel>
)}
{providerOptions.map((option) => (
<ModelOptionItem
key={option.id}
option={option}
providerLabel={providerLabel}
/>
))}
</SelectGroup>
);
})}
{options.length === 0 && (
<SelectItem value="__empty__" disabled>
{emptyMessage}
</SelectItem>
)}
</TooltipProvider>
</SelectContent>
</Select>
);
};
interface ModelOptionItemProps {
option: ModelSelectorOption;
providerLabel: string;
}
const ModelOptionItem: FC<ModelOptionItemProps> = ({
option,
providerLabel,
}) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value={option.id}>{option.displayName}</SelectItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4} className="px-2.5 py-1.5">
<span className="block font-semibold text-content-primary leading-tight">
{option.displayName} via {providerLabel}
</span>
{option.contextLimit != null && option.contextLimit > 0 && (
<span className="block text-content-secondary leading-tight">
{formatContextLimit(option.contextLimit)}
</span>
)}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Response } from "./response";
const sampleMarkdown = `
## Plan update
I checked the auth flow and found two issues:
1. Missing provider fallback for unknown IDs.
2. Error text was not surfaced in the UI.
See [external auth docs](https://coder.com/docs) for expected behavior.
Inline command example: \`git fetch origin\`.
\`\`\`ts
export const ensureProviderLabel = (provider: string) => {
return provider.trim() || "Git provider";
};
\`\`\`
`;
const sampleFileMarkdown = `
\`\`\`go
package auth
import "errors"
func ValidateToken(token string) error {
if token == "" {
return errors.New("token is empty")
}
return nil
}
\`\`\`
`;
const meta: Meta<typeof Response> = {
title: "components/ai-elements/Response",
component: Response,
decorators: [
(Story) => (
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
args: {
children: sampleMarkdown,
},
};
export default meta;
type Story = StoryObj<typeof Response>;
export const MarkdownAndLinks: Story = {};
export const FencedFileBlock: Story = {
args: {
children: sampleFileMarkdown,
},
};
export const MarkdownAndLinksLight: Story = {
globals: {
theme: "light",
},
};
@@ -0,0 +1,155 @@
import { useTheme } from "@emotion/react";
import {
File as FileViewer,
type SupportedLanguages,
} from "@pierre/diffs/react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { useMemo } from "react";
import { type Components, Streamdown } from "streamdown";
import { cn } from "utils/cn";
interface ResponseProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
children: string;
}
const fileViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
const fileViewerTheme = {
light: "github-light",
dark: "github-dark-high-contrast",
} as const;
type HastNode = {
type?: string;
value?: string;
children?: HastNode[];
tagName?: string;
properties?: {
className?: string[] | string;
};
};
type MarkdownComponentProps = {
href?: string;
children?: ReactNode;
node?: HastNode;
};
type FileViewerThemeType = "light" | "dark";
/**
* Recursively extracts text from a HAST node tree. This is plain
* data (not React elements), so it's reliable to traverse.
*/
const getHastText = (node: HastNode | null | undefined): string => {
if (!node) {
return "";
}
if (node.type === "text") return node.value ?? "";
if (node.children) return node.children.map(getHastText).join("");
return "";
};
const getClassNames = (className: string[] | string | undefined): string[] => {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean);
}
if (!Array.isArray(className)) {
return [];
}
return className.filter(
(classToken): classToken is string => typeof classToken === "string",
);
};
const createComponents = (
fileViewerThemeType: FileViewerThemeType,
viewerTheme: (typeof fileViewerTheme)[FileViewerThemeType],
): Components => {
return {
a: ({ href, children }: MarkdownComponentProps) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-content-link no-underline hover:underline hover:decoration-content-link"
>
{children}
</a>
),
// Inline code only — fenced blocks are handled by the pre override.
code: ({ children }: MarkdownComponentProps) => (
<code className="rounded bg-surface-quaternary/25 px-1 py-0.5 font-mono text-content-primary">
{children}
</code>
),
// Fenced code blocks: extract language and content from the HAST
// node directly (plain data), then render with FileViewer.
pre: ({ node }: MarkdownComponentProps) => {
const codeChild = node?.children?.[0];
if (codeChild?.tagName === "code") {
const classes = getClassNames(codeChild.properties?.className);
const langClass = classes.find((c: string) =>
c.startsWith("language-"),
);
const lang = langClass ? langClass.replace("language-", "") : "text";
const content = getHastText(codeChild).trimEnd();
if (content) {
return (
<div className="my-4 overflow-hidden rounded-xl border border-solid border-border-default text-2xs">
<FileViewer
file={{
name: `block.${lang}`,
lang: lang as SupportedLanguages,
contents: content,
cacheKey: content,
}}
options={{
overflow: "scroll",
themeType: fileViewerThemeType,
disableFileHeader: true,
disableLineNumbers: true,
theme: viewerTheme,
unsafeCSS: fileViewerCSS,
}}
/>
</div>
);
}
}
return <pre>{node?.children?.map?.(() => null)}</pre>;
},
};
};
export const Response = ({
className,
children,
ref,
...props
}: ResponseProps) => {
const theme = useTheme();
const fileViewerThemeType: FileViewerThemeType =
theme.palette.mode === "dark" ? "dark" : "light";
const viewerTheme = fileViewerTheme[fileViewerThemeType];
const components = useMemo(
() => createComponents(fileViewerThemeType, viewerTheme),
[fileViewerThemeType, viewerTheme],
);
return (
<div
ref={ref}
className={cn(
"text-[13px] leading-relaxed text-content-primary",
className,
)}
{...props}
>
<Streamdown controls={false} components={components}>
{children}
</Streamdown>
</div>
);
};
@@ -0,0 +1,25 @@
export const asRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
};
export const asString = (value: unknown): string =>
typeof value === "string" ? value : "";
export const asNumber = (
value: unknown,
options?: { readonly parseString?: boolean },
): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (options?.parseString && typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
};
@@ -0,0 +1,77 @@
import type { MotionProps } from "motion/react";
import { MotionConfig, motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
import { cn } from "utils/cn";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements,
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
);
return (
<MotionConfig reducedMotion="user">
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),hsl(var(--background)),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(hsl(var(--muted-foreground)), hsl(var(--muted-foreground)))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
</MotionConfig>
);
};
export const Shimmer = memo(ShimmerComponent);
@@ -0,0 +1,17 @@
import type { ComponentPropsWithRef } from "react";
import { cn } from "utils/cn";
type ThinkingProps = ComponentPropsWithRef<"div">;
export const Thinking = ({ className, ref, ...props }: ThinkingProps) => {
return (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-surface-primary px-3 py-2 text-xs text-content-secondary",
className,
)}
{...props}
/>
);
};
@@ -0,0 +1,470 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { Tool } from "./tool";
const executeCommand = "git fetch origin";
const meta: Meta<typeof Tool> = {
title: "components/ai-elements/Tool",
component: Tool,
decorators: [
(Story) => (
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
args: {
name: "execute",
args: { command: executeCommand },
status: "completed",
},
parameters: {
reactRouter: reactRouterParameters({
routing: { path: "/" },
}),
},
};
export default meta;
type Story = StoryObj<typeof Tool>;
// ---------------------------------------------------------------------------
// Execute stories
// ---------------------------------------------------------------------------
export const ExecuteRunning: Story = {
args: {
status: "running",
result: {
output: "remote: Enumerating objects: 12, done.\nFetching origin...",
},
},
};
export const ExecuteSuccess: Story = {
args: {
result: {
output:
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui",
},
},
};
export const ExecuteAuthRequired: Story = {
args: {
result: {
auth_required: true,
provider_display_name: "GitHub",
authenticate_url: "https://coder.example.com/external-auth/github",
output:
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", {
name: "Authenticate with GitHub",
});
expect(button).toBeInTheDocument();
expect(
canvas.getByRole("link", { name: "Open authentication link" }),
).toHaveAttribute("href", "https://coder.example.com/external-auth/github");
const openSpy = spyOn(window, "open").mockImplementation(() => null);
await userEvent.click(button);
expect(openSpy).toHaveBeenCalledWith(
"https://coder.example.com/external-auth/github",
"_blank",
"width=900,height=600",
);
openSpy.mockRestore();
},
};
// ---------------------------------------------------------------------------
// WaitForExternalAuth stories
// ---------------------------------------------------------------------------
export const WaitForExternalAuthRunning: Story = {
args: {
name: "wait_for_external_auth",
status: "running",
result: {
provider_display_name: "GitHub",
authenticated: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Waiting for GitHub authentication..."),
).toBeInTheDocument();
},
};
export const WaitForExternalAuthAuthenticated: Story = {
args: {
name: "wait_for_external_auth",
status: "completed",
result: {
provider_display_name: "GitHub",
authenticated: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Authenticated with GitHub")).toBeInTheDocument();
},
};
export const WaitForExternalAuthTimedOut: Story = {
args: {
name: "wait_for_external_auth",
status: "completed",
result: {
provider_display_name: "GitHub",
timed_out: true,
},
},
};
export const WaitForExternalAuthError: Story = {
args: {
name: "wait_for_external_auth",
status: "error",
isError: true,
result: {
provider_display_name: "GitHub",
error: "Authentication failed: token exchange was rejected.",
},
},
};
// ---------------------------------------------------------------------------
// Subagent stories
// ---------------------------------------------------------------------------
export const SubagentRunning: Story = {
args: {
name: "spawn_agent",
status: "running",
args: {
title: "Workspace diagnostics",
prompt: "Collect logs and summarize why startup failed.",
},
result: {
chat_id: "child-chat-id",
title: "Workspace diagnostics",
status: "pending",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
},
};
export const SubagentAwaitLinkCard: Story = {
args: {
name: "wait_agent",
args: { title: "Sub-agent" },
result: { chat_id: "child-chat-id", status: "pending" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
},
};
export const SubagentMessageLinkCard: Story = {
args: {
name: "message_agent",
args: { title: "Sub-agent" },
result: { chat_id: "child-chat-id", status: "pending" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
},
};
export const SubagentCompletedDelegatedPending: Story = {
args: {
name: "spawn_agent",
args: undefined,
result: { chat_id: "child-chat-id", status: "pending" },
status: "completed",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
expect(
canvas.getByRole("button", { name: /Spawned Sub-agent/ }),
).toBeInTheDocument();
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
},
};
export const SubagentStreamOverrideStatus: Story = {
args: {
name: "spawn_agent",
args: undefined,
result: { chat_id: "child-chat-id", status: "pending" },
status: "completed",
subagentStatusOverrides: new Map([["child-chat-id", "completed"]]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByRole("button", { name: /Spawned Sub-agent/ }),
).toBeInTheDocument();
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
},
};
export const SubagentNoErrorWhenCompleted: Story = {
args: {
name: "spawn_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
error: "provider metadata noise",
},
status: "error",
isError: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull();
expect(
canvas.getByRole("button", { name: /Spawned Sub-agent/ }),
).toBeInTheDocument();
},
};
export const SubagentAwaitPreferredTitle: Story = {
args: {
name: "wait_agent",
args: { title: "Fallback title" },
result: {
chat_id: "child-chat-id",
title: "Delegated child title",
status: "completed",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Delegated child title")).toBeInTheDocument();
expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute(
"href",
"/agents/child-chat-id",
);
expect(canvas.queryByText("Fallback title")).toBeNull();
},
};
export const SubagentRequestMetadata: Story = {
args: {
name: "spawn_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
export const SubagentAwaitRequestMetadata: Story = {
args: {
name: "wait_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
export const SubagentMessageRequestMetadata: Story = {
args: {
name: "message_agent",
args: undefined,
result: {
chat_id: "child-chat-id",
status: "completed",
request_id: "request-123",
duration_ms: 1530,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Worked for 2s")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// ListTemplates stories
// ---------------------------------------------------------------------------
export const ListTemplatesRunning: Story = {
args: {
name: "list_templates",
status: "running",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Listing templates…")).toBeInTheDocument();
},
};
export const ListTemplatesSuccess: Story = {
args: {
name: "list_templates",
status: "completed",
result: {
templates: [
{
id: "template-1",
name: "go-template",
display_name: "Go Development",
description: "A template for Go development with VS Code",
},
{
id: "template-2",
name: "python-template",
description: "Python development environment",
},
],
count: 2,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Listed 2 templates")).toBeInTheDocument();
const toggle = canvas.getByRole("button");
await userEvent.click(toggle);
expect(canvas.getByText("Go Development")).toBeInTheDocument();
expect(canvas.getByText("python-template")).toBeInTheDocument();
},
};
export const ListTemplatesSingle: Story = {
args: {
name: "list_templates",
status: "completed",
result: {
templates: [
{
id: "template-1",
name: "go-template",
description: "Go development template",
},
],
count: 1,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Listed 1 template")).toBeInTheDocument();
},
};
export const ListTemplatesEmpty: Story = {
args: {
name: "list_templates",
status: "completed",
result: {
templates: [],
count: 0,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Listing templates…")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// ChatSummarized stories
// ---------------------------------------------------------------------------
export const ChatSummarized: Story = {
args: {
name: "chat_summarized",
args: undefined,
result: { summary: "Compaction summary text." },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByRole("button", { name: "Summarized" });
expect(toggle).toBeInTheDocument();
expect(canvas.queryByText("Compaction summary text.")).toBeNull();
await userEvent.click(toggle);
expect(
await canvas.findByText((text) =>
text.includes("Compaction summary text."),
),
).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// SubagentTerminate stories
// ---------------------------------------------------------------------------
export const SubagentTerminate: Story = {
args: {
name: "close_agent",
args: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Terminated/)).toBeInTheDocument();
expect(canvas.getByText("Sub-agent")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// Generic fallback stories
// ---------------------------------------------------------------------------
export const TaskNameGenericRendering: Story = {
args: {
name: "task",
args: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("task")).toBeInTheDocument();
expect(canvas.queryByRole("link", { name: "View agent" })).toBeNull();
},
};
@@ -0,0 +1,68 @@
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import { Response } from "../response";
import { ToolCollapsible } from "./ToolCollapsible";
import type { ToolStatus } from "./utils";
/**
* Collapsed-by-default rendering for `chat_summarized` tool calls.
* Shows "Summarized" and reveals the summary only when expanded.
*/
export const ChatSummarizedTool: React.FC<{
summary: string;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ summary, status, isError, errorMessage }) => {
const hasSummary = summary.trim().length > 0;
const isRunning = status === "running";
return (
<ToolCollapsible
className="w-full"
hasContent={hasSummary}
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{isRunning ? "Summarizing…" : "Summarized"}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to summarize chat"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{summary}</Response>
</div>
</ScrollArea>
</ToolCollapsible>
);
};
@@ -0,0 +1,83 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { Link } from "react-router";
import { cn } from "utils/cn";
import { asRecord, asString, type ToolStatus } from "./utils";
/**
* Rendering for `create_workspace` tool calls.
*
* Shows "Creating workspace…" while running, and "Created <name>" when
* complete with a link to view the workspace.
*/
export const CreateWorkspaceTool: React.FC<{
workspaceName: string;
resultJson: string;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ workspaceName, resultJson, status, isError, errorMessage }) => {
const isRunning = status === "running";
let rec: Record<string, unknown> | null = null;
if (resultJson) {
try {
const parsed = JSON.parse(resultJson);
rec = asRecord(parsed);
} catch {
// resultJson might already be an object or invalid JSON
rec = asRecord(resultJson);
}
}
const ownerName = rec ? asString(rec.owner_name) : "";
const wsName = rec ? asString(rec.workspace_name) : workspaceName;
const workspaceLink = ownerName && wsName ? `/@${ownerName}/${wsName}` : null;
const label = isRunning
? "Creating workspace…"
: wsName
? `Created ${wsName}`
: "Created workspace";
return (
<div className="w-full">
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to create workspace"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{workspaceLink && !isRunning && (
<Link
to={workspaceLink}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View workspace"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
</div>
</div>
);
};
@@ -0,0 +1,107 @@
import { useTheme } from "@emotion/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import {
DIFFS_FONT_STYLE,
type EditFilesFileEntry,
getDiffViewerOptions,
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `edit_files` tool calls.
* Shows "Edited <filename>" (or "Edited N files") with a chevron;
* expanding reveals a unified diff for each file.
*/
export const EditFilesTool: React.FC<{
files: EditFilesFileEntry[];
diffs: (FileDiffMetadata | null)[];
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ files, diffs, status, isError, errorMessage }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const isRunning = status === "running";
const hasDiffs = diffs.some((d) => d !== null);
let label: string;
if (isRunning) {
if (files.length === 1) {
label = `Editing ${files[0].path.split("/").pop() || files[0].path}…`;
} else if (files.length > 1) {
label = `Editing ${files.length} files…`;
} else {
label = "Editing files…";
}
} else if (files.length === 1) {
const filename = files[0].path.split("/").pop() || files[0].path;
label = `Edited ${filename}`;
} else if (files.length > 1) {
label = `Edited ${files.length} files`;
} else {
label = "Edited files";
}
return (
<ToolCollapsible
className="w-full"
hasContent={hasDiffs}
defaultExpanded
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to edit files"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
<div className="mt-1.5 space-y-1.5">
{diffs.map((diff, i) =>
diff ? (
<ScrollArea
key={files[i].path}
className="rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : null,
)}
</div>
</ToolCollapsible>
);
};
@@ -0,0 +1,228 @@
import { Button } from "components/Button/Button";
import { CopyButton } from "components/CopyButton/CopyButton";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
CheckIcon,
ChevronDownIcon,
CircleAlertIcon,
ExternalLinkIcon,
LoaderIcon,
} from "lucide-react";
import type React from "react";
import { useRef, useState } from "react";
import { cn } from "utils/cn";
import {
BORDER_BG_STYLE,
COLLAPSED_OUTPUT_HEIGHT,
type ToolStatus,
} from "./utils";
/**
* Specialized rendering for `execute` tool calls. Shows the command
* in a terminal-style block with a copy button. Output is shown in a
* collapsed preview (~3 lines) with an expand chevron at the bottom.
*/
export const ExecuteTool: React.FC<{
command: string;
output: string;
status: ToolStatus;
isError: boolean;
}> = ({ command, output, status, isError }) => {
const [expanded, setExpanded] = useState(false);
const outputRef = useRef<HTMLPreElement | null>(null);
const hasOutput = output.length > 0;
const isRunning = status === "running";
// Check whether the output overflows the collapsed height so we
// know if we need to show the expand toggle at all.
const [overflows, setOverflows] = useState(false);
const measureRef = (node: HTMLPreElement | null) => {
outputRef.current = node;
if (node) {
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
}
};
return (
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
{/* Header: $ command + copy button */}
<div className="flex w-full items-center justify-between gap-2 px-2.5 py-0.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0 font-mono text-xs text-content-secondary">
$
</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-content-primary">
{command}
</code>
</div>
<div className="flex shrink-0 items-center gap-1">
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
<span className="opacity-0 transition-opacity group-hover/exec:opacity-100">
<CopyButton text={command} label="Copy command" />
</span>
</div>
</div>
{/* Output preview / expanded */}
{hasOutput && (
<>
<div className="h-px" style={BORDER_BG_STYLE} />
<ScrollArea
className="text-2xs"
viewportClassName={expanded ? "max-h-96" : ""}
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
expanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-2.5 py-2 font-mono text-xs",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{output}
</pre>
</ScrollArea>
{/* Expand / collapse toggle at the bottom */}
{overflows && (
<div
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setExpanded((v) => !v);
}
}}
className="flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={expanded ? "Collapse output" : "Expand output"}
>
<ChevronDownIcon
className={cn(
"h-3 w-3 transition-transform",
expanded && "rotate-180",
)}
/>
</div>
)}
</>
)}
</div>
);
};
export const ExecuteAuthRequiredTool: React.FC<{
command: string;
output: string;
authenticateURL: string;
providerLabel: string;
}> = ({ command, output, authenticateURL, providerLabel }) => {
const hasCommand = command.trim().length > 0;
const hasOutput = output.trim().length > 0;
return (
<div className="w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
<CircleAlertIcon className="h-4 w-4 shrink-0 text-content-warning" />
<span className="text-sm text-content-primary">
Authenticate with {providerLabel} to continue this command.
</span>
</div>
<div className="flex flex-wrap items-center gap-2 px-3 pb-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(authenticateURL, "_blank", "width=900,height=600")
}
className="inline-flex cursor-pointer items-center gap-1 text-xs"
>
<ExternalLinkIcon className="h-3.5 w-3.5 shrink-0" />
Authenticate with {providerLabel}
</Button>
<a
href={authenticateURL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-content-link no-underline hover:underline"
>
<ExternalLinkIcon className="h-3.5 w-3.5 shrink-0" />
Open authentication link
</a>
</div>
{hasCommand && (
<div className="px-3 pb-1">
<code className="font-mono text-xs text-content-secondary">
$ {command}
</code>
</div>
)}
{hasOutput && (
<ScrollArea
className="rounded-b-md border-t border-solid border-border-default text-2xs"
viewportClassName="max-h-48"
scrollBarClassName="w-1.5"
>
<pre className="m-0 whitespace-pre-wrap break-all border-0 bg-transparent px-3 py-2 font-mono text-xs text-content-secondary">
{output}
</pre>
</ScrollArea>
)}
</div>
);
};
export const WaitForExternalAuthTool: React.FC<{
providerLabel: string;
status: ToolStatus;
authenticated: boolean;
timedOut: boolean;
isError: boolean;
errorMessage?: string;
}> = ({
providerLabel,
status,
authenticated,
timedOut,
isError,
errorMessage,
}) => {
const isRunning = status === "running";
let label = `Waiting for ${providerLabel} authentication...`;
let icon: React.ReactNode = (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-link" />
);
if (isError) {
label =
errorMessage ||
`Failed while waiting for ${providerLabel} authentication`;
icon = (
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
);
} else if (timedOut) {
label = `Timed out waiting for ${providerLabel} authentication`;
icon = (
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-warning" />
);
} else if (authenticated && !isRunning) {
label = `Authenticated with ${providerLabel}`;
icon = <CheckIcon className="h-3.5 w-3.5 shrink-0 text-content-success" />;
}
return (
<div className="w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary px-3 py-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm text-content-primary">{label}</span>
</div>
</div>
);
};
@@ -0,0 +1,98 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { Link } from "react-router";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import { asRecord, asString, type ToolStatus } from "./utils";
/**
* Collapsed-by-default rendering for `list_templates` tool calls. Shows
* "Listed N templates" with a chevron; expanding reveals the template list.
*/
export const ListTemplatesTool: React.FC<{
templates: unknown[];
count: number;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ templates, count, status, isError, errorMessage }) => {
const hasContent = templates.length > 0;
const isRunning = status === "running";
const label =
isRunning || count === 0
? "Listing templates…"
: count === 1
? "Listed 1 template"
: `Listed ${count} templates`;
return (
<ToolCollapsible
className="w-full"
hasContent={hasContent}
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to list templates"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
<div className="mt-1.5">
{templates.map((template, index) => {
const rec = asRecord(template);
if (!rec) {
return null;
}
const name = asString(rec.name);
const displayName = asString(rec.display_name);
const templateName = displayName || name || `Template ${index + 1}`;
if (!name) {
return (
<div key={index} className="text-sm text-content-secondary">
{templateName}
</div>
);
}
return (
<div key={name} className="flex items-center gap-1.5">
<Link
to={`/templates/${name}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1.5 text-sm text-content-secondary opacity-50 transition-opacity hover:opacity-100"
>
<span>{templateName}</span>
<ExternalLinkIcon className="h-3 w-3 shrink-0" />
</Link>
</div>
);
})}
</div>
</ToolCollapsible>
);
};
@@ -0,0 +1,81 @@
import { useTheme } from "@emotion/react";
import { File as FileViewer } from "@pierre/diffs/react";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import {
DIFFS_FONT_STYLE,
getFileViewerOptionsMinimal,
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `read_file` tool calls. Shows
* "Read <filename>" with a chevron; expanding reveals the file viewer.
*/
export const ReadFileTool: React.FC<{
path: string;
content: string;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ path, content, status, isError, errorMessage }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const hasContent = content.length > 0;
const isRunning = status === "running";
return (
<ToolCollapsible
className="w-full"
hasContent={hasContent}
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
Read {path.split("/").pop() || path}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read file"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: path,
contents: content,
}}
options={getFileViewerOptionsMinimal(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
</ToolCollapsible>
);
};
@@ -0,0 +1,54 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import type { ToolStatus } from "./utils";
/**
* Simple inline rendering for `read_template` tool calls.
* Shows "Read template <name>" with no expandable content.
*/
export const ReadTemplateTool: React.FC<{
templateName: string;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ templateName, status, isError, errorMessage }) => {
const isRunning = status === "running";
const label = isRunning
? "Reading template…"
: templateName
? `Read template ${templateName}`
: "Read template";
return (
<div className="flex items-center gap-1.5">
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to read template"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</div>
);
};
@@ -0,0 +1,184 @@
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
ExternalLinkIcon,
LoaderIcon,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { Link } from "react-router";
import { cn } from "utils/cn";
import { Response } from "../response";
import {
isSubagentSuccessStatus,
shortDurationMs,
type ToolStatus,
} from "./utils";
const SUBAGENT_VERBS: Record<string, { completed: string; running: string }> = {
spawn_agent: { completed: "Spawned ", running: "Spawning " },
wait_agent: { completed: "Waited for ", running: "Waiting for " },
message_agent: { completed: "Messaged ", running: "Messaging " },
close_agent: { completed: "Terminated ", running: "Terminating " },
};
/**
* Resolves a sub-agent status string and tool-level status into a
* display icon. The sub-agent status in the tool result is a
* snapshot from when the tool returned and may be stale (e.g. a
* background sub-agent records "pending" forever). The icon is
* therefore driven primarily by the tool-call status itself.
*/
const SubagentStatusIcon: React.FC<{
subagentStatus: string;
toolStatus: ToolStatus;
isError: boolean;
}> = ({ subagentStatus, toolStatus, isError }) => {
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
if (isError && !subagentCompleted) {
return (
<CircleAlertIcon className="h-4 w-4 shrink-0 text-content-destructive" />
);
}
if (toolStatus === "error") {
return (
<CircleAlertIcon className="h-4 w-4 shrink-0 text-content-destructive" />
);
}
if (toolStatus === "running") {
return (
<LoaderIcon className="h-4 w-4 shrink-0 animate-spin motion-reduce:animate-none text-content-link" />
);
}
return <BotIcon className="h-4 w-4 shrink-0 text-content-secondary" />;
};
/**
* Specialized rendering for delegated sub-agent tool calls.
* Shows a clickable header row with the sub-agent title, status
* icon, and a chevron to expand the prompt / report below. A
* "View Agent" link navigates to the sub-agent chat.
*/
export const SubagentTool: React.FC<{
toolName: string;
title: string;
chatId: string;
subagentStatus: string;
prompt?: string;
message?: string;
durationMs?: number;
report?: string;
toolStatus: ToolStatus;
isError: boolean;
}> = ({
toolName,
title,
chatId,
subagentStatus,
prompt,
message,
durationMs,
report,
toolStatus,
isError,
}) => {
const [expanded, setExpanded] = useState(false);
const hasPrompt = Boolean(prompt?.trim());
const hasMessage = Boolean(message?.trim());
const hasReport = Boolean(report?.trim());
const hasExpandableContent = hasPrompt || hasMessage || hasReport;
const durationLabel = shortDurationMs(durationMs);
return (
<div className="w-full">
<div
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => hasExpandableContent && setExpanded((v) => !v)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && hasExpandableContent) {
setExpanded((v) => !v);
}
}}
className={cn(
"flex items-center gap-2",
hasExpandableContent && "cursor-pointer",
)}
>
<SubagentStatusIcon
subagentStatus={subagentStatus}
toolStatus={toolStatus}
isError={isError}
/>
<span className="min-w-0 flex-1 truncate text-sm text-content-secondary">
{SUBAGENT_VERBS[toolName]?.[
toolStatus === "completed" ? "completed" : "running"
] ?? ""}
<span className="text-content-secondary opacity-60">{title}</span>
{chatId && (
<Link
to={`/agents/${chatId}`}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex align-middle text-content-secondary opacity-50 transition-opacity hover:opacity-100"
aria-label="View agent"
>
<ExternalLinkIcon className="h-3 w-3" />
</Link>
)}
</span>
{durationLabel && (
<span className="shrink-0 text-xs text-content-secondary">
Worked for {durationLabel}
</span>
)}
{hasExpandableContent && (
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-content-secondary transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
)}
</div>
{expanded && hasPrompt && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{prompt ?? ""}</Response>
</div>
</ScrollArea>
)}
{expanded && hasMessage && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{message ?? ""}</Response>
</div>
</ScrollArea>
)}
{expanded && hasReport && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
<Response>{report ?? ""}</Response>
</div>
</ScrollArea>
)}
</div>
);
};
@@ -0,0 +1,483 @@
import { useTheme } from "@emotion/react";
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import type { ComponentPropsWithRef, FC } from "react";
import { memo } from "react";
import { cn } from "utils/cn";
import { ChatSummarizedTool } from "./ChatSummarizedTool";
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
import { EditFilesTool } from "./EditFilesTool";
import {
ExecuteAuthRequiredTool,
ExecuteTool as ExecuteToolComponent,
WaitForExternalAuthTool,
} from "./ExecuteTool";
import { ListTemplatesTool } from "./ListTemplatesTool";
import { ReadFileTool } from "./ReadFileTool";
import { ReadTemplateTool } from "./ReadTemplateTool";
import { SubagentTool } from "./SubagentTool";
import { ToolIcon } from "./ToolIcon";
import { ToolLabel } from "./ToolLabel";
import {
asNumber,
asRecord,
asString,
buildEditDiff,
DIFFS_FONT_STYLE,
formatResultOutput,
getDiffViewerOptions,
getFileContentForViewer,
getFileViewerOptions,
getFileViewerOptionsNoHeader,
getWriteFileDiff,
isSubagentSuccessStatus,
mapSubagentStatusToToolStatus,
parseArgs,
parseEditFilesArgs,
type ToolStatus,
toProviderLabel,
} from "./utils";
import { WriteFileTool } from "./WriteFileTool";
interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
name: string;
status?: ToolStatus;
args?: unknown;
result?: unknown;
isError?: boolean;
/** Maps sub-agent chat IDs to their titles, built from spawn tool results. */
subagentTitles?: Map<string, string>;
/** Maps sub-agent chat IDs to real-time status updates from stream events. */
subagentStatusOverrides?: Map<string, string>;
}
// Props passed to each tool-specific renderer function. Each renderer
// only computes the expensive values it needs from the raw args/result.
type ToolRendererProps = {
name: string;
status: ToolStatus;
args: unknown;
result: unknown;
isError: boolean;
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, string>;
};
// ---------------------------------------------------------------------------
// Tool-specific renderer functions
// ---------------------------------------------------------------------------
const ExecuteRenderer: FC<ToolRendererProps> = ({
status,
args,
result,
isError,
}) => {
const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : "";
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
const authRequired = rec ? Boolean(rec.auth_required) : false;
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
const providerLabel = toProviderLabel(
rec ? asString(rec.provider_display_name).trim() : "",
rec ? asString(rec.provider_id).trim() : "",
rec ? asString(rec.provider_type).trim() : "",
);
if (authRequired && authenticateURL) {
return (
<ExecuteAuthRequiredTool
command={command}
output={output}
authenticateURL={authenticateURL}
providerLabel={providerLabel}
/>
);
}
return (
<ExecuteToolComponent
command={command}
output={output}
status={status}
isError={isError}
/>
);
};
const WaitForExternalAuthRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const providerLabel = toProviderLabel(
rec ? asString(rec.provider_display_name).trim() : "",
rec ? asString(rec.provider_id).trim() : "",
rec ? asString(rec.provider_type).trim() : "",
);
const authenticated = rec ? Boolean(rec.authenticated) : false;
const timedOut = rec ? Boolean(rec.timed_out) : false;
const errorMessage = rec ? asString(rec.error || rec.message) : "";
return (
<WaitForExternalAuthTool
providerLabel={providerLabel}
status={status}
authenticated={authenticated}
timedOut={timedOut}
isError={isError}
errorMessage={errorMessage || undefined}
/>
);
};
const ReadFileRenderer: FC<ToolRendererProps> = ({
status,
args,
result,
isError,
}) => {
const parsedArgs = parseArgs(args);
const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
const rec = asRecord(result);
const content = rec ? asString(rec.content).trim() : "";
return (
<ReadFileTool
path={path || "file"}
content={content}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
const WriteFileRenderer: FC<ToolRendererProps> = ({
status,
args,
result,
isError,
}) => {
const parsedArgs = parseArgs(args);
const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
const rec = asRecord(result);
const writeFileDiff = getWriteFileDiff("write_file", args);
return (
<WriteFileTool
path={path || "file"}
diff={writeFileDiff}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
const EditFilesRenderer: FC<ToolRendererProps> = ({
status,
args,
result,
isError,
}) => {
const rec = asRecord(result);
const editFiles = parseEditFilesArgs(args);
const editDiffs = editFiles.map((file) =>
buildEditDiff(file.path, file.edits),
);
return (
<EditFilesTool
files={editFiles}
diffs={editDiffs}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
// Once the tool finishes, the result becomes a JSON object
// with workspace metadata.
const CreateWorkspaceRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const wsName = rec ? asString(rec.workspace_name) : "";
const resultJson = rec ? JSON.stringify(rec, null, 2) : "";
return (
<CreateWorkspaceTool
workspaceName={wsName}
resultJson={resultJson}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.reason) : undefined}
/>
);
};
const SubagentRenderer: FC<ToolRendererProps> = ({
name,
status,
args,
result,
isError,
subagentTitles,
subagentStatusOverrides,
}) => {
const parsedArgs = parseArgs(args);
const rec = asRecord(result);
// wait_agent and message_agent have chat_id in args, so
// check both result and args.
const chatId =
(rec ? asString(rec.chat_id) : "") ||
(parsedArgs ? asString(parsedArgs.chat_id) : "");
const resultSubagentStatus = rec
? asString(rec.status || rec.subagent_status)
: "";
const streamSubagentStatus =
(chatId && subagentStatusOverrides?.get(chatId)) || "";
const subagentStatus = streamSubagentStatus || resultSubagentStatus;
const durationMs = rec
? asNumber(rec.duration_ms, { parseString: true })
: undefined;
const report = rec ? asString(rec.report) : "";
const prompt = parsedArgs ? asString(parsedArgs.prompt) : "";
const subagentMessage = parsedArgs ? asString(parsedArgs.message) : "";
const title =
(rec ? asString(rec.title) : "") ||
(parsedArgs ? asString(parsedArgs.title) : "") ||
(chatId && subagentTitles?.get(chatId)) ||
"Sub-agent";
const subagentCompleted = isSubagentSuccessStatus(subagentStatus);
const subagentToolStatus = mapSubagentStatusToToolStatus(
subagentStatus,
status,
);
const subagentIsError =
subagentToolStatus === "error" ||
((status === "error" || isError) && !subagentCompleted);
return (
<SubagentTool
toolName={name}
title={title}
chatId={chatId}
subagentStatus={subagentStatus}
prompt={prompt || undefined}
message={subagentMessage || undefined}
durationMs={chatId ? durationMs : undefined}
report={chatId ? report || undefined : undefined}
toolStatus={subagentToolStatus}
isError={subagentIsError}
/>
);
};
const ListTemplatesRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const templates = rec && Array.isArray(rec.templates) ? rec.templates : [];
const count = rec
? (asNumber(rec.count, { parseString: true }) ?? templates.length)
: 0;
return (
<ListTemplatesTool
templates={templates}
count={count}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
const ReadTemplateRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const templateRec = rec ? asRecord(rec.template) : undefined;
const name = templateRec
? asString(templateRec.display_name) || asString(templateRec.name)
: "";
return (
<ReadTemplateTool
templateName={name}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
const ChatSummarizedRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
const rec = asRecord(result);
const summary =
(rec ? asString(rec.summary) : "") ||
(typeof result === "string" ? result : "");
return (
<ChatSummarizedTool
summary={summary}
status={status}
isError={isError}
errorMessage={rec ? asString(rec.error || rec.message) : undefined}
/>
);
};
// Generic fallback renderer — only path that needs theme, diff
// viewers, and file content helpers.
const GenericToolRenderer: FC<ToolRendererProps> = ({
name,
status,
args,
result,
isError,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const resultOutput = formatResultOutput(result);
const fileContent = getFileContentForViewer(name, args, result);
const writeFileDiff = getWriteFileDiff(name, args);
const fileViewerOpts = getFileViewerOptions(isDark);
const fileContentOptions = fileContent
? {
...fileViewerOpts,
disableFileHeader: fileContent.disableHeader,
disableLineNumbers: fileContent.disableLineNumbers,
}
: fileViewerOpts;
return (
<>
<div className="flex items-center gap-2">
<ToolIcon name={name} isError={status === "error" || isError} />
<ToolLabel name={name} args={args} result={result} />
</div>
{writeFileDiff ? (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={writeFileDiff}
options={getDiffViewerOptions(isDark)}
/>
</ScrollArea>
) : fileContent ? (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: fileContent.path,
contents: fileContent.content,
}}
options={fileContentOptions}
/>
</ScrollArea>
) : (
resultOutput && (
<ScrollArea
className="mt-1.5 ml-6 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: "output.json",
contents: resultOutput,
}}
options={getFileViewerOptionsNoHeader(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
)
)}
</>
);
};
// ---------------------------------------------------------------------------
// Renderer lookup map — maps tool names to their specialized renderers.
// ---------------------------------------------------------------------------
const toolRenderers: Record<string, FC<ToolRendererProps>> = {
execute: ExecuteRenderer,
wait_for_external_auth: WaitForExternalAuthRenderer,
read_file: ReadFileRenderer,
write_file: WriteFileRenderer,
edit_files: EditFilesRenderer,
create_workspace: CreateWorkspaceRenderer,
list_templates: ListTemplatesRenderer,
read_template: ReadTemplateRenderer,
spawn_agent: SubagentRenderer,
wait_agent: SubagentRenderer,
message_agent: SubagentRenderer,
close_agent: SubagentRenderer,
chat_summarized: ChatSummarizedRenderer,
};
// ---------------------------------------------------------------------------
// Public Tool component — single wrapper div + map dispatch.
// ---------------------------------------------------------------------------
export const Tool = memo(
({
className,
name,
status = "completed",
args,
result,
isError = false,
subagentTitles,
subagentStatusOverrides,
ref,
...props
}: ToolProps) => {
const Renderer = toolRenderers[name] ?? GenericToolRenderer;
return (
<div
ref={ref}
className={cn(
name === "execute" ? "w-full py-0.5" : "py-0.5",
className,
)}
{...props}
>
<Renderer
name={name}
status={status}
args={args}
result={result}
isError={isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
</div>
);
},
);
Tool.displayName = "Tool";
@@ -0,0 +1,59 @@
import { ChevronDownIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { useState } from "react";
import { cn } from "utils/cn";
interface ToolCollapsibleProps {
children: ReactNode;
header: ReactNode;
hasContent?: boolean;
defaultExpanded?: boolean;
className?: string;
headerClassName?: string;
}
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
children,
header,
hasContent = true,
defaultExpanded = false,
className,
headerClassName,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<div className={className}>
{hasContent ? (
<div
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpanded(!expanded);
}
}}
className={cn(
"flex items-center gap-2 cursor-pointer",
headerClassName,
)}
>
{header}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-content-secondary transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</div>
) : (
<div className={cn("flex items-center gap-2", headerClassName)}>
{header}
</div>
)}
{expanded && hasContent && children}
</div>
);
};
@@ -0,0 +1,35 @@
import {
BotIcon,
FileIcon,
FilePenIcon,
PlusCircleIcon,
TerminalIcon,
WrenchIcon,
} from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
export const ToolIcon: React.FC<{ name: string; isError: boolean }> = ({
name,
isError,
}) => {
const color = isError ? "text-content-destructive" : "text-content-secondary";
const base = cn("h-4 w-4 shrink-0", color);
switch (name) {
case "execute":
return <TerminalIcon className={base} />;
case "read_file":
case "list_templates":
case "read_template":
return <FileIcon className={base} />;
case "write_file":
case "edit_files":
return <FilePenIcon className={base} />;
case "create_workspace":
return <PlusCircleIcon className={base} />;
case "chat_summarized":
return <BotIcon className={base} />;
default:
return <WrenchIcon className={base} />;
}
};
@@ -0,0 +1,156 @@
import type React from "react";
import { asRecord, asString, parseArgs } from "./utils";
export const ToolLabel: React.FC<{
name: string;
args: unknown;
result: unknown;
}> = ({ name, args, result }) => {
const parsed = parseArgs(args);
const parsedResult = asRecord(result);
switch (name) {
case "execute": {
const command = parsed ? asString(parsed.command) : "";
if (command) {
return (
<code className="truncate font-mono text-xs text-content-primary">
{command}
</code>
);
}
return (
<span className="truncate text-sm text-content-secondary">
Running command
</span>
);
}
case "read_file":
return (
<span className="truncate text-sm text-content-secondary">
Reading file…
</span>
);
case "write_file": {
const path = parsed ? asString(parsed.path) : "";
if (path) {
return (
<code className="truncate font-mono text-xs text-content-primary">
{path}
</code>
);
}
return (
<span className="truncate text-sm text-content-secondary">
Writing file
</span>
);
}
case "edit_files": {
const files = parsed?.files;
if (Array.isArray(files) && files.length === 1) {
const path = asString((files[0] as Record<string, unknown>)?.path);
if (path) {
return (
<code className="truncate font-mono text-xs text-content-primary">
{path}
</code>
);
}
}
return (
<span className="truncate text-sm text-content-secondary">
Editing files
</span>
);
}
case "create_workspace": {
const wsName = parsedResult ? asString(parsedResult.workspace_name) : "";
if (wsName) {
return (
<span className="truncate text-sm text-content-secondary">
Created {wsName}
</span>
);
}
return (
<span className="truncate text-sm text-content-secondary">
Creating workspace
</span>
);
}
case "list_templates": {
const count = parsedResult
? ((parsedResult.count as number | undefined) ?? 0)
: 0;
return (
<span className="truncate text-sm text-content-secondary">
{count === 0
? "Listing templates…"
: count === 1
? "Listed 1 template"
: `Listed ${count} templates`}
</span>
);
}
case "read_template": {
const templateRec = parsedResult
? asRecord(parsedResult.template)
: undefined;
const tmplName = templateRec
? asString(templateRec.display_name) || asString(templateRec.name)
: "";
return (
<span className="truncate text-sm text-content-secondary">
{tmplName ? `Read template ${tmplName}` : "Reading template…"}
</span>
);
}
case "spawn_agent": {
const spawnTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
{spawnTitle ? `Spawning ${spawnTitle}` : "Spawning sub-agent…"}
</span>
);
}
case "wait_agent": {
const awaitTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
{awaitTitle ? `Waiting for ${awaitTitle}` : "Waiting for sub-agent…"}
</span>
);
}
case "message_agent": {
const msgTitle =
(parsedResult ? asString(parsedResult.title) : "") ||
(parsed ? asString(parsed.title) : "");
return (
<span className="truncate text-sm text-content-secondary">
{msgTitle ? `Messaging ${msgTitle}` : "Messaging sub-agent…"}
</span>
);
}
case "close_agent":
return (
<span className="truncate text-sm text-content-secondary">
Terminating sub-agent
</span>
);
case "chat_summarized":
return (
<span className="truncate text-sm text-content-secondary">
Summarized
</span>
);
default:
return (
<span className="truncate text-sm text-content-secondary">{name}</span>
);
}
};
@@ -0,0 +1,84 @@
import { useTheme } from "@emotion/react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
type ToolStatus,
} from "./utils";
/**
* Collapsed-by-default rendering for `write_file` tool calls. Shows
* "Wrote <filename>" with a chevron; expanding reveals the unified diff.
*/
export const WriteFileTool: React.FC<{
path: string;
diff: FileDiffMetadata | null;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ path, diff, status, isError, errorMessage }) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const hasDiff = diff !== null;
const isRunning = status === "running";
const filename = path.split("/").pop() || path;
const label = isRunning ? `Writing ${filename}…` : `Wrote ${filename}`;
return (
<ToolCollapsible
className="w-full"
hasContent={hasDiff}
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{label}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to write file"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
{hasDiff && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileDiff
fileDiff={diff}
options={getDiffViewerOptions(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
)}
</ToolCollapsible>
);
};
@@ -0,0 +1 @@
export { Tool } from "./Tool";
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import {
BORDER_BG_STYLE,
buildEditDiff,
buildWriteFileDiff,
COLLAPSED_OUTPUT_HEIGHT,
COLLAPSED_REPORT_HEIGHT,
DIFFS_FONT_STYLE,
diffViewerCSS,
fileViewerCSS,
formatResultOutput,
getDiffViewerOptions,
getFileContentForViewer,
getFileViewerOptions,
getFileViewerOptionsMinimal,
getFileViewerOptionsNoHeader,
getWriteFileDiff,
isSubagentRunningStatus,
isSubagentSuccessStatus,
mapSubagentStatusToToolStatus,
normalizeStatus,
parseArgs,
parseEditFilesArgs,
shortDurationMs,
toProviderLabel,
} from "./utils";
describe("toProviderLabel", () => {
it("returns displayName when provided", () => {
expect(toProviderLabel("GitHub", "gh-id", "oauth")).toBe("GitHub");
});
it("falls back to providerID when displayName is empty", () => {
expect(toProviderLabel("", "gh-id", "oauth")).toBe("gh-id");
});
it("falls back to providerType when displayName and ID are empty", () => {
expect(toProviderLabel("", "", "oauth")).toBe("oauth");
});
it("returns default label when all are empty", () => {
expect(toProviderLabel("", "", "")).toBe("Git provider");
});
});
describe("shortDurationMs", () => {
it("returns empty string for undefined", () => {
expect(shortDurationMs(undefined)).toBe("");
});
it("returns empty string for negative values", () => {
expect(shortDurationMs(-1)).toBe("");
expect(shortDurationMs(-1000)).toBe("");
});
it("returns 0s for zero milliseconds", () => {
expect(shortDurationMs(0)).toBe("0s");
});
it("formats sub-second durations", () => {
expect(shortDurationMs(500)).toBe("1s");
expect(shortDurationMs(100)).toBe("0s");
});
it("formats seconds", () => {
expect(shortDurationMs(1000)).toBe("1s");
expect(shortDurationMs(30_000)).toBe("30s");
expect(shortDurationMs(59_000)).toBe("59s");
});
it("formats minutes", () => {
expect(shortDurationMs(60_000)).toBe("1m");
expect(shortDurationMs(300_000)).toBe("5m");
expect(shortDurationMs(3_540_000)).toBe("59m");
});
it("formats hours", () => {
expect(shortDurationMs(3_600_000)).toBe("1h");
expect(shortDurationMs(7_200_000)).toBe("2h");
});
});
describe("normalizeStatus", () => {
it("lowercases and trims", () => {
expect(normalizeStatus(" COMPLETED ")).toBe("completed");
});
it("handles already-normalized input", () => {
expect(normalizeStatus("running")).toBe("running");
});
it("handles empty string", () => {
expect(normalizeStatus("")).toBe("");
});
});
describe("isSubagentSuccessStatus", () => {
it("returns true for completed", () => {
expect(isSubagentSuccessStatus("completed")).toBe(true);
});
it("returns true for reported", () => {
expect(isSubagentSuccessStatus("reported")).toBe(true);
});
it("is case-insensitive", () => {
expect(isSubagentSuccessStatus("COMPLETED")).toBe(true);
expect(isSubagentSuccessStatus(" Reported ")).toBe(true);
});
it("returns false for other statuses", () => {
expect(isSubagentSuccessStatus("running")).toBe(false);
expect(isSubagentSuccessStatus("error")).toBe(false);
expect(isSubagentSuccessStatus("")).toBe(false);
});
});
describe("isSubagentRunningStatus", () => {
it("returns true for running statuses", () => {
expect(isSubagentRunningStatus("pending")).toBe(true);
expect(isSubagentRunningStatus("running")).toBe(true);
expect(isSubagentRunningStatus("awaiting")).toBe(true);
});
it("is case-insensitive", () => {
expect(isSubagentRunningStatus("RUNNING")).toBe(true);
expect(isSubagentRunningStatus(" Pending ")).toBe(true);
});
it("returns false for non-running statuses", () => {
expect(isSubagentRunningStatus("completed")).toBe(false);
expect(isSubagentRunningStatus("error")).toBe(false);
expect(isSubagentRunningStatus("")).toBe(false);
});
});
describe("mapSubagentStatusToToolStatus", () => {
it("returns fallback for empty status", () => {
expect(mapSubagentStatusToToolStatus("", "running")).toBe("running");
expect(mapSubagentStatusToToolStatus(" ", "error")).toBe("error");
});
it("maps success statuses to completed", () => {
expect(mapSubagentStatusToToolStatus("completed", "running")).toBe(
"completed",
);
expect(mapSubagentStatusToToolStatus("reported", "running")).toBe(
"completed",
);
});
it("maps running statuses to running when fallback is not completed", () => {
expect(mapSubagentStatusToToolStatus("pending", "running")).toBe("running");
expect(mapSubagentStatusToToolStatus("running", "error")).toBe("running");
expect(mapSubagentStatusToToolStatus("awaiting", "running")).toBe(
"running",
);
});
it("preserves completed fallback even with running subagent status", () => {
expect(mapSubagentStatusToToolStatus("pending", "completed")).toBe(
"completed",
);
expect(mapSubagentStatusToToolStatus("running", "completed")).toBe(
"completed",
);
});
it("maps waiting to completed", () => {
expect(mapSubagentStatusToToolStatus("waiting", "running")).toBe(
"completed",
);
});
it("maps terminated to completed", () => {
expect(mapSubagentStatusToToolStatus("terminated", "running")).toBe(
"completed",
);
});
it("maps error to error", () => {
expect(mapSubagentStatusToToolStatus("error", "running")).toBe("error");
});
it("returns fallback for unknown statuses", () => {
expect(mapSubagentStatusToToolStatus("unknown-status", "running")).toBe(
"running",
);
expect(mapSubagentStatusToToolStatus("banana", "error")).toBe("error");
});
});
describe("parseArgs", () => {
it("returns null for falsy values", () => {
expect(parseArgs(null)).toBeNull();
expect(parseArgs(undefined)).toBeNull();
expect(parseArgs("")).toBeNull();
expect(parseArgs(0)).toBeNull();
});
it("parses a JSON string into a record", () => {
expect(parseArgs('{"key": "value"}')).toEqual({ key: "value" });
});
it("returns null for invalid JSON strings", () => {
expect(parseArgs("not json")).toBeNull();
});
it("returns null for JSON strings that parse to non-objects", () => {
expect(parseArgs('"just a string"')).toBeNull();
expect(parseArgs("42")).toBeNull();
expect(parseArgs("[1, 2, 3]")).toBeNull();
});
it("returns object args directly", () => {
const obj = { path: "/foo.ts", content: "hello" };
expect(parseArgs(obj)).toEqual(obj);
});
it("returns null for arrays", () => {
expect(parseArgs([1, 2, 3])).toBeNull();
});
});
describe("formatResultOutput", () => {
it("returns null for null and undefined", () => {
expect(formatResultOutput(null)).toBeNull();
expect(formatResultOutput(undefined)).toBeNull();
});
it("returns trimmed string or null for empty", () => {
expect(formatResultOutput(" hello ")).toBe("hello");
expect(formatResultOutput("")).toBeNull();
expect(formatResultOutput(" ")).toBeNull();
});
it("extracts output field from record", () => {
expect(formatResultOutput({ output: " some output " })).toBe(
"some output",
);
});
it("extracts content field from record when output is empty", () => {
expect(formatResultOutput({ content: "file content" })).toBe(
"file content",
);
});
it("prefers output over content", () => {
expect(
formatResultOutput({ output: "cmd output", content: "file content" }),
).toBe("cmd output");
});
it("falls back to JSON.stringify when output and content are empty", () => {
// Both output and content are empty strings after trim, so
// the function falls through to JSON.stringify the record.
const result = formatResultOutput({ output: "", content: "" });
expect(result).toBe(JSON.stringify({ output: "", content: "" }, null, 2));
});
it("falls back to JSON.stringify for objects without output/content", () => {
const result = formatResultOutput({ status: "ok", code: 0 });
expect(result).toBe(JSON.stringify({ status: "ok", code: 0 }, null, 2));
});
it("returns String representation for non-object/non-string primitives", () => {
expect(formatResultOutput(42)).toBe("42");
expect(formatResultOutput(true)).toBe("true");
});
});
describe("getDiffViewerOptions", () => {
it("returns dark theme options", () => {
const opts = getDiffViewerOptions(true);
expect(opts.themeType).toBe("dark");
expect(opts.theme).toBe("github-dark-high-contrast");
expect(opts.diffStyle).toBe("unified");
expect(opts.diffIndicators).toBe("bars");
expect(opts.overflow).toBe("scroll");
expect(opts.unsafeCSS).toBe(diffViewerCSS);
});
it("returns light theme options", () => {
const opts = getDiffViewerOptions(false);
expect(opts.themeType).toBe("light");
expect(opts.theme).toBe("github-light");
});
});
describe("getFileViewerOptions", () => {
it("returns dark theme options", () => {
const opts = getFileViewerOptions(true);
expect(opts.themeType).toBe("dark");
expect(opts.theme).toBe("github-dark-high-contrast");
expect(opts.overflow).toBe("scroll");
expect(opts.unsafeCSS).toBe(fileViewerCSS);
});
it("returns light theme options", () => {
const opts = getFileViewerOptions(false);
expect(opts.themeType).toBe("light");
expect(opts.theme).toBe("github-light");
});
});
describe("getFileViewerOptionsNoHeader", () => {
it("extends base options with disableFileHeader", () => {
const opts = getFileViewerOptionsNoHeader(true);
expect(opts.disableFileHeader).toBe(true);
expect(opts.themeType).toBe("dark");
});
});
describe("getFileViewerOptionsMinimal", () => {
it("extends base options with disableFileHeader and disableLineNumbers", () => {
const opts = getFileViewerOptionsMinimal(false);
expect(opts.disableFileHeader).toBe(true);
expect(opts.disableLineNumbers).toBe(true);
expect(opts.themeType).toBe("light");
});
});
describe("getFileContentForViewer", () => {
it("returns null for unsupported tool names", () => {
expect(getFileContentForViewer("write_file", {}, {})).toBeNull();
expect(getFileContentForViewer("search", {}, {})).toBeNull();
});
describe("execute tool", () => {
it("returns output with shell path and disabled header/line numbers", () => {
const result = getFileContentForViewer(
"execute",
{},
{ output: "ls -la" },
);
expect(result).toEqual({
path: "output.sh",
content: "ls -la",
disableHeader: true,
disableLineNumbers: true,
});
});
it("returns null when result is not a record", () => {
expect(getFileContentForViewer("execute", {}, "string")).toBeNull();
});
it("returns null when output is empty", () => {
expect(
getFileContentForViewer("execute", {}, { output: " " }),
).toBeNull();
});
});
describe("read_file tool", () => {
it("returns path and content from args and result", () => {
const args = { path: "/src/main.ts" };
const result = { content: "const x = 1;" };
const out = getFileContentForViewer("read_file", args, result);
expect(out).toEqual({
path: "/src/main.ts",
content: "const x = 1;",
});
});
it("parses JSON string args", () => {
const args = JSON.stringify({ path: "/foo.ts" });
const result = { content: "hello" };
expect(getFileContentForViewer("read_file", args, result)).toEqual({
path: "/foo.ts",
content: "hello",
});
});
it("returns null when path is missing", () => {
expect(
getFileContentForViewer("read_file", {}, { content: "hello" }),
).toBeNull();
});
it("returns null when content is empty", () => {
expect(
getFileContentForViewer("read_file", { path: "/x" }, { content: "" }),
).toBeNull();
});
it("returns null when result is not a record", () => {
expect(
getFileContentForViewer("read_file", { path: "/x" }, "not record"),
).toBeNull();
});
});
});
describe("buildWriteFileDiff", () => {
it("returns a FileDiffMetadata for new file content", () => {
const diff = buildWriteFileDiff(
"src/hello.ts",
"const x = 1;\nconst y = 2;\n",
);
expect(diff).not.toBeNull();
expect(diff!.name).toContain("hello.ts");
});
it("returns null for empty content", () => {
expect(buildWriteFileDiff("foo.ts", "")).toBeNull();
});
it("handles content without trailing newline", () => {
const diff = buildWriteFileDiff("foo.ts", "single line");
expect(diff).not.toBeNull();
});
it("handles content that is only a newline", () => {
// A single newline splits into ["", ""], trailing empty is popped,
// leaving [""] which is one line.
const diff = buildWriteFileDiff("foo.ts", "\n");
// After split: ["", ""], pop trailing empty -> [""]
// That's 1 empty-string line, which is still a valid line.
expect(diff).not.toBeNull();
});
});
describe("getWriteFileDiff", () => {
it("returns null for non-write_file tools", () => {
expect(
getWriteFileDiff("read_file", { path: "x", content: "y" }),
).toBeNull();
expect(getWriteFileDiff("execute", { path: "x", content: "y" })).toBeNull();
});
it("returns null when args cannot be parsed", () => {
expect(getWriteFileDiff("write_file", null)).toBeNull();
});
it("returns null when path is missing", () => {
expect(getWriteFileDiff("write_file", { content: "hello" })).toBeNull();
});
it("returns null when content is missing", () => {
expect(getWriteFileDiff("write_file", { path: "/foo.ts" })).toBeNull();
});
it("returns a diff for valid write_file args", () => {
const diff = getWriteFileDiff("write_file", {
path: "src/main.ts",
content: "export default 42;\n",
});
expect(diff).not.toBeNull();
expect(diff!.name).toContain("main.ts");
});
it("parses JSON string args", () => {
const diff = getWriteFileDiff(
"write_file",
JSON.stringify({ path: "x.ts", content: "y" }),
);
expect(diff).not.toBeNull();
});
});
describe("parseEditFilesArgs", () => {
it("returns empty array for null args", () => {
expect(parseEditFilesArgs(null)).toEqual([]);
});
it("returns empty array when files is not an array", () => {
expect(parseEditFilesArgs({ files: "not array" })).toEqual([]);
});
it("returns empty array when files key is missing", () => {
expect(parseEditFilesArgs({ other: "value" })).toEqual([]);
});
it("filters out invalid entries", () => {
const args = {
files: [
{ path: "a.ts", edits: [{ search: "x", replace: "y" }] },
{ path: 42, edits: [] }, // invalid: path not string
null, // invalid: null
{ path: "b.ts" }, // invalid: no edits array
{ path: "c.ts", edits: [{ search: "a", replace: "b" }] },
],
};
const result = parseEditFilesArgs(args);
expect(result).toHaveLength(2);
expect(result[0].path).toBe("a.ts");
expect(result[1].path).toBe("c.ts");
});
it("parses JSON string args", () => {
const args = JSON.stringify({
files: [{ path: "test.ts", edits: [{ search: "old", replace: "new" }] }],
});
const result = parseEditFilesArgs(args);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("test.ts");
});
});
describe("buildEditDiff", () => {
it("returns null for empty edits array", () => {
expect(buildEditDiff("file.ts", [])).toBeNull();
});
it("builds a diff from a single search/replace pair", () => {
const diff = buildEditDiff("src/index.ts", [
{ search: "const x = 1;", replace: "const x = 2;" },
]);
expect(diff).not.toBeNull();
expect(diff!.name).toContain("index.ts");
});
it("builds a diff from multiple search/replace pairs", () => {
const diff = buildEditDiff("src/index.ts", [
{ search: "const x = 1;", replace: "const x = 2;" },
{ search: "const y = 3;", replace: "const y = 4;" },
]);
expect(diff).not.toBeNull();
});
it("strips leading slash from path", () => {
const diff = buildEditDiff("/src/index.ts", [
{ search: "old", replace: "new" },
]);
expect(diff).not.toBeNull();
// The diff path should not have a double slash.
expect(diff!.name).not.toContain("//");
});
it("skips edits with empty search", () => {
const diff = buildEditDiff("file.ts", [{ search: "", replace: "new" }]);
// All edits skipped → only header lines remain. The parser
// still returns a file entry but with no hunks.
expect(diff).not.toBeNull();
expect(diff!.hunks).toHaveLength(0);
});
it("handles multi-line search and replace", () => {
const diff = buildEditDiff("file.ts", [
{
search: "line1\nline2\nline3",
replace: "newLine1\nnewLine2",
},
]);
expect(diff).not.toBeNull();
});
it("handles replace with trailing newline (trailing empty popped)", () => {
const diff = buildEditDiff("file.ts", [
{ search: "old\n", replace: "new\n" },
]);
expect(diff).not.toBeNull();
});
});
describe("constants", () => {
it("COLLAPSED_OUTPUT_HEIGHT is 54", () => {
expect(COLLAPSED_OUTPUT_HEIGHT).toBe(54);
});
it("COLLAPSED_REPORT_HEIGHT is 72", () => {
expect(COLLAPSED_REPORT_HEIGHT).toBe(72);
});
it("DIFFS_FONT_STYLE has expected CSS properties", () => {
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-font-size", "11px");
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
});
it("BORDER_BG_STYLE has expected background", () => {
expect(BORDER_BG_STYLE).toHaveProperty(
"background",
"hsl(var(--border-default))",
);
});
it("fileViewerCSS is a non-empty string", () => {
expect(typeof fileViewerCSS).toBe("string");
expect(fileViewerCSS.length).toBeGreaterThan(0);
});
it("diffViewerCSS includes border-left style", () => {
expect(diffViewerCSS).toContain("border-left");
});
});
@@ -0,0 +1,389 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import type React from "react";
import { asRecord, asString } from "../runtimeTypeUtils";
export type ToolStatus = "completed" | "error" | "running";
export interface EditFilesFileEntry {
path: string;
edits: Array<{ search: string; replace: string }>;
}
export const toProviderLabel = (
providerDisplayName: string,
providerID: string,
providerType: string,
): string => {
if (providerDisplayName) {
return providerDisplayName;
}
if (providerID) {
return providerID;
}
if (providerType) {
return providerType;
}
return "Git provider";
};
/**
* Formats a duration in milliseconds into a compact label using
* the same style as {@link shortRelativeTime} in utils/time.
*/
export const shortDurationMs = (durationMs: number | undefined): string => {
if (durationMs === undefined || durationMs < 0) {
return "";
}
const seconds = Math.round(durationMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.round(minutes / 60);
return `${hours}h`;
};
export const normalizeStatus = (status: string): string =>
status.trim().toLowerCase();
export const isSubagentSuccessStatus = (status: string): boolean => {
switch (normalizeStatus(status)) {
case "completed":
case "reported":
return true;
default:
return false;
}
};
export const isSubagentRunningStatus = (status: string): boolean => {
switch (normalizeStatus(status)) {
case "pending":
case "running":
case "awaiting":
return true;
default:
return false;
}
};
export const mapSubagentStatusToToolStatus = (
subagentStatus: string,
fallback: ToolStatus,
): ToolStatus => {
const normalized = normalizeStatus(subagentStatus);
if (!normalized) {
return fallback;
}
if (isSubagentSuccessStatus(normalized)) {
return "completed";
}
if (isSubagentRunningStatus(normalized)) {
// If the tool call itself has already completed, don't
// override to "running". The spawn/await tool is done;
// the sub-agent may still be working in the background
// but that doesn't mean the tool call is still running.
return fallback === "completed" ? "completed" : "running";
}
switch (normalized) {
case "waiting":
case "terminated":
return "completed";
case "error":
return "error";
default:
return fallback;
}
};
export const parseArgs = (args: unknown): Record<string, unknown> | null => {
if (!args) {
return null;
}
if (typeof args === "string") {
try {
const parsed = JSON.parse(args);
return asRecord(parsed);
} catch {
return null;
}
}
return asRecord(args);
};
export const formatResultOutput = (result: unknown): string | null => {
if (result === undefined || result === null) {
return null;
}
if (typeof result === "string") {
const trimmed = result.trim();
return trimmed || null;
}
const rec = asRecord(result);
if (rec) {
// For execute tool, show the output field.
const output = asString(rec.output).trim();
if (output) {
return output;
}
// For read_file, show the content field.
const content = asString(rec.content).trim();
if (content) {
return content;
}
}
if (typeof result === "object") {
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
return String(result);
};
export const fileViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
export const diffViewerCSS =
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; } [data-diffs-header] { border-left: 1px solid var(--border); }";
// Theme-aware option factories shared across tool renderers.
export function getDiffViewerOptions(isDark: boolean) {
return {
diffStyle: "unified" as const,
diffIndicators: "bars" as const,
overflow: "scroll" as const,
themeType: (isDark ? "dark" : "light") as "dark" | "light",
theme: isDark ? "github-dark-high-contrast" : "github-light",
unsafeCSS: diffViewerCSS,
};
}
export function getFileViewerOptions(isDark: boolean) {
return {
overflow: "scroll" as const,
themeType: (isDark ? "dark" : "light") as "dark" | "light",
theme: isDark ? "github-dark-high-contrast" : "github-light",
unsafeCSS: fileViewerCSS,
};
}
export function getFileViewerOptionsNoHeader(isDark: boolean) {
return {
...getFileViewerOptions(isDark),
disableFileHeader: true,
};
}
export function getFileViewerOptionsMinimal(isDark: boolean) {
return {
...getFileViewerOptions(isDark),
disableFileHeader: true,
disableLineNumbers: true,
};
}
export const DIFFS_FONT_STYLE = {
"--diffs-font-size": "11px",
"--diffs-line-height": "1.5",
} as React.CSSProperties;
export const BORDER_BG_STYLE = {
background: "hsl(var(--border-default))",
};
/**
* Checks whether a tool result should be rendered as a syntax-highlighted
* file viewer. Returns the file path, content, and whether the header
* should be hidden.
*/
export const getFileContentForViewer = (
toolName: string,
args: unknown,
result: unknown,
): {
path: string;
content: string;
disableHeader?: boolean;
disableLineNumbers?: boolean;
} | null => {
if (toolName === "execute") {
const rec = asRecord(result);
if (!rec) {
return null;
}
const output = asString(rec.output).trim();
if (!output) {
return null;
}
return {
path: "output.sh",
content: output,
disableHeader: true,
disableLineNumbers: true,
};
}
if (toolName !== "read_file") {
return null;
}
const parsed = parseArgs(args);
const path = parsed ? asString(parsed.path).trim() : "";
if (!path) {
return null;
}
const rec = asRecord(result);
if (!rec) {
return null;
}
const content = asString(rec.content).trim();
if (!content) {
return null;
}
return { path, content };
};
/**
* Builds a FileDiffMetadata representing a new-file diff (all lines
* are additions) from the content written by a write_file tool call.
* Returns null when the content is empty or unparsable.
*/
export const buildWriteFileDiff = (
path: string,
content: string,
): FileDiffMetadata | null => {
const lines = content.split("\n");
// Remove trailing empty line produced by a final newline.
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
if (lines.length === 0) {
return null;
}
const patchLines = [
`diff --git a/${path} b/${path}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${path}`,
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((l) => `+${l}`),
];
const patch = `${patchLines.join("\n")}\n`;
const parsed = parsePatchFiles(patch);
if (!parsed.length || !parsed[0].files.length) {
return null;
}
return parsed[0].files[0];
};
/**
* For write_file tool calls, extracts the path and content from args
* and builds a FileDiffMetadata showing all lines as additions.
*/
export const getWriteFileDiff = (
toolName: string,
args: unknown,
): FileDiffMetadata | null => {
if (toolName !== "write_file") {
return null;
}
const parsed = parseArgs(args);
if (!parsed) {
return null;
}
const path = asString(parsed.path).trim();
const content = asString(parsed.content).trim();
if (!path || !content) {
return null;
}
return buildWriteFileDiff(path, content);
};
/** Height that fits roughly 3 lines of monospace text-xs output. */
export const COLLAPSED_OUTPUT_HEIGHT = 54;
/** Height for the collapsed report preview (~3 lines of rendered markdown). */
export const COLLAPSED_REPORT_HEIGHT = 72;
/**
* Parses the args of an edit_files tool call into a typed array
* of file entries.
*/
export const parseEditFilesArgs = (args: unknown): EditFilesFileEntry[] => {
const parsed = parseArgs(args);
if (!parsed) return [];
const files = parsed.files;
if (!Array.isArray(files)) return [];
return files.filter(
(f): f is EditFilesFileEntry =>
f !== null &&
typeof f === "object" &&
typeof (f as Record<string, unknown>).path === "string" &&
Array.isArray((f as Record<string, unknown>).edits),
);
};
/**
* Builds a synthetic unified diff from search/replace edit pairs
* for a single file. Each pair becomes a separate hunk in the
* diff. Line numbers are synthetic since we don't have the full
* file content.
*/
export const buildEditDiff = (
path: string,
edits: Array<{ search: string; replace: string }>,
): FileDiffMetadata | null => {
if (!edits.length) return null;
// Strip leading slash so the a/ and b/ prefixes don't
// produce a double-slash that confuses the diff parser.
const diffPath = path.startsWith("/") ? path.slice(1) : path;
const patchLines: string[] = [
`diff --git a/${diffPath} b/${diffPath}`,
`--- a/${diffPath}`,
`+++ b/${diffPath}`,
];
let lineOffset = 1;
for (const edit of edits) {
if (!edit.search) continue;
const searchLines = edit.search.split("\n");
const replaceLines = edit.replace.split("\n");
// Remove trailing empty line produced by a final newline.
if (searchLines.length > 0 && searchLines[searchLines.length - 1] === "") {
searchLines.pop();
}
if (
replaceLines.length > 0 &&
replaceLines[replaceLines.length - 1] === ""
) {
replaceLines.pop();
}
if (searchLines.length === 0 && replaceLines.length === 0) continue;
patchLines.push(
`@@ -${lineOffset},${searchLines.length} +${lineOffset},${replaceLines.length} @@`,
);
for (const l of searchLines) patchLines.push(`-${l}`);
for (const l of replaceLines) patchLines.push(`+${l}`);
lineOffset += Math.max(searchLines.length, replaceLines.length) + 1;
}
const patch = `${patchLines.join("\n")}\n`;
const parsed = parsePatchFiles(patch);
if (!parsed.length || !parsed[0].files.length) return null;
return parsed[0].files[0];
};
// Re-export runtime type utils used by sub-components so they
// can import from a single location.
export { asNumber, asRecord, asString } from "../runtimeTypeUtils";
@@ -0,0 +1,51 @@
import {
type WorkerInitializationRenderOptions,
WorkerPoolContextProvider,
type WorkerPoolOptions,
} from "@pierre/diffs/react";
import type { FC, ReactNode } from "react";
interface DiffsWorkerPoolProviderProps {
children: ReactNode;
}
const highlighterOptions: WorkerInitializationRenderOptions = {
theme: {
dark: "github-dark-high-contrast",
light: "github-light",
},
};
const getPoolSize = (): number => {
const cores = globalThis.navigator?.hardwareConcurrency ?? 2;
// From Kyle: This is just arbitrarily chosen by me.
return Math.min(Math.max(1, cores - 1), 3);
};
const hasWorkerSupport = (): boolean =>
typeof window !== "undefined" && typeof Worker !== "undefined";
export const DiffsWorkerPoolProvider: FC<DiffsWorkerPoolProviderProps> = ({
children,
}) => {
if (!hasWorkerSupport()) {
return <>{children}</>;
}
const poolOptions: WorkerPoolOptions = {
poolSize: getPoolSize(),
workerFactory: () =>
new Worker(new URL("@pierre/diffs/worker/worker.js", import.meta.url), {
type: "module",
}),
};
return (
<WorkerPoolContextProvider
poolOptions={poolOptions}
highlighterOptions={highlighterOptions}
>
{children}
</WorkerPoolContextProvider>
);
};
@@ -1,4 +1,5 @@
import {
MockAgentsTabVisible,
MockAppearanceConfig,
MockBuildInfo,
MockEntitlements,
@@ -43,6 +44,7 @@ const mockDataForTags = {
userAppearance: MockUserAppearanceSettings,
regions: MockRegions,
"tasks-tab-visible": MockTasksTabVisible,
"agents-tab-visible": MockAgentsTabVisible,
} as const satisfies Record<MetadataKey, MetadataValue>;
const emptyMetadata: RuntimeHtmlMetadata = {
@@ -78,6 +80,10 @@ const emptyMetadata: RuntimeHtmlMetadata = {
available: false,
value: undefined,
},
"agents-tab-visible": {
available: false,
value: undefined,
},
};
const populatedMetadata: RuntimeHtmlMetadata = {
@@ -113,6 +119,10 @@ const populatedMetadata: RuntimeHtmlMetadata = {
available: true,
value: MockTasksTabVisible,
},
"agents-tab-visible": {
available: true,
value: MockAgentsTabVisible,
},
};
function seedInitialMetadata(metadataKey: string): () => void {
+2
View File
@@ -30,6 +30,7 @@ type AvailableMetadata = Readonly<{
regions: readonly Region[];
"build-info": BuildInfoResponse;
"tasks-tab-visible": boolean;
"agents-tab-visible": boolean;
}>;
export type MetadataKey = keyof AvailableMetadata;
@@ -92,6 +93,7 @@ export class MetadataManager implements MetadataManagerApi {
"build-info": this.registerValue<BuildInfoResponse>("build-info"),
regions: this.registerRegionValue(),
"tasks-tab-visible": this.registerValue<boolean>("tasks-tab-visible"),
"agents-tab-visible": this.registerValue<boolean>("agents-tab-visible"),
};
}
+19
View File
@@ -53,6 +53,18 @@
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
/*
shadcn-compatible aliases consumed by streamdown and
internal components (e.g. shimmer). These names are
intentionally generic so they match shadcn conventions.
Do NOT rename without updating every consumer.
*/
--background: 0 0% 98%;
--foreground: 240 10% 4%;
--muted: 240 5% 96%;
--muted-foreground: 240 5% 34%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 98%;
--avatar-lg: 2.5rem;
--avatar-default: 1.5rem;
--avatar-sm: 1.125rem;
@@ -100,6 +112,13 @@
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
/* shadcn-compatible aliases – see :root block for details. */
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--muted: 240 6% 10%;
--muted-foreground: 240 5% 65%;
--primary: 213 94% 68%;
--primary-foreground: 240 10% 4%;
}
}
@@ -187,6 +187,7 @@ const NavItems: FC<NavItemsProps> = ({ className, user }) => {
Templates
</NavLink>
<TasksNavItem user={user} />
<AgentsNavItem />
</nav>
);
};
@@ -251,6 +252,30 @@ function idleTasksLabel(count: number) {
return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`;
}
const AgentsNavItem: FC = () => {
const { metadata } = useEmbeddedMetadata();
const canSeeAgents = Boolean(
metadata["agents-tab-visible"].value ||
process.env.NODE_ENV === "development" ||
process.env.STORYBOOK,
);
if (!canSeeAgents) {
return null;
}
return (
<NavLink
className={({ isActive }) => {
return cn(linkStyles.default, { [linkStyles.active]: isActive });
}}
to="/agents"
>
Agents
</NavLink>
);
};
function isNavbarLink(link: TypesGen.LinkConfig): boolean {
return link.location === "navbar";
}
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { AgentChatInput } from "./AgentChatInput";
const defaultModelOptions = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
] as const;
const meta: Meta<typeof AgentChatInput> = {
title: "pages/AgentsPage/AgentChatInput",
component: AgentChatInput,
args: {
onSend: fn(),
onModelChange: fn(),
isDisabled: false,
isLoading: false,
selectedModel: defaultModelOptions[0].id,
modelOptions: [...defaultModelOptions],
modelSelectorPlaceholder: "Select model",
hasModelOptions: true,
inputStatusText: null,
modelCatalogStatusMessage: null,
},
};
export default meta;
type Story = StoryObj<typeof AgentChatInput>;
export const Default: Story = {};
export const DisablesSendUntilInput: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByPlaceholderText("Type a message...");
const sendButton = canvas.getByRole("button", { name: "Send" });
expect(sendButton).toBeDisabled();
await userEvent.type(input, "Write tests");
expect(sendButton).toBeEnabled();
},
};
export const SendsAndClearsInput: Story = {
args: {
onSend: fn().mockResolvedValue(undefined),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByPlaceholderText("Type a message...");
await userEvent.type(input, "Run focused tests");
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(args.onSend).toHaveBeenCalledWith("Run focused tests", undefined);
});
expect(input).toHaveValue("");
},
};
export const DisabledInput: Story = {
args: {
isDisabled: true,
initialValue: "Should not send",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled();
},
};
export const NoModelOptions: Story = {
args: {
isDisabled: false,
hasModelOptions: false,
initialValue: "Model required",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled();
},
};
export const LoadingSpinner: Story = {
args: {
isDisabled: true,
isLoading: true,
initialValue: "Sending...",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const sendButton = canvas.getByRole("button", { name: "Send" });
expect(sendButton).toBeDisabled();
// The Loader2Icon renders with the animate-spin class when
// isLoading is true.
expect(sendButton.querySelector(".animate-spin")).toBeTruthy();
},
};
@@ -0,0 +1,582 @@
import type { ChatQueuedMessage } from "api/typesGenerated";
import {
ModelSelector,
type ModelSelectorOption,
} from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import {
ArrowUpIcon,
ListPlusIcon,
Loader2Icon,
Square,
XIcon,
} from "lucide-react";
import {
memo,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import TextareaAutosize from "react-textarea-autosize";
import { formatProviderLabel } from "./modelOptions";
import { QueuedMessagesList } from "./QueuedMessagesList";
export interface AgentContextUsage {
readonly usedTokens?: number;
readonly contextLimitTokens?: number;
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly cacheReadTokens?: number;
readonly cacheCreationTokens?: number;
readonly reasoningTokens?: number;
// Percentage (0–100) at which the context will be compacted.
readonly compressionThreshold?: number;
}
interface AgentChatInputProps {
onSend: (message: string, editedMessageID?: number) => Promise<void>;
placeholder?: string;
isDisabled: boolean;
isLoading: boolean;
// Optional initial value for the textarea (e.g. restored from
// localStorage on the create page).
initialValue?: string;
// Fires whenever the textarea value changes, useful for persisting
// the draft externally.
onInputChange?: (value: string) => void;
// Model selector.
selectedModel: string;
onModelChange: (value: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
// Status messages.
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
// Streaming controls (optional, for the detail page).
isStreaming?: boolean;
onInterrupt?: () => void;
isInterruptPending?: boolean;
// Extra controls rendered in the left action area (e.g. workspace
// selector on the create page).
leftActions?: ReactNode;
// Queued user messages rendered above the textarea.
queuedMessages?: readonly ChatQueuedMessage[];
onDeleteQueuedMessage?: (id: number) => Promise<void> | void;
onPromoteQueuedMessage?: (id: number) => Promise<void> | void;
// Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown).
// Omit entirely to hide the indicator.
contextUsage?: AgentContextUsage | null;
// When true the entire input sticks to the bottom of the scroll
// container (used in the detail page).
sticky?: boolean;
// External edit request — when set, replaces the input text and
// focuses the textarea. Use a unique `key` to allow re-editing the
// same text.
editRequest?: { text: string; messageId?: number; key: number } | null;
// Called when the user cancels or completes a history edit so the
// parent can clear the editing highlight.
onEditCleared?: () => void;
}
const hasFiniteTokenValue = (value: number | undefined): value is number =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
const formatTokenCount = (value: number | undefined): string =>
hasFiniteTokenValue(value) ? value.toLocaleString() : "--";
const formatTokenCountCompact = (value: number | undefined): string => {
if (!hasFiniteTokenValue(value)) {
return "--";
}
if (value >= 1_000_000) {
const m = value / 1_000_000;
return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, "")}M`;
}
if (value >= 1_000) {
const k = value / 1_000;
return `${Number.isInteger(k) ? k : k.toFixed(1).replace(/\.0$/, "")}K`;
}
return String(value);
};
const getIndicatorToneClassName = (percentUsed: number | null): string => {
if (percentUsed === null) {
return "text-content-secondary/60";
}
if (percentUsed >= 95) {
return "text-content-destructive";
}
if (percentUsed >= 85) {
return "text-content-warning";
}
return "text-content-secondary/60";
};
const RING_SIZE = 18;
const RING_STROKE = 2.5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
({ usage }) => {
const usedTokens = hasFiniteTokenValue(usage?.usedTokens)
? usage.usedTokens
: undefined;
const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens)
? usage.contextLimitTokens
: undefined;
const percentUsed =
usedTokens !== undefined &&
contextLimitTokens !== undefined &&
contextLimitTokens > 0
? (usedTokens / contextLimitTokens) * 100
: null;
const hasPercent = percentUsed !== null;
const percentLabel =
percentUsed === null ? "--" : `${Math.round(percentUsed)}%`;
const indicatorLabel = null;
const clampedPercent = hasPercent
? Math.min(Math.max(percentUsed, 0), 100)
: 100;
const dashOffset =
RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE;
const toneClassName = getIndicatorToneClassName(percentUsed);
const ariaLabel = hasPercent
? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.`
: "Context usage";
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="button"
tabIndex={0}
aria-label={ariaLabel}
className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
>
<svg
className={`h-5 w-5 -rotate-90 ${toneClassName}`}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden
>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
className="stroke-content-secondary/25"
/>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="stroke-current transition-all duration-300 ease-out"
style={{
strokeDasharray: `${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`,
strokeDashoffset: dashOffset,
}}
/>
</svg>
{indicatorLabel !== null && (
<span className="pointer-events-none absolute text-[7px] font-semibold tabular-nums text-content-secondary">
{indicatorLabel}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="text-xs text-content-primary">
{hasPercent
? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used`
: "Context usage unavailable"}
{hasPercent &&
usage?.compressionThreshold !== undefined &&
usage.compressionThreshold > 0 && (
<div className="mt-1 text-content-secondary">
Compacts at {usage.compressionThreshold}%
</div>
)}
</div>
</TooltipContent>
</Tooltip>
);
},
);
ContextUsageIndicator.displayName = "ContextUsageIndicator";
export const AgentChatInput = memo<AgentChatInputProps>(
({
onSend,
placeholder = "Type a message...",
isDisabled,
isLoading,
initialValue = "",
onInputChange,
selectedModel,
onModelChange,
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
inputStatusText,
modelCatalogStatusMessage,
isStreaming = false,
onInterrupt,
isInterruptPending = false,
leftActions,
queuedMessages = [],
onDeleteQueuedMessage,
onPromoteQueuedMessage,
contextUsage,
sticky = false,
editRequest = null,
onEditCleared,
}) => {
const [input, setInput] = useState(initialValue);
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
number | null
>(null);
const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState<
string | null
>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Handle external edit requests (e.g. clicking a historical
// user message's edit icon).
const lastEditKeyRef = useRef<number | null>(null);
const [isEditingHistoryMessage, setIsEditingHistoryMessage] =
useState(false);
const [editingHistoryMessageID, setEditingHistoryMessageID] = useState<
number | null
>(null);
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
string | null
>(null);
useEffect(() => {
if (!editRequest || editRequest.key === lastEditKeyRef.current) {
return;
}
lastEditKeyRef.current = editRequest.key;
setDraftBeforeHistoryEdit((current) =>
isEditingHistoryMessage ? current : input,
);
setIsEditingHistoryMessage(true);
setEditingHistoryMessageID(editRequest.messageId ?? null);
setInput(editRequest.text);
onInputChange?.(editRequest.text);
textareaRef.current?.focus();
}, [editRequest, input, isEditingHistoryMessage, onInputChange]);
const handleCancelHistoryEdit = useCallback(() => {
if (!isEditingHistoryMessage) {
return;
}
const restored = draftBeforeHistoryEdit ?? "";
setIsEditingHistoryMessage(false);
setEditingHistoryMessageID(null);
setDraftBeforeHistoryEdit(null);
setInput(restored);
onInputChange?.(restored);
onEditCleared?.();
textareaRef.current?.focus();
}, [
draftBeforeHistoryEdit,
isEditingHistoryMessage,
onEditCleared,
onInputChange,
]);
useEffect(() => {
if (editingQueuedMessageID === null) {
return;
}
const stillQueued = queuedMessages.some(
(message) => message.id === editingQueuedMessageID,
);
if (stillQueued) {
return;
}
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
}, [editingQueuedMessageID, queuedMessages]);
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isDisabled || !hasModelOptions) {
return;
}
const queueEditID = editingQueuedMessageID;
const editedMessageID =
isEditingHistoryMessage && editingHistoryMessageID !== null
? editingHistoryMessageID
: undefined;
try {
await onSend(input, editedMessageID);
if (queueEditID !== null && onDeleteQueuedMessage) {
await onDeleteQueuedMessage(queueEditID);
}
setInput("");
onInputChange?.("");
if (queueEditID !== null) {
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
}
if (isEditingHistoryMessage) {
setIsEditingHistoryMessage(false);
setEditingHistoryMessageID(null);
setDraftBeforeHistoryEdit(null);
onEditCleared?.();
}
} catch {
// Keep input on failure so the user can retry.
} finally {
// Re-focus the textarea so the user can keep typing.
textareaRef.current?.focus();
}
}, [
editingQueuedMessageID,
editingHistoryMessageID,
hasModelOptions,
input,
isDisabled,
isEditingHistoryMessage,
onDeleteQueuedMessage,
onEditCleared,
onInputChange,
onSend,
]);
const handleStartQueueEdit = useCallback(
(id: number, text: string) => {
setDraftBeforeQueueEdit((current) =>
editingQueuedMessageID === null ? input : current,
);
setEditingQueuedMessageID(id);
setInput(text);
onInputChange?.(text);
textareaRef.current?.focus();
},
[editingQueuedMessageID, input, onInputChange],
);
const handleCancelQueueEdit = useCallback(() => {
if (editingQueuedMessageID === null) {
return;
}
const restored = draftBeforeQueueEdit ?? "";
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
setInput(restored);
onInputChange?.(restored);
textareaRef.current?.focus();
}, [draftBeforeQueueEdit, editingQueuedMessageID, onInputChange]);
const sendButtonLabel =
isStreaming && editingQueuedMessageID === null ? "Queue message" : "Send";
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (editingQueuedMessageID !== null) {
e.preventDefault();
handleCancelQueueEdit();
} else if (isEditingHistoryMessage) {
e.preventDefault();
handleCancelHistoryEdit();
}
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// If the input is empty and there are queued messages,
// promote the first one instead of submitting.
if (
!input.trim() &&
queuedMessages.length > 0 &&
onPromoteQueuedMessage
) {
void onPromoteQueuedMessage(queuedMessages[0].id);
return;
}
void handleSubmit();
}
},
[
editingQueuedMessageID,
handleCancelHistoryEdit,
handleCancelQueueEdit,
handleSubmit,
input,
isEditingHistoryMessage,
onPromoteQueuedMessage,
queuedMessages,
],
);
const content = (
<div className="mx-auto w-full max-w-3xl pb-4">
{queuedMessages.length > 0 && (
<QueuedMessagesList
messages={queuedMessages}
onDelete={(id) => {
if (id === editingQueuedMessageID) {
handleCancelQueueEdit();
}
void onDeleteQueuedMessage?.(id);
}}
onPromote={(id) => {
if (id === editingQueuedMessageID) {
handleCancelQueueEdit();
}
void onPromoteQueuedMessage?.(id);
}}
onEdit={handleStartQueueEdit}
editingMessageID={editingQueuedMessageID}
className="mb-2"
/>
)}
<div className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40">
{editingQueuedMessageID !== null && (
<div className="flex items-center justify-between border-b border-border-default/70 bg-surface-primary/25 px-3 py-1.5">
<span className="text-sm text-content-secondary">
Editing queued message
</span>
<Button
type="button"
variant="subtle"
size="sm"
onClick={handleCancelQueueEdit}
className="h-7 px-2 text-content-secondary hover:text-content-primary"
>
Cancel
</Button>
</div>
)}
{isEditingHistoryMessage && editingQueuedMessageID === null && (
<div className="flex items-center justify-between border-b border-border-default/70 px-3 py-1.5">
<span className="flex items-center gap-1.5 text-sm text-content-secondary">
{isLoading && (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
)}
{isLoading ? "Saving edit..." : "Editing message"}
</span>
<Button
type="button"
variant="subtle"
size="icon"
aria-label="Cancel editing"
onClick={handleCancelHistoryEdit}
disabled={isLoading}
className="size-6 rounded text-content-secondary hover:text-content-primary"
>
<XIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
<TextareaAutosize
ref={textareaRef}
aria-label="Chat message"
className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
placeholder={placeholder}
value={input}
onChange={(e) => {
setInput(e.target.value);
onInputChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}
disabled={isDisabled}
minRows={4}
/>
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
<ModelSelector
value={selectedModel}
onValueChange={onModelChange}
options={modelOptions}
disabled={isDisabled}
placeholder={modelSelectorPlaceholder}
formatProviderLabel={formatProviderLabel}
dropdownSide="top"
dropdownAlign="center"
className=""
/>
{leftActions}
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
{inputStatusText}
</span>
)}
</div>
<div className="flex items-center gap-2">
{contextUsage !== undefined && (
<ContextUsageIndicator usage={contextUsage} />
)}
{isStreaming && onInterrupt && (
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors"
onClick={onInterrupt}
disabled={isInterruptPending}
>
<Square className="h-3 w-3 fill-current" />
<span className="sr-only">Stop</span>
</Button>
)}
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-6 flex items-center justify-center"
onClick={() => void handleSubmit()}
disabled={isDisabled || !hasModelOptions || !input.trim()}
title={sendButtonLabel}
>
{isLoading ? (
<Loader2Icon className="animate-spin" />
) : isStreaming && editingQueuedMessageID === null ? (
<ListPlusIcon />
) : (
<ArrowUpIcon />
)}
<span className="sr-only">{sendButtonLabel}</span>
</Button>
</div>
</div>
{inputStatusText && (
<div className="px-2.5 pb-1 text-xs text-content-secondary sm:hidden">
{inputStatusText}
</div>
)}
{modelCatalogStatusMessage && (
<div className="px-2.5 pb-1 text-2xs text-content-secondary">
{modelCatalogStatusMessage}
</div>
)}
</div>
</div>
);
if (sticky) {
return (
<div className="sticky bottom-0 z-50 bg-surface-primary">{content}</div>
);
}
return content;
},
);
AgentChatInput.displayName = "AgentChatInput";
@@ -0,0 +1,452 @@
import {
MockUserOwner,
MockWorkspace,
MockWorkspaceAgent,
} from "testHelpers/entities";
import { withAuthProvider, withWebSocket } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import {
chatDiffStatusKey,
chatKey,
chatModelsKey,
chatsKey,
} from "api/queries/chats";
import { workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { type FC, useRef, useState } from "react";
import { Outlet } from "react-router";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
reactRouterOutlet,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import AgentDetail from "./AgentDetail";
import type { AgentsOutletContext } from "./AgentsPage";
// ---------------------------------------------------------------------------
// Layout wrapper – provides portal targets for the top-bar and right panel
// so the component can render its portaled actions menu and diff panel.
// ---------------------------------------------------------------------------
const AgentDetailLayout: FC = () => {
const topBarTitleRef = useRef<HTMLDivElement>(null);
const topBarActionsRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
return (
<div className="flex h-full">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
<div ref={topBarTitleRef} className="flex-1" />
<div ref={topBarActionsRef} />
</div>
<div className="flex-1 overflow-hidden">
<Outlet
context={
{
chatErrorReasons: {},
setChatErrorReason: () => {},
clearChatErrorReason: () => {},
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
setRightPanelOpen,
requestArchiveAgent: () => {},
} satisfies AgentsOutletContext
}
/>
</div>
</div>
<div
ref={rightPanelRef}
className={
rightPanelOpen ? "w-[400px] border-l border-border" : "hidden"
}
/>
</div>
);
};
// ---------------------------------------------------------------------------
// Shared mock data
// ---------------------------------------------------------------------------
const CHAT_ID = "chat-1";
const mockWorkspaceAgent: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "workspace-agent-1",
name: "workspace-agent",
expanded_directory: "/workspace/project",
apps: [],
};
const mockWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "workspace-1",
owner_name: "owner",
name: "workspace-name",
latest_build: {
...MockWorkspace.latest_build,
resources: [
{
...MockWorkspace.latest_build.resources[0],
agents: [mockWorkspaceAgent],
},
],
},
};
const mockModelCatalog: TypesGen.ChatModelsResponse = {
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
};
const baseChatFields = {
owner_id: "owner-id",
workspace_id: mockWorkspace.id,
workspace_agent_id: mockWorkspaceAgent.id,
last_model_config_id: "model-config-1",
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
} as const;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build `parameters.queries` entries for a given chat data object. */
const buildQueries = (
chatData: TypesGen.ChatWithMessages,
opts?: { diffUrl?: string },
) => [
{ key: chatKey(CHAT_ID), data: chatData },
{ key: chatsKey, data: [chatData.chat] },
{
key: chatDiffStatusKey(CHAT_ID),
data: {
chat_id: CHAT_ID,
url: opts?.diffUrl,
changes_requested: false,
additions: opts?.diffUrl ? 4 : 0,
deletions: opts?.diffUrl ? 1 : 0,
changed_files: opts?.diffUrl ? 2 : 0,
} satisfies TypesGen.ChatDiffStatus,
},
{
key: workspaceByIdKey(mockWorkspace.id),
data: mockWorkspace,
},
{ key: chatModelsKey, data: mockModelCatalog },
];
/**
* Wrap a chat stream event payload in the JSON string format that
* OneWayWebSocket expects when receiving a WebSocket message event.
* The result is a `ServerSentEvent` of type `"data"` serialised to JSON.
*/
const wrapSSE = (payload: unknown): string =>
JSON.stringify({ type: "data", data: payload });
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailLayout> = {
title: "pages/AgentsPage/AgentDetail",
component: AgentDetailLayout,
decorators: [withAuthProvider, withWebSocket],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
webSocket: [],
reactRouter: reactRouterParameters({
location: {
path: `/agents/${CHAT_ID}`,
pathParams: { agentId: CHAT_ID },
},
routing: reactRouterOutlet({ path: "/agents/:agentId" }, <AgentDetail />),
}),
},
beforeEach: () => {
spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key"));
},
};
export default meta;
type Story = StoryObj<typeof AgentDetailLayout>;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** Skeleton placeholder when no query data is available yet. */
export const Loading: Story = {};
/** Full layout with actions menu and diff panel portaled to the right slot. */
export const CompletedWithDiffPanel: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Build a feature",
status: "completed",
},
messages: [],
queued_messages: [],
},
{ diffUrl: "https://github.com/coder/coder/pull/123" },
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
// Wait for the actions menu trigger to appear in the top bar.
const menuTrigger = await canvas.findByRole("button", {
name: "Open agent actions",
});
await user.click(menuTrigger);
// Verify menu items are rendered.
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Open in Cursor")).toBeInTheDocument();
});
expect(body.getByText("Open in VS Code")).toBeInTheDocument();
expect(body.getByText("View Workspace")).toBeInTheDocument();
expect(body.getByText("Archive Agent")).toBeInTheDocument();
},
};
/** Right panel stays closed when no diff-status URL exists. */
export const NoDiffUrl: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "No diff yet",
status: "completed",
},
messages: [],
queued_messages: [],
},
{ diffUrl: undefined },
),
},
};
/** Subagent tool-call/result messages render subagent cards. */
export const WithSubagentCards: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Parent agent",
status: "running",
},
messages: [
{
id: 1,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:00:01.000Z",
role: "assistant",
content: [
{
type: "tool-call",
tool_call_id: "tool-subagent-1",
tool_name: "spawn_agent",
args: { title: "Child agent" },
},
{
type: "tool-result",
tool_call_id: "tool-subagent-1",
tool_name: "spawn_agent",
result: {
chat_id: "child-chat-1",
title: "Child agent",
status: "pending",
},
},
],
},
],
queued_messages: [],
},
{ diffUrl: undefined },
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(
canvas.getByRole("button", { name: /Spawn(?:ed|ing) Child agent/ }),
).toBeInTheDocument();
});
},
};
/** Reasoning part renders collapsed and can be expanded on click. */
export const WithReasoningCollapsed: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Reasoning title",
status: "completed",
},
messages: [
{
id: 1,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:00:01.000Z",
role: "assistant",
content: [
{
type: "reasoning",
title: "Plan migration",
text: "Reasoning body",
},
],
},
],
queued_messages: [],
},
{ diffUrl: undefined },
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const reasoningToggle = await canvas.findByRole("button", {
name: "Plan migration",
});
expect(reasoningToggle).toHaveAttribute("aria-expanded", "false");
await user.click(reasoningToggle);
expect(reasoningToggle).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Reasoning body")).toBeInTheDocument();
},
};
/**
* Streaming subagent title via WebSocket message_part events.
* The `withWebSocket` decorator replays all events after a setTimeout(0),
* and OneWayWebSocket parses each JSON payload, so the streamed title
* should appear once the play function runs.
*/
export const StreamedSubagentTitle: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Streaming title",
status: "running",
},
messages: [],
queued_messages: [],
},
{ diffUrl: undefined },
),
webSocket: [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "tool-call",
tool_call_id: "tool-subagent-stream-1",
tool_name: "spawn_agent",
args_delta: '{"title":"Streamed Child"',
},
},
}),
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(
canvas.getByRole("button", {
name: /Spawning Streamed Child/,
}),
).toBeInTheDocument();
});
},
};
/**
* Streaming reasoning part via WebSocket — renders collapsed and
* can be expanded on click.
*/
export const StreamedReasoningCollapsed: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Streaming reasoning title",
status: "running",
},
messages: [],
queued_messages: [],
},
{ diffUrl: undefined },
),
webSocket: [
{
event: "message",
data: wrapSSE({
type: "message_part",
message_part: {
part: {
type: "reasoning",
title: "Plan migration",
text: "Streaming reasoning body",
},
},
}),
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const reasoningToggle = await canvas.findByRole("button", {
name: "Plan migration",
});
expect(reasoningToggle).toHaveAttribute("aria-expanded", "false");
await user.click(reasoningToggle);
expect(reasoningToggle).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Streaming reasoning body")).toBeInTheDocument();
},
};
+861
View File
@@ -0,0 +1,861 @@
import { API } from "api/api";
import {
chat,
chatDiffStatus,
chatModelConfigs,
chatModels,
chats,
createChatMessage,
deleteChatQueuedMessage,
editChatMessage,
interruptChat,
promoteChatQueuedMessage,
} from "api/queries/chats";
import { workspaceById } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { getVSCodeHref, SESSION_TOKEN_PLACEHOLDER } from "modules/apps/apps";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import { AgentChatInput } from "./AgentChatInput";
import {
selectChatStatus,
selectHasStreamState,
selectMessagesByID,
selectOrderedMessageIDs,
selectQueuedMessages,
selectStreamError,
selectStreamState,
selectSubagentStatusOverrides,
useChatSelector,
useChatStore,
} from "./AgentDetail/ChatContext";
import { ConversationTimeline } from "./AgentDetail/ConversationTimeline";
import {
getLatestContextUsage,
getParentChatID,
getWorkspaceAgent,
} from "./AgentDetail/chatHelpers";
import {
buildParsedMessageSections,
buildSubagentTitles,
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState";
import { AgentDetailTopBarPortals } from "./AgentDetail/TopBarPortals";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import type { AgentsOutletContext } from "./AgentsPage";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
} from "./modelOptions";
const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
() => {};
const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] =
() => {};
const noopSetRightPanelOpen: AgentsOutletContext["setRightPanelOpen"] =
() => {};
const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] =
() => {};
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
const isChatMessage = (
message: TypesGen.ChatMessage | undefined,
): message is TypesGen.ChatMessage => Boolean(message);
const toOptimisticMessageParts = (
inputParts: readonly TypesGen.ChatInputPart[],
): readonly TypesGen.ChatMessagePart[] =>
inputParts.map((part) => ({
type: "text",
...(part.text !== undefined ? { text: part.text } : {}),
}));
const getOrderedMessagesFromStore = (
store: ChatStoreHandle,
): readonly TypesGen.ChatMessage[] => {
const snapshot = store.getSnapshot();
return snapshot.orderedMessageIDs
.map((messageID) => snapshot.messagesByID.get(messageID))
.filter(isChatMessage);
};
interface AgentDetailTimelineProps {
store: ChatStoreHandle;
chatID: string;
persistedErrorReason: string | undefined;
onEditUserMessage?: (messageId: number, text: string) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
}
const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
store,
chatID,
persistedErrorReason,
onEditUserMessage,
editingMessageId,
savingMessageId,
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
const streamState = useChatSelector(store, selectStreamState);
const chatStatus = useChatSelector(store, selectChatStatus);
const streamError = useChatSelector(store, selectStreamError);
const subagentStatusOverrides = useChatSelector(
store,
selectSubagentStatusOverrides,
);
const messages = useMemo(
() =>
orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
.filter(isChatMessage),
[messagesByID, orderedMessageIDs],
);
const streamTools = useMemo(
() => buildStreamTools(streamState),
[streamState],
);
const { hasMoreMessages, windowedMessages, loadMoreSentinelRef } =
useMessageWindow({
messages,
resetKey: chatID,
});
const parsedMessages = useMemo(
() => parseMessagesWithMergedTools(windowedMessages),
[windowedMessages],
);
const subagentTitles = useMemo(
() => buildSubagentTitles(parsedMessages),
[parsedMessages],
);
const parsedSections = useMemo(
() => buildParsedMessageSections(parsedMessages),
[parsedMessages],
);
const detailErrorMessage =
(chatStatus === "error" ? persistedErrorReason : undefined) || streamError;
const latestMessage = messages[messages.length - 1];
const latestMessageNeedsAssistantResponse =
!latestMessage || latestMessage.role !== "assistant";
const isAwaitingFirstStreamChunk =
!streamState &&
(chatStatus === "running" || chatStatus === "pending") &&
latestMessageNeedsAssistantResponse;
const hasStreamOutput = Boolean(streamState) || isAwaitingFirstStreamChunk;
return (
<ConversationTimeline
isEmpty={messages.length === 0}
hasMoreMessages={hasMoreMessages}
loadMoreSentinelRef={loadMoreSentinelRef}
parsedSections={parsedSections}
hasStreamOutput={hasStreamOutput}
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
detailErrorMessage={detailErrorMessage}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
/>
);
};
interface AgentDetailInputProps {
store: ChatStoreHandle;
compressionThreshold: number | undefined;
onSend: (message: string, editedMessageID?: number) => Promise<void>;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
onInterrupt: () => void;
isInputDisabled: boolean;
isSendPending: boolean;
isInterruptPending: boolean;
hasModelOptions: boolean;
selectedModel: string;
onModelChange: (modelID: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
editRequest?: { text: string; messageId?: number; key: number } | null;
onEditCleared?: () => void;
}
const AgentDetailInput: FC<AgentDetailInputProps> = ({
store,
compressionThreshold,
onSend,
onDeleteQueuedMessage,
onPromoteQueuedMessage,
onInterrupt,
isInputDisabled,
isSendPending,
isInterruptPending,
hasModelOptions,
selectedModel,
onModelChange,
modelOptions,
modelSelectorPlaceholder,
inputStatusText,
modelCatalogStatusMessage,
editRequest,
onEditCleared,
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
const hasStreamState = useChatSelector(store, selectHasStreamState);
const chatStatus = useChatSelector(store, selectChatStatus);
const queuedMessages = useChatSelector(store, selectQueuedMessages);
const messages = useMemo(
() =>
orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
.filter(isChatMessage),
[messagesByID, orderedMessageIDs],
);
const latestContextUsage = useMemo(() => {
const usage = getLatestContextUsage(messages);
if (!usage) {
return usage;
}
return { ...usage, compressionThreshold };
}, [messages, compressionThreshold]);
const isStreaming =
hasStreamState || chatStatus === "running" || chatStatus === "pending";
return (
<AgentChatInput
onSend={onSend}
queuedMessages={queuedMessages}
onDeleteQueuedMessage={onDeleteQueuedMessage}
onPromoteQueuedMessage={onPromoteQueuedMessage}
isDisabled={isInputDisabled}
isLoading={isSendPending}
isStreaming={isStreaming}
onInterrupt={onInterrupt}
isInterruptPending={isInterruptPending}
contextUsage={latestContextUsage}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={onModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
editRequest={editRequest}
onEditCleared={onEditCleared}
sticky
/>
);
};
interface AgentDetailConversationProps {
store: ChatStoreHandle;
chatID: string;
persistedErrorReason: string | undefined;
compressionThreshold: number | undefined;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
onSend: (message: string, editedMessageID?: number) => Promise<void>;
onInterrupt: () => void;
isInputDisabled: boolean;
isSendPending: boolean;
isInterruptPending: boolean;
hasModelOptions: boolean;
selectedModel: string;
onModelChange: (modelID: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
savingMessageId?: number | null;
}
const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
store,
chatID,
persistedErrorReason,
compressionThreshold,
onDeleteQueuedMessage,
onPromoteQueuedMessage,
onSend,
onInterrupt,
isInputDisabled,
isSendPending,
isInterruptPending,
hasModelOptions,
selectedModel,
onModelChange,
modelOptions,
modelSelectorPlaceholder,
inputStatusText,
modelCatalogStatusMessage,
savingMessageId,
}) => {
const [editRequest, setEditRequest] = useState<{
text: string;
messageId: number;
key: number;
} | null>(null);
const handleEditUserMessage = useCallback(
(messageId: number, text: string) => {
setEditRequest({ text, messageId, key: Date.now() });
},
[],
);
const handleEditCleared = useCallback(() => {
setEditRequest(null);
}, []);
return (
<>
<AgentDetailTimeline
store={store}
chatID={chatID}
persistedErrorReason={persistedErrorReason}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editRequest?.messageId ?? null}
savingMessageId={savingMessageId}
/>
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={onSend}
onDeleteQueuedMessage={onDeleteQueuedMessage}
onPromoteQueuedMessage={onPromoteQueuedMessage}
onInterrupt={onInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSendPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={onModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
editRequest={editRequest}
onEditCleared={handleEditCleared}
/>
</>
);
};
const AgentDetail: FC = () => {
const navigate = useNavigate();
const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [showDiffPanel, setShowDiffPanel] = useState(false);
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
const clearChatErrorReason =
outletContext?.clearChatErrorReason ?? noopClearChatErrorReason;
const setRightPanelOpen =
outletContext?.setRightPanelOpen ?? noopSetRightPanelOpen;
const requestArchiveAgent =
outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatQuery = useQuery({
...chat(agentId ?? ""),
enabled: Boolean(agentId),
});
const chatsQuery = useQuery(chats());
const workspaceId = chatQuery.data?.chat?.workspace_id;
const workspaceAgentId = chatQuery.data?.chat?.workspace_agent_id;
const workspaceQuery = useQuery({
...workspaceById(workspaceId ?? ""),
enabled: Boolean(workspaceId),
});
const diffStatusQuery = useQuery({
...chatDiffStatus(agentId ?? ""),
enabled: Boolean(agentId),
});
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const hasDiffStatus = Boolean(diffStatusQuery.data?.url);
const workspace = workspaceQuery.data;
const workspaceAgent = getWorkspaceAgent(workspace, workspaceAgentId);
const chatData = chatQuery.data;
const chatRecord = chatData?.chat;
const chatMessages = chatData?.messages;
const chatQueuedMessages = chatData?.queued_messages;
const chatLastModelConfigID = chatRecord?.last_model_config_id;
// Auto-open the diff panel when diff status first appears.
// See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false);
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus) {
setShowDiffPanel(true);
}
}
// Notify the parent layout about right panel visibility. This
// useEffect is necessary because we're synchronizing with state
// owned by the parent outlet, not adjusting our own state.
useEffect(() => {
setRightPanelOpen(hasDiffStatus && showDiffPanel);
return () => {
setRightPanelOpen(false);
};
}, [hasDiffStatus, setRightPanelOpen, showDiffPanel]);
const modelOptions = useMemo(
() =>
getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
),
[chatModelsQuery.data, chatModelConfigsQuery.data],
);
const modelConfigIDByModelID = useMemo(() => {
const byModelID = new Map<string, string>();
for (const config of chatModelConfigsQuery.data ?? []) {
const provider = config.provider.trim().toLowerCase();
const model = config.model.trim();
if (!provider || !model) {
continue;
}
const colonRef = `${provider}:${model}`;
if (!byModelID.has(colonRef)) {
byModelID.set(colonRef, config.id);
}
const slashRef = `${provider}/${model}`;
if (!byModelID.has(slashRef)) {
byModelID.set(slashRef, config.id);
}
}
return byModelID;
}, [chatModelConfigsQuery.data]);
const modelIDByConfigID = useMemo(() => {
const byConfigID = new Map<string, string>();
for (const [modelID, configID] of modelConfigIDByModelID.entries()) {
if (!byConfigID.has(configID)) {
byConfigID.set(configID, modelID);
}
}
return byConfigID;
}, [modelConfigIDByModelID]);
const sendMutation = useMutation(
createChatMessage(queryClient, agentId ?? ""),
);
const editMutation = useMutation(editChatMessage(queryClient, agentId ?? ""));
const interruptMutation = useMutation(
interruptChat(queryClient, agentId ?? ""),
);
const deleteQueuedMutation = useMutation(
deleteChatQueuedMessage(queryClient, agentId ?? ""),
);
const promoteQueuedMutation = useMutation(
promoteChatQueuedMessage(queryClient, agentId ?? ""),
);
const { store, clearStreamError } = useChatStore({
chatID: agentId,
chatMessages,
chatRecord,
chatData,
chatQueuedMessages,
setChatErrorReason,
clearChatErrorReason,
});
useEffect(() => {
setSelectedModel((current) => {
if (current && modelOptions.some((model) => model.id === current)) {
return current;
}
if (chatLastModelConfigID) {
const fromChat = modelIDByConfigID.get(chatLastModelConfigID);
if (fromChat && modelOptions.some((model) => model.id === fromChat)) {
return fromChat;
}
}
return modelOptions[0]?.id ?? "";
});
}, [chatLastModelConfigID, modelIDByConfigID, modelOptions]);
const compressionThreshold = useMemo(() => {
if (!chatLastModelConfigID) {
return undefined;
}
const config = chatModelConfigsQuery.data?.find(
(c) => c.id === chatLastModelConfigID,
);
return config?.compression_threshold;
}, [chatLastModelConfigID, chatModelConfigsQuery.data]);
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(
chatModelsQuery.data,
);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
modelOptions,
chatModelsQuery.isLoading,
hasConfiguredModels,
);
const modelCatalogStatusMessage = getModelCatalogStatusMessage(
chatModelsQuery.data,
modelOptions,
chatModelsQuery.isLoading,
Boolean(chatModelsQuery.error),
);
const inputStatusText = hasModelOptions
? null
: hasConfiguredModels
? "Models are configured but unavailable. Ask an admin."
: "No models configured. Ask an admin.";
const isSubmissionPending =
sendMutation.isPending ||
editMutation.isPending ||
interruptMutation.isPending;
const isInputDisabled = !hasModelOptions;
const handleSend = async (message: string, editedMessageID?: number) => {
if (
!message.trim() ||
isSubmissionPending ||
!agentId ||
!hasModelOptions
) {
return;
}
const content: TypesGen.ChatInputPart[] = [{ type: "text", text: message }];
if (editedMessageID !== undefined) {
const request: TypesGen.EditChatMessageRequest = { content };
clearChatErrorReason(agentId);
clearStreamError();
setPendingEditMessageId(editedMessageID);
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
const previousChatStatus = store.getSnapshot().chatStatus;
const previousMessages = getOrderedMessagesFromStore(store);
const messageIndex = previousMessages.findIndex(
(msg) => msg.id === editedMessageID,
);
if (messageIndex !== -1) {
const optimisticEditedMessage: TypesGen.ChatMessage = {
...previousMessages[messageIndex],
content: toOptimisticMessageParts(request.content),
};
store.replaceMessages([
...previousMessages.slice(0, messageIndex),
optimisticEditedMessage,
]);
}
store.clearStreamState();
store.setChatStatus("pending");
try {
await editMutation.mutateAsync({
messageId: editedMessageID,
req: request,
});
} catch (error) {
store.replaceMessages(previousMessages);
store.setChatStatus(previousChatStatus);
throw error;
} finally {
setPendingEditMessageId(null);
}
return;
}
const selectedModelConfigID =
(selectedModel && modelConfigIDByModelID.get(selectedModel)) || undefined;
const request: TypesGen.CreateChatMessageRequest = {
content,
model_config_id: selectedModelConfigID,
};
clearChatErrorReason(agentId);
clearStreamError();
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
await sendMutation.mutateAsync(request);
if (typeof window !== "undefined") {
if (selectedModelConfigID) {
localStorage.setItem(
lastModelConfigIDStorageKey,
selectedModelConfigID,
);
} else {
localStorage.removeItem(lastModelConfigIDStorageKey);
}
}
};
const handleInterrupt = () => {
if (!agentId || interruptMutation.isPending) {
return;
}
void interruptMutation.mutateAsync();
};
const handleDeleteQueuedMessage = useCallback(
async (id: number) => {
const previousQueuedMessages = store.getSnapshot().queuedMessages;
store.setQueuedMessages(
previousQueuedMessages.filter((message) => message.id !== id),
);
try {
await deleteQueuedMutation.mutateAsync(id);
} catch (error) {
store.setQueuedMessages(previousQueuedMessages);
throw error;
}
},
[deleteQueuedMutation, store],
);
const handlePromoteQueuedMessage = useCallback(
async (id: number) => {
const previousSnapshot = store.getSnapshot();
const previousQueuedMessages = previousSnapshot.queuedMessages;
const previousChatStatus = previousSnapshot.chatStatus;
store.setQueuedMessages(
previousQueuedMessages.filter((message) => message.id !== id),
);
store.clearStreamState();
store.clearStreamError();
store.setChatStatus("pending");
try {
await promoteQueuedMutation.mutateAsync(id);
} catch (error) {
store.setQueuedMessages(previousQueuedMessages);
store.setChatStatus(previousChatStatus);
throw error;
}
},
[promoteQueuedMutation, store],
);
const topBarTitleRef = outletContext?.topBarTitleRef;
const topBarActionsRef = outletContext?.topBarActionsRef;
const rightPanelRef = outletContext?.rightPanelRef;
const chatTitle = chatQuery.data?.chat?.title;
const parentChatID = getParentChatID(chatQuery.data?.chat);
const parentChat = parentChatID
? chatsQuery.data?.find((chat) => chat.id === parentChatID)
: undefined;
const workspaceRoute = workspace
? `/@${workspace.owner_name}/${workspace.name}`
: null;
const canOpenWorkspace = Boolean(workspaceRoute);
const canOpenEditors = Boolean(workspace && workspaceAgent);
const shouldShowDiffPanel = hasDiffStatus && showDiffPanel;
const handleOpenInEditor = async (editor: "cursor" | "vscode") => {
if (!workspace || !workspaceAgent) {
return;
}
try {
const { key } = await API.getApiKey();
const vscodeHref = getVSCodeHref("vscode", {
owner: workspace.owner_name,
workspace: workspace.name,
token: key,
agent: workspaceAgent.name,
folder: workspaceAgent.expanded_directory,
});
if (editor === "cursor") {
const cursorApp = workspaceAgent.apps.find((app) => {
const name = (app.display_name ?? app.slug).toLowerCase();
return app.slug.toLowerCase() === "cursor" || name === "cursor";
});
if (cursorApp?.external && cursorApp.url) {
const href = cursorApp.url.includes(SESSION_TOKEN_PLACEHOLDER)
? cursorApp.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, key)
: cursorApp.url;
window.location.assign(href);
return;
}
window.location.assign(vscodeHref.replace(/^vscode:/, "cursor:"));
return;
}
window.location.assign(vscodeHref);
} catch {
toast.error(
editor === "cursor"
? "Failed to open in Cursor."
: "Failed to open in VS Code.",
);
}
};
const handleViewWorkspace = () => {
if (!workspaceRoute) {
return;
}
navigate(workspaceRoute);
};
const handleArchiveAgentAction = () => {
if (!agentId) {
return;
}
requestArchiveAgent(agentId);
};
if (chatQuery.isLoading) {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-full flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>
</div>
<AgentChatInput
onSend={handleSend}
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
sticky
/>
</div>
</div>
</div>
);
}
if (!chatQuery.data || !agentId) {
return (
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>
);
}
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<AgentDetailTopBarPortals
topBarTitleRef={topBarTitleRef}
topBarActionsRef={topBarActionsRef}
rightPanelRef={rightPanelRef}
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
diff={{
hasDiffStatus,
diffStatus: diffStatusQuery.data,
showDiffPanel,
onToggleFilesChanged: () => setShowDiffPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: (editor) => {
void handleOpenInEditor(editor);
},
onViewWorkspace: handleViewWorkspace,
}}
onArchiveAgent={handleArchiveAgentAction}
shouldShowDiffPanel={shouldShowDiffPanel}
agentId={agentId}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
<div
ref={scrollContainerRef}
className="flex h-full flex-col-reverse overflow-y-auto [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailConversation
store={store}
chatID={agentId}
persistedErrorReason={chatErrorReasons[agentId]}
compressionThreshold={compressionThreshold}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onSend={handleSend}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
</div>
);
};
export default AgentDetail;
@@ -0,0 +1,847 @@
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { watchChat } from "api/api";
import { chatKey } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import type { FC, PropsWithChildren } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
selectOrderedMessageIDs,
selectQueuedMessages,
selectStreamState,
useChatSelector,
useChatStore,
} from "./ChatContext";
vi.mock("api/api", () => ({
watchChat: vi.fn(),
}));
type MessageListener = (
payload: OneWayMessageEvent<TypesGen.ServerSentEvent>,
) => void;
type ErrorListener = (payload: Event) => void;
interface MockSocket {
addEventListener(event: "message", callback: MessageListener): void;
addEventListener(event: "error", callback: ErrorListener): void;
removeEventListener(event: "message", callback: MessageListener): void;
removeEventListener(event: "error", callback: ErrorListener): void;
close: () => void;
emitData: (event: TypesGen.ChatStreamEvent) => void;
emitDataBatch: (events: readonly TypesGen.ChatStreamEvent[]) => void;
}
const createMockSocket = (): MockSocket => {
const messageListeners = new Set<MessageListener>();
const errorListeners = new Set<ErrorListener>();
const addEventListener = (
event: "message" | "error",
callback: MessageListener | ErrorListener,
): void => {
if (event === "message") {
messageListeners.add(callback as MessageListener);
return;
}
errorListeners.add(callback as ErrorListener);
};
const removeEventListener = (
event: "message" | "error",
callback: MessageListener | ErrorListener,
): void => {
if (event === "message") {
messageListeners.delete(callback as MessageListener);
return;
}
errorListeners.delete(callback as ErrorListener);
};
return {
addEventListener,
removeEventListener,
close: vi.fn(),
emitData: (event) => {
const payload: OneWayMessageEvent<TypesGen.ServerSentEvent> = {
sourceEvent: {} as MessageEvent<string>,
parseError: undefined,
parsedMessage: {
type: "data",
data: event,
},
};
for (const listener of messageListeners) {
listener(payload);
}
},
emitDataBatch: (events) => {
const payload: OneWayMessageEvent<TypesGen.ServerSentEvent> = {
sourceEvent: {} as MessageEvent<string>,
parseError: undefined,
parsedMessage: {
type: "data",
data: events,
},
};
for (const listener of messageListeners) {
listener(payload);
}
},
};
};
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const makeChat = (chatID: string): TypesGen.Chat => ({
id: chatID,
owner_id: "owner-1",
last_model_config_id: "model-1",
title: "test",
status: "running",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
});
const makeMessage = (
chatID: string,
id: number,
role: string,
text: string,
): TypesGen.ChatMessage => ({
id,
chat_id: chatID,
created_at: "2025-01-01T00:00:00.000Z",
role,
content: [{ type: "text", text }],
});
const makeQueuedMessage = (
chatID: string,
id: number,
text: string,
): TypesGen.ChatQueuedMessage => ({
id,
chat_id: chatID,
created_at: "2025-01-01T00:00:00.000Z",
content: [{ type: "text", text }],
});
const immediateAnimationFrame = (): void => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("useChatStore", () => {
it("does not clear in-progress stream parts for duplicate snapshot messages", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "reconnect-part-one",
},
},
});
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "reconnect-part-one" },
]);
});
act(() => {
const duplicateSnapshotMessage: TypesGen.ChatMessage = {
...existingMessage,
content: [...(existingMessage.content ?? [])],
};
mockSocket.emitData({
type: "message",
chat_id: chatID,
message: duplicateSnapshotMessage,
});
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "reconnect-part-one" },
]);
});
});
it("clears stream state when a new durable message arrives", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const newMessage = makeMessage(chatID, 2, "assistant", "done");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "working",
},
},
});
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "working" },
]);
});
act(() => {
mockSocket.emitData({
type: "message",
chat_id: chatID,
message: newMessage,
});
});
await waitFor(() => {
expect(result.current.streamState).toBeNull();
});
});
it("clears stream state when a duplicate message id arrives with new content", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "assistant", "old");
const updatedMessage = makeMessage(chatID, 1, "assistant", "updated");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "partial",
},
},
});
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "partial" },
]);
});
act(() => {
mockSocket.emitData({
type: "message",
chat_id: chatID,
message: updatedMessage,
});
});
await waitFor(() => {
expect(result.current.streamState).toBeNull();
});
});
it("keeps non-stream selectors from rerendering during message_part updates", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
let streamRenderCount = 0;
let queueRenderCount = 0;
let orderedIDsRenderCount = 0;
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
const StreamProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => {
useChatSelector(store, selectStreamState);
streamRenderCount += 1;
return null;
};
const QueueProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => {
useChatSelector(store, selectQueuedMessages);
queueRenderCount += 1;
return null;
};
const OrderedIDsProbe: FC<{ store: ChatStoreHandle }> = ({ store }) => {
useChatSelector(store, selectOrderedMessageIDs);
orderedIDsRenderCount += 1;
return null;
};
const TestHarness: FC = () => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return (
<>
<StreamProbe store={store} />
<QueueProbe store={store} />
<OrderedIDsProbe store={store} />
</>
);
};
render(
<QueryClientProvider client={queryClient}>
<TestHarness />
</QueryClientProvider>,
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
const streamBaseline = streamRenderCount;
const queueBaseline = queueRenderCount;
const orderedIDsBaseline = orderedIDsRenderCount;
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "partial",
},
},
});
});
await waitFor(() => {
expect(streamRenderCount).toBeGreaterThan(streamBaseline);
});
expect(queueRenderCount).toBe(queueBaseline);
expect(orderedIDsRenderCount).toBe(orderedIDsBaseline);
});
it("applies batched message_part events from one payload", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitDataBatch([
{
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "hello ",
},
},
},
{
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "world",
},
},
},
]);
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "hello world" },
]);
});
});
it("ignores message_part updates while chat is pending", async () => {
immediateAnimationFrame();
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
streamState: useChatSelector(store, selectStreamState),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "first",
},
},
});
});
await waitFor(() => {
expect(result.current.streamState?.blocks).toEqual([
{ type: "response", text: "first" },
]);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "pending" },
});
});
await waitFor(() => {
expect(result.current.streamState).toBeNull();
});
act(() => {
mockSocket.emitData({
type: "message_part",
chat_id: chatID,
message_part: {
role: "assistant",
part: {
type: "text",
text: "late",
},
},
});
});
await waitFor(() => {
expect(result.current.streamState).toBeNull();
});
});
it("does not restore stale queued messages after a stream queue_update", async () => {
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const queuedMessage = makeQueuedMessage(chatID, 10, "queued");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const initialOptions = {
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [queuedMessage],
},
chatQueuedMessages: [queuedMessage],
setChatErrorReason,
clearChatErrorReason,
};
const { result, rerender } = renderHook(
(options: Parameters<typeof useChatStore>[0]) => {
const { store } = useChatStore(options);
return {
queuedMessages: useChatSelector(store, selectQueuedMessages),
};
},
{
initialProps: initialOptions,
wrapper,
},
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
expect(result.current.queuedMessages.map((message) => message.id)).toEqual([
queuedMessage.id,
]);
act(() => {
mockSocket.emitData({
type: "queue_update",
chat_id: chatID,
queued_messages: [],
});
});
await waitFor(() => {
expect(result.current.queuedMessages).toEqual([]);
});
rerender({
...initialOptions,
chatData: {
chat: {
...makeChat(chatID),
updated_at: "2025-01-01T00:00:01.000Z",
},
messages: [existingMessage],
queued_messages: [queuedMessage],
},
chatQueuedMessages: [queuedMessage],
});
await waitFor(() => {
expect(result.current.queuedMessages).toEqual([]);
});
});
it("writes queue_update snapshots into the chat query cache", async () => {
const chatID = "chat-1";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const queuedMessage = makeQueuedMessage(chatID, 10, "queued");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChatData: TypesGen.ChatWithMessages = {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [queuedMessage],
};
queryClient.setQueryData(chatKey(chatID), initialChatData);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: initialChatData,
chatQueuedMessages: [queuedMessage],
setChatErrorReason,
clearChatErrorReason,
});
return {
queuedMessages: useChatSelector(store, selectQueuedMessages),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "queue_update",
chat_id: chatID,
queued_messages: [],
});
});
await waitFor(() => {
expect(result.current.queuedMessages).toEqual([]);
});
expect(
queryClient.getQueryData<TypesGen.ChatWithMessages | undefined>(
chatKey(chatID),
)?.queued_messages,
).toEqual([]);
});
it("ignores queue_update events for other chats", async () => {
const chatID = "chat-1";
const otherChatID = "chat-2";
const existingMessage = makeMessage(chatID, 1, "user", "hello");
const queuedMessage = makeQueuedMessage(chatID, 10, "queued");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const { result } = renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [existingMessage],
chatRecord: makeChat(chatID),
chatData: {
chat: makeChat(chatID),
messages: [existingMessage],
queued_messages: [queuedMessage],
},
chatQueuedMessages: [queuedMessage],
setChatErrorReason,
clearChatErrorReason,
});
return {
queuedMessages: useChatSelector(store, selectQueuedMessages),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID);
});
act(() => {
mockSocket.emitData({
type: "queue_update",
chat_id: otherChatID,
queued_messages: [],
});
});
await waitFor(() => {
expect(
result.current.queuedMessages.map((message) => message.id),
).toEqual([queuedMessage.id]);
});
});
});
@@ -0,0 +1,690 @@
import { watchChat } from "api/api";
import {
chatDiffContentsKey,
chatDiffStatusKey,
chatKey,
chatsKey,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils";
import {
startTransition,
useCallback,
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import { useQueryClient } from "react-query";
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
import { applyMessagePartToStreamState } from "./streamState";
import type { StreamState } from "./types";
const VALID_CHAT_STATUSES: ReadonlySet<string> = new Set<TypesGen.ChatStatus>([
"pending",
"running",
"completed",
"error",
"paused",
"waiting",
]);
const isValidChatStatus = (value: unknown): value is TypesGen.ChatStatus =>
typeof value === "string" && VALID_CHAT_STATUSES.has(value);
const isChatStreamEvent = (
data: unknown,
): data is TypesGen.ChatStreamEvent & Record<string, unknown> =>
typeof data === "object" &&
data !== null &&
"type" in data &&
typeof (data as Record<string, unknown>).type === "string";
const isChatStreamEventArray = (
data: unknown,
): data is (TypesGen.ChatStreamEvent & Record<string, unknown>)[] =>
Array.isArray(data) && data.every(isChatStreamEvent);
const toChatStreamEvents = (
data: unknown,
): (TypesGen.ChatStreamEvent & Record<string, unknown>)[] => {
if (isChatStreamEvent(data)) {
return [data];
}
if (isChatStreamEventArray(data)) {
return data;
}
return [];
};
const byMessageCreatedAt = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): number => {
return (
new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
);
};
const buildMessageMap = (
messages: readonly TypesGen.ChatMessage[],
): Map<number, TypesGen.ChatMessage> =>
new Map(messages.map((message) => [message.id, message]));
const buildOrderedMessageIDs = (
messages: readonly TypesGen.ChatMessage[],
): readonly number[] => {
const sorted = [...messages];
sorted.sort(byMessageCreatedAt);
return sorted.map((message) => message.id);
};
const mapsEqualByRef = <K, V>(left: Map<K, V>, right: Map<K, V>): boolean => {
if (left.size !== right.size) {
return false;
}
for (const [key, value] of left) {
if (!right.has(key) || right.get(key) !== value) {
return false;
}
}
return true;
};
const arraysEqual = <T>(left: readonly T[], right: readonly T[]): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
};
const jsonValuesEqual = (left: unknown, right: unknown): boolean => {
if (left === right) {
return true;
}
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
};
const chatMessagesEqualByValue = (
left: TypesGen.ChatMessage,
right: TypesGen.ChatMessage,
): boolean =>
left.id === right.id &&
left.chat_id === right.chat_id &&
left.model_config_id === right.model_config_id &&
left.created_at === right.created_at &&
left.role === right.role &&
jsonValuesEqual(left.content, right.content) &&
jsonValuesEqual(left.usage, right.usage);
const chatQueuedMessagesEqualByID = (
left: readonly TypesGen.ChatQueuedMessage[],
right: readonly TypesGen.ChatQueuedMessage[],
): boolean => {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.id !== right[index]?.id) {
return false;
}
}
return true;
};
type ChatStoreState = {
messagesByID: Map<number, TypesGen.ChatMessage>;
orderedMessageIDs: readonly number[];
streamState: StreamState | null;
chatStatus: TypesGen.ChatStatus | null;
streamError: string | null;
queuedMessages: readonly TypesGen.ChatQueuedMessage[];
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
};
type ChatStore = {
getSnapshot: () => ChatStoreState;
subscribe: (listener: () => void) => () => void;
replaceMessages: (
messages: readonly TypesGen.ChatMessage[] | undefined,
) => void;
upsertDurableMessage: (message: TypesGen.ChatMessage) => {
isDuplicate: boolean;
changed: boolean;
};
applyMessagePart: (part: Record<string, unknown>) => void;
applyMessageParts: (parts: readonly Record<string, unknown>[]) => void;
setQueuedMessages: (
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
) => void;
setChatStatus: (status: TypesGen.ChatStatus | null) => void;
setStreamError: (reason: string | null) => void;
clearStreamError: () => void;
clearStreamState: () => void;
setSubagentStatusOverride: (
chatID: string,
status: TypesGen.ChatStatus,
) => void;
resetTransientState: () => void;
};
const createInitialState = (): ChatStoreState => ({
messagesByID: new Map(),
orderedMessageIDs: [],
streamState: null,
chatStatus: null,
streamError: null,
queuedMessages: [],
subagentStatusOverrides: new Map(),
});
const createChatStore = (): ChatStore => {
let state = createInitialState();
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) {
listener();
}
};
const setState = (
updater: (current: ChatStoreState) => ChatStoreState,
): void => {
const next = updater(state);
if (next === state) {
return;
}
state = next;
emit();
};
const replaceMessages = (
messages: readonly TypesGen.ChatMessage[] | undefined,
): void => {
const safeMessages = messages ?? [];
const nextMessagesByID = buildMessageMap(safeMessages);
const nextOrderedMessageIDs = buildOrderedMessageIDs(safeMessages);
if (
mapsEqualByRef(state.messagesByID, nextMessagesByID) &&
arraysEqual(state.orderedMessageIDs, nextOrderedMessageIDs)
) {
return;
}
setState((current) => ({
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
}));
};
const upsertDurableMessage = (message: TypesGen.ChatMessage) => {
const existing = state.messagesByID.get(message.id);
const isDuplicate = state.messagesByID.has(message.id);
if (existing && chatMessagesEqualByValue(existing, message)) {
return { isDuplicate, changed: false };
}
const nextMessagesByID = new Map(state.messagesByID);
nextMessagesByID.set(message.id, message);
const nextOrderedMessageIDs = isDuplicate
? state.orderedMessageIDs
: buildOrderedMessageIDs(Array.from(nextMessagesByID.values()));
setState((current) => ({
...current,
messagesByID: nextMessagesByID,
orderedMessageIDs: nextOrderedMessageIDs,
}));
return { isDuplicate, changed: true };
};
const applyMessageParts = (parts: readonly Record<string, unknown>[]) => {
if (parts.length === 0) {
return;
}
let nextStreamState: StreamState | null = state.streamState;
for (const part of parts) {
nextStreamState = applyMessagePartToStreamState(nextStreamState, part);
}
if (nextStreamState === state.streamState) {
return;
}
setState((current) => ({
...current,
streamState: nextStreamState,
}));
};
return {
getSnapshot: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
replaceMessages,
upsertDurableMessage,
applyMessagePart: (part) => applyMessageParts([part]),
applyMessageParts,
setQueuedMessages: (queuedMessages) => {
const nextQueuedMessages = queuedMessages ?? [];
if (
chatQueuedMessagesEqualByID(state.queuedMessages, nextQueuedMessages)
) {
return;
}
setState((current) => ({
...current,
queuedMessages: nextQueuedMessages,
}));
},
setChatStatus: (status) => {
if (state.chatStatus === status) {
return;
}
setState((current) => ({
...current,
chatStatus: status,
}));
},
setStreamError: (reason) => {
if (state.streamError === reason) {
return;
}
setState((current) => ({
...current,
streamError: reason,
}));
},
clearStreamError: () => {
if (state.streamError === null) {
return;
}
setState((current) => ({
...current,
streamError: null,
}));
},
clearStreamState: () => {
if (state.streamState === null) {
return;
}
setState((current) => ({
...current,
streamState: null,
}));
},
setSubagentStatusOverride: (chatID, status) => {
if (state.subagentStatusOverrides.get(chatID) === status) {
return;
}
const nextOverrides = new Map(state.subagentStatusOverrides);
nextOverrides.set(chatID, status);
setState((current) => ({
...current,
subagentStatusOverrides: nextOverrides,
}));
},
resetTransientState: () => {
if (
state.streamState === null &&
state.streamError === null &&
state.subagentStatusOverrides.size === 0
) {
return;
}
setState((current) => ({
...current,
streamState: null,
streamError: null,
subagentStatusOverrides: new Map(),
}));
},
};
};
interface UseChatStoreOptions {
chatID: string | undefined;
chatMessages: readonly TypesGen.ChatMessage[] | undefined;
chatRecord: TypesGen.Chat | undefined;
chatData: TypesGen.ChatWithMessages | undefined;
chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined;
setChatErrorReason: (chatID: string, reason: string) => void;
clearChatErrorReason: (chatID: string) => void;
}
export const selectMessagesByID = (state: ChatStoreState) => state.messagesByID;
export const selectOrderedMessageIDs = (state: ChatStoreState) =>
state.orderedMessageIDs;
export const selectStreamState = (state: ChatStoreState) => state.streamState;
export const selectHasStreamState = (state: ChatStoreState) =>
state.streamState !== null;
export const selectChatStatus = (state: ChatStoreState) => state.chatStatus;
export const selectStreamError = (state: ChatStoreState) => state.streamError;
export const selectQueuedMessages = (state: ChatStoreState) =>
state.queuedMessages;
export const selectSubagentStatusOverrides = (state: ChatStoreState) =>
state.subagentStatusOverrides;
export const useChatStore = (
options: UseChatStoreOptions,
): { store: ChatStore; clearStreamError: () => void } => {
const {
chatID,
chatMessages,
chatRecord,
chatData,
chatQueuedMessages,
setChatErrorReason,
clearChatErrorReason,
} = options;
const queryClient = useQueryClient();
const storeRef = useRef<ChatStore>(createChatStore());
const streamResetFrameRef = useRef<number | null>(null);
const queuedMessagesHydratedChatIDRef = useRef<string | null>(null);
const store = storeRef.current;
const updateSidebarChat = useCallback(
(updater: (chat: TypesGen.Chat) => TypesGen.Chat) => {
if (!chatID) {
return;
}
queryClient.setQueryData<readonly TypesGen.Chat[] | undefined>(
chatsKey,
(currentChats) => {
if (!currentChats) {
return currentChats;
}
let didUpdate = false;
const nextChats = currentChats.map((chat) => {
if (chat.id !== chatID) {
return chat;
}
didUpdate = true;
return updater(chat);
});
return didUpdate ? nextChats : currentChats;
},
);
},
[chatID, queryClient],
);
const cancelScheduledStreamReset = useCallback(() => {
if (streamResetFrameRef.current === null) {
return;
}
window.cancelAnimationFrame(streamResetFrameRef.current);
streamResetFrameRef.current = null;
}, []);
const scheduleStreamReset = useCallback(() => {
cancelScheduledStreamReset();
streamResetFrameRef.current = window.requestAnimationFrame(() => {
store.clearStreamState();
streamResetFrameRef.current = null;
});
}, [cancelScheduledStreamReset, store]);
const updateChatQueuedMessages = useCallback(
(queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined) => {
if (!chatID) {
return;
}
const nextQueuedMessages = queuedMessages ?? [];
queryClient.setQueryData<TypesGen.ChatWithMessages | undefined>(
chatKey(chatID),
(currentChat) => {
if (!currentChat) {
return currentChat;
}
if (
chatQueuedMessagesEqualByID(
currentChat.queued_messages,
nextQueuedMessages,
)
) {
return currentChat;
}
return {
...currentChat,
queued_messages: nextQueuedMessages,
};
},
);
},
[chatID, queryClient],
);
useEffect(() => {
store.replaceMessages(chatMessages);
}, [chatMessages, store]);
useEffect(() => {
store.setChatStatus(chatRecord?.status ?? null);
}, [chatRecord?.status, store]);
useEffect(() => {
queuedMessagesHydratedChatIDRef.current = null;
store.setQueuedMessages([]);
if (!chatID) {
return;
}
}, [chatID, store]);
useEffect(() => {
if (!chatID || !chatData) {
return;
}
if (queuedMessagesHydratedChatIDRef.current === chatID) {
return;
}
queuedMessagesHydratedChatIDRef.current = chatID;
store.setQueuedMessages(chatQueuedMessages);
}, [chatData, chatID, chatQueuedMessages, store]);
useEffect(() => {
cancelScheduledStreamReset();
store.resetTransientState();
if (!chatID) {
return;
}
const socket = watchChat(chatID);
const handleMessage = (
payload: OneWayMessageEvent<TypesGen.ServerSentEvent>,
) => {
if (payload.parseError || !payload.parsedMessage) {
store.setStreamError("Failed to parse chat stream update.");
return;
}
if (payload.parsedMessage.type !== "data") {
return;
}
const streamEvents = toChatStreamEvents(payload.parsedMessage.data);
if (streamEvents.length === 0) {
return;
}
const shouldApplyMessagePart = (): boolean => {
const currentStatus = store.getSnapshot().chatStatus;
return currentStatus !== "pending" && currentStatus !== "waiting";
};
const pendingMessageParts: Record<string, unknown>[] = [];
const flushMessageParts = () => {
if (pendingMessageParts.length === 0) {
return;
}
cancelScheduledStreamReset();
const parts = pendingMessageParts.splice(0, pendingMessageParts.length);
startTransition(() => {
store.applyMessageParts(parts);
});
};
for (const streamEvent of streamEvents) {
if (streamEvent.type === "message_part") {
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
continue;
}
if (!shouldApplyMessagePart()) {
continue;
}
const part = asRecord(streamEvent.message_part?.part);
if (part) {
pendingMessageParts.push(part);
}
continue;
}
flushMessageParts();
switch (streamEvent.type) {
case "message": {
const message = streamEvent.message;
if (!message) {
continue;
}
const { changed } = store.upsertDurableMessage(message);
if (changed) {
scheduleStreamReset();
}
updateSidebarChat((chat) => ({
...chat,
updated_at: message.created_at ?? new Date().toISOString(),
}));
continue;
}
case "queue_update":
{
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
continue;
}
}
store.setQueuedMessages(streamEvent.queued_messages);
updateChatQueuedMessages(streamEvent.queued_messages);
continue;
case "status": {
const status = asRecord(streamEvent.status);
const nextStatus = asString(status?.status);
if (!isValidChatStatus(nextStatus)) {
continue;
}
const eventChatID = asString(streamEvent.chat_id);
if (eventChatID && eventChatID !== chatID) {
store.setSubagentStatusOverride(eventChatID, nextStatus);
continue;
}
const previousStatus = store.getSnapshot().chatStatus;
store.setChatStatus(nextStatus);
if (nextStatus === "pending" || nextStatus === "waiting") {
store.clearStreamState();
}
if (nextStatus !== "error") {
clearChatErrorReason(chatID);
}
updateSidebarChat((chat) => ({
...chat,
status: nextStatus,
updated_at: new Date().toISOString(),
}));
if (previousStatus !== nextStatus) {
void Promise.all([
queryClient.invalidateQueries({
queryKey: chatDiffStatusKey(chatID),
}),
queryClient.invalidateQueries({
queryKey: chatDiffContentsKey(chatID),
}),
]);
}
continue;
}
case "error": {
const error = asRecord(streamEvent.error);
const reason =
asString(error?.message).trim() || "Chat processing failed.";
store.setChatStatus("error");
store.setStreamError(reason);
setChatErrorReason(chatID, reason);
updateSidebarChat((chat) => ({
...chat,
status: "error",
updated_at: new Date().toISOString(),
}));
continue;
}
default:
continue;
}
}
flushMessageParts();
};
const handleError = () => {
if (!store.getSnapshot().streamError) {
store.setStreamError("Chat stream disconnected.");
}
};
socket.addEventListener("message", handleMessage);
socket.addEventListener("error", handleError);
return () => {
socket.removeEventListener("message", handleMessage);
socket.removeEventListener("error", handleError);
socket.close();
cancelScheduledStreamReset();
};
}, [
cancelScheduledStreamReset,
chatID,
clearChatErrorReason,
queryClient,
scheduleStreamReset,
setChatErrorReason,
store,
updateChatQueuedMessages,
updateSidebarChat,
]);
return {
store,
clearStreamError: useCallback(() => {
store.clearStreamError();
}, [store]),
};
};
export const useChatSelector = <T>(
store: ChatStore,
selector: (state: ChatStoreState) => T,
): T => {
const getSnapshot = useCallback(
() => selector(store.getSnapshot()),
[selector, store],
);
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
@@ -0,0 +1,703 @@
import type * as TypesGen from "api/typesGenerated";
import {
ConversationItem,
Message,
MessageContent,
Response,
Shimmer,
Tool,
} from "components/ai-elements";
import { ChevronDownIcon, Loader2Icon } from "lucide-react";
import {
type FC,
memo,
type ReactNode,
type RefObject,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
import type {
MergedTool,
ParsedMessageContent,
ParsedMessageSection,
RenderBlock,
StreamState,
} from "./types";
const ReasoningDisclosure: FC<{
id: string;
title?: string;
text: string;
isStreaming?: boolean;
}> = ({ id, title, text, isStreaming = false }) => {
const [isOpen, setIsOpen] = useState(false);
const hasText = text.trim().length > 0;
const label = title ?? "Thinking";
const showStreamingPlaceholder = isStreaming && !hasText;
if (!title && hasText) {
return (
<div className="w-full">
<Response className="text-[11px] text-content-secondary">
{text}
</Response>
</div>
);
}
const labelContent = (
<span className="text-sm">
{showStreamingPlaceholder ? (
<Shimmer as="span">Thinking...</Shimmer>
) : (
label
)}
</span>
);
return (
<div className="w-full">
{hasText ? (
<div
role="button"
tabIndex={0}
aria-expanded={isOpen}
aria-controls={id}
className="flex items-center gap-2 text-content-secondary transition-colors hover:text-content-primary cursor-pointer"
onClick={() => setIsOpen((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
setIsOpen((prev) => !prev);
}
}}
>
{labelContent}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-content-secondary transition-transform",
isOpen ? "rotate-0" : "-rotate-90",
)}
/>
</div>
) : (
<div className="flex items-center gap-2 text-content-secondary transition-colors hover:text-content-primary">
{labelContent}
</div>
)}
{isOpen && hasText ? (
<div id={id} className="mt-1.5">
<Response className="text-[11px] text-content-secondary">
{text}
</Response>
</div>
) : null}
</div>
);
};
// Shared block renderer used by both ChatMessageItem (historical
// messages) and StreamingOutput (live stream). Encapsulates the
// response / thinking / tool switch so the two consumers stay in sync.
type RenderBlockListParams = {
blocks: readonly RenderBlock[];
toolByID: ReadonlyMap<string, MergedTool>;
keyPrefix: string;
isStreaming?: boolean;
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
};
type RenderBlockListResult = {
elements: ReactNode[];
renderedToolIDs: ReadonlySet<string>;
};
function renderBlockList({
blocks,
toolByID,
keyPrefix,
isStreaming = false,
subagentTitles,
subagentStatusOverrides,
}: RenderBlockListParams): RenderBlockListResult {
const renderedToolIDs = new Set<string>();
const elements = blocks
.map((block, index) => {
switch (block.type) {
case "response":
return (
<Response key={`${keyPrefix}-response-${index}`}>
{block.text}
</Response>
);
case "thinking":
return (
<ReasoningDisclosure
key={`${keyPrefix}-thinking-${index}`}
id={`${keyPrefix}-thinking-${index}`}
title={block.title}
text={block.text}
isStreaming={isStreaming}
/>
);
case "tool": {
const tool = toolByID.get(block.id);
if (!tool) {
if (!isStreaming) {
return null;
}
// Streaming placeholder for not-yet-resolved tool.
renderedToolIDs.add(block.id);
return (
<Tool
key={block.id}
name="Tool"
status="running"
isError={false}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
);
}
renderedToolIDs.add(tool.id);
return (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={isStreaming ? subagentTitles : undefined}
subagentStatusOverrides={
isStreaming ? subagentStatusOverrides : undefined
}
/>
);
}
default:
return null;
}
})
.filter((el): el is NonNullable<typeof el> => el != null);
return { elements, renderedToolIDs };
}
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
onEditUserMessage?: (messageId: number, text: string) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
// overlay to indicate truncated content.
fadeFromBottom?: boolean;
}>(
({
message,
parsed,
onEditUserMessage,
editingMessageId,
savingMessageId,
fadeFromBottom = false,
}) => {
const isUser = message.role === "user";
const isSavingMessage = savingMessageId === message.id;
const toolByID = new Map(parsed.tools.map((tool) => [tool.id, tool]));
if (
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
parsed.markdown === "" &&
parsed.reasoning === ""
) {
return null;
}
const hasRenderableContent =
parsed.blocks.length > 0 || parsed.tools.length > 0;
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
blocks: parsed.blocks,
toolByID,
keyPrefix: String(message.id),
});
const remainingTools = parsed.tools.filter(
(tool) => !renderedToolIDs.has(tool.id),
);
return (
<ConversationItem {...conversationItemProps}>
{isUser ? (
<Message className="my-2 w-full max-w-none">
<MessageContent
className={cn(
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-all",
onEditUserMessage &&
!isSavingMessage &&
"cursor-pointer hover:bg-surface-tertiary",
editingMessageId === message.id &&
"ring-2 ring-content-link/40",
isSavingMessage && "ring-2 ring-content-secondary/40",
fadeFromBottom && "relative overflow-hidden",
)}
style={
fadeFromBottom
? { maxHeight: "var(--clip-h, none)" }
: undefined
}
onClick={
onEditUserMessage && !isSavingMessage
? () => onEditUserMessage(message.id, parsed.markdown || "")
: undefined
}
>
<div className="flex items-start gap-2">
<span className="min-w-0 flex-1">{parsed.markdown || ""}</span>
{isSavingMessage && (
<Loader2Icon
className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-content-secondary"
aria-label="Saving message edit"
/>
)}
</div>
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12"
style={{
background:
"linear-gradient(to top, hsl(var(--surface-secondary)), transparent)",
}}
/>
)}
</MessageContent>
</Message>
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{orderedBlocks}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</div>
</MessageContent>
</Message>
)}
</ConversationItem>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
const StreamingOutput = memo<{
streamState: StreamState | null;
streamTools: readonly MergedTool[];
subagentTitles?: Map<string, string>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
showInitialPlaceholder?: boolean;
}>(
({
streamState,
streamTools,
subagentTitles,
subagentStatusOverrides,
showInitialPlaceholder = false,
}) => {
const conversationItemProps = { role: "assistant" as const };
const toolByID = new Map(streamTools.map((tool) => [tool.id, tool]));
const blocks = streamState?.blocks ?? [];
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
blocks,
toolByID,
keyPrefix: "stream",
isStreaming: true,
subagentTitles,
subagentStatusOverrides,
});
const remainingTools = streamTools.filter(
(tool) => !renderedToolIDs.has(tool.id),
);
return (
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{orderedBlocks}
{showInitialPlaceholder ||
(streamState &&
orderedBlocks.length === 0 &&
streamTools.length === 0) ? (
<div className="relative">
<Response aria-hidden className="invisible">
Thinking...
</Response>
<div className="pointer-events-none absolute inset-0">
<Shimmer as="div" className="text-[13px] leading-relaxed">
Thinking...
</Shimmer>
</div>
</div>
) : null}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
/>
))}
</div>
</MessageContent>
</Message>
</ConversationItem>
);
},
);
StreamingOutput.displayName = "StreamingOutput";
const StickyUserMessage: FC<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
onEditUserMessage?: (messageId: number, text: string) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
}> = ({
message,
parsed,
onEditUserMessage,
editingMessageId,
savingMessageId,
}) => {
const [isStuck, setIsStuck] = useState(false);
const [isReady, setIsReady] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// useLayoutEffect so isStuck and --clip-h are both resolved
// before the browser paints, avoiding a flash on load.
useLayoutEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
// Immediate check so the first paint is correct when the
// sentinel is already scrolled out of view.
const scroller = sentinel.closest(".overflow-y-auto");
if (scroller) {
const stuck =
sentinel.getBoundingClientRect().top <
scroller.getBoundingClientRect().top;
if (stuck) {
setIsStuck(true);
}
}
setIsReady(true);
const observer = new IntersectionObserver(
([entry]) => setIsStuck(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, []);
// Sets a single CSS custom property (--clip-h) on the sticky
// container. All visual behaviour (max-height, mask fade) is
// driven by CSS using this variable.
useLayoutEffect(() => {
const sentinel = sentinelRef.current;
const container = containerRef.current;
if (!sentinel || !container) return;
const scroller = sentinel.closest(".overflow-y-auto") as HTMLElement | null;
if (!scroller) return;
const MIN_HEIGHT = 72;
let scrollerTop = scroller.getBoundingClientRect().top;
const update = () => {
const fullHeight = container.offsetHeight;
const sentinelTop = sentinel.getBoundingClientRect().top;
const scrolledPast = scrollerTop - sentinelTop;
if (scrolledPast <= 0) {
// Always set a valid value so the overlay has the
// correct height immediately when isStuck flips.
container.style.setProperty("--clip-h", `${fullHeight}px`);
container.style.setProperty("--fade-opacity", "0");
return;
}
const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT);
container.style.setProperty("--clip-h", `${visible}px`);
// Only show the fade gradient once enough content is
// clipped to be visually meaningful.
container.style.setProperty(
"--fade-opacity",
visible < fullHeight - 8 ? "1" : "0",
);
};
const onResize = () => {
scrollerTop = scroller.getBoundingClientRect().top;
update();
};
// Throttle to one update per animation frame so we don't
// do redundant work on high-refresh-rate displays.
let rafId: number | null = null;
const onScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
update();
});
};
scroller.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
update();
// Set immediately — both --clip-h and --overlay-ready are
// applied before the browser paints since we're in a
// useLayoutEffect.
container.style.setProperty("--overlay-ready", "1");
return () => {
scroller.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
container.style.removeProperty("--overlay-ready");
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
const handleEditUserMessage = onEditUserMessage
? (messageId: number, text: string) => {
onEditUserMessage(messageId, text);
requestAnimationFrame(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const scroller = sentinel.closest(
".overflow-y-auto",
) as HTMLElement | null;
if (!scroller) return;
const offset =
sentinel.getBoundingClientRect().top -
scroller.getBoundingClientRect().top;
scroller.scrollBy({ top: offset, behavior: "smooth" });
});
}
: undefined;
return (
<>
<div ref={sentinelRef} className="h-0" />
<div
ref={containerRef}
className={cn(
"relative sticky top-0 z-10 px-3 -mx-3 pt-2 pb-2",
!isReady && "invisible",
isStuck && "pointer-events-none",
)}
>
{/* Flow element: always in the DOM to preserve
scroll layout. Hidden when stuck so the
clipped overlay takes over visually. */}
<div
className={isStuck ? undefined : "pointer-events-auto"}
style={
isStuck
? { opacity: "calc(1 - var(--overlay-ready, 0))" }
: undefined
}
>
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
/>
</div>
{/* Overlay: absolutely positioned, matching the
sticky container. max-height + mask are driven
entirely by the --clip-h CSS variable which the
scroll handler sets on the container. */}
{isStuck && (
<div
className="absolute inset-0"
style={{
opacity: "var(--overlay-ready, 0)",
contain: "layout style",
}}
>
{/* Blur layer: extends 48px beyond the
clipped content so the frosted effect
is visible around the bubble. Promoted
to its own GPU layer via will-change. */}
<div
className="absolute inset-0 backdrop-blur-[1px] bg-surface-primary/15"
style={{
maxHeight: "calc(var(--clip-h, 100%) + 48px)",
willChange: "max-height, mask-image",
maskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
WebkitMaskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
}}
/>
{/* Content layer: px-3 pt-2 matches the
sticky container's padding so the
overlay aligns with the flow element.
will-change promotes to GPU layer. */}
<div
className="relative px-3 pt-2 pointer-events-auto"
style={{ willChange: "max-height" }}
>
<ChatMessageItem
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
fadeFromBottom
/>
</div>
</div>
)}
</div>
</>
);
};
type ConversationTimelineProps = {
isEmpty: boolean;
hasMoreMessages: boolean;
loadMoreSentinelRef: RefObject<HTMLDivElement | null>;
parsedSections: readonly ParsedMessageSection[];
hasStreamOutput: boolean;
streamState: StreamState | null;
streamTools: readonly MergedTool[];
subagentTitles: Map<string, string>;
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
isAwaitingFirstStreamChunk: boolean;
detailErrorMessage?: string | null;
onEditUserMessage?: (messageId: number, text: string) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
};
export const ConversationTimeline: FC<ConversationTimelineProps> = ({
isEmpty,
hasMoreMessages,
loadMoreSentinelRef,
parsedSections,
hasStreamOutput,
streamState,
streamTools,
subagentTitles,
subagentStatusOverrides,
isAwaitingFirstStreamChunk,
detailErrorMessage,
onEditUserMessage,
editingMessageId,
savingMessageId,
}) => {
const shouldRenderStreamInLastSection =
hasStreamOutput && parsedSections.length > 0;
return (
<div className="mx-auto w-full max-w-3xl py-6">
{isEmpty && !hasStreamOutput ? (
<div className="py-12 text-center text-content-secondary">
<p className="text-sm">Start a conversation with your agent.</p>
</div>
) : (
<div className="flex flex-col">
{hasMoreMessages && (
<div
ref={loadMoreSentinelRef}
className="flex items-center justify-center py-4 text-xs text-content-secondary"
>
Loading earlier messages…
</div>
)}
{parsedSections.map((section, sectionIdx) => (
<div
key={section.userEntry?.message.id ?? `section-${sectionIdx}`}
className="-mx-1 px-1"
style={{
contentVisibility: "auto",
containIntrinsicSize: "1px 600px",
}}
>
<div className="flex flex-col gap-3">
{section.entries.map(({ message, parsed }) =>
message.role === "user" ? (
<StickyUserMessage
key={message.id}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
/>
) : (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
savingMessageId={savingMessageId}
/>
),
)}
{shouldRenderStreamInLastSection &&
sectionIdx === parsedSections.length - 1 && (
<StreamingOutput
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
showInitialPlaceholder={isAwaitingFirstStreamChunk}
/>
)}
</div>
</div>
))}
{hasStreamOutput && parsedSections.length === 0 && (
<StreamingOutput
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
showInitialPlaceholder={isAwaitingFirstStreamChunk}
/>
)}
</div>
)}
{detailErrorMessage && (
<div className="mt-4 rounded-md border border-border-destructive bg-surface-red px-3 py-2 text-xs text-content-destructive">
{detailErrorMessage}
</div>
)}
</div>
);
};
@@ -0,0 +1,199 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import {
ArchiveIcon,
ChevronRightIcon,
EllipsisIcon,
ExternalLinkIcon,
MonitorIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
} from "lucide-react";
import type { FC, RefObject } from "react";
import { createPortal } from "react-dom";
import { FilesChangedPanel } from "../FilesChangedPanel";
interface DiffStatsBadgeProps {
status: ChatDiffStatusResponse;
isOpen: boolean;
onToggle: () => void;
}
const DiffStatsBadge: FC<DiffStatsBadgeProps> = ({
status,
isOpen,
onToggle,
}) => {
const additions = status.additions ?? 0;
const deletions = status.deletions ?? 0;
return (
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onToggle();
}
}}
className="flex cursor-pointer items-center gap-3 px-2 py-1 text-content-secondary transition-colors hover:text-content-primary"
>
<span className="font-mono text-sm font-semibold text-content-success">
+{additions}
</span>
<span className="font-mono text-sm font-semibold text-content-destructive">
−{deletions}
</span>
{isOpen ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</div>
);
};
interface DiffPanelState {
hasDiffStatus: boolean;
diffStatus: ChatDiffStatusResponse | undefined;
showDiffPanel: boolean;
onToggleFilesChanged: () => void;
}
interface WorkspaceActions {
canOpenEditors: boolean;
canOpenWorkspace: boolean;
onOpenInEditor: (editor: "cursor" | "vscode") => void;
onViewWorkspace: () => void;
}
type AgentDetailTopBarPortalsProps = {
topBarTitleRef?: RefObject<HTMLDivElement | null>;
topBarActionsRef?: RefObject<HTMLDivElement | null>;
rightPanelRef?: RefObject<HTMLDivElement | null>;
chatTitle?: string;
parentChat?: TypesGen.Chat;
onOpenParentChat: (chatId: string) => void;
diff: DiffPanelState;
workspace: WorkspaceActions;
onArchiveAgent: () => void;
shouldShowDiffPanel: boolean;
agentId: string;
};
export const AgentDetailTopBarPortals: FC<AgentDetailTopBarPortalsProps> = ({
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
chatTitle,
parentChat,
onOpenParentChat,
diff,
workspace,
onArchiveAgent,
shouldShowDiffPanel,
agentId,
}) => {
return (
<>
{chatTitle &&
topBarTitleRef?.current &&
createPortal(
<div className="flex min-w-0 items-center gap-1.5">
{parentChat && (
<>
<Button
size="sm"
variant="subtle"
className="h-auto max-w-[16rem] rounded-sm px-1 py-0.5 text-xs text-content-secondary shadow-none hover:bg-transparent hover:text-content-primary"
onClick={() => onOpenParentChat(parentChat.id)}
>
<span className="truncate">{parentChat.title}</span>
</Button>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary/70" />
</>
)}
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
</div>,
topBarTitleRef.current,
)}
{diff.hasDiffStatus &&
diff.diffStatus &&
topBarActionsRef?.current &&
createPortal(
<DiffStatsBadge
status={diff.diffStatus}
isOpen={diff.showDiffPanel}
onToggle={diff.onToggleFilesChanged}
/>,
topBarActionsRef.current,
)}
{topBarActionsRef?.current &&
createPortal(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
topBarActionsRef.current,
)}
{shouldShowDiffPanel &&
rightPanelRef?.current &&
createPortal(
<FilesChangedPanel chatId={agentId} />,
rightPanelRef.current,
)}
</>
);
};
@@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import {
appendTextBlock,
asNonEmptyString,
mergeThinkingTitles,
} from "./blockUtils";
import type { RenderBlock } from "./types";
// ---------------------------------------------------------------------------
// asNonEmptyString
// ---------------------------------------------------------------------------
describe("asNonEmptyString", () => {
it("returns the string when it is non-empty", () => {
expect(asNonEmptyString("hello")).toBe("hello");
});
it("returns trimmed string when value has whitespace", () => {
expect(asNonEmptyString(" hello ")).toBe("hello");
});
it("returns undefined for an empty string", () => {
expect(asNonEmptyString("")).toBeUndefined();
});
it("returns undefined for a whitespace-only string", () => {
expect(asNonEmptyString(" ")).toBeUndefined();
});
it("returns undefined for non-string values", () => {
expect(asNonEmptyString(undefined)).toBeUndefined();
expect(asNonEmptyString(null)).toBeUndefined();
expect(asNonEmptyString(42)).toBeUndefined();
expect(asNonEmptyString(true)).toBeUndefined();
expect(asNonEmptyString({})).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// mergeThinkingTitles
// ---------------------------------------------------------------------------
describe("mergeThinkingTitles", () => {
it("merges when both titles are undefined", () => {
expect(mergeThinkingTitles(undefined, undefined)).toEqual({
shouldMerge: true,
title: undefined,
});
});
it("merges and picks nextTitle when current is undefined", () => {
expect(mergeThinkingTitles(undefined, "Thinking")).toEqual({
shouldMerge: true,
title: "Thinking",
});
});
it("merges and keeps currentTitle when next is undefined", () => {
expect(mergeThinkingTitles("Thinking", undefined)).toEqual({
shouldMerge: true,
title: "Thinking",
});
});
it("merges when titles are identical", () => {
expect(mergeThinkingTitles("Thinking", "Thinking")).toEqual({
shouldMerge: true,
title: "Thinking",
});
});
it("merges and uses nextTitle when it extends currentTitle", () => {
expect(mergeThinkingTitles("Think", "Thinking deeply")).toEqual({
shouldMerge: true,
title: "Thinking deeply",
});
});
it("merges and keeps currentTitle when it extends nextTitle", () => {
expect(mergeThinkingTitles("Thinking deeply", "Think")).toEqual({
shouldMerge: true,
title: "Thinking deeply",
});
});
it("does not merge when titles are completely different", () => {
expect(mergeThinkingTitles("Analyzing", "Planning")).toEqual({
shouldMerge: false,
title: "Planning",
});
});
});
// ---------------------------------------------------------------------------
// appendTextBlock
// ---------------------------------------------------------------------------
describe("appendTextBlock", () => {
it("returns the same blocks when text is empty or whitespace", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "hello" }];
expect(appendTextBlock(blocks, "response", "")).toBe(blocks);
expect(appendTextBlock(blocks, "response", " ")).toBe(blocks);
expect(appendTextBlock(blocks, "thinking", "\n\t")).toBe(blocks);
});
it("appends a new response block to an empty list", () => {
const result = appendTextBlock([], "response", "hello");
expect(result).toEqual([{ type: "response", text: "hello" }]);
});
it("appends a new thinking block to an empty list", () => {
const result = appendTextBlock([], "thinking", "pondering", "Deep thought");
expect(result).toEqual([
{ type: "thinking", text: "pondering", title: "Deep thought" },
]);
});
it("merges consecutive response blocks", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "aaa" }];
const result = appendTextBlock(blocks, "response", "bbb");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ type: "response", text: "aaabbb" });
});
it("merges consecutive thinking blocks with compatible titles", () => {
const blocks: RenderBlock[] = [
{ type: "thinking", text: "part1", title: "Reasoning" },
];
const result = appendTextBlock(blocks, "thinking", "part2", "Reasoning");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "thinking",
text: "part1part2",
title: "Reasoning",
});
});
it("does not merge thinking blocks with incompatible titles", () => {
const blocks: RenderBlock[] = [
{ type: "thinking", text: "part1", title: "Analyzing" },
];
const result = appendTextBlock(blocks, "thinking", "part2", "Planning");
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: "thinking",
text: "part1",
title: "Analyzing",
});
expect(result[1]).toEqual({
type: "thinking",
text: "part2",
title: "Planning",
});
});
it("does not merge blocks of different types", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "hello" }];
const result = appendTextBlock(blocks, "thinking", "hmm");
expect(result).toHaveLength(2);
expect(result[1]).toEqual({
type: "thinking",
text: "hmm",
title: undefined,
});
});
it("does not merge when last block is a tool block", () => {
const blocks: RenderBlock[] = [{ type: "tool", id: "tool-1" }];
const result = appendTextBlock(blocks, "response", "after tool");
expect(result).toHaveLength(2);
expect(result[1]).toEqual({ type: "response", text: "after tool" });
});
it("uses the custom joinText function when merging", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "line1" }];
const join = (a: string, b: string) => `${a}\n${b}`;
const result = appendTextBlock(
blocks,
"response",
"line2",
undefined,
join,
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ type: "response", text: "line1\nline2" });
});
it("does not mutate the original blocks array", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "original" }];
const result = appendTextBlock(blocks, "response", " added");
expect(blocks).toHaveLength(1);
expect((blocks[0] as { text: string }).text).toBe("original");
expect(result).not.toBe(blocks);
});
it("merges thinking block when nextTitle extends currentTitle", () => {
const blocks: RenderBlock[] = [
{ type: "thinking", text: "a", title: "Think" },
];
const result = appendTextBlock(blocks, "thinking", "b", "Thinking deeply");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "thinking",
text: "ab",
title: "Thinking deeply",
});
});
it("merges thinking blocks when both have no title", () => {
const blocks: RenderBlock[] = [{ type: "thinking", text: "a" }];
const result = appendTextBlock(blocks, "thinking", "b");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "thinking",
text: "ab",
title: undefined,
});
});
});
@@ -0,0 +1,84 @@
import { asString } from "components/ai-elements/runtimeTypeUtils";
import type { RenderBlock } from "./types";
const createBlock = (
type: "response" | "thinking",
text: string,
title?: string,
): RenderBlock =>
type === "thinking" ? { type, text, title } : { type, text };
export const asNonEmptyString = (value: unknown): string | undefined => {
const next = asString(value).trim();
return next.length > 0 ? next : undefined;
};
export const mergeThinkingTitles = (
currentTitle: string | undefined,
nextTitle: string | undefined,
): { shouldMerge: boolean; title: string | undefined } => {
if (!currentTitle && !nextTitle) {
return { shouldMerge: true, title: undefined };
}
if (!currentTitle) {
return { shouldMerge: true, title: nextTitle };
}
if (!nextTitle) {
return { shouldMerge: true, title: currentTitle };
}
if (currentTitle === nextTitle) {
return { shouldMerge: true, title: currentTitle };
}
if (nextTitle.startsWith(currentTitle)) {
return { shouldMerge: true, title: nextTitle };
}
if (currentTitle.startsWith(nextTitle)) {
return { shouldMerge: true, title: currentTitle };
}
return { shouldMerge: false, title: nextTitle };
};
/**
* Append a text or thinking block to a render block list, merging
* with the previous block when the types match (and thinking titles
* are compatible).
*
* @param joinText Controls how existing and new text are concatenated
* when merging into an existing block. Callers that process
* complete message blocks typically join with a newline, while
* streaming callers concatenate directly.
*/
export const appendTextBlock = (
blocks: RenderBlock[],
type: "response" | "thinking",
text: string,
title?: string,
joinText: (current: string, next: string) => string = (a, b) => `${a}${b}`,
): RenderBlock[] => {
if (!text.trim()) {
return blocks;
}
const nextBlocks = [...blocks];
const last = nextBlocks[nextBlocks.length - 1];
if (last && last.type === type) {
const shouldMerge =
type === "response" ||
(type === "thinking" &&
last.type === "thinking" &&
mergeThinkingTitles(last.title, title).shouldMerge);
if (shouldMerge) {
const mergedTitle =
type === "thinking" && last.type === "thinking"
? mergeThinkingTitles(last.title, title).title
: undefined;
nextBlocks[nextBlocks.length - 1] = createBlock(
type,
joinText(last.text, text),
mergedTitle,
);
return nextBlocks;
}
}
nextBlocks.push(createBlock(type, text, title));
return nextBlocks;
};
@@ -0,0 +1,319 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { describe, expect, it } from "vitest";
import {
extractContextUsageFromMessage,
getLatestContextUsage,
getParentChatID,
getWorkspaceAgent,
resolveModelFromChatConfig,
} from "./chatHelpers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Minimal ChatMessage factory – only required fields. */
const makeMessage = (
overrides: Partial<TypesGen.ChatMessage> = {},
): TypesGen.ChatMessage =>
({
id: 1,
chat_id: "chat-1",
created_at: "2025-01-01T00:00:00Z",
role: "assistant",
...overrides,
}) as TypesGen.ChatMessage;
const makeOption = (
id: string,
provider: string,
model: string,
): ModelSelectorOption => ({
id,
provider,
model,
displayName: `${provider}/${model}`,
});
// ---------------------------------------------------------------------------
// extractContextUsageFromMessage
// ---------------------------------------------------------------------------
describe("extractContextUsageFromMessage", () => {
it("returns null when the message has no usage fields", () => {
expect(extractContextUsageFromMessage(makeMessage())).toBeNull();
});
it("returns usage when input_tokens is present", () => {
const msg = makeMessage({ usage: { input_tokens: 100 } });
const result = extractContextUsageFromMessage(msg);
expect(result).not.toBeNull();
expect(result!.inputTokens).toBe(100);
expect(result!.usedTokens).toBe(100);
});
it("returns usage when output_tokens is present", () => {
const msg = makeMessage({ usage: { output_tokens: 50 } });
const result = extractContextUsageFromMessage(msg);
expect(result).not.toBeNull();
expect(result!.outputTokens).toBe(50);
expect(result!.usedTokens).toBe(50);
});
it("sums all token components into usedTokens", () => {
const msg = makeMessage({
usage: {
input_tokens: 10,
output_tokens: 20,
reasoning_tokens: 5,
cache_creation_tokens: 3,
cache_read_tokens: 2,
},
});
const result = extractContextUsageFromMessage(msg);
expect(result).not.toBeNull();
expect(result!.usedTokens).toBe(10 + 20 + 5 + 3 + 2);
expect(result!.inputTokens).toBe(10);
expect(result!.outputTokens).toBe(20);
expect(result!.reasoningTokens).toBe(5);
expect(result!.cacheCreationTokens).toBe(3);
expect(result!.cacheReadTokens).toBe(2);
});
it("includes contextLimitTokens when context_limit is set", () => {
const msg = makeMessage({ usage: { context_limit: 128000 } });
const result = extractContextUsageFromMessage(msg);
expect(result).not.toBeNull();
expect(result!.contextLimitTokens).toBe(128000);
});
it("returns usage with only contextLimitTokens and no usedTokens", () => {
const msg = makeMessage({ usage: { context_limit: 4096 } });
const result = extractContextUsageFromMessage(msg);
expect(result).not.toBeNull();
expect(result!.usedTokens).toBeUndefined();
expect(result!.contextLimitTokens).toBe(4096);
});
});
// ---------------------------------------------------------------------------
// getLatestContextUsage
// ---------------------------------------------------------------------------
describe("getLatestContextUsage", () => {
it("returns null for an empty message list", () => {
expect(getLatestContextUsage([])).toBeNull();
});
it("returns null when no messages have usage data", () => {
const messages = [makeMessage(), makeMessage({ id: 2 })];
expect(getLatestContextUsage(messages)).toBeNull();
});
it("returns usage from the last message with usage data", () => {
const messages = [
makeMessage({ id: 1, usage: { input_tokens: 100 } }),
makeMessage({ id: 2 }),
makeMessage({ id: 3, usage: { input_tokens: 300 } }),
];
const result = getLatestContextUsage(messages);
expect(result).not.toBeNull();
expect(result!.inputTokens).toBe(300);
});
it("skips trailing messages without usage and finds the latest one", () => {
const messages = [
makeMessage({ id: 1, usage: { input_tokens: 50 } }),
makeMessage({ id: 2, usage: { input_tokens: 200 } }),
makeMessage({ id: 3 }),
];
const result = getLatestContextUsage(messages);
expect(result).not.toBeNull();
expect(result!.inputTokens).toBe(200);
});
});
// ---------------------------------------------------------------------------
// getParentChatID
// ---------------------------------------------------------------------------
describe("getParentChatID", () => {
it("returns undefined for undefined chat", () => {
expect(getParentChatID(undefined)).toBeUndefined();
});
it("returns undefined when parent_chat_id is not present", () => {
const chat = { id: "c1", title: "test" } as TypesGen.Chat;
expect(getParentChatID(chat)).toBeUndefined();
});
it("returns the parent_chat_id when it is a non-empty string", () => {
const chat = {
id: "c1",
title: "test",
parent_chat_id: "parent-1",
} as TypesGen.Chat;
expect(getParentChatID(chat)).toBe("parent-1");
});
it("returns undefined when parent_chat_id is an empty string", () => {
const chat = {
id: "c1",
title: "test",
parent_chat_id: "",
} as TypesGen.Chat;
expect(getParentChatID(chat)).toBeUndefined();
});
it("returns undefined when parent_chat_id is only whitespace", () => {
const chat = {
id: "c1",
title: "test",
parent_chat_id: " ",
} as TypesGen.Chat;
expect(getParentChatID(chat)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// resolveModelFromChatConfig
// ---------------------------------------------------------------------------
describe("resolveModelFromChatConfig", () => {
const options: ModelSelectorOption[] = [
makeOption("openai:gpt-4", "openai", "gpt-4"),
makeOption("anthropic:claude-3", "anthropic", "claude-3"),
];
it("returns empty string when no model options exist", () => {
expect(resolveModelFromChatConfig({ model: "gpt-4" }, [])).toBe("");
});
it("returns first option when modelConfig is undefined", () => {
expect(resolveModelFromChatConfig(undefined, options)).toBe("openai:gpt-4");
});
it("matches by exact model id", () => {
const config = { model: "anthropic:claude-3" };
expect(resolveModelFromChatConfig(config, options)).toBe(
"anthropic:claude-3",
);
});
it("matches by provider:model combined candidate", () => {
// The model field alone doesn't match an option id, but
// provider + model concatenated does.
const config = { model: "gpt-4", provider: "openai" };
expect(resolveModelFromChatConfig(config, options)).toBe("openai:gpt-4");
});
it("falls back to model field match on option.model property", () => {
// Neither `model` nor `provider:model` match an option id,
// so the function falls through to matching option.model.
const altOptions: ModelSelectorOption[] = [
makeOption("custom-id-1", "openai", "gpt-4"),
];
const config = { model: "gpt-4", provider: "openai" };
expect(resolveModelFromChatConfig(config, altOptions)).toBe("custom-id-1");
});
it("falls back to model field match ignoring provider when provider is absent", () => {
const altOptions: ModelSelectorOption[] = [
makeOption("custom-id-1", "openai", "gpt-4"),
];
const config = { model: "gpt-4" };
expect(resolveModelFromChatConfig(config, altOptions)).toBe("custom-id-1");
});
it("respects provider when matching on option.model", () => {
const altOptions: ModelSelectorOption[] = [
makeOption("id-a", "azure", "gpt-4"),
makeOption("id-b", "openai", "gpt-4"),
];
const config = { model: "gpt-4", provider: "openai" };
expect(resolveModelFromChatConfig(config, altOptions)).toBe("id-b");
});
it("returns first option when no match is found", () => {
const config = { model: "unknown-model" };
expect(resolveModelFromChatConfig(config, options)).toBe("openai:gpt-4");
});
it("returns first option when modelConfig is an empty object", () => {
expect(resolveModelFromChatConfig({}, options)).toBe("openai:gpt-4");
});
});
// ---------------------------------------------------------------------------
// getWorkspaceAgent
// ---------------------------------------------------------------------------
describe("getWorkspaceAgent", () => {
const makeAgent = (id: string): TypesGen.WorkspaceAgent =>
({ id, name: `agent-${id}` }) as TypesGen.WorkspaceAgent;
const makeWorkspace = (
agents: TypesGen.WorkspaceAgent[],
): TypesGen.Workspace =>
({
latest_build: {
resources: [{ agents }],
},
}) as unknown as TypesGen.Workspace;
it("returns undefined when workspace is undefined", () => {
expect(getWorkspaceAgent(undefined, "agent-1")).toBeUndefined();
});
it("returns undefined when there are no agents", () => {
const ws = makeWorkspace([]);
expect(getWorkspaceAgent(ws, "agent-1")).toBeUndefined();
});
it("returns the matching agent by id", () => {
const ws = makeWorkspace([makeAgent("a1"), makeAgent("a2")]);
expect(getWorkspaceAgent(ws, "a2")).toEqual(
expect.objectContaining({ id: "a2" }),
);
});
it("returns the first agent when workspaceAgentId does not match", () => {
const ws = makeWorkspace([makeAgent("a1"), makeAgent("a2")]);
expect(getWorkspaceAgent(ws, "no-match")).toEqual(
expect.objectContaining({ id: "a1" }),
);
});
it("returns the first agent when workspaceAgentId is undefined", () => {
const ws = makeWorkspace([makeAgent("a1")]);
expect(getWorkspaceAgent(ws, undefined)).toEqual(
expect.objectContaining({ id: "a1" }),
);
});
it("collects agents from multiple resources", () => {
const ws = {
latest_build: {
resources: [
{ agents: [makeAgent("r1-a1")] },
{ agents: [makeAgent("r2-a1")] },
],
},
} as unknown as TypesGen.Workspace;
expect(getWorkspaceAgent(ws, "r2-a1")).toEqual(
expect.objectContaining({ id: "r2-a1" }),
);
});
it("handles resources with no agents array", () => {
const ws = {
latest_build: {
resources: [{ agents: undefined }, { agents: [makeAgent("a1")] }],
},
} as unknown as TypesGen.Workspace;
expect(getWorkspaceAgent(ws, "a1")).toEqual(
expect.objectContaining({ id: "a1" }),
);
});
});
@@ -0,0 +1,124 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { asString } from "components/ai-elements/runtimeTypeUtils";
import type { AgentContextUsage } from "../AgentChatInput";
import { asNonEmptyString } from "./blockUtils";
export const extractContextUsageFromMessage = (
message: TypesGen.ChatMessage,
): AgentContextUsage | null => {
const usage = message.usage;
if (!usage) {
return null;
}
const inputTokens = usage.input_tokens;
const outputTokens = usage.output_tokens;
const reasoningTokens = usage.reasoning_tokens;
const cacheCreationTokens = usage.cache_creation_tokens;
const cacheReadTokens = usage.cache_read_tokens;
const contextLimitTokens = usage.context_limit;
const components = [
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
].filter((value): value is number => value !== undefined);
const usedTokens =
components.length > 0
? components.reduce((total, value) => total + value, 0)
: undefined;
return {
usedTokens,
contextLimitTokens,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
};
};
export const getLatestContextUsage = (
messages: readonly TypesGen.ChatMessage[],
): AgentContextUsage | null => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const usage = extractContextUsageFromMessage(messages[index]);
if (usage) {
return usage;
}
}
return null;
};
type ChatWithHierarchyMetadata = TypesGen.Chat & {
readonly parent_chat_id?: string;
};
export const getParentChatID = (
chat: TypesGen.Chat | undefined,
): string | undefined => {
return asNonEmptyString(
(chat as ChatWithHierarchyMetadata | undefined)?.parent_chat_id,
);
};
export const resolveModelFromChatConfig = (
modelConfig: unknown,
modelOptions: readonly ModelSelectorOption[],
): string => {
if (modelOptions.length === 0) {
return "";
}
if (!modelConfig || typeof modelConfig !== "object") {
return modelOptions[0]?.id ?? "";
}
const typedModelConfig = modelConfig as Record<string, unknown>;
const model = asString(typedModelConfig.model);
const provider = asString(typedModelConfig.provider);
const candidates = [model];
if (provider && model) {
candidates.push(`${provider}:${model}`);
}
for (const candidate of candidates) {
const match = modelOptions.find((option) => option.id === candidate);
if (match) {
return match.id;
}
}
if (model) {
const modelMatch = modelOptions.find(
(option) =>
option.model === model && (!provider || option.provider === provider),
);
if (modelMatch) {
return modelMatch.id;
}
}
return modelOptions[0]?.id ?? "";
};
export const getWorkspaceAgent = (
workspace: TypesGen.Workspace | undefined,
workspaceAgentId: string | undefined,
): TypesGen.WorkspaceAgent | undefined => {
if (!workspace) {
return undefined;
}
const agents = workspace.latest_build.resources.flatMap(
(resource) => resource.agents ?? [],
);
if (agents.length === 0) {
return undefined;
}
return agents.find((agent) => agent.id === workspaceAgentId) ?? agents[0];
};
@@ -0,0 +1,275 @@
import { describe, expect, it } from "vitest";
import {
mergeTools,
normalizeBlockType,
parseMessageContent,
parseToolResultIsError,
} from "./messageParsing";
describe("normalizeBlockType", () => {
it("lowercases and replaces underscores with hyphens", () => {
expect(normalizeBlockType("Tool_Call")).toBe("tool-call");
expect(normalizeBlockType("TOOL_RESULT")).toBe("tool-result");
});
it("returns empty string for non-string input", () => {
expect(normalizeBlockType(undefined)).toBe("");
expect(normalizeBlockType(null)).toBe("");
});
});
describe("parseToolResultIsError", () => {
it("returns the boolean is_error when present", () => {
expect(parseToolResultIsError("tool", { is_error: true }, null)).toBe(true);
expect(parseToolResultIsError("tool", { is_error: false }, null)).toBe(
false,
);
});
it("returns false when no error indicator is present", () => {
expect(parseToolResultIsError("tool", {}, null)).toBe(false);
});
it("returns true when error field is present for non-subagent tools", () => {
expect(
parseToolResultIsError("some_tool", { error: "something" }, null),
).toBe(true);
});
it("returns false for completed subagent even with error field", () => {
expect(
parseToolResultIsError(
"spawn_agent",
{ error: "metadata" },
{ status: "completed" },
),
).toBe(false);
expect(
parseToolResultIsError(
"wait_agent",
{ error: "metadata" },
{ status: "completed" },
),
).toBe(false);
expect(
parseToolResultIsError(
"message_agent",
{ error: "metadata" },
{ status: "completed" },
),
).toBe(false);
});
});
describe("parseMessageContent", () => {
it("returns empty result for null content", () => {
const result = parseMessageContent(null);
expect(result.markdown).toBe("");
expect(result.blocks).toEqual([]);
expect(result.toolCalls).toEqual([]);
expect(result.toolResults).toEqual([]);
});
it("returns empty result for undefined content", () => {
const result = parseMessageContent(undefined);
expect(result.markdown).toBe("");
expect(result.blocks).toEqual([]);
});
it("returns empty result for an empty array", () => {
const result = parseMessageContent([]);
expect(result.markdown).toBe("");
expect(result.blocks).toEqual([]);
expect(result.toolCalls).toEqual([]);
expect(result.toolResults).toEqual([]);
});
it("handles a plain string content", () => {
const result = parseMessageContent("Hello world");
expect(result.markdown).toBe("Hello world");
expect(result.blocks).toEqual([]);
});
it("parses a single text block", () => {
const result = parseMessageContent([{ type: "text", text: "Hello" }]);
expect(result.markdown).toBe("Hello");
expect(result.blocks).toEqual([{ type: "response", text: "Hello" }]);
});
it("merges multiple text blocks into a single response block", () => {
const result = parseMessageContent([
{ type: "text", text: "Line one" },
{ type: "text", text: "Line two" },
]);
expect(result.markdown).toBe("Line one\nLine two");
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0]).toEqual({
type: "response",
text: "Line one\nLine two",
});
});
it("parses a thinking block", () => {
const result = parseMessageContent([
{ type: "thinking", text: "Let me think...", title: "Reasoning" },
]);
expect(result.reasoning).toBe("Let me think...");
expect(result.blocks).toEqual([
{ type: "thinking", text: "Let me think...", title: "Reasoning" },
]);
});
it("parses a tool_use / tool-call block", () => {
const result = parseMessageContent([
{
type: "tool-call",
tool_name: "bash",
tool_call_id: "call-1",
args: { command: "ls" },
},
]);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0]).toEqual({
id: "call-1",
name: "bash",
args: { command: "ls" },
});
expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]);
});
it("parses a tool-result block", () => {
const result = parseMessageContent([
{
type: "tool-result",
tool_name: "bash",
tool_call_id: "call-1",
result: { output: "file.txt" },
},
]);
expect(result.toolResults).toHaveLength(1);
expect(result.toolResults[0]).toEqual({
id: "call-1",
name: "bash",
result: { output: "file.txt" },
isError: false,
});
expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]);
});
it("handles interleaved text and tool blocks in correct order", () => {
const result = parseMessageContent([
{ type: "text", text: "Starting..." },
{
type: "tool-call",
tool_name: "bash",
tool_call_id: "call-1",
args: {},
},
{
type: "tool-result",
tool_name: "bash",
tool_call_id: "call-1",
result: "ok",
},
{ type: "text", text: "Done!" },
]);
expect(result.blocks).toHaveLength(3);
expect(result.blocks[0]).toEqual({
type: "response",
text: "Starting...",
});
expect(result.blocks[1]).toEqual({ type: "tool", id: "call-1" });
// The second text block creates a new response block after the
// tool block.
expect(result.blocks[2]).toEqual({ type: "response", text: "Done!" });
});
it("generates fallback IDs when tool_call_id is missing", () => {
const result = parseMessageContent([
{ type: "tool-call", tool_name: "run" },
]);
expect(result.toolCalls[0].id).toBe("tool-call-0");
});
it("handles unknown block types gracefully (no crash)", () => {
const result = parseMessageContent([
{ type: "unknown_block_type", text: "some text" },
]);
// Unknown types fall through to the default branch which treats
// the text field as a response.
expect(result.markdown).toBe("some text");
expect(result.blocks).toEqual([{ type: "response", text: "some text" }]);
});
it("handles non-object array entries gracefully", () => {
const result = parseMessageContent(["raw string", 42, null]);
expect(result.markdown).toBe("raw string");
expect(result.blocks).toEqual([{ type: "response", text: "raw string" }]);
});
it("handles an object with a type field (treated as single-element array)", () => {
const result = parseMessageContent({ type: "text", text: "single" });
expect(result.markdown).toBe("single");
});
it("handles an object with text/content fields", () => {
const result = parseMessageContent({ text: "fallback text" });
expect(result.markdown).toBe("fallback text");
});
it("normalizes underscore block types like tool_call", () => {
const result = parseMessageContent([
{
type: "tool_call",
tool_name: "test",
tool_call_id: "tc-1",
args: {},
},
]);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].name).toBe("test");
});
});
describe("mergeTools", () => {
it("merges tool calls with matching results", () => {
const merged = mergeTools(
[{ id: "1", name: "bash", args: { cmd: "ls" } }],
[{ id: "1", name: "bash", result: "ok", isError: false }],
);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual({
id: "1",
name: "bash",
args: { cmd: "ls" },
result: "ok",
isError: false,
status: "completed",
});
});
it("includes orphaned results that have no matching call", () => {
const merged = mergeTools(
[],
[{ id: "1", name: "bash", result: "output", isError: false }],
);
expect(merged).toHaveLength(1);
expect(merged[0].id).toBe("1");
expect(merged[0].status).toBe("completed");
});
it("marks error results with error status", () => {
const merged = mergeTools(
[{ id: "1", name: "bash" }],
[{ id: "1", name: "bash", result: "fail", isError: true }],
);
expect(merged[0].isError).toBe(true);
expect(merged[0].status).toBe("error");
});
it("returns completed for calls without results", () => {
const merged = mergeTools([{ id: "1", name: "bash" }], []);
expect(merged).toHaveLength(1);
expect(merged[0].status).toBe("completed");
});
});
@@ -0,0 +1,339 @@
import type * as TypesGen from "api/typesGenerated";
import { asRecord, asString } from "components/ai-elements/runtimeTypeUtils";
import { appendTextBlock, asNonEmptyString } from "./blockUtils";
import type {
MergedTool,
ParsedMessageContent,
ParsedMessageEntry,
ParsedMessageSection,
ParsedToolCall,
ParsedToolResult,
RenderBlock,
} from "./types";
const appendText = (current: string, next: string): string => {
const trimmed = next.trim();
if (!trimmed) {
return current;
}
if (!current) {
return next;
}
return `${current}\n${next}`;
};
export const asOptionalTitle = (value: unknown): string | undefined =>
asNonEmptyString(value);
export const normalizeBlockType = (value: unknown): string =>
asString(value).toLowerCase().replace(/_/g, "-");
const isSubagentToolName = (name: string): boolean =>
name === "spawn_agent" || name === "wait_agent" || name === "message_agent";
const isCompletedSubagentResult = (
toolName: string,
result: unknown,
): boolean => {
if (!isSubagentToolName(toolName)) {
return false;
}
const typedResult = asRecord(result);
if (!typedResult) {
return false;
}
const status = asString(
typedResult.status ?? typedResult.subagent_status,
).toLowerCase();
return status === "completed" || status === "reported";
};
type ToolResultErrorBlock = {
readonly is_error?: unknown;
readonly error?: unknown;
};
export const parseToolResultIsError = (
toolName: string,
block: ToolResultErrorBlock,
result: unknown,
): boolean => {
if (typeof block.is_error === "boolean") {
return block.is_error;
}
if (!block.error) {
return false;
}
// Some providers include generic error metadata even on successful
// subagent completions.
return !isCompletedSubagentResult(toolName, result);
};
const emptyParsedMessageContent = (): ParsedMessageContent => ({
markdown: "",
reasoning: "",
toolCalls: [],
toolResults: [],
tools: [],
blocks: [],
});
/** Wraps appendTextBlock with newline-joining for complete message blocks. */
const appendParsedTextBlock = (
blocks: RenderBlock[],
type: "response" | "thinking",
text: string,
title?: string,
): RenderBlock[] => appendTextBlock(blocks, type, text, title, appendText);
export const ensureToolBlock = (
blocks: RenderBlock[],
id: string,
): RenderBlock[] => {
if (blocks.some((block) => block.type === "tool" && block.id === id)) {
return blocks;
}
return [...blocks, { type: "tool", id }];
};
export const mergeTools = (
calls: ParsedToolCall[],
results: ParsedToolResult[],
): MergedTool[] => {
const resultById = new Map(results.map((r) => [r.id, r]));
const seen = new Set<string>();
const merged: MergedTool[] = [];
for (const call of calls) {
seen.add(call.id);
const result = resultById.get(call.id);
merged.push({
id: call.id,
name: call.name,
args: call.args,
result: result?.result,
isError: result?.isError ?? false,
status: result ? (result.isError ? "error" : "completed") : "completed",
});
}
for (const result of results) {
if (!seen.has(result.id)) {
merged.push({
id: result.id,
name: result.name,
result: result.result,
isError: result.isError,
status: result.isError ? "error" : "completed",
});
}
}
return merged;
};
export const parseMessageContent = (content: unknown): ParsedMessageContent => {
if (typeof content === "string") {
return {
...emptyParsedMessageContent(),
markdown: content,
};
}
if (Array.isArray(content)) {
const parsed = emptyParsedMessageContent();
for (const [index, block] of content.entries()) {
if (typeof block === "string") {
parsed.markdown = appendText(parsed.markdown, block);
parsed.blocks = appendParsedTextBlock(parsed.blocks, "response", block);
continue;
}
const typedBlock = asRecord(block);
if (!typedBlock) {
continue;
}
switch (normalizeBlockType(typedBlock.type)) {
case "text": {
const text = asString(typedBlock.text);
parsed.markdown = appendText(parsed.markdown, text);
parsed.blocks = appendParsedTextBlock(
parsed.blocks,
"response",
text,
);
break;
}
case "reasoning":
case "thinking": {
const text = asString(typedBlock.text);
const title = asOptionalTitle(typedBlock.title);
parsed.reasoning = appendText(parsed.reasoning, text);
parsed.blocks = appendParsedTextBlock(
parsed.blocks,
"thinking",
text,
title,
);
break;
}
case "tool-call":
case "toolcall": {
const name =
asString(typedBlock.tool_name) || asString(typedBlock.name);
const id =
asString(typedBlock.tool_call_id) ||
asString(typedBlock.id) ||
`tool-call-${index}`;
parsed.toolCalls.push({
id,
name: name || "Tool",
args: typedBlock.args ?? typedBlock.input ?? typedBlock.arguments,
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
case "tool-result":
case "toolresult": {
const name =
asString(typedBlock.tool_name) || asString(typedBlock.name);
const id =
asString(typedBlock.tool_call_id) ||
asString(typedBlock.id) ||
`tool-result-${index}`;
const result =
typedBlock.result ??
typedBlock.output ??
typedBlock.content ??
typedBlock.data;
parsed.toolResults.push({
id,
name: name || "Tool",
result,
isError: parseToolResultIsError(name, typedBlock, result),
});
parsed.blocks = ensureToolBlock(parsed.blocks, id);
break;
}
default: {
const text = asString(typedBlock.text);
parsed.markdown = appendText(parsed.markdown, text);
parsed.blocks = appendParsedTextBlock(
parsed.blocks,
"response",
text,
);
break;
}
}
}
return parsed;
}
if (content === null || content === undefined) {
return emptyParsedMessageContent();
}
const typedContent = asRecord(content);
if (!typedContent) {
const markdown = String(content);
return {
...emptyParsedMessageContent(),
markdown,
blocks: appendParsedTextBlock([], "response", markdown),
};
}
if (typedContent.type) {
return parseMessageContent([typedContent]);
}
const markdown =
asString(typedContent.text) || asString(typedContent.content);
return {
...emptyParsedMessageContent(),
markdown,
blocks: appendParsedTextBlock([], "response", markdown),
};
};
export const parseMessagesWithMergedTools = (
messages: readonly TypesGen.ChatMessage[],
): ParsedMessageEntry[] => {
const rawParsed = messages.map((message) => ({
message,
parsed: parseMessageContent(message.content),
}));
const globalToolResults = new Map<string, ParsedToolResult>();
for (const { parsed } of rawParsed) {
for (const result of parsed.toolResults) {
globalToolResults.set(result.id, result);
}
}
for (const { parsed } of rawParsed) {
const resultById = new Map<string, ParsedToolResult>();
for (const result of parsed.toolResults) {
resultById.set(result.id, result);
}
for (const call of parsed.toolCalls) {
if (!resultById.has(call.id)) {
const global = globalToolResults.get(call.id);
if (global) {
resultById.set(global.id, global);
}
}
}
parsed.tools = mergeTools(
parsed.toolCalls,
Array.from(resultById.values()),
);
}
return rawParsed;
};
export const buildSubagentTitles = (
parsedMessages: readonly ParsedMessageEntry[],
): Map<string, string> => {
const map = new Map<string, string>();
for (const { parsed } of parsedMessages) {
for (const tool of parsed.tools) {
if (tool.name !== "spawn_agent") {
continue;
}
const rec = asRecord(tool.result);
if (!rec) {
continue;
}
const chatId = asString(rec.chat_id);
const title = asString(rec.title);
if (chatId && title) {
map.set(chatId, title);
}
}
}
return map;
};
export const buildParsedMessageSections = (
parsedMessages: readonly ParsedMessageEntry[],
): ParsedMessageSection[] => {
const sections: ParsedMessageSection[] = [];
for (const entry of parsedMessages) {
if (entry.message.role === "user") {
sections.push({ userEntry: entry, entries: [entry] });
continue;
}
if (sections.length === 0) {
sections.push({ userEntry: null, entries: [entry] });
continue;
}
sections[sections.length - 1].entries.push(entry);
}
return sections;
};
@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest";
import {
applyMessagePartToStreamState,
applyStreamThinkingTitle,
buildStreamTools,
createEmptyStreamState,
} from "./streamState";
import type { StreamState } from "./types";
describe("createEmptyStreamState", () => {
it("returns fresh state with empty blocks and tool maps", () => {
const state = createEmptyStreamState();
expect(state.blocks).toEqual([]);
expect(state.toolCalls).toEqual({});
expect(state.toolResults).toEqual({});
});
});
describe("applyStreamThinkingTitle", () => {
it("returns blocks unchanged when title is undefined", () => {
const blocks = [{ type: "response" as const, text: "hello" }];
expect(applyStreamThinkingTitle(blocks, undefined)).toBe(blocks);
});
it("creates a new thinking block when last block is not thinking", () => {
const blocks = [{ type: "response" as const, text: "hello" }];
const result = applyStreamThinkingTitle(blocks, "Plan");
expect(result).toHaveLength(2);
expect(result[1]).toEqual({ type: "thinking", text: "", title: "Plan" });
});
it("merges title into existing thinking block", () => {
const blocks = [
{ type: "thinking" as const, text: "some thought", title: "Old" },
];
const result = applyStreamThinkingTitle(blocks, "Old and more");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "thinking",
text: "some thought",
title: "Old and more",
});
});
});
describe("applyMessagePartToStreamState", () => {
it("creates new state with response block from text part on null prev", () => {
const result = applyMessagePartToStreamState(null, {
type: "text",
text: "Hello",
});
expect(result).not.toBeNull();
expect(result!.blocks).toEqual([{ type: "response", text: "Hello" }]);
});
it("appends text to existing response block", () => {
const prev: StreamState = {
blocks: [{ type: "response", text: "Hello" }],
toolCalls: {},
toolResults: {},
};
const result = applyMessagePartToStreamState(prev, {
type: "text",
text: " world",
});
expect(result!.blocks).toHaveLength(1);
expect(result!.blocks[0]).toEqual({
type: "response",
text: "Hello world",
});
});
it("returns prev when text part has empty text", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "text",
text: "",
});
expect(result).toBe(prev);
});
it("creates thinking block from thinking part", () => {
const result = applyMessagePartToStreamState(null, {
type: "thinking",
text: "Let me reason...",
title: "Analysis",
});
expect(result).not.toBeNull();
expect(result!.blocks).toEqual([
{ type: "thinking", text: "Let me reason...", title: "Analysis" },
]);
});
it("handles reasoning type alias the same as thinking", () => {
const result = applyMessagePartToStreamState(null, {
type: "reasoning",
text: "hmm",
});
expect(result).not.toBeNull();
expect(result!.blocks[0].type).toBe("thinking");
});
it("returns prev for thinking part with no text and no title", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "thinking",
text: "",
});
expect(result).toBe(prev);
});
it("creates tool call entry from tool-call part", () => {
const result = applyMessagePartToStreamState(null, {
type: "tool-call",
tool_name: "bash",
tool_call_id: "tc-1",
args: { command: "ls" },
});
expect(result).not.toBeNull();
expect(result!.toolCalls["tc-1"]).toEqual({
id: "tc-1",
name: "bash",
args: { command: "ls" },
argsRaw: undefined,
});
expect(result!.blocks).toEqual([{ type: "tool", id: "tc-1" }]);
});
it("generates fallback tool call ID when missing", () => {
const result = applyMessagePartToStreamState(null, {
type: "tool-call",
tool_name: "run",
});
expect(result).not.toBeNull();
const ids = Object.keys(result!.toolCalls);
expect(ids).toHaveLength(1);
expect(ids[0]).toBe("tool-call-1");
});
it("creates tool result entry from tool-result part", () => {
const result = applyMessagePartToStreamState(null, {
type: "tool-result",
tool_name: "bash",
tool_call_id: "tc-1",
result: { output: "file.txt" },
});
expect(result).not.toBeNull();
expect(result!.toolResults["tc-1"]).toMatchObject({
id: "tc-1",
name: "bash",
result: { output: "file.txt" },
isError: false,
});
});
it("handles tool_call underscore type alias", () => {
const result = applyMessagePartToStreamState(null, {
type: "tool_call",
tool_name: "test",
tool_call_id: "t1",
});
expect(result).not.toBeNull();
expect(result!.toolCalls.t1).toBeDefined();
});
it("returns prev for unknown part type", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
type: "banana",
});
expect(result).toBe(prev);
});
it("returns null for unknown part type when prev is null", () => {
const result = applyMessagePartToStreamState(null, {
type: "banana",
});
expect(result).toBeNull();
});
it("accumulates multiple tool calls in sequence", () => {
let state: StreamState | null = null;
state = applyMessagePartToStreamState(state, {
type: "tool-call",
tool_name: "bash",
tool_call_id: "tc-1",
args: { cmd: "ls" },
});
state = applyMessagePartToStreamState(state, {
type: "tool-call",
tool_name: "read",
tool_call_id: "tc-2",
args: { path: "/tmp" },
});
expect(Object.keys(state!.toolCalls)).toHaveLength(2);
expect(state!.blocks).toHaveLength(2);
});
});
describe("buildStreamTools", () => {
it("returns empty array for null stream state", () => {
expect(buildStreamTools(null)).toEqual([]);
});
it("returns running status for calls without results", () => {
const state: StreamState = {
blocks: [{ type: "tool", id: "tc-1" }],
toolCalls: {
"tc-1": { id: "tc-1", name: "bash", args: { cmd: "ls" } },
},
toolResults: {},
};
const tools = buildStreamTools(state);
expect(tools).toHaveLength(1);
expect(tools[0].status).toBe("running");
});
it("returns completed status when call has a result", () => {
const state: StreamState = {
blocks: [],
toolCalls: {
"tc-1": { id: "tc-1", name: "bash" },
},
toolResults: {
"tc-1": {
id: "tc-1",
name: "bash",
result: "ok",
isError: false,
},
},
};
const tools = buildStreamTools(state);
expect(tools[0].status).toBe("completed");
});
it("includes orphan results with no matching call", () => {
const state: StreamState = {
blocks: [],
toolCalls: {},
toolResults: {
"tc-1": {
id: "tc-1",
name: "bash",
result: "output",
isError: false,
},
},
};
const tools = buildStreamTools(state);
expect(tools).toHaveLength(1);
expect(tools[0].status).toBe("completed");
});
});
@@ -0,0 +1,194 @@
import { asString } from "components/ai-elements/runtimeTypeUtils";
import { appendTextBlock, mergeThinkingTitles } from "./blockUtils";
import {
asOptionalTitle,
ensureToolBlock,
normalizeBlockType,
parseToolResultIsError,
} from "./messageParsing";
import { mergeStreamPayload } from "./streamingJson";
import type { MergedTool, RenderBlock, StreamState } from "./types";
export const createEmptyStreamState = (): StreamState => ({
blocks: [],
toolCalls: {},
toolResults: {},
});
/** Streaming variant — uses direct concatenation (the default joinText). */
const appendStreamTextBlock = appendTextBlock;
export const applyStreamThinkingTitle = (
blocks: RenderBlock[],
title?: string,
): RenderBlock[] => {
if (!title) {
return blocks;
}
const nextBlocks = [...blocks];
const last = nextBlocks[nextBlocks.length - 1];
if (last && last.type === "thinking") {
const merged = mergeThinkingTitles(last.title, title);
nextBlocks[nextBlocks.length - 1] = {
type: "thinking",
text: last.text,
title: merged.title,
};
return nextBlocks;
}
nextBlocks.push({
type: "thinking",
text: "",
title,
});
return nextBlocks;
};
export const applyMessagePartToStreamState = (
prev: StreamState | null,
part: Record<string, unknown>,
): StreamState | null => {
const partType = normalizeBlockType(part.type);
const nextState: StreamState = prev ?? createEmptyStreamState();
switch (partType) {
case "text": {
const text = asString(part.text);
if (!text) {
return prev;
}
return {
...nextState,
blocks: appendStreamTextBlock(nextState.blocks, "response", text),
};
}
case "reasoning":
case "thinking": {
const text = asString(part.text);
const title = asOptionalTitle(part.title);
if (!text && !title) {
return prev;
}
const nextBlocks = text
? appendStreamTextBlock(nextState.blocks, "thinking", text, title)
: applyStreamThinkingTitle(nextState.blocks, title);
return {
...nextState,
blocks: nextBlocks,
};
}
case "tool-call":
case "toolcall": {
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
`tool-call-${Object.keys(nextState.toolCalls).length + 1}`;
const existing = nextState.toolCalls[toolCallID];
const nextArgs = mergeStreamPayload(
existing?.args,
existing?.argsRaw,
part.args,
part.args_delta,
);
return {
...nextState,
blocks: ensureToolBlock(nextState.blocks, toolCallID),
toolCalls: {
...nextState.toolCalls,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
args: nextArgs.value,
argsRaw: nextArgs.rawText,
},
},
};
}
case "tool-result":
case "toolresult": {
const toolName = asString(part.tool_name);
const existingByName = Object.values(nextState.toolResults).find(
(result) => result.name === toolName,
);
const existingCallByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
existingCallByName?.id ||
`tool-result-${Object.keys(nextState.toolResults).length + 1}`;
const existing = nextState.toolResults[toolCallID];
const nextResult = mergeStreamPayload(
existing?.result,
existing?.resultRaw,
part.result,
part.result_delta,
);
const nextToolName = toolName || existing?.name || "Tool";
const nextIsError =
existing?.isError ||
parseToolResultIsError(nextToolName, part, nextResult.value);
return {
...nextState,
blocks: ensureToolBlock(nextState.blocks, toolCallID),
toolResults: {
...nextState.toolResults,
[toolCallID]: {
id: toolCallID,
name: nextToolName,
result: nextResult.value,
resultRaw: nextResult.rawText,
isError: nextIsError,
},
},
};
}
default:
return prev;
}
};
export const buildStreamTools = (
streamState: StreamState | null,
): MergedTool[] => {
if (!streamState) {
return [];
}
const calls = Object.values(streamState.toolCalls);
const seen = new Set<string>();
const merged: MergedTool[] = [];
for (const call of calls) {
seen.add(call.id);
const result = streamState.toolResults[call.id];
merged.push({
id: call.id,
name: call.name,
args: call.args,
result: result?.result,
isError: result?.isError ?? false,
status: result ? (result.isError ? "error" : "completed") : "running",
});
}
for (const result of Object.values(streamState.toolResults)) {
if (!seen.has(result.id)) {
merged.push({
id: result.id,
name: result.name,
result: result.result,
isError: result.isError,
status: result.isError ? "error" : "completed",
});
}
}
return merged;
};
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { mergeStreamPayload, parseStreamingJSON } from "./streamingJson";
describe("parseStreamingJSON", () => {
it("parses complete JSON objects", () => {
expect(parseStreamingJSON('{"ok":true,"count":2}')).toEqual({
ok: true,
count: 2,
});
});
it("parses partial objects with an in-progress string value", () => {
expect(parseStreamingJSON('{"command":"git che')).toEqual({
command: "git che",
});
});
it("returns parsed fields for partial objects with trailing incomplete field", () => {
expect(parseStreamingJSON('{"a":1,"b":')).toEqual({ a: 1 });
});
it("returns null when content is not JSON-like", () => {
expect(parseStreamingJSON("hello world")).toBeNull();
});
});
describe("mergeStreamPayload", () => {
it("prefers explicit non-string values", () => {
expect(
mergeStreamPayload(undefined, undefined, { done: true }, undefined),
).toEqual({
value: { done: true },
});
});
it("parses explicit string values and preserves raw text", () => {
expect(
mergeStreamPayload(undefined, undefined, '{"output":"ok"}', undefined),
).toEqual({
value: { output: "ok" },
rawText: '{"output":"ok"}',
});
});
it("merges incoming deltas into parsed partial JSON", () => {
const first = mergeStreamPayload(
undefined,
undefined,
undefined,
'{"command":"git ',
);
expect(first).toEqual({
value: { command: "git" },
rawText: '{"command":"git ',
});
const second = mergeStreamPayload(
first.value,
first.rawText,
undefined,
'status"}',
);
expect(second).toEqual({
value: { command: "git status" },
rawText: '{"command":"git status"}',
});
});
it("keeps structured existing values when only deltas arrive", () => {
expect(
mergeStreamPayload({ a: 1 }, undefined, undefined, "ignored"),
).toEqual({
value: { a: 1 },
});
});
it("returns existing payload when delta is empty", () => {
expect(mergeStreamPayload("abc", "abc", undefined, undefined)).toEqual({
value: "abc",
rawText: "abc",
});
});
});
@@ -0,0 +1,434 @@
/**
* Incremental JSON parser for streaming tool call arguments.
*
* LLM tool calls arrive as partial JSON fragments via server-sent events.
* This module provides utilities to extract usable data from incomplete
* JSON strings without waiting for the full payload.
*
* Guarantees:
* - Partial object recovery (returns fields parsed so far).
* - Graceful handling of truncated strings, numbers, and booleans.
*
* Known limitations:
* - Does not handle partial arrays.
* - Does not handle \uXXXX unicode escape sequences in strings.
*/
const tryParseJSONObject = (value: string): unknown | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const first = trimmed[0];
if (first !== "{" && first !== "[") {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
};
const parsePartialJSONString = (
input: string,
startIndex: number,
): { value: string; nextIndex: number } | "incomplete" | null => {
if (input[startIndex] !== '"') {
return null;
}
let escaped = false;
for (let i = startIndex + 1; i < input.length; i += 1) {
const char = input[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char !== '"') {
continue;
}
const token = input.slice(startIndex, i + 1);
try {
return {
value: JSON.parse(token) as string,
nextIndex: i + 1,
};
} catch {
return null;
}
}
return "incomplete";
};
const isJSONValueBoundary = (char: string | undefined): boolean =>
char === undefined ||
char === "," ||
char === "}" ||
char === "]" ||
/\s/.test(char);
const findBalancedJSONEnd = (
input: string,
startIndex: number,
): number | "incomplete" | null => {
const stack: string[] = [];
let escaped = false;
let inString = false;
for (let index = startIndex; index < input.length; index += 1) {
const char = input[index];
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
switch (char) {
case '"':
inString = true;
break;
case "{":
case "[":
stack.push(char);
break;
case "}": {
const top = stack.pop();
if (top !== "{") {
return null;
}
break;
}
case "]": {
const top = stack.pop();
if (top !== "[") {
return null;
}
break;
}
default:
break;
}
if (stack.length === 0) {
return index + 1;
}
}
return "incomplete";
};
type PartialJSONValue =
| { status: "ok"; value: unknown; nextIndex: number }
| { status: "incomplete" }
| { status: "invalid" };
const parsePartialJSONValue = (
input: string,
startIndex: number,
): PartialJSONValue => {
let index = startIndex;
while (index < input.length && /\s/.test(input[index])) {
index += 1;
}
if (index >= input.length) {
return { status: "incomplete" };
}
const char = input[index];
if (char === '"') {
const parsed = parsePartialJSONString(input, index);
if (parsed === "incomplete") {
return { status: "incomplete" };
}
if (!parsed) {
return { status: "invalid" };
}
return {
status: "ok",
value: parsed.value,
nextIndex: parsed.nextIndex,
};
}
if (char === "{" || char === "[") {
const end = findBalancedJSONEnd(input, index);
if (end === "incomplete") {
return { status: "incomplete" };
}
if (end === null) {
return { status: "invalid" };
}
const parsed = tryParseJSONObject(input.slice(index, end));
if (parsed === null) {
return { status: "invalid" };
}
return {
status: "ok",
value: parsed,
nextIndex: end,
};
}
if (input.startsWith("true", index)) {
const next = index + 4;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: true, nextIndex: next };
}
if ("true".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (input.startsWith("false", index)) {
const next = index + 5;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: false, nextIndex: next };
}
if ("false".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (input.startsWith("null", index)) {
const next = index + 4;
if (!isJSONValueBoundary(input[next])) {
return { status: "invalid" };
}
return { status: "ok", value: null, nextIndex: next };
}
if ("null".startsWith(input.slice(index))) {
return { status: "incomplete" };
}
if (char === "-" || (char >= "0" && char <= "9")) {
let end = index;
while (end < input.length && /[0-9eE+.-]/.test(input[end])) {
end += 1;
}
const token = input.slice(index, end);
if (!token) {
return { status: "invalid" };
}
if (
end === input.length &&
/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?)?$/.test(token)
) {
return { status: "incomplete" };
}
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) {
return { status: "invalid" };
}
if (!isJSONValueBoundary(input[end])) {
return { status: "invalid" };
}
return { status: "ok", value: Number(token), nextIndex: end };
}
return { status: "invalid" };
};
const extractIncompleteStringContent = (
input: string,
startIndex: number,
): string | null => {
if (input[startIndex] !== '"') {
return null;
}
let result = "";
let escaped = false;
for (let i = startIndex + 1; i < input.length; i += 1) {
const char = input[i];
if (escaped) {
switch (char) {
case '"':
result += '"';
break;
case "\\":
result += "\\";
break;
case "/":
result += "/";
break;
case "n":
result += "\n";
break;
case "r":
result += "\r";
break;
case "t":
result += "\t";
break;
default:
result += `\\${char}`;
break;
}
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
return result;
}
result += char;
}
return result.length > 0 ? result : null;
};
const parsePartialJSONObject = (
value: string,
): Record<string, unknown> | null => {
const trimmed = value.trim();
if (!trimmed.startsWith("{")) {
return null;
}
let index = 1;
const parsed: Record<string, unknown> = {};
let hasFields = false;
while (index < trimmed.length) {
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length) {
break;
}
if (trimmed[index] === "}") {
return hasFields ? parsed : null;
}
if (trimmed[index] === ",") {
index += 1;
continue;
}
const key = parsePartialJSONString(trimmed, index);
if (key === "incomplete") {
break;
}
if (!key) {
return hasFields ? parsed : null;
}
index = key.nextIndex;
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length || trimmed[index] !== ":") {
break;
}
index += 1;
const nextValue = parsePartialJSONValue(trimmed, index);
if (nextValue.status === "incomplete") {
const partialStr = extractIncompleteStringContent(trimmed, index);
if (partialStr !== null) {
parsed[key.value] = partialStr;
hasFields = true;
}
break;
}
if (nextValue.status === "invalid") {
return hasFields ? parsed : null;
}
parsed[key.value] = nextValue.value;
hasFields = true;
index = nextValue.nextIndex;
while (index < trimmed.length && /\s/.test(trimmed[index])) {
index += 1;
}
if (index >= trimmed.length) {
break;
}
if (trimmed[index] === ",") {
index += 1;
continue;
}
if (trimmed[index] === "}") {
return parsed;
}
return hasFields ? parsed : null;
}
return hasFields ? parsed : null;
};
export const parseStreamingJSON = (value: string): unknown | null => {
const complete = tryParseJSONObject(value);
if (complete !== null) {
return complete;
}
return parsePartialJSONObject(value);
};
type StreamPayloadMerge = {
value: unknown;
rawText?: string;
};
export const mergeStreamPayload = (
existingValue: unknown,
existingRawText: string | undefined,
value: unknown,
delta: unknown,
): StreamPayloadMerge => {
if (value !== undefined) {
if (typeof value !== "string") {
return { value };
}
const parsed = parseStreamingJSON(value);
if (parsed !== null) {
return { value: parsed, rawText: value };
}
return { value, rawText: value };
}
const chunk = typeof delta === "string" ? delta : "";
if (!chunk) {
return {
value: existingValue,
rawText: existingRawText,
};
}
if (
existingValue !== undefined &&
typeof existingValue !== "string" &&
existingRawText === undefined
) {
return {
value: existingValue,
};
}
const base =
existingRawText ?? (typeof existingValue === "string" ? existingValue : "");
const rawText = `${base}${chunk}`;
const parsed = parseStreamingJSON(rawText);
return {
value: parsed ?? rawText,
rawText,
};
};
@@ -0,0 +1,78 @@
import type * as TypesGen from "api/typesGenerated";
export type ParsedToolCall = {
id: string;
name: string;
args?: unknown;
};
export type ParsedToolResult = {
id: string;
name: string;
result?: unknown;
isError: boolean;
};
export type MergedTool = {
id: string;
name: string;
args?: unknown;
result?: unknown;
isError: boolean;
status: "completed" | "error" | "running";
};
export type RenderBlock =
| {
type: "response";
text: string;
}
| {
type: "thinking";
text: string;
title?: string;
}
| {
type: "tool";
id: string;
};
export type ParsedMessageContent = {
markdown: string;
reasoning: string;
toolCalls: ParsedToolCall[];
toolResults: ParsedToolResult[];
tools: MergedTool[];
blocks: RenderBlock[];
};
export type ParsedMessageEntry = {
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
};
export type ParsedMessageSection = {
userEntry: ParsedMessageEntry | null;
entries: ParsedMessageEntry[];
};
type StreamToolCall = {
id: string;
name: string;
args?: unknown;
argsRaw?: string;
};
type StreamToolResult = {
id: string;
name: string;
result?: unknown;
resultRaw?: string;
isError: boolean;
};
export type StreamState = {
blocks: RenderBlock[];
toolCalls: Record<string, StreamToolCall>;
toolResults: Record<string, StreamToolResult>;
};
@@ -0,0 +1,55 @@
import type * as TypesGen from "api/typesGenerated";
import { useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_PAGE_SIZE = 50;
type UseMessageWindowOptions = {
messages: readonly TypesGen.ChatMessage[];
resetKey?: string;
pageSize?: number;
};
export const useMessageWindow = ({
messages,
resetKey,
pageSize = DEFAULT_PAGE_SIZE,
}: UseMessageWindowOptions) => {
const [renderedMessageCount, setRenderedMessageCount] = useState(pageSize);
const loadMoreSentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
void resetKey;
setRenderedMessageCount(pageSize);
}, [resetKey, pageSize]);
const hasMoreMessages = renderedMessageCount < messages.length;
const windowedMessages = useMemo(() => {
if (renderedMessageCount >= messages.length) {
return messages;
}
return messages.slice(messages.length - renderedMessageCount);
}, [messages, renderedMessageCount]);
useEffect(() => {
const node = loadMoreSentinelRef.current;
if (!node || !hasMoreMessages) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setRenderedMessageCount((prev) => prev + pageSize);
}
},
{ rootMargin: "200px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMoreMessages, pageSize]);
return {
hasMoreMessages,
windowedMessages,
loadMoreSentinelRef,
};
};
@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { useRef } from "react";
import {
expect,
fn,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { AgentsEmptyState } from "./AgentsPage";
const modelOptions = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
] as const;
const behaviorStorageKey = "agents.system-prompt";
/**
* Wrapper that creates the top-bar actions ref that AgentsEmptyState
* portals its admin button into.
*/
const AgentsEmptyStateWithPortal = (
props: Omit<
React.ComponentProps<typeof AgentsEmptyState>,
"topBarActionsRef"
>,
) => {
const topBarActionsRef = useRef<HTMLDivElement>(null);
return (
<>
<div
ref={topBarActionsRef}
data-testid="topbar-actions-host"
className="flex items-center gap-2"
/>
<AgentsEmptyState {...props} topBarActionsRef={topBarActionsRef} />
</>
);
};
const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
title: "pages/AgentsPage/AgentsEmptyState",
component: AgentsEmptyStateWithPortal,
args: {
onCreateChat: fn(),
isCreating: false,
createError: undefined,
modelCatalog: null,
modelOptions: [...modelOptions],
isModelCatalogLoading: false,
modelConfigs: [],
isModelConfigsLoading: false,
modelCatalogError: undefined,
canSetSystemPrompt: true,
canManageChatModelConfigs: false,
},
beforeEach: () => {
localStorage.clear();
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [],
count: 0,
});
},
};
export default meta;
type Story = StoryObj<typeof AgentsEmptyStateWithPortal>;
export const Default: Story = {};
export const SavesBehaviorPromptAndRestores: Story = {
play: async ({ canvasElement }) => {
const host = canvasElement.ownerDocument.querySelector(
'[data-testid="topbar-actions-host"]',
)!;
// Open the admin dialog via the portalled button.
await userEvent.click(
await within(host as HTMLElement).findByRole("button", {
name: "Admin",
}),
);
const dialog = await screen.findByRole("dialog");
const textarea = await within(dialog).findByPlaceholderText(
"Optional. Set deployment-wide instructions for all new chats.",
);
await userEvent.type(textarea, "You are a focused coding assistant.");
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(localStorage.getItem(behaviorStorageKey)).toBe(
"You are a focused coding assistant.",
);
});
},
};
export const UsesSavedBehaviorPromptOnSend: Story = {
play: async ({ canvasElement, args }) => {
const host = canvasElement.ownerDocument.querySelector(
'[data-testid="topbar-actions-host"]',
)!;
// First, save a behavior prompt.
await userEvent.click(
await within(host as HTMLElement).findByRole("button", {
name: "Admin",
}),
);
const dialog = await screen.findByRole("dialog");
const textarea = await within(dialog).findByPlaceholderText(
"Optional. Set deployment-wide instructions for all new chats.",
);
await userEvent.type(textarea, "Use concise and actionable answers.");
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
// Modify without saving, then close.
await userEvent.clear(textarea);
await userEvent.type(textarea, "Unsaved draft prompt");
await userEvent.click(
within(dialog).getByRole("button", { name: "Close" }),
);
// Wait for the dialog to fully close (exit animation) before
// interacting with the page content underneath.
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
// Type a chat message and send.
await userEvent.type(
screen.getByPlaceholderText(
"Ask Coder to build, fix bugs, or explore your project...",
),
"Create a README checklist",
);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(args.onCreateChat).toHaveBeenCalledWith(
expect.objectContaining({
message: "Create a README checklist",
}),
);
});
},
};
+785
View File
@@ -0,0 +1,785 @@
import { watchChats } from "api/api";
import { getErrorMessage } from "api/errors";
import {
chatKey,
chatModelConfigs,
chatModels,
chats,
chatsKey,
createChat,
deleteChat,
} from "api/queries/chats";
import { workspaces } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { useAuthenticated } from "hooks";
import { ArrowLeftIcon, MonitorIcon, PanelLeftIcon } from "lucide-react";
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type FC,
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { NavLink, Outlet, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentChatInput } from "./AgentChatInput";
import { AgentsSidebar } from "./AgentsSidebar";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
import { DiffRightPanel } from "./DiffRightPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
} from "./modelOptions";
const emptyInputStorageKey = "agents.empty-input";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
const systemPromptStorageKey = "agents.system-prompt";
const nilUUID = "00000000-0000-0000-0000-000000000000";
type ChatModelOption = ModelSelectorOption;
type CreateChatOptions = {
message: string;
workspaceId?: string;
model?: string;
};
// Type guard for SSE events from the chat list watch endpoint.
function isChatListSSEEvent(
data: unknown,
): data is { kind: string; chat: TypesGen.Chat } {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.kind === "string" &&
typeof obj.chat === "object" &&
obj.chat !== null &&
"id" in obj.chat
);
}
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
topBarTitleRef: React.RefObject<HTMLDivElement | null>;
topBarActionsRef: React.RefObject<HTMLDivElement | null>;
rightPanelRef: React.RefObject<HTMLDivElement | null>;
setRightPanelOpen: (isOpen: boolean) => void;
requestArchiveAgent: (chatId: string) => void;
}
const AgentsPage: FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { agentId } = useParams();
const { permissions, user, signOut } = useAuthenticated();
const { appearance, buildInfo } = useDashboard();
const isAgentsAdmin =
permissions.editDeploymentConfig ||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
const canSetSystemPrompt = isAgentsAdmin;
// The global CSS sets scrollbar-gutter: stable on <html> to prevent
// layout shift on pages that toggle scrollbars. The agents page uses
// its own internal scroll containers so the reserved gutter space is
// unnecessary and wastes horizontal room.
useEffect(() => {
const html = document.documentElement;
const prev = html.style.scrollbarGutter;
html.style.scrollbarGutter = "auto";
return () => {
html.style.scrollbarGutter = prev;
};
}, []);
const chatsQuery = useQuery(chats());
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const createMutation = useMutation(createChat(queryClient));
const archiveMutation = useMutation(deleteChat(queryClient));
const [archivingChatId, setArchivingChatId] = useState<string | null>(null);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
>({});
const catalogModelOptions = useMemo(
() =>
getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
),
[chatModelsQuery.data, chatModelConfigsQuery.data],
);
const modelConfigIDByModelID = useMemo(() => {
const byModelID = new Map<string, string>();
for (const config of chatModelConfigsQuery.data ?? []) {
const provider = config.provider.trim().toLowerCase();
const model = config.model.trim();
if (!provider || !model) {
continue;
}
const colonRef = `${provider}:${model}`;
if (!byModelID.has(colonRef)) {
byModelID.set(colonRef, config.id);
}
const slashRef = `${provider}/${model}`;
if (!byModelID.has(slashRef)) {
byModelID.set(slashRef, config.id);
}
}
return byModelID;
}, [chatModelConfigsQuery.data]);
const setChatErrorReason = useCallback((chatId: string, reason: string) => {
const trimmedReason = reason.trim();
if (!chatId || !trimmedReason) {
return;
}
setChatErrorReasons((current) => {
if (current[chatId] === trimmedReason) {
return current;
}
return {
...current,
[chatId]: trimmedReason,
};
});
}, []);
const clearChatErrorReason = useCallback((chatId: string) => {
if (!chatId) {
return;
}
setChatErrorReasons((current) => {
if (!(chatId in current)) {
return current;
}
const next = { ...current };
delete next[chatId];
return next;
});
}, []);
const topBarTitleRef = useRef<HTMLDivElement>(null);
const topBarActionsRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const chatList = chatsQuery.data ?? [];
const requestArchiveAgent = useCallback(
async (chatId: string) => {
if (archiveMutation.isPending) {
return;
}
setArchivingChatId(chatId);
const nextChatId = (
queryClient.getQueryData(chats().queryKey) as
| TypesGen.Chat[]
| undefined
)?.find((chat) => chat.id !== chatId)?.id;
try {
await archiveMutation.mutateAsync(chatId);
clearChatErrorReason(chatId);
toast.success("Agent archived.");
if (chatId === agentId) {
navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", {
replace: true,
});
}
} catch (error) {
toast.error(getErrorMessage(error, "Failed to archive agent."));
} finally {
setArchivingChatId(null);
}
},
[archiveMutation, queryClient, agentId, navigate, clearChatErrorReason],
);
const outletContext: AgentsOutletContext = useMemo(
() => ({
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
setRightPanelOpen: setIsRightPanelOpen,
requestArchiveAgent,
}),
[
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
],
);
const handleCreateChat = async (options: CreateChatOptions) => {
const { message, workspaceId, model } = options;
const modelConfigID =
(model && modelConfigIDByModelID.get(model)) || nilUUID;
const createdChat = await createMutation.mutateAsync({
content: [{ type: "text", text: message }],
workspace_id: workspaceId,
model_config_id: modelConfigID,
});
if (typeof window !== "undefined") {
localStorage.removeItem(emptyInputStorageKey);
if (modelConfigID !== nilUUID) {
localStorage.setItem(lastModelConfigIDStorageKey, modelConfigID);
} else {
localStorage.removeItem(lastModelConfigIDStorageKey);
}
}
navigate(`/agents/${createdChat.id}`);
};
const handleNewAgent = () => {
if (typeof window !== "undefined") {
localStorage.setItem(emptyInputStorageKey, "");
}
navigate("/agents");
};
useEffect(() => {
const ws = watchChats();
ws.addEventListener("message", (event) => {
const sse = event.parsedMessage;
if (sse?.type !== "data" || !sse.data) {
return;
}
if (!isChatListSSEEvent(sse.data)) {
return;
}
const chatEvent = sse.data;
const updatedChat = chatEvent.chat;
if (chatEvent.kind === "deleted") {
queryClient.setQueryData(
chatsKey,
(prev: TypesGen.Chat[] | undefined) =>
prev?.filter((c) => c.id !== updatedChat.id),
);
queryClient.removeQueries({
queryKey: chatKey(updatedChat.id),
exact: true,
});
return;
}
queryClient.setQueryData(
chatsKey,
(prev: TypesGen.Chat[] | undefined) => {
if (!prev) return prev;
const exists = prev.some((c) => c.id === updatedChat.id);
if (exists) {
return prev.map((c) =>
c.id === updatedChat.id
? {
...c,
status: updatedChat.status,
title: updatedChat.title,
updated_at: updatedChat.updated_at,
}
: c,
);
}
if (chatEvent.kind === "created") {
return [updatedChat, ...prev];
}
return prev;
},
);
queryClient.setQueryData<TypesGen.ChatWithMessages | undefined>(
chatKey(updatedChat.id),
(previousChat) => {
if (!previousChat) {
return previousChat;
}
return {
...previousChat,
chat: {
...previousChat.chat,
status: updatedChat.status,
title: updatedChat.title,
updated_at: updatedChat.updated_at,
},
};
},
);
});
return () => ws.close();
}, [queryClient]);
useEffect(() => {
document.title = pageTitle("Agents");
}, []);
useEffect(() => {
if (!agentId) {
setIsRightPanelOpen(false);
}
}, [agentId]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<div
className={cn(
"md:h-full md:w-[320px] md:min-h-0 md:border-b-0",
agentId
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
isSidebarCollapsed && "md:hidden",
)}
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
onArchiveAgent={requestArchiveAgent}
onNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={archiveMutation.isPending}
archivingChatId={archivingChatId}
isLoading={chatsQuery.isLoading}
loadError={chatsQuery.isError ? chatsQuery.error : undefined}
onRetryLoad={() => void chatsQuery.refetch()}
onCollapse={() => setIsSidebarCollapsed(true)}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 bg-surface-primary",
agentId ? "flex-1" : "order-1 md:order-none flex-none md:flex-1",
isRightPanelOpen && "flex-col xl:flex-row",
)}
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary">
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
{/* Mobile logo: visible when no agent is selected. */}
{!agentId && (
<NavLink
to="/workspaces"
className="inline-flex shrink-0 opacity-50 md:hidden"
>
{appearance.logo_url ? (
<ExternalImage
className="h-6"
src={appearance.logo_url}
alt="Logo"
/>
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
)}
{/* Mobile back button: visible on mobile when an agent is selected. */}
{agentId && (
<Button
variant="subtle"
size="icon"
onClick={() => navigate("/agents")}
aria-label="Back"
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
>
<ArrowLeftIcon />
</Button>
)}
{/* Desktop expand button: visible when sidebar is manually collapsed. */}
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={() => setIsSidebarCollapsed(false)}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div
ref={topBarTitleRef}
className="flex min-w-0 flex-1 items-center"
/>
<div ref={topBarActionsRef} className="flex items-center gap-2" />
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={
appearance.support_links?.filter(
(link) => link.location !== "navbar",
) ?? []
}
onSignOut={signOut}
/>
</div>
</div>
{agentId ? (
<Outlet context={outletContext} />
) : (
<AgentsEmptyState
onCreateChat={handleCreateChat}
isCreating={createMutation.isPending}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
canSetSystemPrompt={canSetSystemPrompt}
canManageChatModelConfigs={isAgentsAdmin}
topBarActionsRef={topBarActionsRef}
/>
)}
</div>
<DiffRightPanel
ref={rightPanelRef}
isOpen={Boolean(agentId && isRightPanelOpen)}
/>
</div>
</div>
);
};
interface AgentsEmptyStateProps {
onCreateChat: (options: CreateChatOptions) => Promise<void>;
isCreating: boolean;
createError: unknown;
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
modelOptions: readonly ChatModelOption[];
isModelCatalogLoading: boolean;
modelConfigs: readonly TypesGen.ChatModelConfig[];
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
canSetSystemPrompt: boolean;
canManageChatModelConfigs: boolean;
topBarActionsRef: React.RefObject<HTMLDivElement | null>;
}
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
onCreateChat,
isCreating,
createError,
modelCatalog,
modelOptions,
modelConfigs,
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
canSetSystemPrompt,
canManageChatModelConfigs,
topBarActionsRef,
}) => {
const initialInput = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(emptyInputStorageKey) ?? "";
}, []);
const initialSystemPrompt = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(systemPromptStorageKey) ?? "";
}, []);
const initialLastModelConfigID = useMemo(() => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
}, []);
const modelIDByConfigID = useMemo(() => {
const optionIDByRef = new Map<string, string>();
for (const option of modelOptions) {
const provider = option.provider.trim().toLowerCase();
const model = option.model.trim();
if (!provider || !model) {
continue;
}
const key = `${provider}:${model}`;
if (!optionIDByRef.has(key)) {
optionIDByRef.set(key, option.id);
}
}
const byConfigID = new Map<string, string>();
for (const config of modelConfigs) {
const provider = config.provider.trim().toLowerCase();
const model = config.model.trim();
if (!provider || !model) {
continue;
}
const modelID = optionIDByRef.get(`${provider}:${model}`);
if (!modelID || byConfigID.has(config.id)) {
continue;
}
byConfigID.set(config.id, modelID);
}
return byConfigID;
}, [modelConfigs, modelOptions]);
const lastUsedModelID = useMemo(() => {
if (!initialLastModelConfigID) {
return "";
}
return modelIDByConfigID.get(initialLastModelConfigID) ?? "";
}, [initialLastModelConfigID, modelIDByConfigID]);
const defaultModelID = useMemo(() => {
const defaultModelConfig = modelConfigs.find((config) => config.is_default);
if (!defaultModelConfig) {
return "";
}
return modelIDByConfigID.get(defaultModelConfig.id) ?? "";
}, [modelConfigs, modelIDByConfigID]);
const preferredModelID =
lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? "");
const [userSelectedModel, setUserSelectedModel] = useState("");
const [hasUserSelectedModel, setHasUserSelectedModel] = useState(false);
// Derive the effective model every render so we never reference
// a stale model id and can honor fallback precedence.
const selectedModel =
hasUserSelectedModel &&
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const [savedSystemPrompt, setSavedSystemPrompt] =
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const workspacesQuery = useQuery(workspaces({ limit: 50 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(selectedWorkspaceIdStorageKey) || null;
},
);
const workspaceOptions = workspacesQuery.data?.workspaces ?? [];
const autoCreateWorkspaceValue = "__auto_create_workspace__";
const hasAdminControls = canSetSystemPrompt || canManageChatModelConfigs;
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
modelOptions,
isModelCatalogLoading,
hasConfiguredModels,
);
const modelCatalogStatusMessage = getModelCatalogStatusMessage(
modelCatalog,
modelOptions,
isModelCatalogLoading,
Boolean(modelCatalogError),
);
const inputStatusText = hasModelOptions
? null
: hasConfiguredModels
? "Models are configured but unavailable. Ask an admin."
: "No models configured. Ask an admin.";
useEffect(() => {
if (typeof window === "undefined") {
return;
}
if (!initialLastModelConfigID) {
return;
}
if (isModelCatalogLoading || isModelConfigsLoading) {
return;
}
if (lastUsedModelID) {
return;
}
localStorage.removeItem(lastModelConfigIDStorageKey);
}, [
initialLastModelConfigID,
isModelCatalogLoading,
isModelConfigsLoading,
lastUsedModelID,
]);
// Keep a mutable ref to selectedWorkspaceId and selectedModel so
// that the onSend callback always sees the latest values without
// the shared input component re-rendering on every change.
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt;
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
setSelectedWorkspaceId(null);
if (typeof window !== "undefined") {
localStorage.removeItem(selectedWorkspaceIdStorageKey);
}
return;
}
setSelectedWorkspaceId(value);
if (typeof window !== "undefined") {
localStorage.setItem(selectedWorkspaceIdStorageKey, value);
}
};
const handleInputChange = useCallback((value: string) => {
if (typeof window !== "undefined") {
localStorage.setItem(emptyInputStorageKey, value);
}
}, []);
const handleModelChange = useCallback((value: string) => {
setHasUserSelectedModel(true);
setUserSelectedModel(value);
}, []);
const handleSaveSystemPrompt = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (!isSystemPromptDirty) {
return;
}
setSavedSystemPrompt(systemPromptDraft);
if (typeof window !== "undefined") {
if (systemPromptDraft) {
localStorage.setItem(systemPromptStorageKey, systemPromptDraft);
} else {
localStorage.removeItem(systemPromptStorageKey);
}
}
},
[isSystemPromptDirty, systemPromptDraft],
);
const handleSend = useCallback(
async (message: string) => {
await onCreateChat({
message,
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
model: selectedModelRef.current || undefined,
});
},
[onCreateChat],
);
const selectedWorkspaceName = selectedWorkspaceId
? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)?.name
: null;
return (
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 pt-12 md:h-full md:items-center md:pt-4">
{hasAdminControls &&
topBarActionsRef.current &&
createPortal(
<Button
variant="subtle"
disabled={isCreating}
className="h-8 gap-1.5 border-none bg-transparent px-1 text-[13px] shadow-none hover:bg-transparent"
onClick={() => setConfigureAgentsDialogOpen(true)}
>
Admin
</Button>,
topBarActionsRef.current,
)}
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{createError ? <ErrorAlert error={createError} /> : null}
{workspacesQuery.isError && (
<ErrorAlert error={workspacesQuery.error} />
)}
<AgentChatInput
onSend={handleSend}
placeholder="Ask Coder to build, fix bugs, or explore your project..."
isDisabled={isCreating}
isLoading={isCreating}
initialValue={initialInput}
onInputChange={handleInputChange}
selectedModel={selectedModel}
onModelChange={handleModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
leftActions={
<Select
value={selectedWorkspaceId ?? autoCreateWorkspaceValue}
onValueChange={handleWorkspaceChange}
disabled={isCreating || workspacesQuery.isLoading}
>
<SelectTrigger className="h-8 w-auto gap-1.5 border-none bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary [&>svg]:transition-colors [&>svg]:hover:text-content-primary focus:ring-0 focus-visible:ring-0">
<MonitorIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary group-hover:text-content-primary" />
<SelectValue>
{selectedWorkspaceName ?? "Workspace"}
</SelectValue>
</SelectTrigger>
<SelectContent
side="top"
align="center"
className="[&_[role=option]]:text-xs"
>
<SelectItem value={autoCreateWorkspaceValue}>
Auto-create Workspace
</SelectItem>
{workspaceOptions.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
{workspaceOptions.length === 0 &&
!workspacesQuery.isLoading && (
<SelectItem value="no-workspaces" disabled>
No workspaces found
</SelectItem>
)}
</SelectContent>
</Select>
}
/>
</div>
{hasAdminControls && (
<ConfigureAgentsDialog
open={isConfigureAgentsDialogOpen}
onOpenChange={setConfigureAgentsDialogOpen}
canManageChatModelConfigs={canManageChatModelConfigs}
canSetSystemPrompt={canSetSystemPrompt}
systemPromptDraft={systemPromptDraft}
onSystemPromptDraftChange={setSystemPromptDraft}
onSaveSystemPrompt={handleSaveSystemPrompt}
isSystemPromptDirty={isSystemPromptDirty}
isDisabled={isCreating}
/>
)}
</div>
);
};
export default AgentsPage;
@@ -0,0 +1,240 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import type { Chat } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { AgentsSidebar } from "./AgentsSidebar";
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
];
const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
{
id: "config-openai-gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 70,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
];
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
...overrides,
});
const agentsRouting = [
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents", useStoryElement: true },
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
const meta: Meta<typeof AgentsSidebar> = {
title: "pages/AgentsPage/AgentsSidebar",
component: AgentsSidebar,
args: {
chatErrorReasons: {},
modelOptions: defaultModelOptions,
modelConfigs: defaultModelConfigs,
onArchiveAgent: fn(),
onNewAgent: fn(),
isCreating: false,
},
parameters: {
layout: "fullscreen",
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
};
export default meta;
type Story = StoryObj<typeof AgentsSidebar>;
export const SearchFiltering: Story = {
args: {
chats: [
buildChat({ id: "parent-1", title: "Parent planner" }),
buildChat({
id: "child-1",
title: "Child executor",
parent_chat_id: "parent-1",
root_chat_id: "parent-1",
}),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByPlaceholderText("Search agents..."),
"child",
);
await waitFor(() => {
expect(canvas.getByText("Parent planner")).toBeInTheDocument();
expect(canvas.getByText("Child executor")).toBeInTheDocument();
});
},
};
export const RunningDelegatedChat: Story = {
args: {
chats: [
buildChat({ id: "root-1", title: "Root agent" }),
buildChat({
id: "child-running",
title: "Running child",
status: "running",
parent_chat_id: "root-1",
root_chat_id: "root-1",
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/child-running",
pathParams: { agentId: "child-running" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByTestId("agents-tree-executing-child-running"),
).toBeInTheDocument();
},
};
export const PendingDelegatedChat: Story = {
args: {
chats: [
buildChat({ id: "root-pending", title: "Root agent" }),
buildChat({
id: "child-pending",
title: "Pending child",
status: "pending",
parent_chat_id: "root-pending",
root_chat_id: "root-pending",
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/child-pending",
pathParams: { agentId: "child-pending" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByTestId("agents-tree-executing-child-pending"),
).toBeInTheDocument();
},
};
export const ExpandCollapse: Story = {
args: {
chats: [
buildChat({ id: "root-2", title: "Root for collapse" }),
buildChat({
id: "child-collapse",
title: "Nested child",
parent_chat_id: "root-2",
root_chat_id: "root-2",
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/child-collapse",
pathParams: { agentId: "child-collapse" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByTestId("agents-tree-toggle-root-2");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Nested child")).toBeInTheDocument();
await userEvent.click(toggle);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(canvas.queryByText("Nested child")).not.toBeInTheDocument();
await userEvent.click(toggle);
await expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Nested child")).toBeInTheDocument();
},
};
export const ActiveChatAncestryExpanded: Story = {
args: {
chats: [
buildChat({ id: "root-active", title: "Active root" }),
buildChat({
id: "child-active",
title: "Active middle",
parent_chat_id: "root-active",
root_chat_id: "root-active",
}),
buildChat({
id: "grandchild-active",
title: "Active leaf",
parent_chat_id: "child-active",
root_chat_id: "root-active",
}),
buildChat({ id: "other-root", title: "Other root" }),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/grandchild-active",
pathParams: { agentId: "grandchild-active" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Active root")).toBeInTheDocument();
await waitFor(() => {
expect(canvas.getByText("Active middle")).toBeInTheDocument();
expect(canvas.getByText("Active leaf")).toBeInTheDocument();
});
await expect(
canvas.getByTestId("agents-tree-toggle-root-active"),
).toHaveAttribute("aria-expanded", "true");
await expect(
canvas.getByTestId("agents-tree-toggle-child-active"),
).toHaveAttribute("aria-expanded", "true");
},
};
+696
View File
@@ -0,0 +1,696 @@
import type {
Chat,
ChatDiffStatus,
ChatModelConfig,
ChatStatus,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { Input } from "components/Input/Input";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import {
AlertTriangleIcon,
ArchiveIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
EllipsisIcon,
Loader2Icon,
PanelLeftCloseIcon,
PauseIcon,
SearchIcon,
} from "lucide-react";
import {
createContext,
type FC,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { NavLink, useParams } from "react-router";
import { cn } from "utils/cn";
import { shortRelativeTime } from "utils/time";
interface AgentsSidebarProps {
chats: readonly Chat[];
chatErrorReasons: Record<string, string>;
modelOptions: readonly ModelSelectorOption[];
modelConfigs: readonly ChatModelConfig[];
logoUrl?: string;
onArchiveAgent: (chatId: string) => void;
onNewAgent: () => void;
isCreating: boolean;
isArchiving?: boolean;
archivingChatId?: string | null;
isLoading?: boolean;
loadError?: unknown;
onRetryLoad?: () => void;
onCollapse?: () => void;
}
const statusConfig = {
waiting: { icon: CheckIcon, className: "text-content-secondary" },
pending: { icon: Loader2Icon, className: "text-content-link animate-spin" },
running: { icon: Loader2Icon, className: "text-content-link animate-spin" },
paused: { icon: PauseIcon, className: "text-content-warning" },
error: { icon: AlertTriangleIcon, className: "text-content-destructive" },
completed: { icon: CheckIcon, className: "text-content-secondary" },
} as const;
type ChatTree = {
readonly rootIds: readonly string[];
readonly childrenById: ReadonlyMap<string, readonly string[]>;
readonly parentById: ReadonlyMap<string, string | undefined>;
};
const TIME_GROUPS = ["Today", "Yesterday", "This Week", "Older"] as const;
type TimeGroup = (typeof TIME_GROUPS)[number];
function getTimeGroup(dateStr: string): TimeGroup {
const now = new Date();
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= weekAgo) return "This Week";
return "Older";
}
const getStatusConfig = (status: ChatStatus) => {
return statusConfig[status] ?? statusConfig.completed;
};
const asNonEmptyString = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const getModelDisplayName = (
lastModelConfigID: Chat["last_model_config_id"] | undefined,
modelConfigs: readonly ChatModelConfig[],
modelOptions: readonly ModelSelectorOption[],
) => {
if (!lastModelConfigID) {
return "Default model";
}
const modelConfig = modelConfigs.find(
(config) => config.id === lastModelConfigID,
);
if (!modelConfig) {
return "Default model";
}
const provider = modelConfig.provider.trim().toLowerCase();
const model = modelConfig.model.trim();
if (!provider || !model) {
return modelConfig.display_name.trim() || "Default model";
}
// Try to find a matching option with a display name.
const match = modelOptions.find(
(opt) =>
opt.id === `${provider}:${model}` ||
(opt.provider === provider && opt.model === model),
);
if (match?.displayName) {
return match.displayName;
}
if (modelConfig.display_name.trim()) {
return modelConfig.display_name.trim();
}
return model;
};
const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => {
return chat.diff_status;
};
const getParentChatID = (chat: Chat): string | undefined => {
return asNonEmptyString(chat.parent_chat_id);
};
const getRootChatID = (chat: Chat): string | undefined => {
return asNonEmptyString(chat.root_chat_id);
};
const buildChatTree = (chats: readonly Chat[]): ChatTree => {
const orderById = new Map<string, number>();
const chatById = new Map<string, Chat>();
const parentById = new Map<string, string | undefined>();
const childrenById = new Map<string, string[]>();
for (const [index, chat] of chats.entries()) {
orderById.set(chat.id, index);
chatById.set(chat.id, chat);
childrenById.set(chat.id, []);
}
for (const chat of chats) {
let parentID = getParentChatID(chat);
if (!parentID || parentID === chat.id || !chatById.has(parentID)) {
parentID = undefined;
}
if (!parentID) {
const rootID = getRootChatID(chat);
if (rootID && rootID !== chat.id && chatById.has(rootID)) {
parentID = rootID;
}
}
parentById.set(chat.id, parentID);
if (parentID) {
childrenById.get(parentID)?.push(chat.id);
}
}
for (const children of childrenById.values()) {
children.sort((leftID, rightID) => {
return (orderById.get(leftID) ?? 0) - (orderById.get(rightID) ?? 0);
});
}
const rootIds = chats
.map((chat) => chat.id)
.filter((chatID) => !parentById.get(chatID));
return {
rootIds,
childrenById,
parentById,
};
};
const collectVisibleChatIDs = ({
chats,
search,
tree,
}: {
readonly chats: readonly Chat[];
readonly search: string;
readonly tree: ChatTree;
}): Set<string> => {
if (!search) {
return new Set(chats.map((chat) => chat.id));
}
const matchedChatIDs = chats
.filter((chat) => chat.title.toLowerCase().includes(search))
.map((chat) => chat.id);
if (matchedChatIDs.length === 0) {
return new Set<string>();
}
const visible = new Set<string>();
for (const matchedChatID of matchedChatIDs) {
let parentCursor: string | undefined = matchedChatID;
const seenParents = new Set<string>();
while (parentCursor && !seenParents.has(parentCursor)) {
seenParents.add(parentCursor);
visible.add(parentCursor);
parentCursor = tree.parentById.get(parentCursor);
}
const stack = [matchedChatID];
const seenDescendants = new Set<string>();
while (stack.length > 0) {
const currentID = stack.pop();
if (!currentID || seenDescendants.has(currentID)) {
continue;
}
seenDescendants.add(currentID);
visible.add(currentID);
for (const childID of tree.childrenById.get(currentID) ?? []) {
stack.push(childID);
}
}
}
return visible;
};
interface ChatTreeContextValue {
readonly chatTree: ChatTree;
readonly chatById: ReadonlyMap<string, Chat>;
readonly visibleChatIDs: ReadonlySet<string>;
readonly normalizedSearch: string;
readonly expandedById: Record<string, boolean>;
readonly modelOptions: readonly ModelSelectorOption[];
readonly modelConfigs: readonly ChatModelConfig[];
readonly chatErrorReasons: Record<string, string>;
readonly isArchiving: boolean;
readonly archivingChatId: string | null;
readonly toggleExpanded: (chatID: string) => void;
readonly onArchiveAgent: (chatId: string) => void;
}
const ChatTreeContext = createContext<ChatTreeContextValue | null>(null);
function useChatTree(): ChatTreeContextValue {
const ctx = useContext(ChatTreeContext);
if (!ctx) {
throw new Error("useChatTree must be used within ChatTreeContext.Provider");
}
return ctx;
}
interface ChatTreeNodeProps {
readonly chat: Chat;
readonly isChildNode: boolean;
}
const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
const {
chatTree,
chatById,
visibleChatIDs,
normalizedSearch,
expandedById,
modelOptions,
modelConfigs,
chatErrorReasons,
isArchiving,
archivingChatId,
toggleExpanded,
onArchiveAgent,
} = useChatTree();
const chatID = chat.id;
const childIDs = (chatTree.childrenById.get(chatID) ?? []).filter((childID) =>
visibleChatIDs.has(childID),
);
const hasChildren = childIDs.length > 0;
const isDelegated = Boolean(getParentChatID(chat));
const config = getStatusConfig(chat.status);
const StatusIcon = config.icon;
const isDelegatedExecuting =
isDelegated && (chat.status === "pending" || chat.status === "running");
const modelName = getModelDisplayName(
chat.last_model_config_id,
modelConfigs,
modelOptions,
);
const errorReason =
chat.status === "error" ? chatErrorReasons[chat.id] : undefined;
const subtitle = errorReason || modelName;
const diffStatus = getChatDiffStatus(chat);
const hasLinkedDiffStatus = Boolean(diffStatus?.url);
const changedFiles = diffStatus?.changed_files ?? 0;
const additions = diffStatus?.additions ?? 0;
const deletions = diffStatus?.deletions ?? 0;
const hasLineStats = additions > 0 || deletions > 0;
const filesChangedLabel = `${changedFiles} ${
changedFiles === 1 ? "file" : "files"
}`;
const isArchivingThisChat = isArchiving && archivingChatId === chat.id;
const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false);
return (
<div className="flex min-w-0 flex-col">
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 items-start gap-1.5 rounded-md pr-1 text-content-secondary",
"transition-none hover:bg-surface-tertiary/50 hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
isChildNode &&
"before:absolute before:-left-2.5 before:top-[17px] before:h-px before:w-2.5 before:bg-border-default/70",
)}
>
<div className="relative mt-1.5 h-5 w-5 shrink-0">
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-md",
hasChildren && "group-hover:invisible",
)}
>
<StatusIcon
data-testid={
isDelegatedExecuting
? `agents-tree-executing-${chat.id}`
: undefined
}
className={cn("h-3.5 w-3.5 shrink-0", config.className)}
/>
</div>
{hasChildren && (
<Button
variant="subtle"
size="icon"
onClick={() => toggleExpanded(chatID)}
className="absolute inset-0 invisible flex h-5 w-5 min-w-0 items-center justify-center rounded-md p-0 text-content-secondary/60 hover:text-content-primary group-hover:visible [&>svg]:size-3.5"
data-testid={`agents-tree-toggle-${chat.id}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
)}
</div>
<NavLink
to={`/agents/${chat.id}`}
className="flex min-h-0 min-w-0 flex-1 items-start gap-2 rounded-[inherit] py-1 pr-0.5 text-inherit no-underline"
>
{({ isActive }) => (
<>
<div className="min-w-0 flex-1 overflow-hidden text-left">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
className={cn(
"block flex-1 truncate text-[13px] text-content-primary",
isActive && "font-medium",
)}
>
{chat.title}
</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
{hasLinkedDiffStatus && hasLineStats && (
<span
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] font-medium leading-none tabular-nums"
title={`${filesChangedLabel}, +${additions} -${deletions}`}
>
<span className="text-content-success">+{additions}</span>
<span className="text-content-destructive">
-{deletions}
</span>
</span>
)}
<div
className={cn(
"min-w-0 overflow-hidden text-[13px] leading-4",
errorReason
? "line-clamp-1 whitespace-normal text-content-destructive [overflow-wrap:anywhere]"
: "truncate text-content-secondary",
)}
title={subtitle}
>
{subtitle}
</div>
</div>
</div>
</>
)}
</NavLink>
<div className="relative mr-1 mt-1 h-6 w-7 shrink-0 text-right">
<span className="absolute inset-0 flex items-center justify-end text-xs text-content-secondary/50 tabular-nums transition-opacity group-hover:opacity-0">
{shortRelativeTime(chat.updated_at)}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className={cn(
"absolute inset-0 h-6 w-7 justify-end rounded-none px-0 text-content-secondary opacity-0 transition-opacity hover:text-content-primary group-hover:opacity-100",
isArchivingThisChat && "opacity-100",
)}
aria-label={`Open actions for ${chat.title}`}
>
{isArchivingThisChat ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<EllipsisIcon className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{hasChildren && isExpanded && (
<div className="relative ml-4 border-l border-border-default/60 pl-2.5">
{childIDs.map((childID) => {
const childChat = chatById.get(childID);
if (!childChat) return null;
return (
<ChatTreeNode key={childChat.id} chat={childChat} isChildNode />
);
})}
</div>
)}
</div>
);
});
ChatTreeNode.displayName = "ChatTreeNode";
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const {
chats,
chatErrorReasons,
modelOptions,
modelConfigs,
logoUrl,
onArchiveAgent,
onNewAgent,
isCreating,
isArchiving = false,
archivingChatId = null,
isLoading = false,
loadError,
onRetryLoad,
onCollapse,
} = props;
const { agentId, chatId } = useParams<{
agentId?: string;
chatId?: string;
}>();
const activeChatId = agentId ?? chatId;
const [search, setSearch] = useState("");
const normalizedSearch = search.trim().toLowerCase();
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const chatTree = useMemo(() => buildChatTree(chats), [chats]);
const chatById = useMemo(() => {
return new Map(chats.map((chat) => [chat.id, chat] as const));
}, [chats]);
const visibleChatIDs = useMemo(
() =>
collectVisibleChatIDs({
chats,
search: normalizedSearch,
tree: chatTree,
}),
[chats, normalizedSearch, chatTree],
);
const visibleRootIDs = useMemo(
() => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)),
[chatTree.rootIds, visibleChatIDs],
);
// Auto-expand ancestors of the active chat so it's always visible.
useEffect(() => {
if (!activeChatId) {
return;
}
const toExpand: string[] = [];
let cursor = chatTree.parentById.get(activeChatId);
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
toExpand.push(cursor);
cursor = chatTree.parentById.get(cursor);
}
if (toExpand.length > 0) {
setExpandedById((prev) => {
const next = { ...prev };
for (const id of toExpand) {
next[id] = true;
}
return next;
});
}
}, [activeChatId, chatTree.parentById]);
const toggleExpanded = useCallback((chatID: string) => {
setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] }));
}, []);
const chatTreeCtx = useMemo<ChatTreeContextValue>(
() => ({
chatTree,
chatById,
visibleChatIDs,
normalizedSearch,
expandedById,
modelOptions,
modelConfigs,
chatErrorReasons,
isArchiving,
archivingChatId,
toggleExpanded,
onArchiveAgent,
}),
[
chatTree,
chatById,
visibleChatIDs,
normalizedSearch,
expandedById,
modelOptions,
modelConfigs,
chatErrorReasons,
isArchiving,
archivingChatId,
toggleExpanded,
onArchiveAgent,
],
);
return (
<div className="flex h-full w-full min-h-0 flex-col border-0 border-r border-solid">
<div className="hidden border-b border-border-default px-3 pb-3 pt-1.5 md:block md:px-3.5">
<div className="mb-2.5 flex items-center justify-between">
<NavLink to="/workspaces" className="inline-flex opacity-50">
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
<div className="flex flex-col gap-2.5">
<div className="relative">
<label className="sr-only" htmlFor="agents-sidebar-search">
Search agents...
</label>
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-icon-xs -translate-y-1/2 text-content-secondary" />
<Input
id="agents-sidebar-search"
type="search"
placeholder="Search agents..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="h-9 rounded-lg border-border-default bg-surface-primary pl-8 text-[13px] shadow-none"
/>
</div>
<Button
size="sm"
variant="outline"
onClick={onNewAgent}
disabled={isCreating}
className="w-full justify-center rounded-lg py-4 text-[13px] text-content-secondary hover:bg-surface-tertiary"
>
New Agent
</Button>
</div>
</div>
<ScrollArea
className="flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
>
<div className="flex flex-col gap-2 px-2 py-3 md:px-2">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</>
) : (
<ChatTreeContext.Provider value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
{normalizedSearch ? "No matching agents" : "No agents yet"}
</div>
) : (
TIME_GROUPS.map((group) => {
const groupChats = visibleRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
chat !== undefined &&
getTimeGroup(chat.updated_at) === group,
);
if (groupChats.length === 0) return null;
return (
<div key={group}>
<div className="mb-1 ml-2.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</div>
);
})
)}
</ChatTreeContext.Provider>
)}
</div>
</ScrollArea>
</div>
);
};
@@ -0,0 +1,82 @@
import { Skeleton } from "components/Skeleton/Skeleton";
import type { FC } from "react";
/**
* Skeleton shown while the AgentsPage chunk is loading. Mimics the
* sidebar + empty main area layout so the user sees structure
* immediately instead of a fullscreen spinner.
*/
export const AgentsPageSkeleton: FC = () => (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<div className="shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default md:h-full md:w-[320px] md:min-h-0 md:border-b-0">
<div className="flex h-full w-full min-h-0 flex-col border-0 border-r border-solid">
<div className="border-b border-border-default px-3 pb-3 pt-1.5 md:px-3.5">
<Skeleton className="mb-2.5 h-6 w-6 rounded" />
<div className="flex flex-col gap-2.5">
<Skeleton className="h-9 w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</div>
</div>
<div className="flex flex-col gap-2 px-2 py-3">
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary" />
</div>
);
/**
* Skeleton shown while the AgentDetail chunk is loading. Mimics a
* chat conversation layout (user bubble + assistant response lines)
* inside the same scroll/padding wrapper used by the real view.
*/
export const AgentDetailSkeleton: FC = () => (
<div className="flex h-full flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>
</div>
</div>
</div>
);
@@ -0,0 +1,535 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
ChatModelAdminPanel,
type ChatModelAdminSection,
} from "./ChatModelAdminPanel";
// ── Helpers ────────────────────────────────────────────────────
const now = "2026-02-18T12:00:00.000Z";
const nilProviderConfigID = "00000000-0000-0000-0000-000000000000";
const createProviderConfig = (
overrides: Partial<TypesGen.ChatProviderConfig> &
Pick<TypesGen.ChatProviderConfig, "id" | "provider">,
): TypesGen.ChatProviderConfig => ({
id: overrides.id,
provider: overrides.provider,
display_name: overrides.display_name ?? "",
enabled: overrides.enabled ?? true,
has_api_key: overrides.has_api_key ?? false,
base_url: overrides.base_url ?? "",
source: overrides.source ?? "database",
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
});
const createModelConfig = (
overrides: Partial<TypesGen.ChatModelConfig> &
Pick<TypesGen.ChatModelConfig, "id" | "provider" | "model">,
): TypesGen.ChatModelConfig => ({
id: overrides.id,
provider: overrides.provider,
model: overrides.model,
display_name: overrides.display_name ?? overrides.model,
enabled: overrides.enabled ?? true,
is_default: overrides.is_default ?? false,
context_limit: overrides.context_limit ?? 200000,
compression_threshold: overrides.compression_threshold ?? 70,
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
});
/**
* Set up spies for all chat admin API methods. The mutable `state`
* object lets mutation spies update what queries return on refetch,
* mimicking the real server round-trip.
*/
const setupChatSpies = (state: {
providerConfigs: TypesGen.ChatProviderConfig[];
modelConfigs: TypesGen.ChatModelConfig[];
modelCatalog: TypesGen.ChatModelsResponse;
}) => {
spyOn(API, "getChatProviderConfigs").mockImplementation(async () => {
return state.providerConfigs;
});
spyOn(API, "getChatModelConfigs").mockImplementation(async () => {
return state.modelConfigs;
});
spyOn(API, "getChatModels").mockImplementation(async () => {
return state.modelCatalog;
});
spyOn(API, "createChatProviderConfig").mockImplementation(async (req) => {
const created = createProviderConfig({
id: `provider-${Date.now()}`,
provider: req.provider,
display_name: req.display_name ?? "",
has_api_key: (req.api_key ?? "").trim().length > 0,
base_url: req.base_url ?? "",
source: "database",
});
state.providerConfigs = [
...state.providerConfigs.filter((p) => p.provider !== req.provider),
created,
];
return created;
});
spyOn(API, "updateChatProviderConfig").mockImplementation(
async (providerConfigId, req) => {
const idx = state.providerConfigs.findIndex(
(p) => p.id === providerConfigId,
);
if (idx < 0) {
throw new Error("Provider config not found.");
}
const current = state.providerConfigs[idx];
const updated: TypesGen.ChatProviderConfig = {
...current,
display_name:
typeof req.display_name === "string"
? req.display_name
: current.display_name,
has_api_key:
typeof req.api_key === "string"
? req.api_key.trim().length > 0
: current.has_api_key,
base_url:
typeof req.base_url === "string" ? req.base_url : current.base_url,
updated_at: now,
};
state.providerConfigs = state.providerConfigs.map((p, i) =>
i === idx ? updated : p,
);
return updated;
},
);
spyOn(API, "createChatModelConfig").mockImplementation(async (req) => {
const created = createModelConfig({
id: `model-${state.modelConfigs.length + 1}`,
provider: req.provider,
model: req.model,
display_name: req.display_name || req.model,
context_limit:
typeof req.context_limit === "number" &&
Number.isFinite(req.context_limit)
? req.context_limit
: 200000,
compression_threshold:
typeof req.compression_threshold === "number" &&
Number.isFinite(req.compression_threshold)
? req.compression_threshold
: 70,
});
state.modelConfigs = [...state.modelConfigs, created];
return created;
});
spyOn(API, "deleteChatModelConfig").mockImplementation(
async (modelConfigId) => {
state.modelConfigs = state.modelConfigs.filter(
(m) => m.id !== modelConfigId,
);
},
);
// Unused but mock to avoid errors.
spyOn(API, "deleteChatProviderConfig").mockResolvedValue(undefined);
spyOn(API, "updateChatModelConfig").mockResolvedValue(
createModelConfig({
id: "stub",
provider: "stub",
model: "stub",
}),
);
};
// ── Meta ───────────────────────────────────────────────────────
const meta: Meta<typeof ChatModelAdminPanel> = {
title: "pages/AgentsPage/ChatModelAdminPanel",
component: ChatModelAdminPanel,
};
export default meta;
type Story = StoryObj<typeof ChatModelAdminPanel>;
// ── Providers section stories ──────────────────────────────────
export const ProviderAccordionCards: Story = {
args: { section: "providers" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: nilProviderConfigID,
provider: "openrouter",
display_name: "OpenRouter",
source: "supported",
enabled: false,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(await body.findByText("OpenRouter")).toBeInTheDocument();
// OpenAI should not be rendered.
expect(body.queryByText("OpenAI")).not.toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: /OpenRouter/i }));
await expect(body.getByLabelText("Base URL")).toBeInTheDocument();
},
};
export const EnvPresetProviders: Story = {
args: { section: "providers" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: nilProviderConfigID,
provider: "openai",
display_name: "OpenAI",
has_api_key: true,
source: "env_preset",
enabled: true,
}),
createProviderConfig({
id: nilProviderConfigID,
provider: "anthropic",
display_name: "Anthropic",
has_api_key: true,
source: "env_preset",
enabled: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
await expect(
await body.findByText("API key managed by environment variable."),
).toBeVisible();
expect(body.getByText("Anthropic")).toBeInTheDocument();
expect(
body.getByText(
"This provider API key is managed by an environment variable.",
),
).toBeVisible();
expect(
body.getByText(
"This provider key is configured from deployment environment settings and cannot be edited in this UI.",
),
).toBeVisible();
expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument();
expect(
body.queryByRole("button", {
name: "Create provider config",
}),
).not.toBeInTheDocument();
},
};
export const CreateAndUpdateProvider: Story = {
args: { section: "providers" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: nilProviderConfigID,
provider: "openai",
display_name: "OpenAI",
source: "supported",
enabled: false,
has_api_key: false,
}),
],
modelConfigs: [],
modelCatalog: {
providers: [
{
provider: "openai",
available: false,
unavailable_reason: "missing_api_key",
models: [],
},
],
},
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
// Expand the accordion.
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
// Fill in form to create a provider config.
await userEvent.type(
await body.findByLabelText(/API key/i),
"sk-provider-key",
);
await userEvent.type(
body.getByLabelText("Base URL"),
"https://proxy.example.com/v1",
);
await userEvent.click(
body.getByRole("button", { name: "Create provider config" }),
);
// The create spy should have been called.
await waitFor(() => {
expect(API.createChatProviderConfig).toHaveBeenCalledTimes(1);
});
expect(API.createChatProviderConfig).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
api_key: "sk-provider-key",
base_url: "https://proxy.example.com/v1",
}),
);
// After creation the form should switch to "Save changes".
await waitFor(() => {
expect(
body.getByRole("button", { name: "Save changes" }),
).toBeInTheDocument();
});
// Update the display name and base URL.
const displayNameInput = body.getByPlaceholderText(
"Friendly provider label",
);
await userEvent.clear(displayNameInput);
await userEvent.type(displayNameInput, "Primary OpenAI");
const baseURLInput = body.getByLabelText("Base URL");
await userEvent.clear(baseURLInput);
await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2");
await userEvent.type(
body.getByLabelText(/API key/i),
"sk-updated-provider-key",
);
await userEvent.click(body.getByRole("button", { name: "Save changes" }));
await waitFor(() => {
expect(API.updateChatProviderConfig).toHaveBeenCalledTimes(1);
});
expect(API.updateChatProviderConfig).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
display_name: "Primary OpenAI",
api_key: "sk-updated-provider-key",
base_url: "https://internal-proxy.example.com/v2",
}),
);
},
};
// ── Models section stories ─────────────────────────────────────
export const ProviderSpecificModelConfigSchema: Story = {
args: { section: "models" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
createProviderConfig({
id: "provider-anthropic",
provider: "anthropic",
display_name: "Anthropic",
source: "database",
has_api_key: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "Add model" }),
);
const schemaBlock = await body.findByTestId("chat-model-config-schema");
expect(schemaBlock).toHaveTextContent('"provider": "openai"');
expect(schemaBlock).toHaveTextContent('"openai": {');
expect(schemaBlock).toHaveTextContent('"reasoning_effort": "high"');
// Switch provider to Anthropic.
await userEvent.click(body.getByRole("combobox", { name: "Provider" }));
await userEvent.click(
await body.findByRole("option", { name: /Anthropic/i }),
);
await waitFor(() => {
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
'"provider": "anthropic"',
);
});
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
'"anthropic": {',
);
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
'"thinking": {',
);
},
};
export const NoModelConfigByDefault: Story = {
args: { section: "models" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "Add model" }),
);
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue(
"",
);
await userEvent.click(body.getByRole("button", { name: "Add model" }));
await waitFor(() => {
expect(API.createChatModelConfig).toHaveBeenCalledTimes(1);
});
expect(API.createChatModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
model: "gpt-5-pro",
}),
);
// The request should not include a model_config key.
const callArgs = (
API.createChatModelConfig as unknown as ReturnType<typeof spyOn>
).mock.calls[0][0] as Record<string, unknown>;
expect(callArgs).not.toHaveProperty("model_config");
},
};
export const SubmitModelConfigExplicitly: Story = {
args: { section: "models" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "Add model" }),
);
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro-custom");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
await userEvent.type(
await body.findByLabelText(/Max output tokens/i),
"32000",
);
await userEvent.click(
body.getByRole("combobox", {
name: "Reasoning effort",
}),
);
await userEvent.click(await body.findByRole("option", { name: "high" }));
await userEvent.click(body.getByRole("button", { name: "Add model" }));
await waitFor(() => {
expect(API.createChatModelConfig).toHaveBeenCalledTimes(1);
});
expect(API.createChatModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
model: "gpt-5-pro-custom",
model_config: expect.objectContaining({
max_output_tokens: 32000,
provider_options: {
openai: {
reasoning_effort: "high",
},
},
}),
}),
);
},
};
export const ValidatesModelConfigFields: Story = {
args: { section: "models" as ChatModelAdminSection },
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: "Add model" }),
);
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
const maxOutputTokensInput =
await body.findByLabelText(/Max output tokens/i);
await userEvent.type(maxOutputTokensInput, "not-a-number");
await waitFor(() => {
expect(body.getByRole("button", { name: "Add model" })).toBeDisabled();
});
// No API call should have been made.
expect(API.createChatModelConfig).not.toHaveBeenCalled();
},
};
@@ -0,0 +1,376 @@
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
createChatModelConfig as createChatModelConfigMutation,
createChatProviderConfig as createChatProviderConfigMutation,
deleteChatModelConfig as deleteChatModelConfigMutation,
updateChatModelConfig as updateChatModelConfigMutation,
updateChatProviderConfig as updateChatProviderConfigMutation,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader2Icon } from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions";
import { normalizeProvider, readOptionalString } from "./helpers";
import { ModelsSection } from "./ModelsSection";
import { ProvidersSection } from "./ProvidersSection";
// ── Exported types ─────────────────────────────────────────────
export type ProviderState = {
provider: string;
label: string;
providerConfig: TypesGen.ChatProviderConfig | undefined;
modelConfigs: readonly TypesGen.ChatModelConfig[];
catalogModelCount: number;
hasManagedAPIKey: boolean;
hasCatalogAPIKey: boolean;
hasEffectiveAPIKey: boolean;
isEnvPreset: boolean;
baseURL: string;
};
export type ChatModelAdminSection = "providers" | "models";
// ── Internal helpers ───────────────────────────────────────────
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
const nilUUID = "00000000-0000-0000-0000-000000000000";
const envPresetProviders = new Set(["openai", "anthropic"]);
const hasProviderAPIKey = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): boolean => {
if (!providerConfig) return false;
return providerConfig.has_api_key;
};
const getProviderConfigSource = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): TypesGen.ChatProviderConfigSource | undefined => {
return providerConfig?.source;
};
const isDatabaseProviderConfig = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
source: TypesGen.ChatProviderConfigSource | undefined,
): providerConfig is TypesGen.ChatProviderConfig => {
if (!providerConfig) return false;
if (providerConfig.id === nilUUID) return false;
return source === undefined || source === "database";
};
const getCatalogProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): readonly CatalogProvider[] => {
const providers = catalog?.providers;
return Array.isArray(providers) ? providers : [];
};
const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean =>
provider.available ||
(Boolean(provider.unavailable_reason) &&
provider.unavailable_reason !== "missing_api_key");
const getProviderModels = (
provider: CatalogProvider | undefined,
): readonly CatalogProvider["models"][number][] => {
const models = provider?.models;
return Array.isArray(models) ? models : [];
};
const getProviderBaseURL = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): string => {
return readOptionalString(providerConfig?.base_url) ?? "";
};
// ── Hook: compute provider states from query data ──────────────
const useProviderStates = (
modelConfigs: readonly TypesGen.ChatModelConfig[],
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
catalogData: TypesGen.ChatModelsResponse | null | undefined,
): readonly ProviderState[] =>
useMemo(() => {
const orderedProviders: string[] = [];
const seenProviders = new Set<string>();
const includeProvider = (providerValue: string) => {
const normalized = normalizeProvider(providerValue);
if (!normalized || seenProviders.has(normalized)) return;
seenProviders.add(normalized);
orderedProviders.push(normalized);
};
const catalogProviders = getCatalogProviders(catalogData);
const catalogProvidersByProvider = new Map<string, CatalogProvider>();
for (const cp of catalogProviders) {
const normalized = normalizeProvider(cp.provider);
if (!normalized) continue;
includeProvider(normalized);
catalogProvidersByProvider.set(normalized, cp);
}
for (const pc of providerConfigsData ?? []) {
includeProvider(pc.provider);
}
for (const mc of modelConfigs) {
includeProvider(mc.provider);
}
const providerConfigsByProvider = new Map<
string,
TypesGen.ChatProviderConfig
>();
for (const pc of providerConfigsData ?? []) {
const normalized = normalizeProvider(pc.provider);
if (!normalized) continue;
providerConfigsByProvider.set(normalized, pc);
}
const modelConfigsByProvider = new Map<
string,
TypesGen.ChatModelConfig[]
>();
for (const mc of modelConfigs) {
const normalized = normalizeProvider(mc.provider);
if (!normalized) continue;
const existing = modelConfigsByProvider.get(normalized);
if (existing) {
existing.push(mc);
} else {
modelConfigsByProvider.set(normalized, [mc]);
}
}
return orderedProviders.map((provider) => {
const providerConfigEntry = providerConfigsByProvider.get(provider);
const providerConfigSource = getProviderConfigSource(providerConfigEntry);
const providerConfig = isDatabaseProviderConfig(
providerConfigEntry,
providerConfigSource,
)
? providerConfigEntry
: undefined;
const catalogProvider = catalogProvidersByProvider.get(provider);
const catalogProviderSource = readOptionalString(
(catalogProvider as CatalogProvider & { source?: string })?.source,
);
const hasManagedAPIKey = hasProviderAPIKey(providerConfig);
const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry);
const hasCatalogAPIKey = catalogProvider
? providerHasCatalogAPIKey(catalogProvider)
: false;
const label =
readOptionalString(providerConfigEntry?.display_name) ??
formatProviderLabel(provider);
const modelConfigsForProvider =
modelConfigsByProvider.get(provider) ?? [];
const isCatalogEnvPreset =
!providerConfig &&
envPresetProviders.has(provider) &&
(catalogProviderSource === "env" || hasCatalogAPIKey);
const isEnvPreset =
providerConfigSource === "env_preset" || isCatalogEnvPreset;
return {
provider,
label,
providerConfig,
modelConfigs: modelConfigsForProvider,
catalogModelCount: getProviderModels(catalogProvider).length,
hasManagedAPIKey,
hasCatalogAPIKey,
hasEffectiveAPIKey: providerConfigEntry
? hasProviderEntryAPIKey
: hasManagedAPIKey || hasCatalogAPIKey,
isEnvPreset,
baseURL: getProviderBaseURL(providerConfigEntry),
};
});
}, [modelConfigs, catalogData, providerConfigsData]);
// ── Component ──────────────────────────────────────────────────
type ChatModelAdminPanelProps = {
className?: string;
section?: ChatModelAdminSection;
};
export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
className,
section = "providers",
}) => {
const queryClient = useQueryClient();
const [requestedProvider, setRequestedProvider] = useState<string | null>(
null,
);
// ── Queries ────────────────────────────────────────────────
const providerConfigsQuery = useQuery(chatProviderConfigs());
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
// ── Mutations ──────────────────────────────────────────────
const createProviderMut = useMutation(
createChatProviderConfigMutation(queryClient),
);
const updateProviderMut = useMutation(
updateChatProviderConfigMutation(queryClient),
);
const createModelMut = useMutation(
createChatModelConfigMutation(queryClient),
);
const updateModelMut = useMutation(
updateChatModelConfigMutation(queryClient),
);
const deleteModelMut = useMutation(
deleteChatModelConfigMutation(queryClient),
);
// ── Sorted model configs ───────────────────────────────────
const modelConfigs = useMemo(
() =>
(modelConfigsQuery.data ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider);
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
}),
[modelConfigsQuery.data],
);
// ── Provider states ────────────────────────────────────────
const providerStates = useProviderStates(
modelConfigs,
providerConfigsQuery.data,
modelCatalogQuery.data,
);
// Derive the effective selected provider from user intent + available
// providers. This avoids a useEffect + setState cycle that would cause
// an extra render with a stale value.
const selectedProvider = useMemo(() => {
if (
requestedProvider &&
providerStates.some((ps) => ps.provider === requestedProvider)
) {
return requestedProvider;
}
return providerStates[0]?.provider ?? null;
}, [requestedProvider, providerStates]);
const selectedProviderState = useMemo(
() =>
selectedProvider
? (providerStates.find((ps) => ps.provider === selectedProvider) ??
null)
: null,
[providerStates, selectedProvider],
);
// ── Derived state ──────────────────────────────────────────
const isLoading =
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading;
const providerConfigsUnavailable = providerConfigsQuery.data === null;
const modelConfigsUnavailable = modelConfigsQuery.data === null;
const isProviderMutationPending =
createProviderMut.isPending || updateProviderMut.isPending;
const providerMutationError =
createProviderMut.error ?? updateProviderMut.error;
const modelMutationError =
createModelMut.error ?? updateModelMut.error ?? deleteModelMut.error;
return (
<div className={cn("space-y-3", className)}>
{/* Header */}
<div className="flex items-center justify-between gap-4">
<p className="m-0 text-[13px] leading-relaxed text-content-secondary">
{section === "providers"
? "Configure provider credentials and network settings."
: "Manage models available in Agents across all providers."}
</p>
{isLoading && (
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
<Loader2Icon className="h-4 w-4 animate-spin" />
Loading
</div>
)}
</div>
{/* Alerts */}
{providerConfigsQuery.isError && (
<ErrorAlert error={providerConfigsQuery.error} />
)}
{modelConfigsQuery.isError && (
<ErrorAlert error={modelConfigsQuery.error} />
)}
{modelCatalogQuery.isError && (
<ErrorAlert error={modelCatalogQuery.error} />
)}
{providerMutationError && <ErrorAlert error={providerMutationError} />}
{modelMutationError && <ErrorAlert error={modelMutationError} />}
{providerConfigsUnavailable && (
<Alert severity="info" className="mb-3">
<AlertTitle>
Chat provider admin API is unavailable on this deployment.
</AlertTitle>
<AlertDetail>/api/v2/chats/providers is missing.</AlertDetail>
</Alert>
)}
{modelConfigsUnavailable && (
<Alert severity="info" className="mb-3">
<AlertTitle>
Chat model admin API is unavailable on this deployment.
</AlertTitle>
<AlertDetail>/api/v2/chats/model-configs is missing.</AlertDetail>
</Alert>
)}
{/* Content */}
{section === "providers" ? (
<ProvidersSection
providerStates={providerStates}
providerConfigsUnavailable={providerConfigsUnavailable}
isProviderMutationPending={isProviderMutationPending}
onCreateProvider={(req) => createProviderMut.mutateAsync(req)}
onUpdateProvider={(providerConfigId, req) =>
updateProviderMut.mutateAsync({
providerConfigId,
req,
})
}
onSelectedProviderChange={setRequestedProvider}
/>
) : (
<ModelsSection
providerStates={providerStates}
selectedProvider={selectedProvider}
selectedProviderState={selectedProviderState}
onSelectedProviderChange={setRequestedProvider}
modelConfigs={modelConfigs}
modelConfigsUnavailable={modelConfigsUnavailable}
isCreating={createModelMut.isPending}
isUpdating={updateModelMut.isPending}
isDeleting={deleteModelMut.isPending}
onCreateModel={(req) => createModelMut.mutateAsync(req)}
onUpdateModel={(modelConfigId, req) =>
updateModelMut.mutateAsync({
modelConfigId,
req,
})
}
onDeleteModel={(id) => deleteModelMut.mutateAsync(id)}
/>
)}
</div>
);
};
@@ -0,0 +1,536 @@
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Textarea } from "components/Textarea/Textarea";
import { type FormikContextType, getIn } from "formik";
import type { FC } from "react";
import { cn } from "utils/cn";
import { normalizeProvider } from "./helpers";
import type {
ModelConfigFormBuildResult,
ModelFormValues,
} from "./modelConfigFormLogic";
export const modelConfigReasoningEffortOptions = [
"minimal",
"low",
"medium",
"high",
"xhigh",
"none",
] as const;
export const modelConfigAnthropicEffortOptions = [
"low",
"medium",
"high",
"max",
] as const;
export const modelConfigTextVerbosityOptions = [
"low",
"medium",
"high",
] as const;
/** Sentinel value for Select components to represent "no selection". */
const unsetSelectValue = "__unset__";
// ── Generic field renderers ────────────────────────────────────
type FieldRenderContext = {
form: FormikContextType<ModelFormValues>;
fieldErrors: ModelConfigFormBuildResult["fieldErrors"];
disabled: boolean;
};
const InputField: FC<
FieldRenderContext & {
fieldKey: string;
label: string;
placeholder: string;
}
> = ({ form, fieldErrors, disabled, fieldKey, label, placeholder }) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[fieldKey];
const fieldProps = form.getFieldProps(fieldKey);
return (
<div className="grid gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
<Input
id={fieldKey}
className={cn(
"h-10 text-[13px] placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
aria-invalid={!!fieldError}
aria-describedby={fieldError ? errorId : undefined}
/>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
};
const SelectField: FC<
FieldRenderContext & {
fieldKey: string;
label: string;
options: readonly string[];
}
> = ({ form, fieldErrors, disabled, fieldKey, label, options }) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[fieldKey];
const currentValue = (getIn(form.values, fieldKey) as string) || "";
return (
<div className="grid gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
<Select
value={currentValue || unsetSelectValue}
onValueChange={(value) =>
void form.setFieldValue(
fieldKey,
value === unsetSelectValue ? "" : value,
)
}
disabled={disabled}
>
<SelectTrigger
id={fieldKey}
className={cn(
"h-10 text-[13px]",
fieldError && "border-content-destructive",
)}
aria-invalid={!!fieldError}
aria-describedby={fieldError ? errorId : undefined}
>
<SelectValue placeholder="Use backend default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={unsetSelectValue}>Use backend default</SelectItem>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
};
const JSONField: FC<
FieldRenderContext & {
fieldKey: string;
label: string;
placeholder: string;
}
> = ({ form, fieldErrors, disabled, fieldKey, label, placeholder }) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[fieldKey];
const fieldProps = form.getFieldProps(fieldKey);
return (
<div className="grid gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
<Textarea
id={fieldKey}
className={cn(
"min-h-[96px] font-mono text-xs placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
aria-invalid={!!fieldError}
aria-describedby={fieldError ? errorId : undefined}
/>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
};
// ── Provider-specific field sets ───────────────────────────────
const OpenAIFields: FC<FieldRenderContext & { sectionTitle: string }> = (
props,
) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
{props.sectionTitle}
</p>
<div className="grid gap-3 md:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openai.reasoningEffort"
label="Reasoning effort"
options={modelConfigReasoningEffortOptions}
/>
<SelectField
{...props}
fieldKey="config.openai.parallelToolCalls"
label="Parallel tool calls"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openai.textVerbosity"
label="Text verbosity"
options={modelConfigTextVerbosityOptions}
/>
<InputField
{...props}
fieldKey="config.openai.serviceTier"
label="Service tier"
placeholder="auto"
/>
<InputField
{...props}
fieldKey="config.openai.reasoningSummary"
label="Reasoning summary"
placeholder="detailed"
/>
<InputField
{...props}
fieldKey="config.openai.user"
label="User"
placeholder="end-user-id"
/>
</div>
</div>
);
const AnthropicFields: FC<FieldRenderContext & { sectionTitle: string }> = (
props,
) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
{props.sectionTitle}
</p>
<div className="grid gap-3 md:grid-cols-2">
<SelectField
{...props}
fieldKey="config.anthropic.effort"
label="Output effort"
options={modelConfigAnthropicEffortOptions}
/>
<InputField
{...props}
fieldKey="config.anthropic.thinkingBudgetTokens"
label="Thinking budget tokens"
placeholder="4000"
/>
<SelectField
{...props}
fieldKey="config.anthropic.sendReasoning"
label="Send reasoning"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.anthropic.disableParallelToolUse"
label="Disable parallel tool use"
options={["true", "false"]}
/>
</div>
</div>
);
const GoogleFields: FC<FieldRenderContext> = (props) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
Google options
</p>
<div className="grid gap-3 md:grid-cols-2">
<InputField
{...props}
fieldKey="config.google.thinkingBudget"
label="Thinking budget"
placeholder="1024"
/>
<SelectField
{...props}
fieldKey="config.google.includeThoughts"
label="Include thoughts"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.google.cachedContent"
label="Cached content"
placeholder="cached-contents/abc123"
/>
<JSONField
{...props}
fieldKey="config.google.safetySettingsJSON"
label="Safety settings JSON"
placeholder={`[
{"category":"HARM_CATEGORY_DANGEROUS_CONTENT","threshold":"BLOCK_ONLY_HIGH"}
]`}
/>
</div>
</div>
);
const OpenAICompatFields: FC<FieldRenderContext> = (props) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
OpenAI-compatible options
</p>
<div className="grid gap-3 md:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openaicompat.reasoningEffort"
label="Reasoning effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.openaicompat.user"
label="User"
placeholder="end-user-id"
/>
</div>
</div>
);
const OpenRouterFields: FC<FieldRenderContext> = (props) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
OpenRouter options
</p>
<div className="grid gap-3 md:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openrouter.reasoningEnabled"
label="Reasoning enabled"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.reasoningEffort"
label="Reasoning effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.openrouter.reasoningMaxTokens"
label="Reasoning max tokens"
placeholder="2048"
/>
<SelectField
{...props}
fieldKey="config.openrouter.reasoningExclude"
label="Reasoning exclude"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.parallelToolCalls"
label="Parallel tool calls"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.includeUsage"
label="Include usage"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.openrouter.user"
label="User"
placeholder="end-user-id"
/>
</div>
</div>
);
const VercelFields: FC<FieldRenderContext> = (props) => (
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
Vercel options
</p>
<div className="grid gap-3 md:grid-cols-2">
<SelectField
{...props}
fieldKey="config.vercel.reasoningEnabled"
label="Reasoning enabled"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.vercel.reasoningEffort"
label="Reasoning effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.vercel.reasoningMaxTokens"
label="Reasoning max tokens"
placeholder="2048"
/>
<SelectField
{...props}
fieldKey="config.vercel.reasoningExclude"
label="Reasoning exclude"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.vercel.parallelToolCalls"
label="Parallel tool calls"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.vercel.user"
label="User"
placeholder="end-user-id"
/>
</div>
</div>
);
// ── Main component ─────────────────────────────────────────────
type ModelConfigFieldsProps = {
provider: string;
form: FormikContextType<ModelFormValues>;
fieldErrors: ModelConfigFormBuildResult["fieldErrors"];
disabled: boolean;
};
export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
form,
fieldErrors,
disabled,
}) => {
const ctx: FieldRenderContext = {
form,
fieldErrors,
disabled,
};
const normalized = normalizeProvider(provider);
const renderProviderSpecificFields = () => {
switch (normalized) {
case "openai":
return <OpenAIFields {...ctx} sectionTitle="OpenAI options" />;
case "azure":
return <OpenAIFields {...ctx} sectionTitle="OpenAI options (Azure)" />;
case "anthropic":
return <AnthropicFields {...ctx} sectionTitle="Anthropic options" />;
case "bedrock":
return (
<AnthropicFields
{...ctx}
sectionTitle="Anthropic options (Bedrock)"
/>
);
case "google":
return <GoogleFields {...ctx} />;
case "openaicompat":
return <OpenAICompatFields {...ctx} />;
case "openrouter":
return <OpenRouterFields {...ctx} />;
case "vercel":
return <VercelFields {...ctx} />;
default:
return (
<p className="m-0 text-xs text-content-secondary">
No provider-specific options are available for this provider.
</p>
);
}
};
return (
<div className="space-y-2">
<p className="m-0 text-[13px] font-medium text-content-primary">
Model call config
</p>
<div className="space-y-2">
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
General options
</p>
<div className="grid gap-3 md:grid-cols-2">
<InputField
{...ctx}
fieldKey="config.maxOutputTokens"
label="Max output tokens"
placeholder="32000"
/>
<InputField
{...ctx}
fieldKey="config.temperature"
label="Temperature"
placeholder="0.2"
/>
<InputField
{...ctx}
fieldKey="config.topP"
label="Top P"
placeholder="0.95"
/>
<InputField
{...ctx}
fieldKey="config.topK"
label="Top K"
placeholder="40"
/>
<InputField
{...ctx}
fieldKey="config.presencePenalty"
label="Presence penalty"
placeholder="0"
/>
<InputField
{...ctx}
fieldKey="config.frequencyPenalty"
label="Frequency penalty"
placeholder="0"
/>
</div>
</div>
{renderProviderSpecificFields()}
</div>
);
};
@@ -0,0 +1,604 @@
import { getErrorMessage } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { Checkbox } from "components/Checkbox/Checkbox";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { useFormik } from "formik";
import { ArrowLeftIcon, Loader2Icon, PlusIcon, SaveIcon } from "lucide-react";
import { type FC, useMemo } from "react";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { getFormHelpers } from "utils/formUtils";
import * as Yup from "yup";
import type { ProviderState } from "./ChatModelAdminPanel";
import { ModelConfigFields } from "./ModelConfigFields";
import {
buildInitialModelFormValues,
buildModelConfigFromForm,
type ModelFormValues,
parsePositiveInteger,
parseThresholdInteger,
} from "./modelConfigFormLogic";
import { getModelConfigSchemaReference } from "./modelConfigSchemas";
import { ProviderIcon } from "./ProviderIcon";
// ── Validation ──────────────────────────────────────────────────
const makeValidationSchema = (isEditing: boolean) =>
Yup.object({
model: Yup.string().trim().required("Model ID is required."),
displayName: Yup.string(),
contextLimit: isEditing
? Yup.string()
.trim()
.required("Context limit is required.")
.test(
"positive-integer",
"Context limit must be a positive integer.",
(value) => !value || parsePositiveInteger(value) !== null,
)
: Yup.string().test(
"positive-integer",
"Context limit must be a positive integer.",
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
),
compressionThreshold: isEditing
? Yup.string()
.trim()
.required("Compression threshold is required.")
.test(
"threshold-range",
"Compression threshold must be a number between 0 and 100.",
(value) => !value || parseThresholdInteger(value) !== null,
)
: Yup.string().test(
"threshold-range",
"Compression threshold must be a number between 0 and 100.",
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
),
isDefault: Yup.boolean(),
});
// ── Component ──────────────────────────────────────────────────
type ModelFormProps = {
/** When set, the form is in "edit" mode for the given model. */
editingModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
onSelectedProviderChange: (provider: string) => void;
modelConfigsUnavailable: boolean;
isSaving: boolean;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onCancel: () => void;
};
export const ModelForm: FC<ModelFormProps> = ({
editingModel,
providerStates,
selectedProvider,
selectedProviderState,
onSelectedProviderChange,
modelConfigsUnavailable,
isSaving,
onCreateModel,
onUpdateModel,
onCancel,
}) => {
const isEditing = Boolean(editingModel);
const canManageModels = Boolean(
selectedProviderState?.providerConfig &&
selectedProviderState.hasEffectiveAPIKey,
);
const validationSchema = useMemo(
() => makeValidationSchema(isEditing),
[isEditing],
);
const form = useFormik<ModelFormValues>({
initialValues: buildInitialModelFormValues(editingModel),
validationSchema,
validateOnMount: true,
validateOnBlur: false,
onSubmit: async (values) => {
if (isSaving) return;
const trimmedModel = values.model.trim();
if (!trimmedModel) return;
const parsedContextLimit = parsePositiveInteger(values.contextLimit);
const parsedCompressionThreshold = parseThresholdInteger(
values.compressionThreshold,
);
const buildResult = buildModelConfigFromForm(
selectedProviderState?.provider,
values.config,
);
if (Object.keys(buildResult.fieldErrors).length > 0) return;
const trimmedDisplayName = values.displayName.trim();
const builtModelConfig = buildResult.modelConfig;
try {
if (isEditing && editingModel) {
const req: TypesGen.UpdateChatModelConfigRequest = {
...(trimmedModel !== editingModel.model && {
model: trimmedModel,
}),
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
display_name: trimmedDisplayName,
}),
...(parsedContextLimit !== null &&
parsedContextLimit !== editingModel.context_limit && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null &&
parsedCompressionThreshold !==
editingModel.compression_threshold && {
compression_threshold: parsedCompressionThreshold,
}),
...(values.isDefault !== editingModel.is_default && {
is_default: values.isDefault,
}),
// Always send model_config so it can be cleared or updated.
model_config: builtModelConfig,
};
await onUpdateModel(editingModel.id, req);
} else {
if (!selectedProviderState?.providerConfig) return;
const req: TypesGen.CreateChatModelConfigRequest = {
provider: selectedProviderState.provider,
model: trimmedModel,
...(parsedContextLimit !== null && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null && {
compression_threshold: parsedCompressionThreshold,
}),
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
...(values.isDefault && {
is_default: true,
}),
...(builtModelConfig && {
model_config: builtModelConfig,
}),
};
await onCreateModel(req);
}
// Navigation is handled by the parent (ModelsSection) after
// the mutation promise resolves, so we do not call onCancel()
// here to avoid a double view-transition.
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to save model configuration."),
);
}
},
});
const getFieldHelpers = getFormHelpers(form);
const modelConfigFormBuildResult = useMemo(
() =>
buildModelConfigFromForm(
selectedProviderState?.provider,
form.values.config,
),
[selectedProviderState?.provider, form.values.config],
);
const hasFieldErrors =
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
const modelConfigSchemaReference = useMemo(
() => getModelConfigSchemaReference(selectedProviderState),
[selectedProviderState],
);
// ── Provider select (shared across all form states) ───────
const providerSelect = (
<div className="grid gap-1.5">
<Label
htmlFor="providerSelect"
className="text-[13px] font-medium text-content-primary"
>
Provider
</Label>
<Select
value={selectedProvider ?? ""}
onValueChange={onSelectedProviderChange}
disabled={isEditing || providerStates.length === 0}
>
<SelectTrigger
id="providerSelect"
className="h-10 max-w-[240px] text-[13px]"
>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providerStates.map((ps) => (
<SelectItem key={ps.provider} value={ps.provider}>
<span className="flex items-center gap-2">
<ProviderIcon
provider={ps.provider}
className="h-4 w-4"
active={ps.hasEffectiveAPIKey}
/>
{ps.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// No provider selected or configs unavailable.
if (!selectedProviderState || modelConfigsUnavailable) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
<Button
variant="subtle"
size="icon"
className="h-8 w-8 shrink-0"
onClick={onCancel}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<h3 className="m-0 text-base font-semibold text-content-primary">
{isEditing ? "Edit model" : "Add model"}
</h3>
</div>
<div className="space-y-3 p-6">{providerSelect}</div>
</div>
);
}
// Provider can't manage models.
if (!canManageModels && !isEditing) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
<Button
variant="subtle"
size="icon"
className="h-8 w-8 shrink-0"
onClick={onCancel}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<h3 className="m-0 text-base font-semibold text-content-primary">
Add model
</h3>
</div>
<div className="space-y-3 p-6">
{providerSelect}
<p className="text-[13px] text-content-secondary">
{!selectedProviderState.providerConfig
? "Create a managed provider config on the Providers tab before adding models."
: "Set an API key for this provider on the Providers tab before adding models."}
</p>
</div>
</div>
);
}
// ── Full form ─────────────────────────────────────────────
const modelField = getFieldHelpers("model");
const displayNameField = getFieldHelpers("displayName");
const contextLimitField = getFieldHelpers("contextLimit");
const compressionThresholdField = getFieldHelpers("compressionThreshold");
return (
<div className="flex h-full flex-col">
{/* Header bar with back button */}
<div className="flex items-center justify-between gap-3 border-b border-border px-6 py-4">
<div className="flex items-center gap-2">
<Button
variant="subtle"
size="icon"
className="h-8 w-8 shrink-0"
onClick={onCancel}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
<h3 className="m-0 text-base font-semibold text-content-primary">
{isEditing ? "Edit model" : "Add model"}
</h3>
{selectedProviderState && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-secondary/40 px-2 py-0.5 text-xs text-content-secondary">
<ProviderIcon
provider={selectedProviderState.provider}
className="h-3.5 w-3.5"
active
/>
{selectedProviderState.label}
</span>
)}
</div>
</div>
{/* Form body */}
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit}
>
<div className="flex-1 space-y-5 overflow-y-auto p-6">
{/* Model identity */}
<div className="space-y-3">
<div>
<p className="m-0 text-[13px] font-medium text-content-primary">
Model identity
</p>
<p className="m-0 text-xs text-content-secondary">
Select provider and model naming details.
</p>
</div>
<div className="grid items-start gap-3 md:grid-cols-3">
{providerSelect}
<div className="grid gap-1.5">
<Label
htmlFor={modelField.id}
className="text-[13px] font-medium text-content-primary"
>
Model ID{" "}
<span className="text-xs text-content-destructive font-bold">
*
</span>
</Label>
<Input
id={modelField.id}
name={modelField.name}
className={cn(
"h-10 text-[13px] placeholder:text-content-disabled",
modelField.error && "border-content-destructive",
)}
placeholder="gpt-5, claude-sonnet-4-5, etc."
value={modelField.value}
onChange={modelField.onChange}
onBlur={modelField.onBlur}
disabled={isSaving}
aria-invalid={modelField.error}
aria-describedby={
modelField.error ? `${modelField.id}-error` : undefined
}
/>
{modelField.error && (
<p
id={`${modelField.id}-error`}
className="m-0 text-xs text-content-destructive"
>
{modelField.helperText}
</p>
)}
</div>
<div className="grid gap-1.5">
<Label
htmlFor={displayNameField.id}
className="text-[13px] font-medium text-content-primary"
>
Display name
</Label>
<Input
id={displayNameField.id}
name={displayNameField.name}
className="h-10 text-[13px] placeholder:text-content-disabled"
placeholder="Friendly label"
value={displayNameField.value}
onChange={displayNameField.onChange}
onBlur={displayNameField.onBlur}
disabled={isSaving}
/>
</div>
</div>
</div>
{/* Runtime limits */}
<div className="space-y-3">
<div>
<p className="m-0 text-[13px] font-medium text-content-primary">
Runtime limits
</p>
<p className="m-0 text-xs text-content-secondary">
{isEditing
? "These values are required for existing models."
: "Leave values blank to use backend defaults."}
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
className="text-[13px] font-medium text-content-primary"
>
Context limit{" "}
{isEditing && (
<span className="text-xs text-content-destructive font-bold">
*
</span>
)}
</Label>
<Input
id={contextLimitField.id}
name={contextLimitField.name}
className={cn(
"h-10 text-[13px] placeholder:text-content-disabled",
contextLimitField.error && "border-content-destructive",
)}
placeholder="200000"
value={contextLimitField.value}
onChange={contextLimitField.onChange}
onBlur={contextLimitField.onBlur}
disabled={isSaving}
aria-invalid={contextLimitField.error}
aria-describedby={
contextLimitField.error
? `${contextLimitField.id}-error`
: undefined
}
/>
{contextLimitField.error && (
<p
id={`${contextLimitField.id}-error`}
className="m-0 text-xs text-content-destructive"
>
{contextLimitField.helperText}
</p>
)}
</div>
<div className="grid gap-1.5">
<Label
htmlFor={compressionThresholdField.id}
className="text-[13px] font-medium text-content-primary"
>
Compression threshold{" "}
{isEditing && (
<span className="text-xs text-content-destructive font-bold">
*
</span>
)}
</Label>
<Input
id={compressionThresholdField.id}
name={compressionThresholdField.name}
className={cn(
"h-10 text-[13px] placeholder:text-content-disabled",
compressionThresholdField.error &&
"border-content-destructive",
)}
placeholder="70"
value={compressionThresholdField.value}
onChange={compressionThresholdField.onChange}
onBlur={compressionThresholdField.onBlur}
disabled={isSaving}
aria-invalid={compressionThresholdField.error}
aria-describedby={
compressionThresholdField.error
? `${compressionThresholdField.id}-error`
: undefined
}
/>
{compressionThresholdField.error && (
<p
id={`${compressionThresholdField.id}-error`}
className="m-0 text-xs text-content-destructive"
>
{compressionThresholdField.helperText}
</p>
)}
</div>
</div>
</div>
<div className="space-y-3">
<div>
<p className="m-0 text-[13px] font-medium text-content-primary">
Default behavior
</p>
<p className="m-0 text-xs text-content-secondary">
Only one model can be the default for new prompts.
</p>
</div>
<label
htmlFor="isDefault"
className="flex items-start gap-2 text-[13px] text-content-primary"
>
<Checkbox
id="isDefault"
checked={form.values.isDefault}
onCheckedChange={(checked) =>
void form.setFieldValue("isDefault", checked === true)
}
disabled={isSaving}
/>
<span>Use this as the default model for new prompts.</span>
</label>
</div>
{/* Model call config fields */}
<ModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
{/* Schema reference */}
<details className="group rounded-xl border border-border-default/80 bg-surface-secondary/20 shadow-sm">
<summary className="cursor-pointer select-none px-4 py-3 text-[13px] font-medium text-content-secondary hover:text-content-primary">
Model config schema reference (
{modelConfigSchemaReference.providerLabel})
</summary>
<div className="space-y-2 border-t border-border/60 px-4 pb-4 pt-3">
<p className="m-0 text-xs text-content-secondary">
Reference JSON for <code>create/update chat model config</code>{" "}
payloads.
</p>
{modelConfigSchemaReference.notes.map((note) => (
<p key={note} className="m-0 text-xs text-content-secondary">
{note}
</p>
))}
<pre
data-testid="chat-model-config-schema"
className="max-h-60 overflow-auto rounded-md border border-border-default/80 bg-surface-primary/80 p-2 font-mono text-[11px] leading-relaxed text-content-secondary"
>
{modelConfigSchemaReference.schemaJSON}
</pre>
</div>
</details>
</div>
{/* Sticky footer actions */}
<div className="flex items-center justify-end gap-2 border-t border-border bg-surface-primary px-6 py-4">
<Button size="sm" variant="outline" type="button" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
type="submit"
disabled={isSaving || !form.isValid || hasFieldErrors}
>
{isSaving ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : isEditing ? (
<SaveIcon className="h-4 w-4" />
) : (
<PlusIcon className="h-4 w-4" />
)}
{isEditing ? "Save changes" : "Add model"}
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,230 @@
import type * as TypesGen from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions";
import type { ProviderState } from "./ChatModelAdminPanel";
import { ModelForm } from "./ModelForm";
import { ProviderIcon } from "./ProviderIcon";
type ModelView =
| { mode: "list" }
| { mode: "add" }
| { mode: "edit"; model: TypesGen.ChatModelConfig };
type ModelsSectionProps = {
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
onSelectedProviderChange: (provider: string) => void;
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsUnavailable: boolean;
isCreating: boolean;
isUpdating: boolean;
isDeleting: boolean;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onDeleteModel: (modelConfigId: string) => Promise<void>;
};
export const ModelsSection: FC<ModelsSectionProps> = ({
providerStates,
selectedProvider,
selectedProviderState,
onSelectedProviderChange,
modelConfigs,
modelConfigsUnavailable,
isCreating,
isUpdating,
isDeleting,
onCreateModel,
onUpdateModel,
onDeleteModel,
}) => {
const [view, setView] = useState<ModelView>({ mode: "list" });
const [modelToDelete, setModelToDelete] =
useState<TypesGen.ChatModelConfig | null>(null);
// When the form is open it takes over the full panel.
if (view.mode === "add" || view.mode === "edit") {
const editingModel = view.mode === "edit" ? view.model : undefined;
// When editing, select the model's provider so the form shows
// the correct provider-specific fields.
const effectiveProvider = editingModel
? editingModel.provider
: selectedProvider;
const effectiveProviderState = editingModel
? (providerStates.find((ps) => ps.provider === editingModel.provider) ??
null)
: selectedProviderState;
return (
<ModelForm
key={editingModel?.id ?? effectiveProvider ?? "new"}
editingModel={editingModel}
providerStates={providerStates}
selectedProvider={effectiveProvider}
selectedProviderState={effectiveProviderState}
onSelectedProviderChange={onSelectedProviderChange}
modelConfigsUnavailable={modelConfigsUnavailable}
isSaving={isCreating || isUpdating}
onCreateModel={async (req) => {
await onCreateModel(req);
setView({ mode: "list" });
}}
onUpdateModel={async (id, req) => {
await onUpdateModel(id, req);
setView({ mode: "list" });
}}
onCancel={() => setView({ mode: "list" })}
/>
);
}
// ── List view ──────────────────────────────────────────────
return (
<>
<div className="space-y-4">
{/* Add model button */}
<div className="flex items-center justify-end">
<Button
size="sm"
className="gap-1.5"
onClick={() => setView({ mode: "add" })}
>
<PlusIcon className="h-4 w-4" />
Add model
</Button>
</div>
{/* Model list */}
{modelConfigs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-surface-secondary/20 px-6 py-12 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-tertiary/50">
<PlusIcon className="h-5 w-5 text-content-secondary" />
</div>
<div>
<p className="m-0 text-[13px] font-medium text-content-primary">
No models configured
</p>
<p className="m-0 mt-1 text-xs text-content-secondary">
Add a model to get started with Agents.
</p>
</div>
<Button
size="sm"
variant="outline"
className="mt-1 gap-1.5"
onClick={() => setView({ mode: "add" })}
>
<PlusIcon className="h-3.5 w-3.5" />
Add your first model
</Button>
</div>
) : (
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{modelConfigs.map((modelConfig) => (
<div
key={modelConfig.id}
className="group flex items-center gap-4 bg-surface-primary px-5 py-3.5 transition-colors hover:bg-surface-secondary/30"
>
<ProviderIcon
provider={modelConfig.provider}
className="h-8 w-8 shrink-0"
active={modelConfig.enabled !== false}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"truncate text-[13px] font-semibold",
modelConfig.enabled === false
? "text-content-secondary"
: "text-content-primary",
)}
>
{modelConfig.display_name || modelConfig.model}
</span>
{modelConfig.is_default && (
<Badge size="sm" variant="info">
default
</Badge>
)}
{modelConfig.enabled === false && (
<Badge size="sm" variant="warning">
disabled
</Badge>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-content-secondary">
<span className="inline-flex items-center gap-1">
{formatProviderLabel(modelConfig.provider)}
</span>
<span className="font-mono">{modelConfig.model}</span>
<span>
{modelConfig.context_limit.toLocaleString()} ctx
</span>
<span>{modelConfig.compression_threshold}% compress</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<Button
size="icon"
variant="subtle"
className="h-8 w-8 text-content-secondary hover:text-content-primary"
onClick={() =>
setView({
mode: "edit",
model: modelConfig,
})
}
>
<PencilIcon className="h-4 w-4" />
<span className="sr-only">Edit model</span>
</Button>
<Button
size="icon"
variant="subtle"
className="h-8 w-8 text-content-secondary hover:text-content-destructive"
onClick={() => setModelToDelete(modelConfig)}
disabled={isDeleting}
>
<Trash2Icon className="h-4 w-4" />
<span className="sr-only">Delete model</span>
</Button>
</div>
</div>
))}
</div>
)}
</div>
<DeleteDialog
isOpen={modelToDelete !== null}
onCancel={() => setModelToDelete(null)}
onConfirm={() => {
if (modelToDelete) {
void onDeleteModel(modelToDelete.id).finally(() =>
setModelToDelete(null),
);
}
}}
entity="model"
name={modelToDelete?.display_name || modelToDelete?.model || ""}
confirmLoading={isDeleting}
/>
</>
);
};
@@ -0,0 +1,226 @@
import { getErrorMessage } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
import { Button } from "components/Button/Button";
import { CollapsibleContent } from "components/Collapsible/Collapsible";
import { Input } from "components/Input/Input";
import { Loader2Icon } from "lucide-react";
import { type FC, type FormEvent, useEffect, useId, useState } from "react";
import { toast } from "sonner";
import { readOptionalString } from "./helpers";
type ProviderFormProps = {
provider: string;
providerConfig: TypesGen.ChatProviderConfig | undefined;
baseURL: string;
isEnvPreset: boolean;
providerConfigsUnavailable: boolean;
isProviderMutationPending: boolean;
onCreateProvider: (
req: TypesGen.CreateChatProviderConfigRequest,
) => Promise<unknown>;
onUpdateProvider: (
providerConfigId: string,
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
};
export const ProviderForm: FC<ProviderFormProps> = ({
provider,
providerConfig,
baseURL,
isEnvPreset,
providerConfigsUnavailable,
isProviderMutationPending,
onCreateProvider,
onUpdateProvider,
}) => {
const displayNameInputId = useId();
const apiKeyInputId = useId();
const baseURLInputId = useId();
const [displayName, setDisplayName] = useState("");
const [apiKey, setApiKey] = useState("");
const [baseURLValue, setBaseURLValue] = useState("");
useEffect(() => {
setDisplayName(readOptionalString(providerConfig?.display_name) ?? "");
setApiKey("");
setBaseURLValue(baseURL);
}, [providerConfig, baseURL]);
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
const requiresAPIKey = !providerConfig && !isAPIKeyEnvManaged;
const canSave =
!providerConfigsUnavailable &&
!isProviderMutationPending &&
!isAPIKeyEnvManaged &&
(!requiresAPIKey || apiKey.trim());
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (
providerConfigsUnavailable ||
isProviderMutationPending ||
isAPIKeyEnvManaged
) {
return;
}
const trimmedDisplayName = displayName.trim();
const trimmedAPIKey = apiKey.trim();
const trimmedBaseURL = baseURLValue.trim();
try {
if (providerConfig) {
const currentDisplayName =
readOptionalString(providerConfig.display_name) ?? "";
const currentBaseURL = baseURL.trim();
const req: TypesGen.UpdateChatProviderConfigRequest = {
...(trimmedDisplayName !== currentDisplayName && {
display_name: trimmedDisplayName,
}),
...(trimmedAPIKey && { api_key: trimmedAPIKey }),
...(trimmedBaseURL !== currentBaseURL && {
base_url: trimmedBaseURL,
}),
};
if (!req.display_name && !req.api_key && !req.base_url) {
return;
}
await onUpdateProvider(providerConfig.id, req);
} else {
if (!trimmedAPIKey) {
return;
}
const req: TypesGen.CreateChatProviderConfigRequest = {
provider,
api_key: trimmedAPIKey,
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
...(trimmedBaseURL && { base_url: trimmedBaseURL }),
};
await onCreateProvider(req);
}
// Only clear the API key field on success.
setApiKey("");
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to save provider configuration."),
);
}
};
return (
<CollapsibleContent className="border-t border-border px-5 py-4">
<div className="space-y-3">
<p className="m-0 text-xs text-content-secondary">
{providerConfig
? "Update this managed provider config for your deployment."
: isAPIKeyEnvManaged
? "This provider API key is managed by an environment variable."
: "Create a managed provider config before enabling models."}
</p>
{isAPIKeyEnvManaged && (
<Alert severity="info">
<AlertTitle>API key managed by environment variable.</AlertTitle>
<AlertDetail>
This provider key is configured from deployment environment
settings and cannot be edited in this UI.
</AlertDetail>
</Alert>
)}
{!isAPIKeyEnvManaged && (
<form
className="space-y-3"
onSubmit={(event) => void handleSubmit(event)}
>
<div className="grid gap-3 lg:grid-cols-3">
<div className="grid gap-1.5">
<label
htmlFor={displayNameInputId}
className="text-[13px] font-medium text-content-primary"
>
Display name
</label>
<Input
id={displayNameInputId}
className="h-10 text-[13px]"
placeholder="Friendly provider label"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={
providerConfigsUnavailable || isProviderMutationPending
}
/>
</div>
<div className="grid gap-1.5">
<label
htmlFor={apiKeyInputId}
className="text-[13px] font-medium text-content-primary"
>
API key{" "}
{!providerConfig && (
<span className="text-xs text-content-destructive font-bold">
*
</span>
)}
</label>
<Input
id={apiKeyInputId}
type="password"
autoComplete="off"
className="h-10 text-[13px]"
placeholder={
providerConfig
? "Leave blank to keep existing key"
: "Paste provider API key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={
providerConfigsUnavailable || isProviderMutationPending
}
/>
</div>
<div className="grid gap-1.5">
<label
htmlFor={baseURLInputId}
className="text-[13px] font-medium text-content-primary"
>
Base URL
</label>
<Input
id={baseURLInputId}
className="h-10 text-[13px]"
placeholder="https://api.example.com/v1"
value={baseURLValue}
onChange={(e) => setBaseURLValue(e.target.value)}
disabled={
providerConfigsUnavailable || isProviderMutationPending
}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border pt-3">
<Button size="sm" type="submit" disabled={!canSave}>
{isProviderMutationPending && (
<Loader2Icon className="h-4 w-4 animate-spin" />
)}
{providerConfig ? "Save changes" : "Create provider config"}
</Button>
</div>
</form>
)}
</div>
</CollapsibleContent>
);
};
@@ -0,0 +1,57 @@
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { ServerIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions";
import { normalizeProvider } from "./helpers";
const providerIconMap: Record<string, string> = {
openai: "/icon/openai.svg",
anthropic: "/icon/claude.svg",
azure: "/icon/azure.svg",
bedrock: "/icon/aws.svg",
google: "/icon/google.svg",
gemini: "/icon/gemini.svg",
};
// Some provider SVGs (e.g. OpenAI) are pure black and need
// inversion in dark mode to remain visible.
const darkInvertProviders = new Set(["openai"]);
type ProviderIconProps = {
provider: string;
className?: string;
active?: boolean;
};
export const ProviderIcon: FC<ProviderIconProps> = ({
provider,
className,
active,
}) => {
const normalized = normalizeProvider(provider);
const iconPath = providerIconMap[normalized];
if (iconPath) {
return (
<ExternalImage
src={iconPath}
alt={`${formatProviderLabel(provider)} logo`}
className={cn(
"shrink-0",
!active && "grayscale opacity-50",
darkInvertProviders.has(normalized) && "dark:invert",
className,
)}
/>
);
}
return (
<ServerIcon
className={cn(
"shrink-0",
active ? "text-content-primary" : "text-content-secondary",
className,
)}
/>
);
};
@@ -0,0 +1,149 @@
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Collapsible,
CollapsibleTrigger,
} from "components/Collapsible/Collapsible";
import { ChevronRightIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { cn } from "utils/cn";
import type { ProviderState } from "./ChatModelAdminPanel";
import { ProviderForm } from "./ProviderForm";
import { ProviderIcon } from "./ProviderIcon";
const getProviderModelsLabel = (providerState: ProviderState): string => {
if (providerState.modelConfigs.length > 0) {
return `${providerState.modelConfigs.length} configured model${providerState.modelConfigs.length === 1 ? "" : "s"}`;
}
if (providerState.catalogModelCount > 0) {
return `${providerState.catalogModelCount} catalog model${providerState.catalogModelCount === 1 ? "" : "s"}`;
}
return "No models configured";
};
type ProvidersSectionProps = {
providerStates: readonly ProviderState[];
providerConfigsUnavailable: boolean;
isProviderMutationPending: boolean;
onCreateProvider: (
req: TypesGen.CreateChatProviderConfigRequest,
) => Promise<unknown>;
onUpdateProvider: (
providerConfigId: string,
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onSelectedProviderChange: (provider: string) => void;
};
export const ProvidersSection: FC<ProvidersSectionProps> = ({
providerStates,
providerConfigsUnavailable,
isProviderMutationPending,
onCreateProvider,
onUpdateProvider,
onSelectedProviderChange,
}) => {
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
// Reset expanded provider when available providers change.
useEffect(() => {
setExpandedProvider((current) => {
if (current && providerStates.some((ps) => ps.provider === current)) {
return current;
}
return null;
});
}, [providerStates]);
if (providerStates.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-4 text-[13px] text-content-secondary">
No provider types were returned by the backend.
</div>
);
}
return (
<div className="space-y-2">
{providerStates.map((providerState) => {
const isExpanded = expandedProvider === providerState.provider;
const modelsLabel = getProviderModelsLabel(providerState);
return (
<Collapsible
key={providerState.provider}
open={isExpanded}
onOpenChange={(nextOpen) => {
setExpandedProvider(nextOpen ? providerState.provider : null);
if (nextOpen) {
onSelectedProviderChange(providerState.provider);
}
}}
>
<div
className={cn(
"rounded-xl border border-border-default bg-surface-primary shadow-sm transition-all",
isExpanded &&
"border-border-default bg-surface-secondary/30 shadow-md",
)}
>
<CollapsibleTrigger asChild>
<Button
variant="subtle"
className={cn(
"h-auto w-full justify-between gap-4 rounded-[inherit] px-5 py-3.5 text-left shadow-none",
isExpanded
? "bg-surface-secondary/30 hover:bg-surface-secondary/30"
: "hover:bg-surface-tertiary/30",
)}
>
<div className="flex min-w-0 items-center gap-3">
<ProviderIcon
provider={providerState.provider}
className="h-7 w-7"
active={providerState.hasEffectiveAPIKey}
/>
<div className="min-w-0">
<span
className={cn(
"truncate text-[15px] font-semibold",
providerState.hasEffectiveAPIKey
? "text-content-primary"
: "text-content-secondary",
)}
>
{providerState.label}
</span>
<div className="mt-0.5 flex items-center gap-2 text-xs text-content-secondary">
<span className="truncate">{modelsLabel}</span>
</div>
</div>
</div>
<ChevronRightIcon
className={cn(
"h-4 w-4 shrink-0 text-content-secondary transition-transform duration-200",
isExpanded && "rotate-90 text-content-primary",
)}
/>
</Button>
</CollapsibleTrigger>
{isExpanded && (
<ProviderForm
provider={providerState.provider}
providerConfig={providerState.providerConfig}
baseURL={providerState.baseURL}
isEnvPreset={providerState.isEnvPreset}
providerConfigsUnavailable={providerConfigsUnavailable}
isProviderMutationPending={isProviderMutationPending}
onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider}
/>
)}
</div>
</Collapsible>
);
})}
</div>
);
};
@@ -0,0 +1,16 @@
/**
* Reads a value as a non-empty string, returning undefined for
* empty strings, null, or undefined values.
*/
export function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
/**
* Normalizes a provider name for case-insensitive comparison.
*/
export function normalizeProvider(provider: string): string {
return provider.trim().toLowerCase();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,741 @@
import type * as TypesGen from "api/typesGenerated";
import * as Yup from "yup";
import { normalizeProvider } from "./helpers";
import {
modelConfigAnthropicEffortOptions,
modelConfigReasoningEffortOptions,
modelConfigTextVerbosityOptions,
} from "./ModelConfigFields";
// ── Per-provider form state types ──────────────────────────────
export type OpenAIFormState = {
reasoningEffort: string;
parallelToolCalls: string;
textVerbosity: string;
serviceTier: string;
reasoningSummary: string;
user: string;
};
export type AnthropicFormState = {
effort: string;
thinkingBudgetTokens: string;
sendReasoning: string;
disableParallelToolUse: string;
};
export type GoogleFormState = {
thinkingBudget: string;
includeThoughts: string;
cachedContent: string;
safetySettingsJSON: string;
};
export type OpenAICompatFormState = {
reasoningEffort: string;
user: string;
};
export type OpenRouterFormState = {
reasoningEnabled: string;
reasoningEffort: string;
reasoningMaxTokens: string;
reasoningExclude: string;
parallelToolCalls: string;
includeUsage: string;
user: string;
};
export type VercelFormState = {
reasoningEnabled: string;
reasoningEffort: string;
reasoningMaxTokens: string;
reasoningExclude: string;
parallelToolCalls: string;
user: string;
};
// ── Main form state type ───────────────────────────────────────
export type ModelConfigFormState = {
maxOutputTokens: string;
temperature: string;
topP: string;
topK: string;
presencePenalty: string;
frequencyPenalty: string;
openai: OpenAIFormState;
anthropic: AnthropicFormState;
google: GoogleFormState;
openaicompat: OpenAICompatFormState;
openrouter: OpenRouterFormState;
vercel: VercelFormState;
};
export type ModelConfigFormBuildResult = {
modelConfig?: TypesGen.ChatModelCallConfig;
fieldErrors: Record<string, string>;
};
// ── Empty defaults ─────────────────────────────────────────────
export const emptyOpenAIFormState: OpenAIFormState = {
reasoningEffort: "",
parallelToolCalls: "",
textVerbosity: "",
serviceTier: "",
reasoningSummary: "",
user: "",
};
export const emptyAnthropicFormState: AnthropicFormState = {
effort: "",
thinkingBudgetTokens: "",
sendReasoning: "",
disableParallelToolUse: "",
};
export const emptyGoogleFormState: GoogleFormState = {
thinkingBudget: "",
includeThoughts: "",
cachedContent: "",
safetySettingsJSON: "",
};
export const emptyOpenAICompatFormState: OpenAICompatFormState = {
reasoningEffort: "",
user: "",
};
export const emptyOpenRouterFormState: OpenRouterFormState = {
reasoningEnabled: "",
reasoningEffort: "",
reasoningMaxTokens: "",
reasoningExclude: "",
parallelToolCalls: "",
includeUsage: "",
user: "",
};
export const emptyVercelFormState: VercelFormState = {
reasoningEnabled: "",
reasoningEffort: "",
reasoningMaxTokens: "",
reasoningExclude: "",
parallelToolCalls: "",
user: "",
};
export const emptyModelConfigFormState: ModelConfigFormState = {
maxOutputTokens: "",
temperature: "",
topP: "",
topK: "",
presencePenalty: "",
frequencyPenalty: "",
openai: { ...emptyOpenAIFormState },
anthropic: { ...emptyAnthropicFormState },
google: { ...emptyGoogleFormState },
openaicompat: { ...emptyOpenAICompatFormState },
openrouter: { ...emptyOpenRouterFormState },
vercel: { ...emptyVercelFormState },
};
// ── Helpers ────────────────────────────────────────────────────
const hasObjectKeys = (value: Record<string, unknown>): boolean =>
Object.keys(value).length > 0;
export const parsePositiveInteger = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
};
export const parseThresholdInteger = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return null;
return parsed;
};
// ── Extract model config form state from an existing model ────
export const extractModelConfigFormState = (
model: TypesGen.ChatModelConfig,
): ModelConfigFormState => {
const config = model.model_config;
if (!config) {
return structuredClone(emptyModelConfigFormState);
}
const toFormString = (v: unknown): string =>
v !== undefined && v !== null ? String(v) : "";
const po = config.provider_options;
const openai = po?.openai;
const anthropic = po?.anthropic;
const google = po?.google;
const openaicompat = po?.openaicompat;
const openrouter = po?.openrouter;
const vercel = po?.vercel;
return {
maxOutputTokens: toFormString(config.max_output_tokens),
temperature: toFormString(config.temperature),
topP: toFormString(config.top_p),
topK: toFormString(config.top_k),
presencePenalty: toFormString(config.presence_penalty),
frequencyPenalty: toFormString(config.frequency_penalty),
openai: {
reasoningEffort: toFormString(openai?.reasoning_effort),
parallelToolCalls: toFormString(openai?.parallel_tool_calls),
textVerbosity: toFormString(openai?.text_verbosity),
serviceTier: toFormString(openai?.service_tier),
reasoningSummary: toFormString(openai?.reasoning_summary),
user: toFormString(openai?.user),
},
anthropic: {
effort: toFormString(anthropic?.effort),
thinkingBudgetTokens: toFormString(anthropic?.thinking?.budget_tokens),
sendReasoning: toFormString(anthropic?.send_reasoning),
disableParallelToolUse: toFormString(
anthropic?.disable_parallel_tool_use,
),
},
google: {
thinkingBudget: toFormString(google?.thinking_config?.thinking_budget),
includeThoughts: toFormString(google?.thinking_config?.include_thoughts),
cachedContent: toFormString(google?.cached_content),
safetySettingsJSON: google?.safety_settings
? JSON.stringify(google.safety_settings, null, 2)
: "",
},
openaicompat: {
reasoningEffort: toFormString(openaicompat?.reasoning_effort),
user: toFormString(openaicompat?.user),
},
openrouter: {
reasoningEnabled: toFormString(openrouter?.reasoning?.enabled),
reasoningEffort: toFormString(openrouter?.reasoning?.effort),
reasoningMaxTokens: toFormString(openrouter?.reasoning?.max_tokens),
reasoningExclude: toFormString(openrouter?.reasoning?.exclude),
parallelToolCalls: toFormString(openrouter?.parallel_tool_calls),
includeUsage: toFormString(openrouter?.include_usage),
user: toFormString(openrouter?.user),
},
vercel: {
reasoningEnabled: toFormString(vercel?.reasoning?.enabled),
reasoningEffort: toFormString(vercel?.reasoning?.effort),
reasoningMaxTokens: toFormString(vercel?.reasoning?.max_tokens),
reasoningExclude: toFormString(vercel?.reasoning?.exclude),
parallelToolCalls: toFormString(vercel?.parallel_tool_calls),
user: toFormString(vercel?.user),
},
};
};
// ── Unified form values type ─────────────────────────────────
export type ModelFormValues = {
model: string;
displayName: string;
contextLimit: string;
compressionThreshold: string;
isDefault: boolean;
config: ModelConfigFormState;
};
/**
* Build initial form values from an editing model or defaults.
*/
export const buildInitialModelFormValues = (
editingModel?: TypesGen.ChatModelConfig,
): ModelFormValues => ({
model: editingModel?.model ?? "",
displayName: editingModel?.display_name ?? "",
contextLimit: editingModel ? String(editingModel.context_limit) : "",
compressionThreshold: editingModel
? String(editingModel.compression_threshold)
: "",
isDefault: editingModel?.is_default ?? false,
config: editingModel
? extractModelConfigFormState(editingModel)
: structuredClone(emptyModelConfigFormState),
});
// ── Parsing utilities ─────────────────────────────────────────
type FieldErrors = Record<string, string>;
// ── Yup transforms ──────────────────────────────────────────
function yupOptionalInteger(label: string) {
return Yup.string().test(
"optional-integer",
`${label} must be a valid number.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return Number.isFinite(Number.parseInt(trimmed, 10));
},
);
}
function yupOptionalNumber(label: string) {
return Yup.string().test(
"optional-number",
`${label} must be a valid number.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return Number.isFinite(Number(trimmed));
},
);
}
function yupOptionalBoolean(label: string) {
return Yup.string().test(
"optional-boolean",
`${label} must be true or false.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return trimmed === "true" || trimmed === "false";
},
);
}
function yupOptionalSelect(label: string, options: readonly string[]) {
return Yup.string().test(
"optional-select",
`${label} has an invalid value.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return options.includes(trimmed);
},
);
}
function yupOptionalJSONArray(label: string) {
return Yup.string().test("optional-json-array", "", function validate(value) {
const trimmed = value?.trim();
if (!trimmed) return true;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return this.createError({
message: `${label} must be valid JSON.`,
});
}
if (!Array.isArray(parsed)) {
return this.createError({
message: `${label} must be an array.`,
});
}
return true;
});
}
// ── Per-provider Yup schemas ─────────────────────────────────
const topLevelSchema = Yup.object({
maxOutputTokens: yupOptionalInteger("Max output tokens"),
temperature: yupOptionalNumber("Temperature"),
topP: yupOptionalNumber("Top P"),
topK: yupOptionalInteger("Top K"),
presencePenalty: yupOptionalNumber("Presence penalty"),
frequencyPenalty: yupOptionalNumber("Frequency penalty"),
});
const openaiSchema = Yup.object({
reasoningEffort: yupOptionalSelect(
"Reasoning effort",
modelConfigReasoningEffortOptions,
),
parallelToolCalls: yupOptionalBoolean("Parallel tool calls"),
textVerbosity: yupOptionalSelect(
"Text verbosity",
modelConfigTextVerbosityOptions,
),
});
const anthropicSchema = Yup.object({
effort: yupOptionalSelect("Output effort", modelConfigAnthropicEffortOptions),
thinkingBudgetTokens: yupOptionalInteger("Thinking budget tokens"),
sendReasoning: yupOptionalBoolean("Send reasoning"),
disableParallelToolUse: yupOptionalBoolean("Disable parallel tool use"),
});
const googleSchema = Yup.object({
thinkingBudget: yupOptionalInteger("Thinking budget"),
includeThoughts: yupOptionalBoolean("Include thoughts"),
safetySettingsJSON: yupOptionalJSONArray("Safety settings JSON"),
});
const openaiCompatSchema = Yup.object({
reasoningEffort: yupOptionalSelect(
"Reasoning effort",
modelConfigReasoningEffortOptions,
),
});
const reasoningProviderSchema = Yup.object({
reasoningEnabled: yupOptionalBoolean("Reasoning enabled"),
reasoningEffort: yupOptionalSelect(
"Reasoning effort",
modelConfigReasoningEffortOptions,
),
reasoningMaxTokens: yupOptionalInteger("Reasoning max tokens"),
reasoningExclude: yupOptionalBoolean("Reasoning exclude"),
parallelToolCalls: yupOptionalBoolean("Parallel tool calls"),
});
const openrouterExtraSchema = Yup.object({
includeUsage: yupOptionalBoolean("Include usage"),
});
// ── Yup error collection ─────────────────────────────────────
function collectYupErrors(
schema: Yup.ObjectSchema<Record<string, unknown>>,
data: Record<string, unknown>,
fieldErrors: FieldErrors,
prefix?: string,
): void {
try {
schema.validateSync(data, { abortEarly: false });
} catch (err) {
if (err instanceof Yup.ValidationError) {
for (const inner of err.inner) {
const key = prefix ? `${prefix}.${inner.path}` : (inner.path ?? "");
fieldErrors[key] = inner.message;
}
}
}
}
// ── Post-validation transform helpers ────────────────────────
// These assume validation has already passed. Empty strings
// yield undefined so callers can conditionally include fields.
function toInt(s: string): number | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
return Number.parseInt(trimmed, 10);
}
function toNum(s: string): number | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
return Number(trimmed);
}
function toBool(s: string): boolean | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
return trimmed === "true";
}
function toTrimmedString(s: string): string | undefined {
const trimmed = s.trim();
return trimmed || undefined;
}
function toJSON(s: string): unknown | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
return JSON.parse(trimmed);
}
// ── Per-provider option builders ──────────────────────────────
/**
* Build OpenAI/Azure provider options from form state.
* Validation has already passed; uses transform helpers only.
*/
function buildOpenAIOptions(form: OpenAIFormState): Record<string, unknown> {
const reasoningEffort = toTrimmedString(form.reasoningEffort);
const parallelToolCalls = toBool(form.parallelToolCalls);
const textVerbosity = toTrimmedString(form.textVerbosity);
const serviceTier = toTrimmedString(form.serviceTier);
const reasoningSummary = toTrimmedString(form.reasoningSummary);
const user = toTrimmedString(form.user);
return {
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
...(parallelToolCalls !== undefined
? { parallel_tool_calls: parallelToolCalls }
: {}),
...(textVerbosity ? { text_verbosity: textVerbosity } : {}),
...(serviceTier ? { service_tier: serviceTier } : {}),
...(reasoningSummary ? { reasoning_summary: reasoningSummary } : {}),
...(user ? { user } : {}),
};
}
/**
* Build Anthropic/Bedrock provider options from form state.
* Validation has already passed; uses transform helpers only.
*/
function buildAnthropicOptions(
form: AnthropicFormState,
): Record<string, unknown> {
const effort = toTrimmedString(form.effort);
const budgetTokens = toInt(form.thinkingBudgetTokens);
const sendReasoning = toBool(form.sendReasoning);
const disableParallelToolUse = toBool(form.disableParallelToolUse);
return {
...(effort ? { effort } : {}),
...(budgetTokens !== undefined
? { thinking: { budget_tokens: budgetTokens } }
: {}),
...(sendReasoning !== undefined ? { send_reasoning: sendReasoning } : {}),
...(disableParallelToolUse !== undefined
? { disable_parallel_tool_use: disableParallelToolUse }
: {}),
};
}
/**
* Build Google provider options from form state.
* Validation has already passed; uses transform helpers only.
*/
function buildGoogleOptions(form: GoogleFormState): Record<string, unknown> {
const thinkingBudget = toInt(form.thinkingBudget);
const includeThoughts = toBool(form.includeThoughts);
const cachedContent = toTrimmedString(form.cachedContent);
const safetySettings = toJSON(form.safetySettingsJSON) as
| unknown[]
| undefined;
return {
...(thinkingBudget !== undefined || includeThoughts !== undefined
? {
thinking_config: {
...(thinkingBudget !== undefined
? { thinking_budget: thinkingBudget }
: {}),
...(includeThoughts !== undefined
? { include_thoughts: includeThoughts }
: {}),
},
}
: {}),
...(cachedContent ? { cached_content: cachedContent } : {}),
...(safetySettings ? { safety_settings: safetySettings } : {}),
};
}
/**
* Build OpenAI-compatible provider options from form state.
* Validation has already passed; uses transform helpers only.
*/
function buildOpenAICompatOptions(
form: OpenAICompatFormState,
): Record<string, unknown> {
const reasoningEffort = toTrimmedString(form.reasoningEffort);
const user = toTrimmedString(form.user);
return {
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
...(user ? { user } : {}),
};
}
/**
* Shared builder for OpenRouter/Vercel provider options. Both
* providers use an identical reasoning + parallel_tool_calls + user
* structure. Validation has already passed.
*/
function buildReasoningProviderOptions(form: {
reasoningEnabled: string;
reasoningEffort: string;
reasoningMaxTokens: string;
reasoningExclude: string;
parallelToolCalls: string;
user: string;
}): Record<string, unknown> {
const reasoningEnabled = toBool(form.reasoningEnabled);
const reasoningEffort = toTrimmedString(form.reasoningEffort);
const reasoningMaxTokens = toInt(form.reasoningMaxTokens);
const reasoningExclude = toBool(form.reasoningExclude);
const parallelToolCalls = toBool(form.parallelToolCalls);
const user = toTrimmedString(form.user);
const reasoning: Record<string, unknown> = {
...(reasoningEnabled !== undefined ? { enabled: reasoningEnabled } : {}),
...(reasoningEffort ? { effort: reasoningEffort } : {}),
...(reasoningMaxTokens !== undefined
? { max_tokens: reasoningMaxTokens }
: {}),
...(reasoningExclude !== undefined ? { exclude: reasoningExclude } : {}),
};
return {
...(hasObjectKeys(reasoning) ? { reasoning } : {}),
...(parallelToolCalls !== undefined
? { parallel_tool_calls: parallelToolCalls }
: {}),
...(user ? { user } : {}),
};
}
// ── Form → model config builder ──────────────────────────────
export const buildModelConfigFromForm = (
provider: string | null | undefined,
form: ModelConfigFormState,
): ModelConfigFormBuildResult => {
const fieldErrors: FieldErrors = {};
// Validate top-level fields.
collectYupErrors(topLevelSchema, form, fieldErrors);
// Validate provider-specific fields.
const normalizedProvider = normalizeProvider(provider ?? "");
switch (normalizedProvider) {
case "openai":
case "azure":
collectYupErrors(openaiSchema, form.openai, fieldErrors, "openai");
break;
case "anthropic":
case "bedrock":
collectYupErrors(
anthropicSchema,
form.anthropic,
fieldErrors,
"anthropic",
);
break;
case "google":
collectYupErrors(googleSchema, form.google, fieldErrors, "google");
break;
case "openaicompat":
collectYupErrors(
openaiCompatSchema,
form.openaicompat,
fieldErrors,
"openaicompat",
);
break;
case "openrouter":
collectYupErrors(
reasoningProviderSchema,
form.openrouter,
fieldErrors,
"openrouter",
);
collectYupErrors(
openrouterExtraSchema,
form.openrouter,
fieldErrors,
"openrouter",
);
break;
case "vercel":
collectYupErrors(
reasoningProviderSchema,
form.vercel,
fieldErrors,
"vercel",
);
break;
}
if (Object.keys(fieldErrors).length > 0) {
return { fieldErrors };
}
// Transform top-level fields.
const maxOutputTokens = toInt(form.maxOutputTokens);
const temperature = toNum(form.temperature);
const topP = toNum(form.topP);
const topK = toInt(form.topK);
const presencePenalty = toNum(form.presencePenalty);
const frequencyPenalty = toNum(form.frequencyPenalty);
// Build provider-specific options.
let providerOptions: TypesGen.ChatModelProviderOptions | undefined;
switch (normalizedProvider) {
case "openai":
case "azure": {
const opts = buildOpenAIOptions(form.openai);
if (hasObjectKeys(opts)) {
providerOptions = { openai: opts };
}
break;
}
case "anthropic":
case "bedrock": {
const opts = buildAnthropicOptions(form.anthropic);
if (hasObjectKeys(opts)) {
providerOptions = { anthropic: opts };
}
break;
}
case "google": {
const opts = buildGoogleOptions(form.google);
if (hasObjectKeys(opts)) {
providerOptions = { google: opts };
}
break;
}
case "openaicompat": {
const opts = buildOpenAICompatOptions(form.openaicompat);
if (hasObjectKeys(opts)) {
providerOptions = { openaicompat: opts };
}
break;
}
case "openrouter": {
const opts = buildReasoningProviderOptions(form.openrouter);
const includeUsage = toBool(form.openrouter.includeUsage);
if (includeUsage !== undefined) {
opts.include_usage = includeUsage;
}
if (hasObjectKeys(opts)) {
providerOptions = { openrouter: opts };
}
break;
}
case "vercel": {
const opts = buildReasoningProviderOptions(form.vercel);
if (hasObjectKeys(opts)) {
providerOptions = { vercel: opts };
}
break;
}
}
const modelConfig: TypesGen.ChatModelCallConfig = {
...(maxOutputTokens !== undefined
? { max_output_tokens: maxOutputTokens }
: {}),
...(temperature !== undefined ? { temperature } : {}),
...(topP !== undefined ? { top_p: topP } : {}),
...(topK !== undefined ? { top_k: topK } : {}),
...(presencePenalty !== undefined
? { presence_penalty: presencePenalty }
: {}),
...(frequencyPenalty !== undefined
? { frequency_penalty: frequencyPenalty }
: {}),
...(providerOptions ? { provider_options: providerOptions } : {}),
};
if (!hasObjectKeys(modelConfig as Record<string, unknown>)) {
return { fieldErrors: {} };
}
return { modelConfig, fieldErrors: {} };
};
@@ -0,0 +1,166 @@
import type * as TypesGen from "api/typesGenerated";
import type { ProviderState } from "./ChatModelAdminPanel";
type ProviderModelConfigSchemaReference = {
modelConfig: TypesGen.ChatModelCallConfig;
notes?: readonly string[];
};
const modelConfigSchemaByProvider: Record<
string,
ProviderModelConfigSchemaReference
> = {
openai: {
modelConfig: {
max_output_tokens: 32000,
temperature: 0.2,
top_p: 0.95,
top_k: 40,
presence_penalty: 0,
frequency_penalty: 0,
provider_options: {
openai: {
reasoning_effort: "high",
parallel_tool_calls: true,
text_verbosity: "low",
service_tier: "auto",
user: "end-user-id",
},
},
},
notes: ["Responses API models may also use reasoning_summary and include."],
},
azure: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
openai: {
reasoning_effort: "high",
parallel_tool_calls: true,
user: "end-user-id",
},
},
},
notes: ["Azure uses OpenAI provider option keys in Fantasy."],
},
anthropic: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
anthropic: {
effort: "medium",
thinking: { budget_tokens: 4000 },
send_reasoning: true,
disable_parallel_tool_use: false,
},
},
},
},
bedrock: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
anthropic: {
effort: "medium",
thinking: { budget_tokens: 4000 },
send_reasoning: true,
disable_parallel_tool_use: false,
},
},
},
notes: ["Bedrock uses Anthropic option keys in Fantasy."],
},
google: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
google: {
thinking_config: {
thinking_budget: 1024,
include_thoughts: true,
},
safety_settings: [
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_ONLY_HIGH",
},
],
},
},
},
},
openaicompat: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
openaicompat: {
reasoning_effort: "medium",
user: "end-user-id",
},
},
},
},
openrouter: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
openrouter: {
reasoning: {
enabled: true,
effort: "medium",
max_tokens: 2048,
exclude: false,
},
parallel_tool_calls: true,
include_usage: true,
user: "end-user-id",
},
},
},
},
vercel: {
modelConfig: {
max_output_tokens: 32000,
provider_options: {
vercel: {
reasoning: {
enabled: true,
effort: "medium",
max_tokens: 2048,
exclude: false,
},
parallel_tool_calls: true,
user: "end-user-id",
},
},
},
},
};
export const getModelConfigSchemaReference = (
providerState: ProviderState | null,
) => {
const providerLabel = providerState?.label ?? "Provider";
const normalizedProvider = (providerState?.provider ?? "")
.trim()
.toLowerCase();
const providerConfigSchema = modelConfigSchemaByProvider[normalizedProvider];
const modelConfigTemplate = providerConfigSchema?.modelConfig ?? {};
const notes = providerConfigSchema?.notes ?? [
"No provider-specific options are documented for this provider yet.",
];
const schema: TypesGen.CreateChatModelConfigRequest = {
provider: normalizedProvider || "<provider>",
model: "<model-id>",
context_limit: 200000,
compression_threshold: 70,
model_config: modelConfigTemplate,
};
return {
providerLabel,
notes,
schemaJSON: JSON.stringify(schema, null, 2),
};
};
@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
chatModelConfigsKey,
chatModelsKey,
chatProviderConfigsKey,
} from "api/queries/chats";
import type {
ChatModelConfig,
ChatModelsResponse,
ChatProviderConfig,
} from "api/typesGenerated";
import { fn } from "storybook/test";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
// Pre-seeded query data so that ChatModelAdminPanel renders
// without hitting a real backend.
const mockProviderConfigs: ChatProviderConfig[] = [
{
id: "provider-1",
provider: "openai",
display_name: "OpenAI",
enabled: true,
has_api_key: true,
base_url: "https://api.openai.com/v1",
source: "database",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
];
const mockModelConfigs: ChatModelConfig[] = [
{
id: "model-cfg-1",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 128000,
compression_threshold: 80000,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
];
const mockChatModels: ChatModelsResponse = {
providers: [
{
provider: "openai",
available: true,
models: [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
},
],
},
],
};
const chatQueries = [
{ key: chatProviderConfigsKey, data: mockProviderConfigs },
{ key: chatModelConfigsKey, data: mockModelConfigs },
{ key: chatModelsKey, data: mockChatModels },
];
const meta: Meta<typeof ConfigureAgentsDialog> = {
title: "pages/AgentsPage/ConfigureAgentsDialog",
component: ConfigureAgentsDialog,
args: {
open: true,
onOpenChange: fn(),
canManageChatModelConfigs: false,
canSetSystemPrompt: false,
systemPromptDraft: "",
onSystemPromptDraftChange: fn(),
onSaveSystemPrompt: fn(),
isSystemPromptDirty: false,
isDisabled: false,
},
};
export default meta;
type Story = StoryObj<typeof ConfigureAgentsDialog>;
export const SystemPromptOnly: Story = {
args: {
canSetSystemPrompt: true,
canManageChatModelConfigs: false,
systemPromptDraft: "You are a helpful coding assistant.",
},
};
export const ModelConfigOnly: Story = {
args: {
canSetSystemPrompt: false,
canManageChatModelConfigs: true,
},
parameters: { queries: chatQueries },
};
export const BothEnabled: Story = {
args: {
canSetSystemPrompt: true,
canManageChatModelConfigs: true,
systemPromptDraft: "Follow company coding standards.",
},
parameters: { queries: chatQueries },
};
@@ -0,0 +1,203 @@
import { Button } from "components/Button/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import type { LucideIcon } from "lucide-react";
import { BoxesIcon, KeyRoundIcon, UserIcon, XIcon } from "lucide-react";
import { type FC, type FormEvent, useEffect, useMemo, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { cn } from "utils/cn";
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
type ConfigureAgentsSection = "providers" | "system-prompt" | "models";
type ConfigureAgentsSectionOption = {
id: ConfigureAgentsSection;
label: string;
icon: LucideIcon;
};
interface ConfigureAgentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
canManageChatModelConfigs: boolean;
canSetSystemPrompt: boolean;
systemPromptDraft: string;
onSystemPromptDraftChange: (value: string) => void;
onSaveSystemPrompt: (event: FormEvent) => void;
isSystemPromptDirty: boolean;
isDisabled: boolean;
}
export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
open,
onOpenChange,
canManageChatModelConfigs,
canSetSystemPrompt,
systemPromptDraft,
onSystemPromptDraftChange,
onSaveSystemPrompt,
isSystemPromptDirty,
isDisabled,
}) => {
const configureSectionOptions = useMemo<
readonly ConfigureAgentsSectionOption[]
>(() => {
const options: ConfigureAgentsSectionOption[] = [];
if (canManageChatModelConfigs) {
options.push({
id: "providers",
label: "Providers",
icon: KeyRoundIcon,
});
options.push({
id: "models",
label: "Models",
icon: BoxesIcon,
});
}
if (canSetSystemPrompt) {
options.push({
id: "system-prompt",
label: "Behavior",
icon: UserIcon,
});
}
return options;
}, [canManageChatModelConfigs, canSetSystemPrompt]);
const [userActiveSection, setUserActiveSection] =
useState<ConfigureAgentsSection>("providers");
// Derive the effective section — validated against current options
// every render so we never show an unavailable tab.
const activeSection = configureSectionOptions.some(
(s) => s.id === userActiveSection,
)
? userActiveSection
: (configureSectionOptions[0]?.id ?? "providers");
// Reset to the preferred initial section each time the dialog opens.
useEffect(() => {
if (open) {
setUserActiveSection("providers");
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[200px_minmax(0,1fr)]">
{/* Visually hidden for accessibility */}
<DialogHeader className="sr-only">
<DialogTitle>Configure Agents</DialogTitle>
<DialogDescription>
Manage providers, system prompt, and available models.
</DialogDescription>
</DialogHeader>
{/* Sidebar */}
<nav className="flex flex-row gap-0.5 overflow-x-auto border-b border-border p-2 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:p-3">
<DialogClose asChild>
<Button
variant="subtle"
size="icon"
className="mb-2 h-8 w-8 shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/30"
>
<XIcon className="h-[18px] w-[18px] text-content-secondary" />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
{configureSectionOptions.map((section) => {
const isActive = section.id === activeSection;
const SectionIcon = section.icon;
return (
<Button
key={section.id}
variant="subtle"
className={cn(
"h-auto justify-start gap-2.5 rounded-lg border-none px-3 py-2 text-left shadow-none",
isActive
? "bg-surface-tertiary/50 text-content-primary hover:bg-surface-tertiary/50"
: "bg-transparent text-content-secondary hover:bg-surface-tertiary/30 hover:text-content-primary",
)}
onClick={() => setUserActiveSection(section.id)}
>
<SectionIcon className="h-[18px] w-[18px] shrink-0" />
<span className="text-[13px] font-medium">{section.label}</span>
</Button>
);
})}
</nav>
{/* Content */}
<div className="flex min-h-0 flex-col pt-5">
<h2 className="m-0 px-6 text-xl font-semibold text-content-primary">
{configureSectionOptions.find((s) => s.id === activeSection)
?.label ?? "Settings"}
</h2>
<ScrollArea className="min-h-0 flex-1" viewportClassName="px-6 pb-6">
{activeSection === "providers" && canManageChatModelConfigs && (
<ChatModelAdminPanel section="providers" />
)}
{activeSection === "system-prompt" && canSetSystemPrompt && (
<form
className="space-y-4"
onSubmit={(event) => void onSaveSystemPrompt(event)}
>
<p className="m-0 text-[13px] leading-relaxed text-content-secondary">
Configure how the AI agent behaves across this deployment.
</p>
<div className="space-y-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
System Prompt
</h3>
<p className="m-0 text-xs text-content-secondary">
Admin-only instruction applied to all new chats.
</p>
<TextareaAutosize
className="min-h-[220px] w-full resize-y rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30"
placeholder="Optional. Set deployment-wide instructions for all new chats."
value={systemPromptDraft}
onChange={(event) =>
onSystemPromptDraftChange(event.target.value)
}
disabled={isDisabled}
minRows={7}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => onSystemPromptDraftChange("")}
disabled={isDisabled || !systemPromptDraft}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isDisabled || !isSystemPromptDirty}
>
Save
</Button>
</div>
</div>
</form>
)}
{activeSection === "models" && canManageChatModelConfigs && (
<ChatModelAdminPanel section="models" />
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,119 @@
import {
type PointerEvent as ReactPointerEvent,
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "utils/cn";
const STORAGE_KEY = "agents.diff-panel-width";
const MIN_WIDTH = 360;
const MAX_WIDTH = 960;
const DEFAULT_WIDTH = 480;
function loadPersistedWidth(): number {
if (typeof window === "undefined") {
return DEFAULT_WIDTH;
}
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
return DEFAULT_WIDTH;
}
const parsed = Number.parseInt(stored, 10);
if (Number.isNaN(parsed) || parsed < MIN_WIDTH || parsed > MAX_WIDTH) {
return DEFAULT_WIDTH;
}
return parsed;
}
interface DiffRightPanelProps {
ref?: Ref<HTMLDivElement>;
isOpen: boolean;
}
/**
* The right-side panel for the diff/files-changed view. Always mounted
* so the portal ref is always available (fixes blank-on-reopen). When
* closed the panel is hidden via CSS and takes no layout space. On xl+
* screens the panel is horizontally resizable via a drag handle.
*/
export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
const [width, setWidth] = useState(loadPersistedWidth);
const isDragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
startWidth.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const handlePointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
// Dragging left (negative delta) should make the panel wider
// since the handle is on the left edge.
const delta = startX.current - e.clientX;
const next = Math.min(
MAX_WIDTH,
Math.max(MIN_WIDTH, startWidth.current + delta),
);
setWidth(next);
},
[],
);
const handlePointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
isDragging.current = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
},
[],
);
// Persist width to localStorage when dragging ends.
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, String(width));
}
}, [width]);
return (
<div
ref={ref}
data-testid="agents-detail-right-panel"
style={
isOpen
? ({ "--panel-width": `${width}px` } as React.CSSProperties)
: undefined
}
className={cn(
"relative min-h-0 min-w-0 border-t border-border-default bg-surface-primary",
isOpen
? "h-[42dvh] min-h-[260px] max-h-[56dvh] xl:h-auto xl:max-h-none xl:w-[var(--panel-width)] xl:min-w-[360px] xl:max-w-[960px] xl:border-l xl:border-t-0"
: "hidden",
)}
>
{/* Drag handle (xl+ only, on the left edge of the panel) */}
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="absolute top-0 left-0 z-10 hidden h-full w-1 cursor-col-resize select-none transition-colors hover:bg-content-link xl:block"
/>
</div>
);
};
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import { API } from "api/api";
import type { ChatDiffContents } from "api/typesGenerated";
import { expect, screen, spyOn } from "storybook/test";
import { FilesChangedPanel } from "./FilesChangedPanel";
const defaultDiffStatus: ChatDiffStatusResponse = {
chat_id: "test-chat",
changes_requested: false,
additions: 0,
deletions: 0,
changed_files: 0,
};
const defaultDiffContents: ChatDiffContents = {
chat_id: "test-chat",
};
const meta: Meta<typeof FilesChangedPanel> = {
title: "pages/AgentsPage/FilesChangedPanel",
component: FilesChangedPanel,
args: {
chatId: "test-chat",
},
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue(defaultDiffStatus);
spyOn(API, "getChatDiffContents").mockResolvedValue(defaultDiffContents);
},
};
export default meta;
type Story = StoryObj<typeof FilesChangedPanel>;
export const EmptyDiff: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: undefined,
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: "",
});
},
play: async () => {
await screen.findByText("No file changes to display.");
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
},
};
export const ParseError: Story = {
beforeEach: () => {
spyOn(API, "getChatDiffStatus").mockResolvedValue({
...defaultDiffStatus,
url: "https://github.com/coder/coder/pull/123",
});
spyOn(API, "getChatDiffContents").mockResolvedValue({
...defaultDiffContents,
diff: "not-a-valid-unified-diff",
});
},
play: async () => {
await screen.findByText("No file changes to display.");
expect(screen.getByText("No file changes to display.")).toBeInTheDocument();
},
};
@@ -0,0 +1,161 @@
import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff } from "@pierre/diffs/react";
import { chatDiffContents, chatDiffStatus } from "api/queries/chats";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Skeleton } from "components/Skeleton/Skeleton";
import {
ExternalLinkIcon,
GitBranchIcon,
GitPullRequestIcon,
} from "lucide-react";
import { type FC, useMemo } from "react";
import { useQuery } from "react-query";
interface FilesChangedPanelProps {
chatId: string;
}
/**
* Extracts a short label like "owner/repo#123" from a GitHub PR URL.
* Falls back to the raw URL if parsing fails.
*/
function formatPullRequestLabel(url: string): string {
try {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
if (match) {
return `${match[1]}/${match[2]}#${match[3]}`;
}
} catch {
// Fall through to return the raw URL.
}
return url;
}
export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({ chatId }) => {
const diffStatusQuery = useQuery(chatDiffStatus(chatId));
const diffContentsQuery = useQuery({
...chatDiffContents(chatId),
enabled: Boolean(diffStatusQuery.data?.url),
});
const parsedFiles = useMemo(() => {
const diff = diffContentsQuery.data?.diff;
if (!diff) {
return [];
}
try {
const patches = parsePatchFiles(diff);
return patches.flatMap((p) => p.files);
} catch {
return [];
}
}, [diffContentsQuery.data?.diff]);
const pullRequestUrl = diffStatusQuery.data?.url;
const pullRequestLabel = pullRequestUrl
? formatPullRequestLabel(pullRequestUrl)
: undefined;
if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
<div className="flex items-center gap-2 border-0 border-b border-l border-solid px-4 py-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-28" />
</div>
<div className="space-y-4 p-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</div>
</div>
);
}
if (diffContentsQuery.isError) {
return (
<div className="p-3">
<ErrorAlert error={diffContentsQuery.error} />
</div>
);
}
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden border-0 border-l border-solid bg-surface-primary">
{/* Header */}
<div className="flex items-center justify-between gap-3 border-0 border-b border-l border-solid px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
{pullRequestUrl ? (
<>
<GitPullRequestIcon className="h-4 w-4 shrink-0 text-content-secondary" />
<span className="truncate text-sm font-medium text-content-primary">
{pullRequestLabel}
</span>
</>
) : (
<>
<GitBranchIcon className="h-4 w-4 text-content-secondary" />
<span className="text-sm font-medium text-content-primary">
Files Changed
</span>
</>
)}
</div>
{pullRequestUrl && (
<a
href={pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex shrink-0 items-center gap-1.5 rounded-md border border-border-default px-2.5 py-1 text-xs text-content-secondary no-underline transition-colors hover:bg-surface-tertiary hover:text-content-primary"
>
View PR
<ExternalLinkIcon className="h-3 w-3" />
</a>
)}
</div>
{/* Diff contents */}
{parsedFiles.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
No file changes to display.
</div>
) : (
<ScrollArea className="min-w-0 flex-1" scrollBarClassName="w-1.5">
<div className="min-w-0 text-xs">
{parsedFiles.map((fileDiff) => (
<FileDiff
key={fileDiff.name}
fileDiff={fileDiff}
options={{
diffStyle: "unified",
diffIndicators: "bars",
overflow: "scroll",
themeType: "dark",
enableLineSelection: true,
enableHoverUtility: true,
onLineSelected() {
// TODO: Make this add context to the input so the user can type.
},
theme: "github-dark-high-contrast",
unsafeCSS:
"pre, [data-line], [data-diffs-header] { background-color: transparent !important; } [data-diffs-header] { border-left: 1px solid var(--border); }",
}}
style={{
"--diffs-font-size": "11px",
"--diffs-line-height": "1.5",
}}
/>
))}
</div>
</ScrollArea>
)}
</div>
);
};
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatQueuedMessage } from "api/typesGenerated";
import { fn } from "storybook/test";
import { QueuedMessagesList } from "./QueuedMessagesList";
// Helper to build a ChatQueuedMessage with minimal boilerplate.
function makeMessage(
id: number,
content: ChatQueuedMessage["content"],
): ChatQueuedMessage {
return {
id,
chat_id: "test-chat-id",
content,
created_at: new Date().toISOString(),
};
}
const meta: Meta<typeof QueuedMessagesList> = {
title: "pages/AgentsPage/QueuedMessagesList",
component: QueuedMessagesList,
args: {
onDelete: fn(),
onPromote: fn(),
},
};
export default meta;
type Story = StoryObj<typeof QueuedMessagesList>;
// When the messages array is empty the component renders nothing.
export const Empty: Story = {
args: {
messages: [],
},
};
const textContent = (text: string): ChatQueuedMessage["content"] =>
[
{
type: "text",
text,
},
] as ChatQueuedMessage["content"];
// A single queued message with text-part content.
export const SingleMessage: Story = {
args: {
messages: [makeMessage(1, textContent("Run the test suite"))],
},
};
// Several messages queued up at once.
export const SeveralMessages: Story = {
args: {
messages: [
makeMessage(1, textContent("Install dependencies")),
makeMessage(2, textContent("Run database migrations")),
makeMessage(3, textContent("Start the dev server")),
],
},
};
// Messages with different content shapes to exercise the parsing logic.
export const MixedContentTypes: Story = {
args: {
messages: [
// Typed text content.
makeMessage(1, textContent("Plain text content")),
// Legacy serialized payload in a text field.
makeMessage(
2,
textContent('[{"type":"text","data":{"text":"legacy payload"}}]'),
),
// Empty content falls back to the generic label.
makeMessage(3, [] as ChatQueuedMessage["content"]),
],
},
};
// A longer queue to verify scrolling and layout with many items.
export const LongQueue: Story = {
args: {
messages: Array.from({ length: 10 }, (_, i) =>
makeMessage(i + 1, textContent(`Queued task number ${i + 1}`)),
),
},
};
// A message whose content is a long string to test truncation.
export const LongMessageText: Story = {
args: {
messages: [
makeMessage(
1,
textContent(
"This is an extremely long queued message that should be truncated by the component layout because it exceeds the available horizontal space in the queue list container",
),
),
makeMessage(2, textContent("Short follow-up")),
],
},
};
@@ -0,0 +1,325 @@
import type { ChatQueuedMessage } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import {
ArrowUpIcon,
CornerDownLeftIcon,
Loader2Icon,
PencilIcon,
Trash2Icon,
} from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "utils/cn";
interface QueuedMessagesListProps {
messages: readonly ChatQueuedMessage[];
onDelete: (id: number) => Promise<void> | void;
onPromote: (id: number) => Promise<void> | void;
onEdit?: (id: number, text: string) => void;
editingMessageID?: number | null;
className?: string;
}
const asRecord = (value: unknown): Record<string, unknown> | undefined => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
};
const extractBlockText = (value: unknown): string | undefined => {
if (typeof value === "string") {
return value;
}
const record = asRecord(value);
if (!record) {
return undefined;
}
if (typeof record.text === "string") {
return record.text;
}
const data = asRecord(record.data);
if (data && typeof data.text === "string") {
return data.text;
}
return undefined;
};
const extractQueuedContentText = (value: unknown): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") {
return "";
}
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
return extractQueuedContentText(JSON.parse(trimmed));
} catch {
return value;
}
}
return value;
}
if (Array.isArray(value)) {
const texts = value
.map(extractBlockText)
.filter((text): text is string => Boolean(text?.trim()));
if (texts.length > 0) {
return texts.join(" ");
}
try {
return JSON.stringify(value);
} catch {
return "";
}
}
const record = asRecord(value);
if (record) {
const text = extractBlockText(record);
if (text?.trim()) {
return text;
}
if ("content" in record) {
const nested = extractQueuedContentText(record.content);
if (nested.trim()) {
return nested;
}
}
try {
return JSON.stringify(record);
} catch {
return "";
}
}
return "";
};
const getQueuedMessageText = (message: ChatQueuedMessage): string => {
const text = extractQueuedContentText(message.content).trim();
return text || "Queued message";
};
export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
messages,
onDelete,
onPromote,
onEdit,
editingMessageID = null,
className,
}) => {
const items = useMemo(
() =>
messages.map((message) => ({
id: message.id,
text: getQueuedMessageText(message),
})),
[messages],
);
const [hoveredID, setHoveredID] = useState<number | null>(null);
// Tracks which item has an async action in flight and what kind.
const [busyItem, setBusyItem] = useState<{
id: number;
action: "delete" | "promote";
} | null>(null);
const [optimisticallyHiddenIDs, setOptimisticallyHiddenIDs] = useState<
ReadonlySet<number>
>(new Set());
const hideItemOptimistically = useCallback((id: number) => {
setOptimisticallyHiddenIDs((current) => {
if (current.has(id)) {
return current;
}
const next = new Set(current);
next.add(id);
return next;
});
}, []);
const restoreHiddenItem = useCallback((id: number) => {
setOptimisticallyHiddenIDs((current) => {
if (!current.has(id)) {
return current;
}
const next = new Set(current);
next.delete(id);
return next;
});
}, []);
useEffect(() => {
const liveIDs = new Set(messages.map((message) => message.id));
setOptimisticallyHiddenIDs((current) => {
if (current.size === 0) {
return current;
}
let didChange = false;
const next = new Set<number>();
for (const id of current) {
if (liveIDs.has(id)) {
next.add(id);
continue;
}
didChange = true;
}
return didChange ? next : current;
});
}, [messages]);
const handleDelete = useCallback(
async (id: number) => {
setBusyItem({ id, action: "delete" });
hideItemOptimistically(id);
try {
await onDelete(id);
} catch {
restoreHiddenItem(id);
} finally {
setBusyItem((current) => (current?.id === id ? null : current));
}
},
[hideItemOptimistically, onDelete, restoreHiddenItem],
);
const handlePromote = useCallback(
async (id: number) => {
setBusyItem({ id, action: "promote" });
hideItemOptimistically(id);
try {
await onPromote(id);
} catch {
restoreHiddenItem(id);
} finally {
setBusyItem((current) => (current?.id === id ? null : current));
}
},
[hideItemOptimistically, onPromote, restoreHiddenItem],
);
const visibleItems = items.filter(
(item) => !optimisticallyHiddenIDs.has(item.id),
);
if (visibleItems.length === 0) {
return null;
}
const isBusy = busyItem !== null;
return (
<div className={cn("flex w-full flex-col", className)}>
{visibleItems.map((item, index) => {
const isEditing = item.id === editingMessageID;
const isFirst = index === 0;
const isItemBusy = busyItem !== null && busyItem.id === item.id;
const isHovered = hoveredID === item.id;
// Show actions when: first and nothing else hovered,
// or this item is hovered, or being edited.
const showActions =
isEditing || isHovered || (isFirst && hoveredID === null);
return (
<div
key={item.id}
className={cn(
"my-1 opacity-40 hover:opacity-80 transition-opacity",
isEditing && "rounded-lg opacity-100 ring-2 ring-content-link/40",
)}
onMouseEnter={() => setHoveredID(item.id)}
onMouseLeave={() =>
setHoveredID((current) => (current === item.id ? null : current))
}
>
<div className="flex items-center gap-2 rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans text-sm leading-relaxed text-content-primary shadow-sm">
<span className="min-w-0 flex-1 truncate">
{item.text.split("\n")[0]}
{item.text.includes("\n") ? "…" : ""}
</span>
{isFirst && (
<span
className={cn(
"flex shrink-0 items-center gap-1 text-xs text-content-secondary transition-opacity",
showActions ? "opacity-100" : "opacity-0",
)}
>
<CornerDownLeftIcon className="h-3 w-3" />
to send
</span>
)}
<div
className={cn(
"flex shrink-0 items-center gap-0.5 transition-opacity",
showActions ? "opacity-100" : "opacity-0",
)}
>
{onEdit && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Edit"
disabled={isBusy}
onClick={() => onEdit(item.id, item.text)}
className="size-6 rounded text-content-secondary hover:bg-surface-tertiary hover:text-content-primary"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Send now"
disabled={isBusy}
onClick={() => void handlePromote(item.id)}
className="size-6 rounded text-content-secondary hover:bg-surface-tertiary hover:text-content-primary"
>
{isItemBusy && busyItem.action === "promote" ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUpIcon className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">Send now</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Remove from queue"
disabled={isBusy}
onClick={() => void handleDelete(item.id)}
className="size-6 rounded text-content-secondary hover:bg-surface-tertiary hover:text-content-destructive"
>
{isItemBusy && busyItem.action === "delete" ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2Icon className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">Remove</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
})}
</div>
);
};
+167
View File
@@ -0,0 +1,167 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
const getCatalogProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): readonly CatalogProvider[] => {
const providers = catalog?.providers;
return Array.isArray(providers) ? providers : [];
};
const getProviderModels = (
provider: CatalogProvider,
): readonly CatalogProvider["models"][number][] => {
const models = provider.models;
return Array.isArray(models) ? models : [];
};
const isProviderConfiguredInCatalog = (provider: CatalogProvider): boolean => {
if (getProviderModels(provider).length > 0) {
return true;
}
if (provider.available) {
return true;
}
return (
Boolean(provider.unavailable_reason) &&
provider.unavailable_reason !== "missing_api_key"
);
};
export const hasConfiguredModelsInCatalog = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): boolean => {
return getCatalogProviders(catalog).some(isProviderConfiguredInCatalog);
};
export const getModelOptionsFromCatalog = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
configs?: readonly TypesGen.ChatModelConfig[],
): readonly ModelSelectorOption[] => {
const optionsByID = new Map<string, ModelSelectorOption>();
// Build a lookup of context limits from admin model configs so
// we can surface this in the model selector tooltip.
const contextLimitByKey = new Map<string, number>();
if (configs) {
for (const config of configs) {
if (config.context_limit > 0) {
const key = `${config.provider.trim().toLowerCase()}:${config.model.trim()}`;
if (!contextLimitByKey.has(key)) {
contextLimitByKey.set(key, config.context_limit);
}
}
}
}
for (const provider of getCatalogProviders(catalog)) {
const models = getProviderModels(provider);
if (!provider.available || models.length === 0) {
continue;
}
for (const model of models) {
if (!model) {
continue;
}
const modelID = model.id.trim();
const modelProvider = model.provider.trim();
const modelRef = model.model.trim();
if (!modelID || !modelProvider || !modelRef) {
continue;
}
if (optionsByID.has(modelID)) {
continue;
}
const configKey = `${modelProvider.toLowerCase()}:${modelRef}`;
optionsByID.set(modelID, {
id: modelID,
provider: modelProvider,
model: modelRef,
displayName:
(typeof model.display_name === "string" &&
model.display_name.trim()) ||
modelRef,
contextLimit: contextLimitByKey.get(configKey),
});
}
}
return Array.from(optionsByID.values()).sort((a, b) => {
const providerCompare = a.provider.localeCompare(b.provider);
if (providerCompare !== 0) {
return providerCompare;
}
return a.displayName.localeCompare(b.displayName);
});
};
export const formatProviderLabel = (provider: string): string => {
const normalized = provider.trim().toLowerCase();
switch (normalized) {
case "openai":
return "OpenAI";
case "anthropic":
return "Anthropic";
case "azure":
return "Azure OpenAI";
case "bedrock":
return "AWS Bedrock";
case "google":
return "Google";
case "openai-compatible":
case "openai_compatible":
return "OpenAI-compatible";
case "openrouter":
return "OpenRouter";
case "vercel":
return "Vercel AI Gateway";
default:
if (!normalized) {
return "Unknown";
}
return `${normalized[0].toUpperCase()}${normalized.slice(1)}`;
}
};
export const getModelSelectorPlaceholder = (
modelOptions: readonly ModelSelectorOption[],
isModelCatalogLoading: boolean,
hasConfiguredModels: boolean,
): string => {
if (modelOptions.length > 0) {
return "Select model";
}
if (isModelCatalogLoading) {
return "Loading models...";
}
if (hasConfiguredModels) {
return "No available models";
}
return "No models configured";
};
export const getModelCatalogStatusMessage = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
modelOptions: readonly ModelSelectorOption[],
isModelCatalogLoading: boolean,
hasModelCatalogError: boolean,
): string | null => {
if (modelOptions.length > 0) {
return null;
}
if (isModelCatalogLoading) {
return "Loading model catalog...";
}
if (hasModelCatalogError) {
return "Model catalog unavailable. Unable to verify model availability.";
}
if (hasConfiguredModelsInCatalog(catalog)) {
return "Models are configured but unavailable. Check provider settings.";
}
return "No chat models are configured. Ask an admin to configure one.";
};
@@ -43,15 +43,16 @@ export const OnTagsChange: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const expandButton = canvas.getByRole("button", {
name: "Expand provisioner tags",
});
await userEvent.click(expandButton);
const keyInput = await canvas.findByLabelText("Tag key");
const valueInput = await canvas.findByLabelText("Tag value");
const addButton = await canvas.findByRole("button", {
const keyInput = await body.findByLabelText("Tag key");
const valueInput = await body.findByLabelText("Tag value");
const addButton = await body.findByRole("button", {
name: "Add tag",
hidden: true,
});
@@ -59,14 +60,14 @@ export const OnTagsChange: Story = {
await user.type(keyInput, "cluster");
await user.type(valueInput, "dogfood-2");
await user.click(addButton);
const addedTag = await canvas.findByTestId("tag-cluster");
const addedTag = await body.findByTestId("tag-cluster");
await expect(addedTag).toHaveTextContent("cluster dogfood-2");
const removeButton = canvas.getByRole("button", {
const removeButton = body.getByRole("button", {
name: "Delete cluster",
hidden: true,
});
await user.click(removeButton);
await expect(canvas.queryByTestId("tag-cluster")).toBeNull();
await expect(body.queryByTestId("tag-cluster")).toBeNull();
},
};
+25
View File
@@ -343,6 +343,14 @@ const ProvisionerJobsPage = lazy(
"./pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/OrganizationProvisionerJobsPage"
),
);
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
const AgentDetail = lazy(() => import("./pages/AgentsPage/AgentDetail"));
import {
AgentDetailSkeleton,
AgentsPageSkeleton,
} from "./pages/AgentsPage/AgentsSkeletons";
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
const AIBridgeLayout = lazy(
@@ -623,6 +631,23 @@ export const router = createBrowserRouter(
<Route path="/cli-auth" element={<CliAuthPage />} />
<Route path="/icons" element={<IconsPage />} />
<Route path="/tasks/:username/:taskId" element={<TaskPage />} />
<Route
path="/agents"
element={
<Suspense fallback={<AgentsPageSkeleton />}>
<AgentsPage />
</Suspense>
}
>
<Route
path=":agentId"
element={
<Suspense fallback={<AgentDetailSkeleton />}>
<AgentDetail />
</Suspense>
}
/>
</Route>
</Route>
</Route>,
),
+2
View File
@@ -559,6 +559,8 @@ export const MockUserAppearanceSettings: TypesGen.UserAppearanceSettings = {
export const MockTasksTabVisible: boolean = false;
export const MockAgentsTabVisible: boolean = false;
export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = {
organization_id: MockOrganization.id,
user_id: MockUserOwner.id,
@@ -0,0 +1,23 @@
import type { FC, PropsWithChildren } from "react";
export type WorkerInitializationRenderOptions = {
theme?: {
light?: string;
dark?: string;
};
};
export type WorkerPoolOptions = {
poolSize?: number;
workerFactory?: () => Worker;
};
export type SupportedLanguages = string;
export const WorkerPoolContextProvider: FC<PropsWithChildren> = ({
children,
}) => <>{children}</>;
export const FileDiff: FC = () => null;
export const File: FC = () => null;
+7 -2
View File
@@ -98,8 +98,13 @@ export class OneWayWebSocket<TData = unknown>
websocketInit = defaultInit,
} = init;
if (!apiRoute.startsWith("/api/v2/")) {
throw new Error(`API route '${apiRoute}' does not begin with '/api/v2/'`);
if (
!apiRoute.startsWith("/api/v2/") &&
!apiRoute.startsWith("/api/experimental")
) {
throw new Error(
`API route '${apiRoute}' does not begin with '/api/v2/' or '/api/experimental'`,
);
}
const formattedParams =
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shortRelativeTime } from "./time";
describe("shortRelativeTime", () => {
// Pin "now" so tests are deterministic.
const NOW = new Date("2025-06-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "now" for 0 seconds ago', () => {
expect(shortRelativeTime(NOW)).toBe("now");
});
it('returns "now" for 30 seconds ago', () => {
const date = new Date(NOW.getTime() - 30 * 1000);
expect(shortRelativeTime(date)).toBe("now");
});
it('returns "now" for 59 seconds ago', () => {
const date = new Date(NOW.getTime() - 59 * 1000);
expect(shortRelativeTime(date)).toBe("now");
});
it('returns "1m" for 60 seconds ago', () => {
const date = new Date(NOW.getTime() - 60 * 1000);
expect(shortRelativeTime(date)).toBe("1m");
});
it('returns "5m" for 5 minutes ago', () => {
const date = new Date(NOW.getTime() - 5 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("5m");
});
it('returns "59m" for 59 minutes ago', () => {
const date = new Date(NOW.getTime() - 59 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("59m");
});
it('returns "1h" for 60 minutes ago', () => {
const date = new Date(NOW.getTime() - 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("1h");
});
it('returns "23h" for 23 hours ago', () => {
const date = new Date(NOW.getTime() - 23 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("23h");
});
it('returns "1d" for 24 hours ago', () => {
const date = new Date(NOW.getTime() - 24 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("1d");
});
it('returns "6d" for 6 days ago', () => {
const date = new Date(NOW.getTime() - 6 * 24 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("6d");
});
it('returns "1w" for 7 days ago', () => {
const date = new Date(NOW.getTime() - 7 * 24 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("1w");
});
it('returns "4w" for 30 days ago', () => {
const date = new Date(NOW.getTime() - 30 * 24 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("4w");
});
it("returns months for dates 2-11 months ago", () => {
// ~3 months ago
const date = new Date(NOW.getTime() - 90 * 24 * 60 * 60 * 1000);
const result = shortRelativeTime(date);
expect(result).toMatch(/^\d+mo$/);
});
it('returns "1y" for a date 1 year ago', () => {
const date = new Date(NOW.getTime() - 365 * 24 * 60 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("1y");
});
it('returns "now" for a future date (graceful handling)', () => {
// A date 5 minutes in the future results in a negative diff,
// which dayjs reports as 0 seconds.
const date = new Date(NOW.getTime() + 5 * 60 * 1000);
expect(shortRelativeTime(date)).toBe("now");
});
it("accepts ISO string input", () => {
const isoStr = new Date(NOW.getTime() - 2 * 60 * 60 * 1000).toISOString();
expect(shortRelativeTime(isoStr)).toBe("2h");
});
it("accepts numeric timestamp input", () => {
const timestamp = NOW.getTime() - 10 * 60 * 1000;
expect(shortRelativeTime(timestamp)).toBe("10m");
});
});
+37
View File
@@ -99,6 +99,43 @@ export function relativeTime(date: DateTimeInput) {
return dayjs(date).fromNow();
}
/**
* Returns a compact relative time string like "now", "5m", "2h",
* "3d", "1w", "2mo", or "1y". Useful for tight UI spaces like
* sidebar timestamps.
*/
export function shortRelativeTime(date: DateTimeInput): string {
const now = dayjs();
const then = dayjs(date);
const diffSeconds = now.diff(then, "second");
if (diffSeconds < 60) {
return "now";
}
const diffMinutes = now.diff(then, "minute");
if (diffMinutes < 60) {
return `${diffMinutes}m`;
}
const diffHours = now.diff(then, "hour");
if (diffHours < 24) {
return `${diffHours}h`;
}
const diffDays = now.diff(then, "day");
if (diffDays < 7) {
return `${diffDays}d`;
}
const diffWeeks = now.diff(then, "week");
if (diffWeeks < 5) {
return `${diffWeeks}w`;
}
const diffMonths = now.diff(then, "month");
if (diffMonths < 12) {
return `${diffMonths}mo`;
}
const diffYears = now.diff(then, "year");
return `${diffYears}y`;
}
export function relativeTimeWithoutSuffix(date: DateTimeInput) {
return dayjs(date).fromNow(true);
}

Some files were not shown because too many files have changed in this diff Show More