mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Cian Johnston
Danielle Maywood
Jeremy Ruppel
Claude Sonnet 4.6
parent
67da4e8b56
commit
edee917d88
+16
-1
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+381
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+628
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user