mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): opt AgentsPage and ai-elements into React Compiler (#23371)
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
|
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
|
||||||
"ignoreBinaries": ["protoc"],
|
"ignoreBinaries": ["protoc"],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
|
"@babel/plugin-syntax-typescript",
|
||||||
"@types/react-virtualized-auto-sizer",
|
"@types/react-virtualized-auto-sizer",
|
||||||
|
"babel-plugin-react-compiler",
|
||||||
"jest_workaround",
|
"jest_workaround",
|
||||||
"ts-proto"
|
"ts-proto"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ When investigating or editing TypeScript/React code, always use the TypeScript l
|
|||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
|
- `src/pages/AgentsPage/` and `src/components/ai-elements/` are opted
|
||||||
|
into React Compiler via `babel-plugin-react-compiler`. The compiler
|
||||||
|
automatically memoizes values, callbacks, and JSX at build time. Do
|
||||||
|
not add `useMemo`, `useCallback`, or `memo()` in these directories
|
||||||
|
— the compiler handles it. The only exception is `memo()` on
|
||||||
|
list-item components rendered in a `.map()` (e.g. `ChatMessageItem`,
|
||||||
|
`Tool`, `ChatTreeNode`, `LazyFileDiff`) because the compiler does
|
||||||
|
not add `React.memo()` behavior across component boundaries.
|
||||||
- When adding state that changes frequently (scroll position, hover,
|
- When adding state that changes frequently (scroll position, hover,
|
||||||
animation frame), **extract the state and its dependent UI into a child
|
animation frame), **extract the state and its dependent UI into a child
|
||||||
component** rather than keeping it in a parent that renders a large
|
component** rather than keeping it in a parent that renders a large
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "//",
|
"extends": "//",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["!e2e/**/*Generated.ts"]
|
"includes": ["!e2e/**/*Generated.ts", "!scripts/*.mjs"]
|
||||||
},
|
},
|
||||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -14,7 +14,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
"lint": "pnpm run lint:check && pnpm run lint:types && pnpm run lint:circular-deps && knip",
|
"lint": "pnpm run lint:check && pnpm run lint:types && pnpm run lint:circular-deps && pnpm run lint:compiler && knip",
|
||||||
|
"lint:compiler": "node scripts/check-compiler.mjs",
|
||||||
"lint:check": "biome lint --error-on-warnings .",
|
"lint:check": "biome lint --error-on-warnings .",
|
||||||
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
|
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
|
||||||
"lint:knip": "knip",
|
"lint:knip": "knip",
|
||||||
@@ -132,6 +133,8 @@
|
|||||||
"yup": "1.7.1"
|
"yup": "1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "7.29.0",
|
||||||
|
"@babel/plugin-syntax-typescript": "7.28.6",
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.4",
|
||||||
"@chromatic-com/storybook": "5.0.1",
|
"@chromatic-com/storybook": "5.0.1",
|
||||||
"@octokit/types": "12.6.0",
|
"@octokit/types": "12.6.0",
|
||||||
@@ -171,6 +174,7 @@
|
|||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"@vitest/browser-playwright": "4.0.14",
|
"@vitest/browser-playwright": "4.0.14",
|
||||||
"autoprefixer": "10.4.22",
|
"autoprefixer": "10.4.22",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"chromatic": "11.29.0",
|
"chromatic": "11.29.0",
|
||||||
"dpdm": "3.14.0",
|
"dpdm": "3.14.0",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
|
|||||||
Generated
+194
-105
@@ -302,6 +302,12 @@ importers:
|
|||||||
specifier: 1.7.1
|
specifier: 1.7.1
|
||||||
version: 1.7.1
|
version: 1.7.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@babel/core':
|
||||||
|
specifier: 7.29.0
|
||||||
|
version: 7.29.0
|
||||||
|
'@babel/plugin-syntax-typescript':
|
||||||
|
specifier: 7.28.6
|
||||||
|
version: 7.28.6(@babel/core@7.29.0)
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.2.4
|
specifier: 2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -419,6 +425,9 @@ importers:
|
|||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: 10.4.22
|
specifier: 10.4.22
|
||||||
version: 10.4.22(postcss@8.5.6)
|
version: 10.4.22(postcss@8.5.6)
|
||||||
|
babel-plugin-react-compiler:
|
||||||
|
specifier: 1.0.0
|
||||||
|
version: 1.0.0
|
||||||
chromatic:
|
chromatic:
|
||||||
specifier: 11.29.0
|
specifier: 11.29.0
|
||||||
version: 11.29.0
|
version: 11.29.0
|
||||||
@@ -531,20 +540,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/compat-data@7.28.5':
|
'@babel/compat-data@7.29.0':
|
||||||
resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz}
|
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/core@7.28.5':
|
'@babel/core@7.29.0':
|
||||||
resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz}
|
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/generator@7.28.5':
|
'@babel/generator@7.28.5':
|
||||||
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz}
|
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-compilation-targets@7.27.2':
|
'@babel/generator@7.29.1':
|
||||||
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz}
|
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-compilation-targets@7.28.6':
|
||||||
|
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-globals@7.28.0':
|
'@babel/helper-globals@7.28.0':
|
||||||
@@ -555,8 +568,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz}
|
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-module-transforms@7.28.3':
|
'@babel/helper-module-imports@7.28.6':
|
||||||
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz}
|
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-module-transforms@7.28.6':
|
||||||
|
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0
|
'@babel/core': ^7.0.0
|
||||||
@@ -565,6 +582,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==, tarball: https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz}
|
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==, tarball: https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-plugin-utils@7.28.6':
|
||||||
|
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==, tarball: https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1':
|
'@babel/helper-string-parser@7.27.1':
|
||||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz}
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -590,6 +611,11 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@babel/plugin-syntax-async-generators@7.8.4':
|
'@babel/plugin-syntax-async-generators@7.8.4':
|
||||||
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz}
|
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -675,8 +701,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
'@babel/plugin-syntax-typescript@7.24.7':
|
'@babel/plugin-syntax-typescript@7.28.6':
|
||||||
resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz}
|
resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
@@ -701,14 +727,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/template@7.28.6':
|
||||||
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/traverse@7.28.5':
|
'@babel/traverse@7.28.5':
|
||||||
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz}
|
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/traverse@7.29.0':
|
||||||
|
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/types@7.28.5':
|
'@babel/types@7.28.5':
|
||||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz}
|
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3':
|
'@bcoe/v8-coverage@0.2.3':
|
||||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, tarball: https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz}
|
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, tarball: https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz}
|
||||||
|
|
||||||
@@ -3147,6 +3185,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==, tarball: https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz}
|
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==, tarball: https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz}
|
||||||
engines: {node: '>=10', npm: '>=6'}
|
engines: {node: '>=10', npm: '>=6'}
|
||||||
|
|
||||||
|
babel-plugin-react-compiler@1.0.0:
|
||||||
|
resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==, tarball: https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz}
|
||||||
|
|
||||||
babel-preset-current-node-syntax@1.1.0:
|
babel-preset-current-node-syntax@1.1.0:
|
||||||
resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==, tarball: https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz}
|
resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==, tarball: https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6837,19 +6878,19 @@ snapshots:
|
|||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
'@babel/compat-data@7.28.5': {}
|
'@babel/compat-data@7.29.0': {}
|
||||||
|
|
||||||
'@babel/core@7.28.5':
|
'@babel/core@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.29.0
|
||||||
'@babel/generator': 7.28.5
|
'@babel/generator': 7.29.1
|
||||||
'@babel/helper-compilation-targets': 7.27.2
|
'@babel/helper-compilation-targets': 7.28.6
|
||||||
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
|
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||||
'@babel/helpers': 7.26.10
|
'@babel/helpers': 7.26.10
|
||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.29.2
|
||||||
'@babel/template': 7.27.2
|
'@babel/template': 7.28.6
|
||||||
'@babel/traverse': 7.28.5
|
'@babel/traverse': 7.29.0
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.29.0
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -6867,9 +6908,17 @@ snapshots:
|
|||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jsesc: 3.1.0
|
jsesc: 3.1.0
|
||||||
|
|
||||||
'@babel/helper-compilation-targets@7.27.2':
|
'@babel/generator@7.29.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/compat-data': 7.28.5
|
'@babel/parser': 7.29.2
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
jsesc: 3.1.0
|
||||||
|
|
||||||
|
'@babel/helper-compilation-targets@7.28.6':
|
||||||
|
dependencies:
|
||||||
|
'@babel/compat-data': 7.29.0
|
||||||
'@babel/helper-validator-option': 7.27.1
|
'@babel/helper-validator-option': 7.27.1
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
lru-cache: 5.1.1
|
lru-cache: 5.1.1
|
||||||
@@ -6884,17 +6933,26 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
|
'@babel/helper-module-imports@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/traverse': 7.29.0
|
||||||
'@babel/helper-module-imports': 7.27.1
|
'@babel/types': 7.29.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/helper-module-imports': 7.28.6
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
'@babel/traverse': 7.28.5
|
'@babel/traverse': 7.29.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/helper-plugin-utils@7.27.1': {}
|
'@babel/helper-plugin-utils@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-plugin-utils@7.28.6': {}
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.27.1': {}
|
'@babel/helper-validator-identifier@7.27.1': {}
|
||||||
@@ -6905,106 +6963,110 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helpers@7.26.10':
|
'@babel/helpers@7.26.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/template': 7.27.2
|
'@babel/template': 7.28.6
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
'@babel/parser@7.28.5':
|
'@babel/parser@7.28.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
|
|
||||||
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)':
|
'@babel/parser@7.29.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-import-attributes@7.24.7(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-import-attributes@7.24.7(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.28.5)':
|
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
|
|
||||||
|
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)':
|
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
|
||||||
|
|
||||||
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)':
|
|
||||||
dependencies:
|
|
||||||
'@babel/core': 7.28.5
|
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
'@babel/runtime@7.26.10':
|
'@babel/runtime@7.26.10':
|
||||||
@@ -7017,6 +7079,12 @@ snapshots:
|
|||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.28.5
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
|
|
||||||
|
'@babel/template@7.28.6':
|
||||||
|
dependencies:
|
||||||
|
'@babel/code-frame': 7.29.0
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
'@babel/traverse@7.28.5':
|
'@babel/traverse@7.28.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -7029,11 +7097,28 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/traverse@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/code-frame': 7.29.0
|
||||||
|
'@babel/generator': 7.29.1
|
||||||
|
'@babel/helper-globals': 7.28.0
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@babel/template': 7.28.6
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@babel/types@7.28.5':
|
'@babel/types@7.28.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3': {}
|
'@bcoe/v8-coverage@0.2.3': {}
|
||||||
|
|
||||||
'@biomejs/biome@2.2.4':
|
'@biomejs/biome@2.2.4':
|
||||||
@@ -7624,7 +7709,7 @@ snapshots:
|
|||||||
|
|
||||||
'@jest/transform@29.7.0':
|
'@jest/transform@29.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
babel-plugin-istanbul: 6.1.1
|
babel-plugin-istanbul: 6.1.1
|
||||||
@@ -9333,9 +9418,9 @@ snapshots:
|
|||||||
|
|
||||||
'@vitejs/plugin-react@5.1.1(vite@7.2.6(@types/node@20.19.25)(jiti@1.21.7)(yaml@2.7.0))':
|
'@vitejs/plugin-react@5.1.1(vite@7.2.6(@types/node@20.19.25)(jiti@1.21.7)(yaml@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
|
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
|
||||||
'@rolldown/pluginutils': 1.0.0-beta.47
|
'@rolldown/pluginutils': 1.0.0-beta.47
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.18.0
|
react-refresh: 0.18.0
|
||||||
@@ -9648,13 +9733,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
|
|
||||||
babel-jest@29.7.0(@babel/core@7.28.5):
|
babel-jest@29.7.0(@babel/core@7.29.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
babel-plugin-istanbul: 6.1.1
|
babel-plugin-istanbul: 6.1.1
|
||||||
babel-preset-jest: 29.6.3(@babel/core@7.28.5)
|
babel-preset-jest: 29.6.3(@babel/core@7.29.0)
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
slash: 3.0.0
|
slash: 3.0.0
|
||||||
@@ -9684,30 +9769,34 @@ snapshots:
|
|||||||
cosmiconfig: 7.1.0
|
cosmiconfig: 7.1.0
|
||||||
resolve: 1.22.11
|
resolve: 1.22.11
|
||||||
|
|
||||||
babel-preset-current-node-syntax@1.1.0(@babel/core@7.28.5):
|
babel-plugin-react-compiler@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-import-attributes': 7.24.7(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5)
|
|
||||||
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5)
|
|
||||||
|
|
||||||
babel-preset-jest@29.6.3(@babel/core@7.28.5):
|
babel-preset-current-node-syntax@1.1.0(@babel/core@7.29.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-import-attributes': 7.24.7(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0)
|
||||||
|
|
||||||
|
babel-preset-jest@29.6.3(@babel/core@7.29.0):
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
babel-plugin-jest-hoist: 29.6.3
|
babel-plugin-jest-hoist: 29.6.3
|
||||||
babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.5)
|
babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.0)
|
||||||
|
|
||||||
bail@2.0.2: {}
|
bail@2.0.2: {}
|
||||||
|
|
||||||
@@ -11142,7 +11231,7 @@ snapshots:
|
|||||||
|
|
||||||
istanbul-lib-instrument@5.2.1:
|
istanbul-lib-instrument@5.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.28.5
|
||||||
'@istanbuljs/schema': 0.1.3
|
'@istanbuljs/schema': 0.1.3
|
||||||
istanbul-lib-coverage: 3.2.2
|
istanbul-lib-coverage: 3.2.2
|
||||||
@@ -11152,7 +11241,7 @@ snapshots:
|
|||||||
|
|
||||||
istanbul-lib-instrument@6.0.3:
|
istanbul-lib-instrument@6.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.28.5
|
||||||
'@istanbuljs/schema': 0.1.3
|
'@istanbuljs/schema': 0.1.3
|
||||||
istanbul-lib-coverage: 3.2.2
|
istanbul-lib-coverage: 3.2.2
|
||||||
@@ -11243,10 +11332,10 @@ snapshots:
|
|||||||
|
|
||||||
jest-config@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.3.38)(@types/node@20.19.25)(typescript@5.6.3)):
|
jest-config@29.7.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.3.38)(@types/node@20.19.25)(typescript@5.6.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@jest/test-sequencer': 29.7.0
|
'@jest/test-sequencer': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
babel-jest: 29.7.0(@babel/core@7.28.5)
|
babel-jest: 29.7.0(@babel/core@7.29.0)
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
ci-info: 3.9.0
|
ci-info: 3.9.0
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
@@ -11479,15 +11568,15 @@ snapshots:
|
|||||||
|
|
||||||
jest-snapshot@29.7.0:
|
jest-snapshot@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/generator': 7.28.5
|
'@babel/generator': 7.28.5
|
||||||
'@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.28.5)
|
'@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.29.0)
|
||||||
'@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.28.5)
|
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
'@jest/expect-utils': 29.7.0
|
'@jest/expect-utils': 29.7.0
|
||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.5)
|
babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.0)
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
expect: 29.7.0
|
expect: 29.7.0
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -12678,7 +12767,7 @@ snapshots:
|
|||||||
|
|
||||||
react-docgen@8.0.2:
|
react-docgen@8.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.29.0
|
||||||
'@babel/traverse': 7.28.5
|
'@babel/traverse': 7.28.5
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
import { transformSync } from "@babel/core";
|
||||||
|
|
||||||
|
const siteDir = new URL("..", import.meta.url).pathname;
|
||||||
|
|
||||||
|
const targetDirs = [
|
||||||
|
"src/pages/AgentsPage",
|
||||||
|
"src/components/ai-elements",
|
||||||
|
];
|
||||||
|
|
||||||
|
const skipPatterns = [".test.", ".stories.", ".jest."];
|
||||||
|
|
||||||
|
function collectFiles(dir) {
|
||||||
|
const results = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...collectFiles(full));
|
||||||
|
} else if (
|
||||||
|
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
|
||||||
|
!skipPatterns.some((p) => entry.name.includes(p))
|
||||||
|
) {
|
||||||
|
results.push(relative(siteDir, full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = targetDirs.flatMap((d) => collectFiles(join(siteDir, d)));
|
||||||
|
|
||||||
|
let totalCompiled = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const code = readFileSync(join(siteDir, file), "utf-8");
|
||||||
|
const isTSX = file.endsWith(".tsx");
|
||||||
|
const diagnostics = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = transformSync(code, {
|
||||||
|
plugins: [
|
||||||
|
["@babel/plugin-syntax-typescript", { isTSX }],
|
||||||
|
["babel-plugin-react-compiler", {
|
||||||
|
logger: {
|
||||||
|
logEvent(_filename, event) {
|
||||||
|
if (event.kind === "CompileError" || event.kind === "CompileSkip") {
|
||||||
|
const msg = event.detail || event.reason || "";
|
||||||
|
const short = typeof msg === "string"
|
||||||
|
? msg.replace(/^Error: /, "").split(".")[0].split("(http")[0].trim()
|
||||||
|
: String(msg);
|
||||||
|
diagnostics.push({ line: event.fnLoc?.start?.line, short });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
filename: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = result.code.match(/const \$ = _c\(\d+\)/g) || [];
|
||||||
|
totalCompiled += slots.length;
|
||||||
|
|
||||||
|
if (diagnostics.length) {
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = diagnostics.filter((d) => {
|
||||||
|
const key = `${d.line}:${d.short}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
failures.push({ file, compiled: slots.length, diagnostics: unique });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failures.push({
|
||||||
|
file, compiled: 0,
|
||||||
|
diagnostics: [{ line: 0, short: `Transform error: ${String(e.message).substring(0, 120)}` }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTotal: ${totalCompiled} functions compiled across ${files.length} files`);
|
||||||
|
console.log(`Files with diagnostics: ${failures.length}\n`);
|
||||||
|
|
||||||
|
for (const f of failures) {
|
||||||
|
const short = f.file.replace("src/pages/AgentsPage/", "").replace("src/components/ai-elements/", "ai/");
|
||||||
|
console.log(`✗ ${short} (${f.compiled} compiled)`);
|
||||||
|
for (const d of f.diagnostics) {
|
||||||
|
console.log(` line ${d.line}: ${d.short}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
console.log("✓ All files compile cleanly.");
|
||||||
|
} else {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "components/Tooltip/Tooltip";
|
} from "components/Tooltip/Tooltip";
|
||||||
import { type FC, useMemo } from "react";
|
import type { FC } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
export interface ModelSelectorOption {
|
export interface ModelSelectorOption {
|
||||||
@@ -67,11 +67,8 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
|||||||
dropdownAlign = "start",
|
dropdownAlign = "start",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedModel = useMemo(
|
const selectedModel = options.find((option) => option.id === value);
|
||||||
() => options.find((option) => option.id === value),
|
const optionsByProvider = (() => {
|
||||||
[options, value],
|
|
||||||
);
|
|
||||||
const optionsByProvider = useMemo(() => {
|
|
||||||
const grouped = new Map<string, ModelSelectorOption[]>();
|
const grouped = new Map<string, ModelSelectorOption[]>();
|
||||||
|
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
@@ -84,7 +81,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(grouped.entries());
|
return Array.from(grouped.entries());
|
||||||
}, [options]);
|
})();
|
||||||
const isDisabled = disabled || options.length === 0;
|
const isDisabled = disabled || options.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type SupportedLanguages,
|
type SupportedLanguages,
|
||||||
} from "@pierre/diffs/react";
|
} from "@pierre/diffs/react";
|
||||||
import type { ComponentPropsWithRef, ReactNode } from "react";
|
import type { ComponentPropsWithRef, ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
type Components,
|
type Components,
|
||||||
defaultRehypePlugins,
|
defaultRehypePlugins,
|
||||||
@@ -238,10 +237,7 @@ export const Response = ({
|
|||||||
const fileViewerThemeType: FileViewerThemeType =
|
const fileViewerThemeType: FileViewerThemeType =
|
||||||
theme.palette.mode === "dark" ? "dark" : "light";
|
theme.palette.mode === "dark" ? "dark" : "light";
|
||||||
const viewerTheme = fileViewerTheme[fileViewerThemeType];
|
const viewerTheme = fileViewerTheme[fileViewerThemeType];
|
||||||
const components = useMemo(
|
const components = createComponents(fileViewerThemeType, viewerTheme);
|
||||||
() => createComponents(fileViewerThemeType, viewerTheme),
|
|
||||||
[fileViewerThemeType, viewerTheme],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MotionProps } from "motion/react";
|
import type { MotionProps } from "motion/react";
|
||||||
import { MotionConfig, motion } from "motion/react";
|
import { MotionConfig, motion } from "motion/react";
|
||||||
import type { CSSProperties, ElementType, JSX } from "react";
|
import type { CSSProperties, ElementType, JSX } from "react";
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||||
@@ -40,10 +40,7 @@ const ShimmerComponent = ({
|
|||||||
Component as keyof JSX.IntrinsicElements,
|
Component as keyof JSX.IntrinsicElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dynamicSpread = useMemo(
|
const dynamicSpread = (children?.length ?? 0) * spread;
|
||||||
() => (children?.length ?? 0) * spread,
|
|
||||||
[children, spread],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionConfig reducedMotion="user">
|
<MotionConfig reducedMotion="user">
|
||||||
@@ -75,4 +72,4 @@ const ShimmerComponent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Shimmer = memo(ShimmerComponent);
|
export const Shimmer = ShimmerComponent;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
|
import { FileDiff, File as FileViewer } from "@pierre/diffs/react";
|
||||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||||
import type { ComponentPropsWithRef, FC } from "react";
|
import { type ComponentPropsWithRef, type FC, memo } from "react";
|
||||||
import { memo } from "react";
|
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { ChatSummarizedTool } from "./ChatSummarizedTool";
|
import { ChatSummarizedTool } from "./ChatSummarizedTool";
|
||||||
import { ComputerTool } from "./ComputerTool";
|
import { ComputerTool } from "./ComputerTool";
|
||||||
@@ -555,5 +554,3 @@ export const Tool = memo(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Tool.displayName = "Tool";
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||||
import { type FC, useMemo } from "react";
|
import type { FC } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { ToolCollapsible } from "./ToolCollapsible";
|
import { ToolCollapsible } from "./ToolCollapsible";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ interface WebSearchSourcesProps {
|
|||||||
*/
|
*/
|
||||||
const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
|
const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
|
||||||
// Deduplicate sources by URL, keeping the first occurrence.
|
// Deduplicate sources by URL, keeping the first occurrence.
|
||||||
const unique = useMemo(() => {
|
const unique = (() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return sources.filter((s) => {
|
return sources.filter((s) => {
|
||||||
if (!s.url || seen.has(s.url)) {
|
if (!s.url || seen.has(s.url)) {
|
||||||
@@ -23,7 +23,7 @@ const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
|
|||||||
seen.add(s.url);
|
seen.add(s.url);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sources]);
|
})();
|
||||||
|
|
||||||
if (unique.length === 0) {
|
if (unique.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,14 +21,7 @@ import {
|
|||||||
} from "components/Popover/Popover";
|
} from "components/Popover/Popover";
|
||||||
import { Check, MonitorIcon } from "lucide-react";
|
import { Check, MonitorIcon } from "lucide-react";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import {
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AgentChatInput } from "./AgentChatInput";
|
import { AgentChatInput } from "./AgentChatInput";
|
||||||
@@ -76,7 +69,7 @@ export function useEmptyStateDraft() {
|
|||||||
const inputValueRef = useRef(initialInputValue);
|
const inputValueRef = useRef(initialInputValue);
|
||||||
const sentRef = useRef(false);
|
const sentRef = useRef(false);
|
||||||
|
|
||||||
const handleContentChange = useCallback((content: string) => {
|
const handleContentChange = (content: string) => {
|
||||||
inputValueRef.current = content;
|
inputValueRef.current = content;
|
||||||
if (typeof window !== "undefined" && !sentRef.current) {
|
if (typeof window !== "undefined" && !sentRef.current) {
|
||||||
if (content) {
|
if (content) {
|
||||||
@@ -85,20 +78,20 @@ export function useEmptyStateDraft() {
|
|||||||
localStorage.removeItem(emptyInputStorageKey);
|
localStorage.removeItem(emptyInputStorageKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const submitDraft = useCallback(() => {
|
const submitDraft = () => {
|
||||||
// Mark as sent so that editor change events firing during
|
// Mark as sent so that editor change events firing during
|
||||||
// the async gap cannot re-persist the draft.
|
// the async gap cannot re-persist the draft.
|
||||||
sentRef.current = true;
|
sentRef.current = true;
|
||||||
localStorage.removeItem(emptyInputStorageKey);
|
localStorage.removeItem(emptyInputStorageKey);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const resetDraft = useCallback(() => {
|
const resetDraft = () => {
|
||||||
sentRef.current = false;
|
sentRef.current = false;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const getCurrentContent = useCallback(() => inputValueRef.current, []);
|
const getCurrentContent = () => inputValueRef.current;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialInputValue,
|
initialInputValue,
|
||||||
@@ -143,7 +136,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
}
|
}
|
||||||
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
|
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
|
||||||
});
|
});
|
||||||
const modelIDByConfigID = useMemo(() => {
|
const modelIDByConfigID = (() => {
|
||||||
const optionIDByRef = new Map<string, string>();
|
const optionIDByRef = new Map<string, string>();
|
||||||
for (const option of modelOptions) {
|
for (const option of modelOptions) {
|
||||||
const provider = option.provider.trim().toLowerCase();
|
const provider = option.provider.trim().toLowerCase();
|
||||||
@@ -170,20 +163,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
byConfigID.set(config.id, modelID);
|
byConfigID.set(config.id, modelID);
|
||||||
}
|
}
|
||||||
return byConfigID;
|
return byConfigID;
|
||||||
}, [modelConfigs, modelOptions]);
|
})();
|
||||||
const lastUsedModelID = useMemo(() => {
|
const lastUsedModelID = initialLastModelConfigID
|
||||||
if (!initialLastModelConfigID) {
|
? (modelIDByConfigID.get(initialLastModelConfigID) ?? "")
|
||||||
return "";
|
: "";
|
||||||
}
|
const defaultModelID = (() => {
|
||||||
return modelIDByConfigID.get(initialLastModelConfigID) ?? "";
|
|
||||||
}, [initialLastModelConfigID, modelIDByConfigID]);
|
|
||||||
const defaultModelID = useMemo(() => {
|
|
||||||
const defaultModelConfig = modelConfigs.find((config) => config.is_default);
|
const defaultModelConfig = modelConfigs.find((config) => config.is_default);
|
||||||
if (!defaultModelConfig) {
|
if (!defaultModelConfig) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return modelIDByConfigID.get(defaultModelConfig.id) ?? "";
|
return modelIDByConfigID.get(defaultModelConfig.id) ?? "";
|
||||||
}, [modelConfigs, modelIDByConfigID]);
|
})();
|
||||||
const preferredModelID =
|
const preferredModelID =
|
||||||
lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? "");
|
lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? "");
|
||||||
const [userSelectedModel, setUserSelectedModel] = useState("");
|
const [userSelectedModel, setUserSelectedModel] = useState("");
|
||||||
@@ -249,9 +239,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
// that the onSend callback always sees the latest values without
|
// that the onSend callback always sees the latest values without
|
||||||
// the shared input component re-rendering on every change.
|
// the shared input component re-rendering on every change.
|
||||||
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
|
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
|
||||||
selectedWorkspaceIdRef.current = selectedWorkspaceId;
|
|
||||||
const selectedModelRef = useRef(selectedModel);
|
const selectedModelRef = useRef(selectedModel);
|
||||||
selectedModelRef.current = selectedModel;
|
useEffect(() => {
|
||||||
|
selectedWorkspaceIdRef.current = selectedWorkspaceId;
|
||||||
|
selectedModelRef.current = selectedModel;
|
||||||
|
});
|
||||||
|
|
||||||
const handleWorkspaceChange = (value: string) => {
|
const handleWorkspaceChange = (value: string) => {
|
||||||
if (value === autoCreateWorkspaceValue) {
|
if (value === autoCreateWorkspaceValue) {
|
||||||
@@ -267,27 +259,24 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelChange = useCallback((value: string) => {
|
const handleModelChange = (value: string) => {
|
||||||
setHasUserSelectedModel(true);
|
setHasUserSelectedModel(true);
|
||||||
setUserSelectedModel(value);
|
setUserSelectedModel(value);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = async (message: string, fileIDs?: string[]) => {
|
||||||
async (message: string, fileIDs?: string[]) => {
|
submitDraft();
|
||||||
submitDraft();
|
await onCreateChat({
|
||||||
await onCreateChat({
|
message,
|
||||||
message,
|
fileIDs,
|
||||||
fileIDs,
|
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
|
||||||
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
model: selectedModelRef.current || undefined,
|
}).catch(() => {
|
||||||
}).catch(() => {
|
// Re-enable draft persistence so the user can edit
|
||||||
// Re-enable draft persistence so the user can edit
|
// and retry after a failed send attempt.
|
||||||
// and retry after a failed send attempt.
|
resetDraft();
|
||||||
resetDraft();
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[submitDraft, resetDraft, onCreateChat],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedWorkspace = selectedWorkspaceId
|
const selectedWorkspace = selectedWorkspaceId
|
||||||
? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)
|
? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)
|
||||||
@@ -305,34 +294,32 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
|||||||
resetAttachments,
|
resetAttachments,
|
||||||
} = useFileAttachments(organizations[0]?.id);
|
} = useFileAttachments(organizations[0]?.id);
|
||||||
|
|
||||||
const handleSendWithAttachments = useCallback(
|
const handleSendWithAttachments = async (message: string) => {
|
||||||
async (message: string) => {
|
const fileIds: string[] = [];
|
||||||
const fileIds: string[] = [];
|
let skippedErrors = 0;
|
||||||
let skippedErrors = 0;
|
for (const file of attachments) {
|
||||||
for (const file of attachments) {
|
const state = uploadStates.get(file);
|
||||||
const state = uploadStates.get(file);
|
if (state?.status === "error") {
|
||||||
if (state?.status === "error") {
|
skippedErrors++;
|
||||||
skippedErrors++;
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (state?.status === "uploaded" && state.fileId) {
|
|
||||||
fileIds.push(state.fileId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (skippedErrors > 0) {
|
if (state?.status === "uploaded" && state.fileId) {
|
||||||
toast.warning(
|
fileIds.push(state.fileId);
|
||||||
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
try {
|
}
|
||||||
await handleSend(message, fileIds.length > 0 ? fileIds : undefined);
|
if (skippedErrors > 0) {
|
||||||
resetAttachments();
|
toast.warning(
|
||||||
} catch {
|
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
|
||||||
// Attachments preserved for retry on failure.
|
);
|
||||||
}
|
}
|
||||||
},
|
const fileArg = fileIds.length > 0 ? fileIds : undefined;
|
||||||
[attachments, handleSend, resetAttachments, uploadStates],
|
try {
|
||||||
);
|
await handleSend(message, fileArg);
|
||||||
|
resetAttachments();
|
||||||
|
} catch {
|
||||||
|
// Attachments preserved for retry on failure.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { API, watchWorkspace } from "api/api";
|
import { API, watchWorkspace } from "api/api";
|
||||||
|
|
||||||
import { isApiError } from "api/errors";
|
import { isApiError } from "api/errors";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
@@ -19,14 +20,7 @@ import {
|
|||||||
getVSCodeHref,
|
getVSCodeHref,
|
||||||
openAppInNewWindow,
|
openAppInNewWindow,
|
||||||
} from "modules/apps/apps";
|
} from "modules/apps/apps";
|
||||||
import {
|
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -88,13 +82,21 @@ export function useConversationEditingState(deps: {
|
|||||||
if (typeof window === "undefined" || !draftStorageKey) {
|
if (typeof window === "undefined" || !draftStorageKey) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem(draftStorageKey);
|
return localStorage.getItem(draftStorageKey) ?? "";
|
||||||
if (saved) {
|
|
||||||
inputValueRef.current = saved;
|
|
||||||
}
|
|
||||||
return saved ?? "";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync the ref with the initial draft value so callers that
|
||||||
|
// read inputValueRef.current see the persisted draft. Uses a
|
||||||
|
// layout effect so the value is available before paint.
|
||||||
|
const initialSyncDone = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!initialSyncDone.current && editorInitialValue) {
|
||||||
|
initialSyncDone.current = true;
|
||||||
|
(inputValueRef as React.MutableRefObject<string>).current =
|
||||||
|
editorInitialValue;
|
||||||
|
}
|
||||||
|
}, [editorInitialValue, inputValueRef]);
|
||||||
|
|
||||||
// -- History editing state --
|
// -- History editing state --
|
||||||
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
||||||
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
|
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
|
||||||
@@ -104,24 +106,21 @@ export function useConversationEditingState(deps: {
|
|||||||
readonly ChatMessagePart[]
|
readonly ChatMessagePart[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const handleEditUserMessage = useCallback(
|
const handleEditUserMessage = (
|
||||||
(
|
messageId: number,
|
||||||
messageId: number,
|
text: string,
|
||||||
text: string,
|
fileBlocks?: readonly ChatMessagePart[],
|
||||||
fileBlocks?: readonly ChatMessagePart[],
|
) => {
|
||||||
) => {
|
setDraftBeforeHistoryEdit((prev) =>
|
||||||
setDraftBeforeHistoryEdit((prev) =>
|
editingMessageId !== null ? prev : inputValueRef.current,
|
||||||
editingMessageId !== null ? prev : inputValueRef.current,
|
);
|
||||||
);
|
setEditingMessageId(messageId);
|
||||||
setEditingMessageId(messageId);
|
setEditorInitialValue(text);
|
||||||
setEditorInitialValue(text);
|
inputValueRef.current = text;
|
||||||
inputValueRef.current = text;
|
setEditingFileBlocks(fileBlocks ?? []);
|
||||||
setEditingFileBlocks(fileBlocks ?? []);
|
};
|
||||||
},
|
|
||||||
[editingMessageId, inputValueRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancelHistoryEdit = useCallback(() => {
|
const handleCancelHistoryEdit = () => {
|
||||||
setEditorInitialValue(draftBeforeHistoryEdit ?? "");
|
setEditorInitialValue(draftBeforeHistoryEdit ?? "");
|
||||||
inputValueRef.current = draftBeforeHistoryEdit ?? "";
|
inputValueRef.current = draftBeforeHistoryEdit ?? "";
|
||||||
setEditingMessageId(null);
|
setEditingMessageId(null);
|
||||||
@@ -131,7 +130,7 @@ export function useConversationEditingState(deps: {
|
|||||||
if (draftBeforeHistoryEdit) {
|
if (draftBeforeHistoryEdit) {
|
||||||
chatInputRef.current?.insertText(draftBeforeHistoryEdit);
|
chatInputRef.current?.insertText(draftBeforeHistoryEdit);
|
||||||
}
|
}
|
||||||
}, [draftBeforeHistoryEdit, inputValueRef, chatInputRef]);
|
};
|
||||||
|
|
||||||
// -- Queue editing state --
|
// -- Queue editing state --
|
||||||
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
|
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
|
||||||
@@ -141,81 +140,68 @@ export function useConversationEditingState(deps: {
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handleStartQueueEdit = useCallback(
|
const handleStartQueueEdit = (
|
||||||
(id: number, text: string, fileBlocks: readonly ChatMessagePart[]) => {
|
id: number,
|
||||||
setDraftBeforeQueueEdit((prev) =>
|
text: string,
|
||||||
editingQueuedMessageID === null ? inputValueRef.current : prev,
|
fileBlocks: readonly ChatMessagePart[],
|
||||||
);
|
) => {
|
||||||
setEditingQueuedMessageID(id);
|
setDraftBeforeQueueEdit((prev) =>
|
||||||
setEditorInitialValue(text);
|
editingQueuedMessageID === null ? inputValueRef.current : prev,
|
||||||
inputValueRef.current = text;
|
);
|
||||||
setEditingFileBlocks(fileBlocks);
|
setEditingQueuedMessageID(id);
|
||||||
},
|
setEditorInitialValue(text);
|
||||||
[editingQueuedMessageID, inputValueRef],
|
inputValueRef.current = text;
|
||||||
);
|
setEditingFileBlocks(fileBlocks);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelQueueEdit = useCallback(() => {
|
const handleCancelQueueEdit = () => {
|
||||||
setEditorInitialValue(draftBeforeQueueEdit ?? "");
|
setEditorInitialValue(draftBeforeQueueEdit ?? "");
|
||||||
inputValueRef.current = draftBeforeQueueEdit ?? "";
|
inputValueRef.current = draftBeforeQueueEdit ?? "";
|
||||||
setEditingQueuedMessageID(null);
|
setEditingQueuedMessageID(null);
|
||||||
setDraftBeforeQueueEdit(null);
|
setDraftBeforeQueueEdit(null);
|
||||||
setEditingFileBlocks([]);
|
setEditingFileBlocks([]);
|
||||||
}, [draftBeforeQueueEdit, inputValueRef]);
|
};
|
||||||
|
|
||||||
// Wraps the parent onSend to clear local input/editing state
|
// Wraps the parent onSend to clear local input/editing state
|
||||||
// and handle queue-edit deletion.
|
// and handle queue-edit deletion.
|
||||||
const handleSendFromInput = useCallback(
|
const handleSendFromInput = async (message: string, fileIds?: string[]) => {
|
||||||
async (message: string, fileIds?: string[]) => {
|
const editedMessageID =
|
||||||
const editedMessageID =
|
editingMessageId !== null ? editingMessageId : undefined;
|
||||||
editingMessageId !== null ? editingMessageId : undefined;
|
const queueEditID = editingQueuedMessageID;
|
||||||
const queueEditID = editingQueuedMessageID;
|
|
||||||
|
|
||||||
await onSend(message, fileIds, editedMessageID);
|
await onSend(message, fileIds, editedMessageID);
|
||||||
// Clear input and editing state on success.
|
// Clear input and editing state on success.
|
||||||
chatInputRef.current?.clear();
|
chatInputRef.current?.clear();
|
||||||
if (!isMobileViewport()) {
|
if (!isMobileViewport()) {
|
||||||
chatInputRef.current?.focus();
|
chatInputRef.current?.focus();
|
||||||
}
|
}
|
||||||
inputValueRef.current = "";
|
inputValueRef.current = "";
|
||||||
if (typeof window !== "undefined" && draftStorageKey) {
|
if (typeof window !== "undefined" && draftStorageKey) {
|
||||||
|
localStorage.removeItem(draftStorageKey);
|
||||||
|
}
|
||||||
|
if (editingMessageId !== null) {
|
||||||
|
setEditingMessageId(null);
|
||||||
|
setDraftBeforeHistoryEdit(null);
|
||||||
|
setEditingFileBlocks([]);
|
||||||
|
}
|
||||||
|
if (queueEditID !== null) {
|
||||||
|
setEditingQueuedMessageID(null);
|
||||||
|
setDraftBeforeQueueEdit(null);
|
||||||
|
setEditingFileBlocks([]);
|
||||||
|
void onDeleteQueuedMessage(queueEditID);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (content: string) => {
|
||||||
|
inputValueRef.current = content;
|
||||||
|
if (typeof window !== "undefined" && draftStorageKey) {
|
||||||
|
if (content) {
|
||||||
|
localStorage.setItem(draftStorageKey, content);
|
||||||
|
} else {
|
||||||
localStorage.removeItem(draftStorageKey);
|
localStorage.removeItem(draftStorageKey);
|
||||||
}
|
}
|
||||||
if (editingMessageId !== null) {
|
}
|
||||||
setEditingMessageId(null);
|
};
|
||||||
setDraftBeforeHistoryEdit(null);
|
|
||||||
setEditingFileBlocks([]);
|
|
||||||
}
|
|
||||||
if (queueEditID !== null) {
|
|
||||||
setEditingQueuedMessageID(null);
|
|
||||||
setDraftBeforeQueueEdit(null);
|
|
||||||
setEditingFileBlocks([]);
|
|
||||||
void onDeleteQueuedMessage(queueEditID);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
chatInputRef,
|
|
||||||
editingMessageId,
|
|
||||||
editingQueuedMessageID,
|
|
||||||
onDeleteQueuedMessage,
|
|
||||||
onSend,
|
|
||||||
draftStorageKey,
|
|
||||||
inputValueRef,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContentChange = useCallback(
|
|
||||||
(content: string) => {
|
|
||||||
inputValueRef.current = content;
|
|
||||||
if (typeof window !== "undefined" && draftStorageKey) {
|
|
||||||
if (content) {
|
|
||||||
localStorage.setItem(draftStorageKey, content);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(draftStorageKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[draftStorageKey, inputValueRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputValueRef,
|
inputValueRef,
|
||||||
@@ -263,7 +249,11 @@ const AgentDetail: FC = () => {
|
|||||||
} = outletContext;
|
} = outletContext;
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
|
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
|
||||||
const inputValueRef = useRef("");
|
const inputValueRef = useRef(
|
||||||
|
typeof window !== "undefined" && agentId
|
||||||
|
? (localStorage.getItem(`${draftInputStorageKeyPrefix}${agentId}`) ?? "")
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
// Right panel open/closed state is owned here so the loading
|
// Right panel open/closed state is owned here so the loading
|
||||||
// skeleton and the loaded view share the same layout, preventing
|
// skeleton and the loaded view share the same layout, preventing
|
||||||
@@ -272,18 +262,17 @@ const AgentDetail: FC = () => {
|
|||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true";
|
return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true";
|
||||||
});
|
});
|
||||||
const handleSetShowSidebarPanel = useCallback(
|
const handleSetShowSidebarPanel = (
|
||||||
(next: boolean | ((prev: boolean) => boolean)) => {
|
next: boolean | ((prev: boolean) => boolean),
|
||||||
setShowSidebarPanel((prev) => {
|
) => {
|
||||||
const value = typeof next === "function" ? next(prev) : next;
|
setShowSidebarPanel((prev) => {
|
||||||
if (typeof window !== "undefined") {
|
const value = typeof next === "function" ? next(prev) : next;
|
||||||
localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value));
|
if (typeof window !== "undefined") {
|
||||||
}
|
localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value));
|
||||||
return value;
|
}
|
||||||
});
|
return value;
|
||||||
},
|
});
|
||||||
[],
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const chatQuery = useQuery({
|
const chatQuery = useQuery({
|
||||||
...chat(agentId ?? ""),
|
...chat(agentId ?? ""),
|
||||||
@@ -316,9 +305,25 @@ const AgentDetail: FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.parsedMessage.type === "data") {
|
if (event.parsedMessage.type === "data") {
|
||||||
queryClient.setQueryData(
|
const next = event.parsedMessage.data as TypesGen.Workspace;
|
||||||
|
queryClient.setQueryData<TypesGen.Workspace | undefined>(
|
||||||
workspaceByIdKey(workspaceId),
|
workspaceByIdKey(workspaceId),
|
||||||
event.parsedMessage.data as TypesGen.Workspace,
|
(prev) => {
|
||||||
|
// Return the same reference when nothing the UI
|
||||||
|
// reads has changed. This prevents react-query
|
||||||
|
// from notifying subscribers and avoids a full
|
||||||
|
// AgentDetail re-render on every heartbeat.
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
prev.latest_build.status === next.latest_build.status &&
|
||||||
|
prev.latest_build.resources === next.latest_build.resources &&
|
||||||
|
prev.name === next.name &&
|
||||||
|
prev.owner_name === next.owner_name
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -329,39 +334,44 @@ const AgentDetail: FC = () => {
|
|||||||
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
|
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
|
||||||
const { proxy } = useProxy();
|
const { proxy } = useProxy();
|
||||||
|
|
||||||
const urlTransform = useCallback<UrlTransform>(
|
// Extract the primitive fields used by the transform so the
|
||||||
(url) => {
|
// compiler can see the real dependencies and avoid invalidating
|
||||||
const host = proxy.preferredWildcardHostname;
|
// the closure when the workspace object reference changes but
|
||||||
if (!host || !workspaceAgent || !workspace) {
|
// the relevant fields haven't.
|
||||||
|
const proxyHost = proxy.preferredWildcardHostname;
|
||||||
|
const agentName = workspaceAgent?.name;
|
||||||
|
const wsName = workspace?.name;
|
||||||
|
const wsOwner = workspace?.owner_name;
|
||||||
|
|
||||||
|
const urlTransform: UrlTransform = (url) => {
|
||||||
|
if (!proxyHost || !agentName || !wsName || !wsOwner) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!localHosts.has(parsed.hostname)) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
try {
|
return portForwardURL(
|
||||||
const parsed = new URL(url);
|
proxyHost,
|
||||||
if (!localHosts.has(parsed.hostname)) {
|
Number.parseInt(parsed.port, 10),
|
||||||
return url;
|
agentName,
|
||||||
}
|
wsName,
|
||||||
return portForwardURL(
|
wsOwner,
|
||||||
host,
|
"http",
|
||||||
Number.parseInt(parsed.port, 10),
|
parsed.pathname,
|
||||||
workspaceAgent.name,
|
parsed.search,
|
||||||
workspace.name,
|
);
|
||||||
workspace.owner_name,
|
} catch {
|
||||||
"http",
|
return url;
|
||||||
parsed.pathname,
|
}
|
||||||
parsed.search,
|
};
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[proxy.preferredWildcardHostname, workspaceAgent, workspace],
|
|
||||||
);
|
|
||||||
|
|
||||||
const chatRecord = chatQuery.data;
|
const chatRecord = chatQuery.data;
|
||||||
// Flatten paginated messages into chronological order.
|
// Flatten paginated messages into chronological order.
|
||||||
// Pages arrive newest-first per page, and pages[0] is the
|
// Pages arrive newest-first per page, and pages[0] is the
|
||||||
// most recent page.
|
// most recent page.
|
||||||
const chatMessagesList = useMemo(() => {
|
const chatMessagesList = (() => {
|
||||||
const pages = chatMessagesQuery.data?.pages;
|
const pages = chatMessagesQuery.data?.pages;
|
||||||
if (!pages || pages.length === 0) return undefined;
|
if (!pages || pages.length === 0) return undefined;
|
||||||
// Collect all messages, then sort chronologically by ID.
|
// Collect all messages, then sort chronologically by ID.
|
||||||
@@ -369,7 +379,7 @@ const AgentDetail: FC = () => {
|
|||||||
// Sort ascending by ID for chronological order.
|
// Sort ascending by ID for chronological order.
|
||||||
all.sort((a, b) => a.id - b.id);
|
all.sort((a, b) => a.id - b.id);
|
||||||
return all;
|
return all;
|
||||||
}, [chatMessagesQuery.data]);
|
})();
|
||||||
|
|
||||||
// Queued messages are only in the first page (most recent).
|
// Queued messages are only in the first page (most recent).
|
||||||
const chatQueuedMessages = chatMessagesQuery.data?.pages[0]?.queued_messages;
|
const chatQueuedMessages = chatMessagesQuery.data?.pages[0]?.queued_messages;
|
||||||
@@ -377,14 +387,13 @@ const AgentDetail: FC = () => {
|
|||||||
// Build a synthetic ChatMessagesResponse from the flattened
|
// Build a synthetic ChatMessagesResponse from the flattened
|
||||||
// data for backward compat with useChatStore.
|
// data for backward compat with useChatStore.
|
||||||
const chatMessagesData: TypesGen.ChatMessagesResponse | undefined =
|
const chatMessagesData: TypesGen.ChatMessagesResponse | undefined =
|
||||||
useMemo(() => {
|
chatMessagesList
|
||||||
if (!chatMessagesList) return undefined;
|
? {
|
||||||
return {
|
messages: chatMessagesList,
|
||||||
messages: chatMessagesList,
|
queued_messages: chatQueuedMessages ?? [],
|
||||||
queued_messages: chatQueuedMessages ?? [],
|
has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false,
|
||||||
has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false,
|
}
|
||||||
};
|
: undefined;
|
||||||
}, [chatMessagesList, chatQueuedMessages, chatMessagesQuery.data]);
|
|
||||||
const isArchived = chatRecord?.archived ?? false;
|
const isArchived = chatRecord?.archived ?? false;
|
||||||
const chatLastModelConfigID = chatRecord?.last_model_config_id;
|
const chatLastModelConfigID = chatRecord?.last_model_config_id;
|
||||||
|
|
||||||
@@ -427,7 +436,7 @@ const AgentDetail: FC = () => {
|
|||||||
chatID: agentId,
|
chatID: agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCommit = useCallback((repoRoot: string) => {
|
const handleCommit = (repoRoot: string) => {
|
||||||
const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`;
|
const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`;
|
||||||
const current = inputValueRef.current;
|
const current = inputValueRef.current;
|
||||||
if (current.includes(commitPrompt)) {
|
if (current.includes(commitPrompt)) {
|
||||||
@@ -436,7 +445,7 @@ const AgentDetail: FC = () => {
|
|||||||
const prefix = current.trim() ? "\n\n" : "";
|
const prefix = current.trim() ? "\n\n" : "";
|
||||||
chatInputRef.current?.insertText(prefix + commitPrompt);
|
chatInputRef.current?.insertText(prefix + commitPrompt);
|
||||||
chatInputRef.current?.focus();
|
chatInputRef.current?.focus();
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Prefer the explicit PR number from the API, and only fall back to URL
|
// Prefer the explicit PR number from the API, and only fall back to URL
|
||||||
// parsing when older metadata does not provide it.
|
// parsing when older metadata does not provide it.
|
||||||
@@ -448,7 +457,7 @@ const AgentDetail: FC = () => {
|
|||||||
// Compute an effective selected model by validating the user's
|
// Compute an effective selected model by validating the user's
|
||||||
// explicit choice against the current model options, falling
|
// explicit choice against the current model options, falling
|
||||||
// back to the chat's last model or the first available option.
|
// back to the chat's last model or the first available option.
|
||||||
const effectiveSelectedModel = useMemo(() => {
|
const effectiveSelectedModel = (() => {
|
||||||
if (
|
if (
|
||||||
selectedModel &&
|
selectedModel &&
|
||||||
modelOptions.some((model) => model.id === selectedModel)
|
modelOptions.some((model) => model.id === selectedModel)
|
||||||
@@ -462,15 +471,12 @@ const AgentDetail: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modelOptions[0]?.id ?? "";
|
return modelOptions[0]?.id ?? "";
|
||||||
}, [selectedModel, chatLastModelConfigID, modelIDByConfigID, modelOptions]);
|
})();
|
||||||
|
|
||||||
const compressionThreshold = useMemo(() => {
|
const compressionThreshold = chatLastModelConfigID
|
||||||
if (!chatLastModelConfigID) {
|
? modelConfigs.find((c) => c.id === chatLastModelConfigID)
|
||||||
return undefined;
|
?.compression_threshold
|
||||||
}
|
: undefined;
|
||||||
const config = modelConfigs.find((c) => c.id === chatLastModelConfigID);
|
|
||||||
return config?.compression_threshold;
|
|
||||||
}, [chatLastModelConfigID, modelConfigs]);
|
|
||||||
const hasModelOptions = modelOptions.length > 0;
|
const hasModelOptions = modelOptions.length > 0;
|
||||||
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
|
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
|
||||||
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
|
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
|
||||||
@@ -495,29 +501,26 @@ const AgentDetail: FC = () => {
|
|||||||
interruptMutation.isPending;
|
interruptMutation.isPending;
|
||||||
const isInputDisabled = !hasModelOptions || isArchived;
|
const isInputDisabled = !hasModelOptions || isArchived;
|
||||||
|
|
||||||
const handleUsageLimitError = useCallback(
|
const handleUsageLimitError = (error: unknown): void => {
|
||||||
(error: unknown): void => {
|
if (!agentId) {
|
||||||
if (!agentId) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (
|
||||||
if (
|
isApiError(error) &&
|
||||||
isApiError(error) &&
|
error.response?.status === 409 &&
|
||||||
error.response?.status === 409 &&
|
isUsageLimitData(error.response.data)
|
||||||
isUsageLimitData(error.response.data)
|
) {
|
||||||
) {
|
setChatErrorReason(agentId, {
|
||||||
setChatErrorReason(agentId, {
|
kind: "usage-limit",
|
||||||
kind: "usage-limit",
|
message: formatUsageLimitMessage(error.response.data),
|
||||||
message: formatUsageLimitMessage(error.response.data),
|
});
|
||||||
});
|
} else if (isApiError(error)) {
|
||||||
} else if (isApiError(error)) {
|
setChatErrorReason(agentId, {
|
||||||
setChatErrorReason(agentId, {
|
kind: "generic",
|
||||||
kind: "generic",
|
message: error.message || "An unexpected error occurred.",
|
||||||
message: error.message || "An unexpected error occurred.",
|
});
|
||||||
});
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[agentId, setChatErrorReason],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSend = async (
|
const handleSend = async (
|
||||||
message: string,
|
message: string,
|
||||||
@@ -584,11 +587,11 @@ const AgentDetail: FC = () => {
|
|||||||
messageId: editedMessageID,
|
messageId: editedMessageID,
|
||||||
req: request,
|
req: request,
|
||||||
});
|
});
|
||||||
|
setPendingEditMessageId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setPendingEditMessageId(null);
|
||||||
handleUsageLimitError(error);
|
handleUsageLimitError(error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
setPendingEditMessageId(null);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -610,28 +613,29 @@ const AgentDetail: FC = () => {
|
|||||||
// timeline when the server confirms via the POST response or
|
// timeline when the server confirms via the POST response or
|
||||||
// via the SSE stream.
|
// via the SSE stream.
|
||||||
store.clearStreamState();
|
store.clearStreamState();
|
||||||
|
let response: Awaited<ReturnType<typeof sendMutation.mutateAsync>>;
|
||||||
try {
|
try {
|
||||||
const response = await sendMutation.mutateAsync(request);
|
response = await sendMutation.mutateAsync(request);
|
||||||
// When the server accepts the message immediately (not
|
|
||||||
// queued), insert it into the store so it appears in the
|
|
||||||
// timeline without waiting for the SSE stream.
|
|
||||||
if (!response.queued && response.message) {
|
|
||||||
store.upsertDurableMessage(response.message);
|
|
||||||
}
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
if (selectedModelConfigID) {
|
|
||||||
localStorage.setItem(
|
|
||||||
lastModelConfigIDStorageKey,
|
|
||||||
selectedModelConfigID,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(lastModelConfigIDStorageKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleUsageLimitError(error);
|
handleUsageLimitError(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// When the server accepts the message immediately (not
|
||||||
|
// queued), insert it into the store so it appears in the
|
||||||
|
// timeline without waiting for the SSE stream.
|
||||||
|
if (!response.queued && response.message) {
|
||||||
|
store.upsertDurableMessage(response.message);
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (selectedModelConfigID) {
|
||||||
|
localStorage.setItem(
|
||||||
|
lastModelConfigIDStorageKey,
|
||||||
|
selectedModelConfigID,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(lastModelConfigIDStorageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInterrupt = () => {
|
const handleInterrupt = () => {
|
||||||
@@ -641,57 +645,45 @@ const AgentDetail: FC = () => {
|
|||||||
void interruptMutation.mutateAsync();
|
void interruptMutation.mutateAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteQueuedMessage = useCallback(
|
const handleDeleteQueuedMessage = async (id: number) => {
|
||||||
async (id: number) => {
|
const previousQueuedMessages = store.getSnapshot().queuedMessages;
|
||||||
const previousQueuedMessages = store.getSnapshot().queuedMessages;
|
store.setQueuedMessages(
|
||||||
store.setQueuedMessages(
|
previousQueuedMessages.filter((message) => message.id !== id),
|
||||||
previousQueuedMessages.filter((message) => message.id !== id),
|
);
|
||||||
);
|
try {
|
||||||
try {
|
await deleteQueuedMutation.mutateAsync(id);
|
||||||
await deleteQueuedMutation.mutateAsync(id);
|
} catch (error) {
|
||||||
} catch (error) {
|
store.setQueuedMessages(previousQueuedMessages);
|
||||||
store.setQueuedMessages(previousQueuedMessages);
|
throw error;
|
||||||
throw error;
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[deleteQueuedMutation, store],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePromoteQueuedMessage = useCallback(
|
const handlePromoteQueuedMessage = async (id: number) => {
|
||||||
async (id: number) => {
|
const previousSnapshot = store.getSnapshot();
|
||||||
const previousSnapshot = store.getSnapshot();
|
const previousQueuedMessages = previousSnapshot.queuedMessages;
|
||||||
const previousQueuedMessages = previousSnapshot.queuedMessages;
|
const previousChatStatus = previousSnapshot.chatStatus;
|
||||||
const previousChatStatus = previousSnapshot.chatStatus;
|
store.setQueuedMessages(
|
||||||
store.setQueuedMessages(
|
previousQueuedMessages.filter((message) => message.id !== id),
|
||||||
previousQueuedMessages.filter((message) => message.id !== id),
|
);
|
||||||
);
|
store.clearStreamState();
|
||||||
store.clearStreamState();
|
if (agentId) {
|
||||||
if (agentId) {
|
clearChatErrorReason(agentId);
|
||||||
clearChatErrorReason(agentId);
|
}
|
||||||
}
|
store.clearStreamError();
|
||||||
store.clearStreamError();
|
store.setChatStatus("pending");
|
||||||
store.setChatStatus("pending");
|
try {
|
||||||
try {
|
const promotedMessage = await promoteQueuedMutation.mutateAsync(id);
|
||||||
const promotedMessage = await promoteQueuedMutation.mutateAsync(id);
|
// Insert the promoted message into the store immediately
|
||||||
// Insert the promoted message into the store immediately
|
// so it appears in the timeline without waiting for the
|
||||||
// so it appears in the timeline without waiting for the
|
// WebSocket to deliver it.
|
||||||
// WebSocket to deliver it.
|
store.upsertDurableMessage(promotedMessage);
|
||||||
store.upsertDurableMessage(promotedMessage);
|
} catch (error) {
|
||||||
} catch (error) {
|
store.setQueuedMessages(previousQueuedMessages);
|
||||||
store.setQueuedMessages(previousQueuedMessages);
|
store.setChatStatus(previousChatStatus);
|
||||||
store.setChatStatus(previousChatStatus);
|
handleUsageLimitError(error);
|
||||||
handleUsageLimitError(error);
|
throw error;
|
||||||
throw error;
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[
|
|
||||||
agentId,
|
|
||||||
clearChatErrorReason,
|
|
||||||
handleUsageLimitError,
|
|
||||||
promoteQueuedMutation,
|
|
||||||
store,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editing = useConversationEditingState({
|
const editing = useConversationEditingState({
|
||||||
chatID: agentId,
|
chatID: agentId,
|
||||||
@@ -821,6 +813,7 @@ const AgentDetail: FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentDetailView
|
<AgentDetailView
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { watchChat } from "api/api";
|
|||||||
import { chatMessagesKey, updateInfiniteChatsCache } from "api/queries/chats";
|
import { chatMessagesKey, updateInfiniteChatsCache } from "api/queries/chats";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
|
||||||
import {
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
startTransition,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useSyncExternalStore,
|
|
||||||
} from "react";
|
|
||||||
import { type InfiniteData, useQueryClient } from "react-query";
|
import { type InfiniteData, useQueryClient } from "react-query";
|
||||||
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
|
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
|
||||||
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
|
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
|
||||||
@@ -135,6 +129,7 @@ type ChatStoreState = {
|
|||||||
type ChatStore = {
|
type ChatStore = {
|
||||||
getSnapshot: () => ChatStoreState;
|
getSnapshot: () => ChatStoreState;
|
||||||
subscribe: (listener: () => void) => () => void;
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
batch: (fn: () => void) => void;
|
||||||
replaceMessages: (
|
replaceMessages: (
|
||||||
messages: readonly TypesGen.ChatMessage[] | undefined,
|
messages: readonly TypesGen.ChatMessage[] | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -142,6 +137,7 @@ type ChatStore = {
|
|||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
};
|
};
|
||||||
|
upsertDurableMessages: (messages: readonly TypesGen.ChatMessage[]) => void;
|
||||||
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
|
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
|
||||||
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
|
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
|
||||||
setQueuedMessages: (
|
setQueuedMessages: (
|
||||||
@@ -181,6 +177,25 @@ export const createChatStore = (): ChatStore => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Batching: suppress emit() during a batch and fire once
|
||||||
|
// at the end. This collapses N store mutations from a
|
||||||
|
// single WebSocket message into one subscriber notification.
|
||||||
|
let batchDepth = 0;
|
||||||
|
let batchDirty = false;
|
||||||
|
|
||||||
|
const batch = (fn: () => void): void => {
|
||||||
|
batchDepth += 1;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
batchDepth -= 1;
|
||||||
|
if (batchDepth === 0 && batchDirty) {
|
||||||
|
batchDirty = false;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setState = (
|
const setState = (
|
||||||
updater: (current: ChatStoreState) => ChatStoreState,
|
updater: (current: ChatStoreState) => ChatStoreState,
|
||||||
): void => {
|
): void => {
|
||||||
@@ -189,7 +204,11 @@ export const createChatStore = (): ChatStore => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = next;
|
state = next;
|
||||||
emit();
|
if (batchDepth > 0) {
|
||||||
|
batchDirty = true;
|
||||||
|
} else {
|
||||||
|
emit();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceMessages = (
|
const replaceMessages = (
|
||||||
@@ -265,6 +284,43 @@ export const createChatStore = (): ChatStore => {
|
|||||||
return { isDuplicate, changed: actuallyChanged };
|
return { isDuplicate, changed: actuallyChanged };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bulk variant that applies all messages in a single pass —
|
||||||
|
// one Map copy and one sort instead of N copies and N sorts.
|
||||||
|
const upsertDurableMessages = (
|
||||||
|
messages: readonly TypesGen.ChatMessage[],
|
||||||
|
): void => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((current) => {
|
||||||
|
let nextMessagesByID: Map<number, TypesGen.ChatMessage> | null = null;
|
||||||
|
for (const message of messages) {
|
||||||
|
const map = nextMessagesByID ?? current.messagesByID;
|
||||||
|
const existing = map.get(message.id);
|
||||||
|
if (existing && chatMessagesEqualByValue(existing, message)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Lazily copy the map on first actual change.
|
||||||
|
if (!nextMessagesByID) {
|
||||||
|
nextMessagesByID = new Map(current.messagesByID);
|
||||||
|
}
|
||||||
|
nextMessagesByID.set(message.id, message);
|
||||||
|
}
|
||||||
|
if (!nextMessagesByID) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const needsReorder = nextMessagesByID.size !== current.messagesByID.size;
|
||||||
|
const nextOrderedMessageIDs = needsReorder
|
||||||
|
? buildOrderedMessageIDs(Array.from(nextMessagesByID.values()))
|
||||||
|
: current.orderedMessageIDs;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
messagesByID: nextMessagesByID,
|
||||||
|
orderedMessageIDs: nextOrderedMessageIDs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyMessageParts = (parts: readonly TypesGen.ChatMessagePart[]) => {
|
const applyMessageParts = (parts: readonly TypesGen.ChatMessagePart[]) => {
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -293,8 +349,10 @@ export const createChatStore = (): ChatStore => {
|
|||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
batch,
|
||||||
replaceMessages,
|
replaceMessages,
|
||||||
upsertDurableMessage,
|
upsertDurableMessage,
|
||||||
|
upsertDurableMessages,
|
||||||
applyMessagePart: (part) => applyMessageParts([part]),
|
applyMessagePart: (part) => applyMessageParts([part]),
|
||||||
applyMessageParts,
|
applyMessageParts,
|
||||||
setQueuedMessages: (queuedMessages) => {
|
setQueuedMessages: (queuedMessages) => {
|
||||||
@@ -436,7 +494,7 @@ export const useChatStore = (
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const storeRef = useRef<ChatStore>(createChatStore());
|
const [store] = useState(createChatStore);
|
||||||
const streamResetFrameRef = useRef<number | null>(null);
|
const streamResetFrameRef = useRef<number | null>(null);
|
||||||
const queuedMessagesHydratedChatIDRef = useRef<string | null>(null);
|
const queuedMessagesHydratedChatIDRef = useRef<string | null>(null);
|
||||||
// Tracks whether the WebSocket has delivered a queue_update for the
|
// Tracks whether the WebSocket has delivered a queue_update for the
|
||||||
@@ -459,18 +517,29 @@ export const useChatStore = (
|
|||||||
// server.
|
// server.
|
||||||
const lastSyncedMessagesRef = useRef<readonly TypesGen.ChatMessage[]>([]);
|
const lastSyncedMessagesRef = useRef<readonly TypesGen.ChatMessage[]>([]);
|
||||||
|
|
||||||
const store = storeRef.current;
|
|
||||||
|
|
||||||
// Compute the last REST-fetched message ID so the stream can
|
// Compute the last REST-fetched message ID so the stream can
|
||||||
// skip messages the client already has. We use a ref so the
|
// skip messages the client already has. We use a ref so the
|
||||||
// socket effect can read the latest value without including
|
// socket effect can read the latest value without including
|
||||||
// chatMessages in its dependency array (which would cause
|
// chatMessages in its dependency array (which would cause
|
||||||
// unnecessary reconnections).
|
// unnecessary reconnections).
|
||||||
const lastMessageIdRef = useRef<number | undefined>(undefined);
|
const lastMessageIdRef = useRef<number | undefined>(undefined);
|
||||||
lastMessageIdRef.current =
|
useEffect(() => {
|
||||||
chatMessages && chatMessages.length > 0
|
lastMessageIdRef.current =
|
||||||
? chatMessages[chatMessages.length - 1].id
|
chatMessages && chatMessages.length > 0
|
||||||
: undefined;
|
? chatMessages[chatMessages.length - 1].id
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep error-reason callbacks in refs so the WebSocket effect
|
||||||
|
// can call them without including them in its dependency array.
|
||||||
|
// This prevents the socket from tearing down when the parent
|
||||||
|
// re-renders with new callback identities.
|
||||||
|
const setChatErrorReasonRef = useRef(setChatErrorReason);
|
||||||
|
const clearChatErrorReasonRef = useRef(clearChatErrorReason);
|
||||||
|
useEffect(() => {
|
||||||
|
setChatErrorReasonRef.current = setChatErrorReason;
|
||||||
|
clearChatErrorReasonRef.current = clearChatErrorReason;
|
||||||
|
}, [setChatErrorReason, clearChatErrorReason]);
|
||||||
|
|
||||||
// True once the initial REST page has resolved for the current
|
// True once the initial REST page has resolved for the current
|
||||||
// chat. The WebSocket effect gates on this so that
|
// chat. The WebSocket effect gates on this so that
|
||||||
@@ -479,119 +548,46 @@ export const useChatStore = (
|
|||||||
// its snapshot, defeating pagination.
|
// its snapshot, defeating pagination.
|
||||||
const initialDataLoaded = chatMessages !== undefined;
|
const initialDataLoaded = chatMessages !== undefined;
|
||||||
|
|
||||||
const updateSidebarChat = useCallback(
|
|
||||||
(updater: (chat: TypesGen.Chat) => TypesGen.Chat) => {
|
|
||||||
if (!chatID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateInfiniteChatsCache(queryClient, (chats) => {
|
|
||||||
let didUpdate = false;
|
|
||||||
const nextChats = chats.map((chat) => {
|
|
||||||
if (chat.id !== chatID) {
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
didUpdate = true;
|
|
||||||
return updater(chat);
|
|
||||||
});
|
|
||||||
return didUpdate ? nextChats : chats;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[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<
|
|
||||||
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
|
|
||||||
>(chatMessagesKey(chatID), (currentData) => {
|
|
||||||
if (!currentData?.pages?.length) {
|
|
||||||
return currentData;
|
|
||||||
}
|
|
||||||
const firstPage = currentData.pages[0];
|
|
||||||
if (
|
|
||||||
chatQueuedMessagesEqualByID(
|
|
||||||
firstPage.queued_messages,
|
|
||||||
nextQueuedMessages,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return currentData;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...currentData,
|
|
||||||
pages: [
|
|
||||||
{ ...firstPage, queued_messages: nextQueuedMessages },
|
|
||||||
...currentData.pages.slice(1),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[chatID, queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When the active chat changes, clear stale messages immediately
|
store.batch(() => {
|
||||||
// so the previous chat's messages aren't briefly visible while
|
// When the active chat changes, clear stale messages
|
||||||
// the new chat's query resolves.
|
// immediately so the previous chat's messages aren't
|
||||||
if (prevChatIDRef.current !== chatID) {
|
// briefly visible while the new chat's query resolves.
|
||||||
prevChatIDRef.current = chatID;
|
if (prevChatIDRef.current !== chatID) {
|
||||||
lastSyncedMessagesRef.current = [];
|
prevChatIDRef.current = chatID;
|
||||||
store.replaceMessages([]);
|
lastSyncedMessagesRef.current = [];
|
||||||
}
|
store.replaceMessages([]);
|
||||||
// Merge REST-fetched messages into the store one-by-one instead
|
}
|
||||||
// of replacing the entire map. This preserves any messages the
|
// Merge REST-fetched messages into the store, preserving
|
||||||
// WebSocket delivered via upsertDurableMessage that haven't
|
// any messages the WebSocket delivered that haven't
|
||||||
// appeared in a REST page yet.
|
// appeared in a REST page yet.
|
||||||
//
|
//
|
||||||
// However, if the fetched set is missing message IDs the store
|
// If the fetched set is missing message IDs the store
|
||||||
// already has (e.g. after an edit truncation), a full replace
|
// already has (e.g. after an edit truncation), a full
|
||||||
// is needed because upsert can only add/update, not remove.
|
// replace is needed. We must only do this when the
|
||||||
// We must only do this when the fetched messages actually
|
// fetched messages actually changed (new elements from
|
||||||
// changed (new elements from a refetch), not when an
|
// a refetch), not when an unrelated field like
|
||||||
// unrelated field like queued_messages caused the query
|
// queued_messages caused the query data reference to
|
||||||
// data reference to update. Without this guard, a
|
// update.
|
||||||
// queue_update WebSocket event would trigger
|
if (chatMessages) {
|
||||||
// replaceMessages with the stale REST data, wiping any
|
const prev = lastSyncedMessagesRef.current;
|
||||||
// message the WebSocket just delivered.
|
const contentChanged =
|
||||||
if (chatMessages) {
|
chatMessages.length !== prev.length ||
|
||||||
const prev = lastSyncedMessagesRef.current;
|
chatMessages.some((m, i) => m !== prev[i]);
|
||||||
const contentChanged =
|
lastSyncedMessagesRef.current = chatMessages;
|
||||||
chatMessages.length !== prev.length ||
|
|
||||||
chatMessages.some((m, i) => m !== prev[i]);
|
|
||||||
lastSyncedMessagesRef.current = chatMessages;
|
|
||||||
|
|
||||||
const storeSnap = store.getSnapshot();
|
const storeSnap = store.getSnapshot();
|
||||||
const fetchedIDs = new Set(chatMessages.map((m) => m.id));
|
const fetchedIDs = new Set(chatMessages.map((m) => m.id));
|
||||||
const hasStaleEntries =
|
const hasStaleEntries =
|
||||||
contentChanged &&
|
contentChanged &&
|
||||||
storeSnap.orderedMessageIDs.some((id) => !fetchedIDs.has(id));
|
storeSnap.orderedMessageIDs.some((id) => !fetchedIDs.has(id));
|
||||||
if (hasStaleEntries) {
|
if (hasStaleEntries) {
|
||||||
store.replaceMessages(chatMessages);
|
store.replaceMessages(chatMessages);
|
||||||
} else {
|
} else {
|
||||||
for (const message of chatMessages) {
|
store.upsertDurableMessages(chatMessages);
|
||||||
store.upsertDurableMessage(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [chatID, chatMessages, store]);
|
}, [chatID, chatMessages, store]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -627,6 +623,75 @@ export const useChatStore = (
|
|||||||
}, [chatMessagesData, chatID, chatQueuedMessages, store]);
|
}, [chatMessagesData, chatID, chatQueuedMessages, store]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const updateSidebarChat = (
|
||||||
|
updater: (chat: TypesGen.Chat) => TypesGen.Chat,
|
||||||
|
) => {
|
||||||
|
if (!chatID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateInfiniteChatsCache(queryClient, (chats) => {
|
||||||
|
let didUpdate = false;
|
||||||
|
const nextChats = chats.map((chat) => {
|
||||||
|
if (chat.id !== chatID) {
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
|
const updated = updater(chat);
|
||||||
|
if (updated !== chat) {
|
||||||
|
didUpdate = true;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return didUpdate ? nextChats : chats;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelScheduledStreamReset = () => {
|
||||||
|
if (streamResetFrameRef.current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.cancelAnimationFrame(streamResetFrameRef.current);
|
||||||
|
streamResetFrameRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleStreamReset = () => {
|
||||||
|
cancelScheduledStreamReset();
|
||||||
|
streamResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
store.clearStreamState();
|
||||||
|
streamResetFrameRef.current = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChatQueuedMessages = (
|
||||||
|
queuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!chatID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextQueuedMessages = queuedMessages ?? [];
|
||||||
|
queryClient.setQueryData<
|
||||||
|
InfiniteData<TypesGen.ChatMessagesResponse> | undefined
|
||||||
|
>(chatMessagesKey(chatID), (currentData) => {
|
||||||
|
if (!currentData?.pages?.length) {
|
||||||
|
return currentData;
|
||||||
|
}
|
||||||
|
const firstPage = currentData.pages[0];
|
||||||
|
if (
|
||||||
|
chatQueuedMessagesEqualByID(
|
||||||
|
firstPage.queued_messages,
|
||||||
|
nextQueuedMessages,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return currentData;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...currentData,
|
||||||
|
pages: [
|
||||||
|
{ ...firstPage, queued_messages: nextQueuedMessages },
|
||||||
|
...currentData.pages.slice(1),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
cancelScheduledStreamReset();
|
cancelScheduledStreamReset();
|
||||||
store.resetTransientState();
|
store.resetTransientState();
|
||||||
activeChatIDRef.current = chatID ?? null;
|
activeChatIDRef.current = chatID ?? null;
|
||||||
@@ -641,6 +706,54 @@ export const useChatStore = (
|
|||||||
// outside the utility) can bail out after cleanup.
|
// outside the utility) can bail out after cleanup.
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
|
// Parts buffer lives at the effect scope so it persists
|
||||||
|
// across WebSocket messages. A rAF-based flush coalesces
|
||||||
|
// parts from multiple WS messages into a single render,
|
||||||
|
// capping stream renders to once per animation frame.
|
||||||
|
const partsBuf: TypesGen.ChatMessagePart[] = [];
|
||||||
|
let partsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const shouldApplyMessagePart = (): boolean => {
|
||||||
|
const currentStatus = store.getSnapshot().chatStatus;
|
||||||
|
return currentStatus !== "pending" && currentStatus !== "waiting";
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePartsFlush = () => {
|
||||||
|
if (partsFlushTimer !== null || partsBuf.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelScheduledStreamReset();
|
||||||
|
partsFlushTimer = setTimeout(() => {
|
||||||
|
partsFlushTimer = null;
|
||||||
|
if (disposed || activeChatIDRef.current !== chatID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = partsBuf.splice(0);
|
||||||
|
if (parts.length === 0 || !shouldApplyMessagePart()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.applyMessageParts(parts);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Immediate flush for non-message_part events that need
|
||||||
|
// the parts applied before they execute (e.g. a status
|
||||||
|
// change right after the last part).
|
||||||
|
const flushMessageParts = () => {
|
||||||
|
if (partsBuf.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (partsFlushTimer !== null) {
|
||||||
|
clearTimeout(partsFlushTimer);
|
||||||
|
partsFlushTimer = null;
|
||||||
|
}
|
||||||
|
cancelScheduledStreamReset();
|
||||||
|
const parts = partsBuf.splice(0);
|
||||||
|
if (activeChatIDRef.current !== chatID || !shouldApplyMessagePart()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.applyMessageParts(parts);
|
||||||
|
};
|
||||||
const handleMessage = (
|
const handleMessage = (
|
||||||
payload: OneWayMessageEvent<TypesGen.ServerSentEvent>,
|
payload: OneWayMessageEvent<TypesGen.ServerSentEvent>,
|
||||||
) => {
|
) => {
|
||||||
@@ -659,158 +772,144 @@ export const useChatStore = (
|
|||||||
if (streamEvents.length === 0) {
|
if (streamEvents.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Collect durable messages for bulk upsert so the
|
||||||
|
// entire batch produces one Map copy + one sort
|
||||||
|
// instead of N copies and N sorts.
|
||||||
|
const pendingMessages: TypesGen.ChatMessage[] = [];
|
||||||
|
let needsStreamReset = false;
|
||||||
|
|
||||||
const shouldApplyMessagePart = (): boolean => {
|
// Wrap all store mutations in a batch so subscribers
|
||||||
const currentStatus = store.getSnapshot().chatStatus;
|
// are notified exactly once at the end, not per event.
|
||||||
return currentStatus !== "pending" && currentStatus !== "waiting";
|
store.batch(() => {
|
||||||
};
|
for (const streamEvent of streamEvents) {
|
||||||
|
if (streamEvent.type === "message_part") {
|
||||||
const pendingMessageParts: TypesGen.ChatMessagePart[] = [];
|
|
||||||
const flushMessageParts = () => {
|
|
||||||
if (pendingMessageParts.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancelScheduledStreamReset();
|
|
||||||
const parts = pendingMessageParts.splice(0, pendingMessageParts.length);
|
|
||||||
const currentChatID = chatID;
|
|
||||||
startTransition(() => {
|
|
||||||
if (activeChatIDRef.current !== currentChatID) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Re-check status at execution time. A status
|
|
||||||
// event processed between scheduling and running
|
|
||||||
// this callback may have cleared stream state.
|
|
||||||
if (!shouldApplyMessagePart()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.applyMessageParts(parts);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const streamEvent of streamEvents) {
|
|
||||||
if (streamEvent.type === "message_part") {
|
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!shouldApplyMessagePart()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const part = streamEvent.message_part?.part;
|
|
||||||
if (part) {
|
|
||||||
cancelScheduledStreamReset();
|
|
||||||
pendingMessageParts.push(part);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
flushMessageParts();
|
|
||||||
|
|
||||||
switch (streamEvent.type) {
|
|
||||||
case "message": {
|
|
||||||
const message = streamEvent.message;
|
|
||||||
if (!message) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { changed } = store.upsertDurableMessage(message);
|
if (!shouldApplyMessagePart()) {
|
||||||
// Keep lastMessageIdRef in sync with
|
continue;
|
||||||
// stream-delivered messages so reconnections use
|
|
||||||
// the correct after_id and don't re-fetch or
|
|
||||||
// miss events.
|
|
||||||
if (
|
|
||||||
message.id !== undefined &&
|
|
||||||
(lastMessageIdRef.current === undefined ||
|
|
||||||
message.id > lastMessageIdRef.current)
|
|
||||||
) {
|
|
||||||
lastMessageIdRef.current = message.id;
|
|
||||||
}
|
}
|
||||||
if (changed && message.role === "assistant") {
|
const part = streamEvent.message_part?.part;
|
||||||
scheduleStreamReset();
|
if (part) {
|
||||||
|
cancelScheduledStreamReset();
|
||||||
|
partsBuf.push(part);
|
||||||
}
|
}
|
||||||
// Do not update updated_at here. The global
|
|
||||||
// chat-list WebSocket delivers the authoritative
|
|
||||||
// server timestamp; fabricating a client-side
|
|
||||||
// value causes the chat to flicker between time
|
|
||||||
// groups when the two sources race.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
case "queue_update":
|
flushMessageParts();
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
wsQueueUpdateReceivedRef.current = true;
|
|
||||||
store.setQueuedMessages(streamEvent.queued_messages);
|
|
||||||
updateChatQueuedMessages(streamEvent.queued_messages);
|
|
||||||
continue;
|
|
||||||
case "status": {
|
|
||||||
const nextStatus = streamEvent.status?.status;
|
|
||||||
if (!nextStatus) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
switch (streamEvent.type) {
|
||||||
store.setSubagentStatusOverride(streamEvent.chat_id, nextStatus);
|
case "message": {
|
||||||
|
const message = streamEvent.message;
|
||||||
|
if (!message) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pendingMessages.push(message);
|
||||||
|
if (
|
||||||
|
message.id !== undefined &&
|
||||||
|
(lastMessageIdRef.current === undefined ||
|
||||||
|
message.id > lastMessageIdRef.current)
|
||||||
|
) {
|
||||||
|
lastMessageIdRef.current = message.id;
|
||||||
|
}
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
needsStreamReset = true;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
case "queue_update":
|
||||||
store.setChatStatus(nextStatus);
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
if (nextStatus === "pending" || nextStatus === "waiting") {
|
continue;
|
||||||
store.clearStreamState();
|
}
|
||||||
|
wsQueueUpdateReceivedRef.current = true;
|
||||||
|
store.setQueuedMessages(streamEvent.queued_messages);
|
||||||
|
updateChatQueuedMessages(streamEvent.queued_messages);
|
||||||
|
continue;
|
||||||
|
case "status": {
|
||||||
|
const nextStatus = streamEvent.status?.status;
|
||||||
|
if (!nextStatus) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
|
store.setSubagentStatusOverride(
|
||||||
|
streamEvent.chat_id,
|
||||||
|
nextStatus,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
store.setChatStatus(nextStatus);
|
||||||
|
if (nextStatus === "pending" || nextStatus === "waiting") {
|
||||||
|
store.clearStreamState();
|
||||||
|
store.clearRetryState();
|
||||||
|
}
|
||||||
|
if (nextStatus === "running") {
|
||||||
|
store.clearRetryState();
|
||||||
|
}
|
||||||
|
if (nextStatus !== "error") {
|
||||||
|
clearChatErrorReasonRef.current(chatID);
|
||||||
|
}
|
||||||
|
updateSidebarChat((chat) =>
|
||||||
|
chat.status === nextStatus
|
||||||
|
? chat
|
||||||
|
: { ...chat, status: nextStatus },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const reason =
|
||||||
|
streamEvent.error?.message.trim() || "Chat processing failed.";
|
||||||
|
store.setChatStatus("error");
|
||||||
|
store.setStreamError(reason);
|
||||||
store.clearRetryState();
|
store.clearRetryState();
|
||||||
}
|
setChatErrorReasonRef.current(chatID, {
|
||||||
if (nextStatus === "running") {
|
kind: "generic",
|
||||||
store.clearRetryState();
|
message: reason,
|
||||||
}
|
|
||||||
if (nextStatus !== "error") {
|
|
||||||
clearChatErrorReason(chatID);
|
|
||||||
}
|
|
||||||
updateSidebarChat((chat) => ({
|
|
||||||
...chat,
|
|
||||||
status: nextStatus,
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
case "error": {
|
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const reason =
|
|
||||||
streamEvent.error?.message.trim() || "Chat processing failed.";
|
|
||||||
store.setChatStatus("error");
|
|
||||||
store.setStreamError(reason);
|
|
||||||
store.clearRetryState();
|
|
||||||
setChatErrorReason(chatID, {
|
|
||||||
kind: "generic",
|
|
||||||
message: reason,
|
|
||||||
});
|
|
||||||
updateSidebarChat((chat) => ({
|
|
||||||
...chat,
|
|
||||||
status: "error",
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
case "retry": {
|
|
||||||
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const retry = streamEvent.retry;
|
|
||||||
if (retry) {
|
|
||||||
store.clearStreamState();
|
|
||||||
store.setRetryState({
|
|
||||||
attempt: retry.attempt,
|
|
||||||
error: retry.error,
|
|
||||||
});
|
});
|
||||||
|
updateSidebarChat((chat) =>
|
||||||
|
chat.status === "error" ? chat : { ...chat, status: "error" },
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
case "retry": {
|
||||||
|
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const retry = streamEvent.retry;
|
||||||
|
if (retry) {
|
||||||
|
store.clearStreamState();
|
||||||
|
store.setRetryState({
|
||||||
|
attempt: retry.attempt,
|
||||||
|
error: retry.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
flushMessageParts();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Schedule a rAF-coalesced flush for any remaining
|
||||||
|
// parts. If parts were already flushed by a
|
||||||
|
// non-message_part event above, this is a no-op.
|
||||||
|
schedulePartsFlush();
|
||||||
|
|
||||||
|
// Bulk-upsert all collected durable messages in one
|
||||||
|
// pass: one Map copy + one sort instead of N each.
|
||||||
|
if (pendingMessages.length > 0) {
|
||||||
|
store.upsertDurableMessages(pendingMessages);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (needsStreamReset) {
|
||||||
|
scheduleStreamReset();
|
||||||
|
}
|
||||||
|
};
|
||||||
const disposeSocket = createReconnectingWebSocket({
|
const disposeSocket = createReconnectingWebSocket({
|
||||||
connect() {
|
connect() {
|
||||||
// Use the latest known message ID so the server only
|
// Use the latest known message ID so the server only
|
||||||
@@ -848,25 +947,17 @@ export const useChatStore = (
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
disposeSocket();
|
disposeSocket();
|
||||||
cancelScheduledStreamReset();
|
cancelScheduledStreamReset();
|
||||||
|
if (partsFlushTimer !== null) {
|
||||||
|
clearTimeout(partsFlushTimer);
|
||||||
|
}
|
||||||
activeChatIDRef.current = null;
|
activeChatIDRef.current = null;
|
||||||
};
|
};
|
||||||
}, [
|
}, [chatID, initialDataLoaded, queryClient, store]);
|
||||||
cancelScheduledStreamReset,
|
|
||||||
chatID,
|
|
||||||
clearChatErrorReason,
|
|
||||||
initialDataLoaded,
|
|
||||||
scheduleStreamReset,
|
|
||||||
setChatErrorReason,
|
|
||||||
store,
|
|
||||||
updateChatQueuedMessages,
|
|
||||||
updateSidebarChat,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
clearStreamError: useCallback(() => {
|
clearStreamError: () => {
|
||||||
store.clearStreamError();
|
store.clearStreamError();
|
||||||
}, [store]),
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -874,9 +965,6 @@ export const useChatSelector = <T>(
|
|||||||
store: ChatStore,
|
store: ChatStore,
|
||||||
selector: (state: ChatStoreState) => T,
|
selector: (state: ChatStoreState) => T,
|
||||||
): T => {
|
): T => {
|
||||||
const getSnapshot = useCallback(
|
const getSnapshot = () => selector(store.getSnapshot());
|
||||||
() => selector(store.getSnapshot()),
|
|
||||||
[selector, store],
|
|
||||||
);
|
|
||||||
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -491,9 +491,8 @@ const ChatMessageItem = memo<{
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ChatMessageItem.displayName = "ChatMessageItem";
|
|
||||||
|
|
||||||
export const StreamingOutput = memo<{
|
export const StreamingOutput: FC<{
|
||||||
streamState: StreamState | null;
|
streamState: StreamState | null;
|
||||||
streamTools: readonly MergedTool[];
|
streamTools: readonly MergedTool[];
|
||||||
subagentTitles?: Map<string, string>;
|
subagentTitles?: Map<string, string>;
|
||||||
@@ -501,78 +500,75 @@ export const StreamingOutput = memo<{
|
|||||||
showInitialPlaceholder?: boolean;
|
showInitialPlaceholder?: boolean;
|
||||||
retryState?: { attempt: number; error: string } | null;
|
retryState?: { attempt: number; error: string } | null;
|
||||||
urlTransform?: UrlTransform;
|
urlTransform?: UrlTransform;
|
||||||
}>(
|
}> = ({
|
||||||
({
|
streamState,
|
||||||
streamState,
|
streamTools,
|
||||||
streamTools,
|
subagentTitles,
|
||||||
|
subagentStatusOverrides,
|
||||||
|
showInitialPlaceholder = false,
|
||||||
|
retryState,
|
||||||
|
urlTransform,
|
||||||
|
}) => {
|
||||||
|
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,
|
subagentTitles,
|
||||||
subagentStatusOverrides,
|
subagentStatusOverrides,
|
||||||
showInitialPlaceholder = false,
|
|
||||||
retryState,
|
|
||||||
urlTransform,
|
urlTransform,
|
||||||
}) => {
|
});
|
||||||
const conversationItemProps = { role: "assistant" as const };
|
const remainingTools = streamTools.filter(
|
||||||
const toolByID = new Map(streamTools.map((tool) => [tool.id, tool]));
|
(tool) => !renderedToolIDs.has(tool.id),
|
||||||
const blocks = streamState?.blocks ?? [];
|
);
|
||||||
const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({
|
|
||||||
blocks,
|
|
||||||
toolByID,
|
|
||||||
keyPrefix: "stream",
|
|
||||||
isStreaming: true,
|
|
||||||
subagentTitles,
|
|
||||||
subagentStatusOverrides,
|
|
||||||
urlTransform,
|
|
||||||
});
|
|
||||||
const remainingTools = streamTools.filter(
|
|
||||||
(tool) => !renderedToolIDs.has(tool.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationItem {...conversationItemProps}>
|
<ConversationItem {...conversationItemProps}>
|
||||||
<Message className="w-full">
|
<Message className="w-full">
|
||||||
<MessageContent className="whitespace-normal">
|
<MessageContent className="whitespace-normal">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{orderedBlocks}
|
{orderedBlocks}
|
||||||
{showInitialPlaceholder ||
|
{showInitialPlaceholder ||
|
||||||
(streamState &&
|
(streamState &&
|
||||||
orderedBlocks.length === 0 &&
|
orderedBlocks.length === 0 &&
|
||||||
streamTools.length === 0) ? (
|
streamTools.length === 0) ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Response aria-hidden className="invisible">
|
<Response aria-hidden className="invisible">
|
||||||
{`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`}
|
{`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`}
|
||||||
</Response>
|
</Response>
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-baseline gap-2">
|
<div className="pointer-events-none absolute inset-0 flex items-baseline gap-2">
|
||||||
<Shimmer as="div" className="text-[13px] leading-relaxed">
|
<Shimmer as="div" className="text-[13px] leading-relaxed">
|
||||||
Thinking...
|
Thinking...
|
||||||
</Shimmer>
|
</Shimmer>
|
||||||
{retryState && (
|
{retryState && (
|
||||||
<span className="text-[11px] text-content-secondary">
|
<span className="text-[11px] text-content-secondary">
|
||||||
attempt {retryState.attempt}
|
attempt {retryState.attempt}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
{remainingTools.map((tool) => (
|
) : null}
|
||||||
<Tool
|
{remainingTools.map((tool) => (
|
||||||
key={tool.id}
|
<Tool
|
||||||
name={tool.name}
|
key={tool.id}
|
||||||
args={tool.args}
|
name={tool.name}
|
||||||
result={tool.result}
|
args={tool.args}
|
||||||
status={tool.status}
|
result={tool.result}
|
||||||
isError={tool.isError}
|
status={tool.status}
|
||||||
subagentTitles={subagentTitles}
|
isError={tool.isError}
|
||||||
subagentStatusOverrides={subagentStatusOverrides}
|
subagentTitles={subagentTitles}
|
||||||
/>
|
subagentStatusOverrides={subagentStatusOverrides}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</MessageContent>
|
</div>
|
||||||
</Message>
|
</MessageContent>
|
||||||
</ConversationItem>
|
</Message>
|
||||||
);
|
</ConversationItem>
|
||||||
},
|
);
|
||||||
);
|
};
|
||||||
StreamingOutput.displayName = "StreamingOutput";
|
|
||||||
|
|
||||||
const StickyUserMessage: FC<{
|
const StickyUserMessage: FC<{
|
||||||
message: TypesGen.ChatMessage;
|
message: TypesGen.ChatMessage;
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { chatKey } from "api/queries/chats";
|
import { chatKey } from "api/queries/chats";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useQueryClient } from "react-query";
|
import { useQueryClient } from "react-query";
|
||||||
import { useChatSelector } from "./ChatContext";
|
import { useChatSelector } from "./ChatContext";
|
||||||
import type { StreamState } from "./types";
|
import type { StreamState } from "./types";
|
||||||
|
|
||||||
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
|
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
|
||||||
|
|
||||||
const selectStreamState = (state: { streamState: StreamState | null }) =>
|
// Only extract the toolResults record from the stream state.
|
||||||
state.streamState;
|
// This reference is stable during pure text/thinking streaming
|
||||||
|
// and only changes when a tool result actually appears, avoiding
|
||||||
|
// a re-render of AgentDetail on every token.
|
||||||
|
const selectStreamToolResults = (state: {
|
||||||
|
streamState: StreamState | null;
|
||||||
|
}): Record<string, { id: string; name: string }> | null =>
|
||||||
|
state.streamState?.toolResults ?? null;
|
||||||
|
|
||||||
interface UseWorkspaceCreationWatcherOptions {
|
interface UseWorkspaceCreationWatcherOptions {
|
||||||
store: ChatStoreHandle;
|
store: ChatStoreHandle;
|
||||||
@@ -28,27 +34,26 @@ export function useWorkspaceCreationWatcher({
|
|||||||
chatID,
|
chatID,
|
||||||
}: UseWorkspaceCreationWatcherOptions): void {
|
}: UseWorkspaceCreationWatcherOptions): void {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const streamState = useChatSelector(store, selectStreamState);
|
const toolResults = useChatSelector(store, selectStreamToolResults);
|
||||||
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
|
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const chatIDRef = useRef(chatID);
|
||||||
// Reset processed IDs when chatID changes during render,
|
|
||||||
// before effects run.
|
|
||||||
const [previousChatID, setPreviousChatID] = useState(chatID);
|
|
||||||
if (previousChatID !== chatID) {
|
|
||||||
setPreviousChatID(chatID);
|
|
||||||
processedToolCallIdsRef.current = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch stream tool results for create_workspace completions.
|
// Watch stream tool results for create_workspace completions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!streamState || !chatID) {
|
// Reset processed IDs when chatID changes.
|
||||||
|
if (chatIDRef.current !== chatID) {
|
||||||
|
chatIDRef.current = chatID;
|
||||||
|
processedToolCallIdsRef.current = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toolResults || !chatID) {
|
||||||
processedToolCallIdsRef.current.clear();
|
processedToolCallIdsRef.current.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let shouldInvalidateChat = false;
|
let shouldInvalidateChat = false;
|
||||||
|
|
||||||
for (const toolResult of Object.values(streamState.toolResults)) {
|
for (const toolResult of Object.values(toolResults)) {
|
||||||
if (processedToolCallIdsRef.current.has(toolResult.id)) {
|
if (processedToolCallIdsRef.current.has(toolResult.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -67,5 +72,5 @@ export function useWorkspaceCreationWatcher({
|
|||||||
queryKey: chatKey(chatID),
|
queryKey: chatKey(chatID),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [chatID, streamState, queryClient]);
|
}, [toolResults, queryClient, chatID]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
import type { ModelSelectorOption } from "components/ai-elements";
|
import type { ModelSelectorOption } from "components/ai-elements";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import { type FC, useEffect, useMemo } from "react";
|
import { type FC, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { UrlTransform } from "streamdown";
|
import type { UrlTransform } from "streamdown";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
parseMessagesWithMergedTools,
|
parseMessagesWithMergedTools,
|
||||||
} from "./AgentDetail/messageParsing";
|
} from "./AgentDetail/messageParsing";
|
||||||
import { buildStreamTools } from "./AgentDetail/streamState";
|
import { buildStreamTools } from "./AgentDetail/streamState";
|
||||||
|
import type { ParsedMessageEntry } from "./AgentDetail/types";
|
||||||
import type { ChatDetailError } from "./usageLimitMessage";
|
import type { ChatDetailError } from "./usageLimitMessage";
|
||||||
import { useFileAttachments } from "./useFileAttachments";
|
import { useFileAttachments } from "./useFileAttachments";
|
||||||
|
|
||||||
@@ -52,7 +53,10 @@ interface AgentDetailTimelineProps {
|
|||||||
urlTransform?: UrlTransform;
|
urlTransform?: UrlTransform;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
// Reads only message-related store state (stable during streaming).
|
||||||
|
// Computes parsedMessages once and passes them to ConversationTimeline
|
||||||
|
// via a memo boundary so that streaming ticks don't re-parse history.
|
||||||
|
const MessageListProvider: FC<AgentDetailTimelineProps> = ({
|
||||||
store,
|
store,
|
||||||
persistedErrorReason,
|
persistedErrorReason,
|
||||||
onOpenAnalytics,
|
onOpenAnalytics,
|
||||||
@@ -63,7 +67,6 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||||
const streamState = useChatSelector(store, selectStreamState);
|
|
||||||
const chatStatus = useChatSelector(store, selectChatStatus);
|
const chatStatus = useChatSelector(store, selectChatStatus);
|
||||||
const streamError = useChatSelector(store, selectStreamError);
|
const streamError = useChatSelector(store, selectStreamError);
|
||||||
const subagentStatusOverrides = useChatSelector(
|
const subagentStatusOverrides = useChatSelector(
|
||||||
@@ -72,25 +75,11 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
|||||||
);
|
);
|
||||||
const retryState = useChatSelector(store, selectRetryState);
|
const retryState = useChatSelector(store, selectRetryState);
|
||||||
|
|
||||||
const messages = useMemo(
|
const messages = orderedMessageIDs
|
||||||
() =>
|
.map((messageID) => messagesByID.get(messageID))
|
||||||
orderedMessageIDs
|
.filter(isChatMessage);
|
||||||
.map((messageID) => messagesByID.get(messageID))
|
const parsedMessages = parseMessagesWithMergedTools(messages);
|
||||||
.filter(isChatMessage),
|
const subagentTitles = buildSubagentTitles(parsedMessages);
|
||||||
[messagesByID, orderedMessageIDs],
|
|
||||||
);
|
|
||||||
const streamTools = useMemo(
|
|
||||||
() => buildStreamTools(streamState),
|
|
||||||
[streamState],
|
|
||||||
);
|
|
||||||
const parsedMessages = useMemo(
|
|
||||||
() => parseMessagesWithMergedTools(messages),
|
|
||||||
[messages],
|
|
||||||
);
|
|
||||||
const subagentTitles = useMemo(
|
|
||||||
() => buildSubagentTitles(parsedMessages),
|
|
||||||
[parsedMessages],
|
|
||||||
);
|
|
||||||
const detailError: ChatDetailError | undefined =
|
const detailError: ChatDetailError | undefined =
|
||||||
(persistedErrorReason?.kind === "usage-limit" || chatStatus === "error"
|
(persistedErrorReason?.kind === "usage-limit" || chatStatus === "error"
|
||||||
? persistedErrorReason
|
? persistedErrorReason
|
||||||
@@ -101,6 +90,66 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
|||||||
const latestMessage = messages[messages.length - 1];
|
const latestMessage = messages[messages.length - 1];
|
||||||
const latestMessageNeedsAssistantResponse =
|
const latestMessageNeedsAssistantResponse =
|
||||||
!latestMessage || latestMessage.role !== "assistant";
|
!latestMessage || latestMessage.role !== "assistant";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StreamingBridge
|
||||||
|
store={store}
|
||||||
|
isEmpty={messages.length === 0}
|
||||||
|
parsedMessages={parsedMessages}
|
||||||
|
subagentTitles={subagentTitles}
|
||||||
|
subagentStatusOverrides={subagentStatusOverrides}
|
||||||
|
retryState={retryState}
|
||||||
|
detailError={detailError}
|
||||||
|
latestMessageNeedsAssistantResponse={latestMessageNeedsAssistantResponse}
|
||||||
|
chatStatus={chatStatus}
|
||||||
|
onOpenAnalytics={onOpenAnalytics}
|
||||||
|
onEditUserMessage={onEditUserMessage}
|
||||||
|
editingMessageId={editingMessageId}
|
||||||
|
savingMessageId={savingMessageId}
|
||||||
|
urlTransform={urlTransform}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reads stream-specific store state (changes every token). Isolated
|
||||||
|
// so that streamState changes don't invalidate parsedMessages above.
|
||||||
|
const StreamingBridge: FC<{
|
||||||
|
store: ChatStoreHandle;
|
||||||
|
isEmpty: boolean;
|
||||||
|
parsedMessages: ParsedMessageEntry[];
|
||||||
|
subagentTitles: Map<string, string>;
|
||||||
|
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
|
||||||
|
retryState: { attempt: number; error: string } | null;
|
||||||
|
detailError: ChatDetailError | undefined;
|
||||||
|
latestMessageNeedsAssistantResponse: boolean;
|
||||||
|
chatStatus: TypesGen.ChatStatus | null;
|
||||||
|
onOpenAnalytics?: () => void;
|
||||||
|
onEditUserMessage?: (
|
||||||
|
messageId: number,
|
||||||
|
text: string,
|
||||||
|
fileBlocks?: readonly TypesGen.ChatMessagePart[],
|
||||||
|
) => void;
|
||||||
|
editingMessageId?: number | null;
|
||||||
|
savingMessageId?: number | null;
|
||||||
|
urlTransform?: UrlTransform;
|
||||||
|
}> = ({
|
||||||
|
store,
|
||||||
|
isEmpty,
|
||||||
|
parsedMessages,
|
||||||
|
subagentTitles,
|
||||||
|
subagentStatusOverrides,
|
||||||
|
retryState,
|
||||||
|
detailError,
|
||||||
|
latestMessageNeedsAssistantResponse,
|
||||||
|
chatStatus,
|
||||||
|
onOpenAnalytics,
|
||||||
|
onEditUserMessage,
|
||||||
|
editingMessageId,
|
||||||
|
savingMessageId,
|
||||||
|
urlTransform,
|
||||||
|
}) => {
|
||||||
|
const streamState = useChatSelector(store, selectStreamState);
|
||||||
|
const streamTools = buildStreamTools(streamState);
|
||||||
const isAwaitingFirstStreamChunk =
|
const isAwaitingFirstStreamChunk =
|
||||||
!streamState &&
|
!streamState &&
|
||||||
(chatStatus === "running" || chatStatus === "pending") &&
|
(chatStatus === "running" || chatStatus === "pending") &&
|
||||||
@@ -109,7 +158,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationTimeline
|
<ConversationTimeline
|
||||||
isEmpty={messages.length === 0}
|
isEmpty={isEmpty}
|
||||||
parsedMessages={parsedMessages}
|
parsedMessages={parsedMessages}
|
||||||
hasStreamOutput={hasStreamOutput}
|
hasStreamOutput={hasStreamOutput}
|
||||||
streamState={streamState}
|
streamState={streamState}
|
||||||
@@ -128,6 +177,10 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = (props) => {
|
||||||
|
return <MessageListProvider {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
interface AgentDetailInputProps {
|
interface AgentDetailInputProps {
|
||||||
store: ChatStoreHandle;
|
store: ChatStoreHandle;
|
||||||
compressionThreshold: number | undefined;
|
compressionThreshold: number | undefined;
|
||||||
@@ -197,22 +250,18 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
|||||||
const chatStatus = useChatSelector(store, selectChatStatus);
|
const chatStatus = useChatSelector(store, selectChatStatus);
|
||||||
const queuedMessages = useChatSelector(store, selectQueuedMessages);
|
const queuedMessages = useChatSelector(store, selectQueuedMessages);
|
||||||
|
|
||||||
const messages = useMemo(
|
const messages = orderedMessageIDs
|
||||||
() =>
|
.map((messageID) => messagesByID.get(messageID))
|
||||||
orderedMessageIDs
|
.filter(isChatMessage);
|
||||||
.map((messageID) => messagesByID.get(messageID))
|
|
||||||
.filter(isChatMessage),
|
|
||||||
[messagesByID, orderedMessageIDs],
|
|
||||||
);
|
|
||||||
const { organizations } = useDashboard();
|
const { organizations } = useDashboard();
|
||||||
const organizationId = organizations[0]?.id;
|
const organizationId = organizations[0]?.id;
|
||||||
const latestContextUsage = useMemo(() => {
|
const latestContextUsage = (() => {
|
||||||
const usage = getLatestContextUsage(messages);
|
const usage = getLatestContextUsage(messages);
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
return usage;
|
return usage;
|
||||||
}
|
}
|
||||||
return { ...usage, compressionThreshold };
|
return { ...usage, compressionThreshold };
|
||||||
}, [messages, compressionThreshold]);
|
})();
|
||||||
const {
|
const {
|
||||||
attachments,
|
attachments,
|
||||||
uploadStates,
|
uploadStates,
|
||||||
@@ -273,31 +322,33 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
|||||||
<AgentChatInput
|
<AgentChatInput
|
||||||
onSend={(message) => {
|
onSend={(message) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
// Collect file IDs from already-uploaded attachments.
|
||||||
|
// Skip files in error state (e.g. too large).
|
||||||
|
const fileIds: string[] = [];
|
||||||
|
let skippedErrors = 0;
|
||||||
|
for (const file of attachments) {
|
||||||
|
const state = uploadStates.get(file);
|
||||||
|
if (state?.status === "error") {
|
||||||
|
skippedErrors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (state?.status === "uploaded" && state.fileId) {
|
||||||
|
fileIds.push(state.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skippedErrors > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fileArg = fileIds.length > 0 ? fileIds : undefined;
|
||||||
try {
|
try {
|
||||||
// Collect file IDs from already-uploaded attachments.
|
await onSend(message, fileArg);
|
||||||
// Skip files in error state (e.g. too large).
|
|
||||||
const fileIds: string[] = [];
|
|
||||||
let skippedErrors = 0;
|
|
||||||
for (const file of attachments) {
|
|
||||||
const state = uploadStates.get(file);
|
|
||||||
if (state?.status === "error") {
|
|
||||||
skippedErrors++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (state?.status === "uploaded" && state.fileId) {
|
|
||||||
fileIds.push(state.fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (skippedErrors > 0) {
|
|
||||||
toast.warning(
|
|
||||||
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await onSend(message, fileIds.length > 0 ? fileIds : undefined);
|
|
||||||
resetAttachments();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Attachments preserved for retry on failure.
|
// Attachments preserved for retry on failure.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
resetAttachments();
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
|
|||||||
@@ -3,14 +3,7 @@ import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
|
|||||||
import type { ModelSelectorOption } from "components/ai-elements";
|
import type { ModelSelectorOption } from "components/ai-elements";
|
||||||
import { Button } from "components/Button/Button";
|
import { Button } from "components/Button/Button";
|
||||||
import { ArchiveIcon, ArrowDownIcon } from "lucide-react";
|
import { ArchiveIcon, ArrowDownIcon } from "lucide-react";
|
||||||
import {
|
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import type { UrlTransform } from "streamdown";
|
import type { UrlTransform } from "streamdown";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
@@ -529,9 +522,11 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const isFetchingRef = useRef(isFetchingMoreMessages);
|
const isFetchingRef = useRef(isFetchingMoreMessages);
|
||||||
isFetchingRef.current = isFetchingMoreMessages;
|
|
||||||
const onFetchRef = useRef(onFetchMoreMessages);
|
const onFetchRef = useRef(onFetchMoreMessages);
|
||||||
onFetchRef.current = onFetchMoreMessages;
|
useEffect(() => {
|
||||||
|
isFetchingRef.current = isFetchingMoreMessages;
|
||||||
|
onFetchRef.current = onFetchMoreMessages;
|
||||||
|
}, [isFetchingMoreMessages, onFetchMoreMessages]);
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
|
|
||||||
// Sentinel observer — triggers loading older messages.
|
// Sentinel observer — triggers loading older messages.
|
||||||
@@ -593,8 +588,10 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (rafId !== null) return;
|
if (rafId !== null) return;
|
||||||
rafId = requestAnimationFrame(() => {
|
rafId = requestAnimationFrame(() => {
|
||||||
const isAtBottom = Math.abs(container.scrollTop) < SCROLL_THRESHOLD;
|
const shouldShow = Math.abs(container.scrollTop) >= SCROLL_THRESHOLD;
|
||||||
setShowScrollToBottom(!isAtBottom);
|
setShowScrollToBottom((prev) =>
|
||||||
|
prev === shouldShow ? prev : shouldShow,
|
||||||
|
);
|
||||||
rafId = null;
|
rafId = null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -608,7 +605,7 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
};
|
};
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = () => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.scrollTo({ top: 0, behavior: "smooth" });
|
container.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
@@ -617,7 +614,7 @@ const ScrollAnchoredContainer: FC<{
|
|||||||
// before it reaches the bottom, the scroll handler will
|
// before it reaches the bottom, the scroll handler will
|
||||||
// re-show the button.
|
// re-show the button.
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
}, [scrollContainerRef]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
|
|||||||
import { ProxyProvider } from "contexts/ProxyContext";
|
import { ProxyProvider } from "contexts/ProxyContext";
|
||||||
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
|
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
|
||||||
import { permissionChecks } from "modules/permissions";
|
import { permissionChecks } from "modules/permissions";
|
||||||
import {
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useMutation, useQueryClient } from "react-query";
|
import { useMutation, useQueryClient } from "react-query";
|
||||||
import { Outlet, useParams } from "react-router";
|
import { Outlet, useParams } from "react-router";
|
||||||
import type { AgentsOutletContext } from "./AgentsPage";
|
import type { AgentsOutletContext } from "./AgentsPage";
|
||||||
@@ -61,7 +54,9 @@ const AgentEmbedPage: FC = () => {
|
|||||||
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
|
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
|
||||||
);
|
);
|
||||||
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
|
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
|
||||||
latestEmbedSessionMutationRef.current = embedSessionMutation;
|
useEffect(() => {
|
||||||
|
latestEmbedSessionMutationRef.current = embedSessionMutation;
|
||||||
|
});
|
||||||
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
|
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
|
||||||
|
|
||||||
const [chatErrorReasons, setChatErrorReasons] = useState<
|
const [chatErrorReasons, setChatErrorReasons] = useState<
|
||||||
@@ -69,31 +64,28 @@ const AgentEmbedPage: FC = () => {
|
|||||||
>({});
|
>({});
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
const setChatErrorReason = useCallback(
|
const setChatErrorReason = (chatId: string, reason: ChatDetailError) => {
|
||||||
(chatId: string, reason: ChatDetailError) => {
|
const trimmedMessage = reason.message.trim();
|
||||||
const trimmedMessage = reason.message.trim();
|
if (!chatId || !trimmedMessage) {
|
||||||
if (!chatId || !trimmedMessage) {
|
return;
|
||||||
return;
|
}
|
||||||
|
setChatErrorReasons((current) => {
|
||||||
|
const existing = current[chatId];
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.kind === reason.kind &&
|
||||||
|
existing.message === trimmedMessage
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
setChatErrorReasons((current) => {
|
return {
|
||||||
const existing = current[chatId];
|
...current,
|
||||||
if (
|
[chatId]: { kind: reason.kind, message: trimmedMessage },
|
||||||
existing &&
|
};
|
||||||
existing.kind === reason.kind &&
|
});
|
||||||
existing.message === trimmedMessage
|
};
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[chatId]: { kind: reason.kind, message: trimmedMessage },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearChatErrorReason = useCallback((chatId: string) => {
|
const clearChatErrorReason = (chatId: string) => {
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,51 +97,39 @@ const AgentEmbedPage: FC = () => {
|
|||||||
delete next[chatId];
|
delete next[chatId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const requestArchiveAgent = useCallback((_chatId: string) => {}, []);
|
const requestArchiveAgent = (_chatId: string) => {};
|
||||||
|
|
||||||
const requestUnarchiveAgent = useCallback((_chatId: string) => {}, []);
|
const requestUnarchiveAgent = (_chatId: string) => {};
|
||||||
|
|
||||||
const requestArchiveAndDeleteWorkspace = useCallback(
|
const requestArchiveAndDeleteWorkspace = (
|
||||||
(_chatId: string, _workspaceId: string) => {},
|
_chatId: string,
|
||||||
[],
|
_workspaceId: string,
|
||||||
);
|
) => {};
|
||||||
|
|
||||||
const onToggleSidebarCollapsed = useCallback(() => {
|
const onToggleSidebarCollapsed = () => {
|
||||||
setIsSidebarCollapsed((current) => !current);
|
setIsSidebarCollapsed((current) => !current);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const outletContext = useMemo<AgentsOutletContext>(
|
const outletContext: AgentsOutletContext = {
|
||||||
() => ({
|
chatErrorReasons,
|
||||||
chatErrorReasons,
|
setChatErrorReason,
|
||||||
setChatErrorReason,
|
clearChatErrorReason,
|
||||||
clearChatErrorReason,
|
requestArchiveAgent,
|
||||||
requestArchiveAgent,
|
requestUnarchiveAgent,
|
||||||
requestUnarchiveAgent,
|
requestArchiveAndDeleteWorkspace,
|
||||||
requestArchiveAndDeleteWorkspace,
|
isSidebarCollapsed,
|
||||||
isSidebarCollapsed,
|
onToggleSidebarCollapsed,
|
||||||
onToggleSidebarCollapsed,
|
modelOptions: [],
|
||||||
modelOptions: [],
|
modelConfigIDByModelID: new Map(),
|
||||||
modelConfigIDByModelID: new Map(),
|
modelIDByConfigID: new Map(),
|
||||||
modelIDByConfigID: new Map(),
|
modelConfigs: [],
|
||||||
modelConfigs: [],
|
modelCatalog: undefined,
|
||||||
modelCatalog: undefined,
|
isModelCatalogLoading: false,
|
||||||
isModelCatalogLoading: false,
|
modelCatalogError: null,
|
||||||
modelCatalogError: null,
|
desktopEnabled: false,
|
||||||
desktopEnabled: false,
|
};
|
||||||
}),
|
|
||||||
[
|
|
||||||
chatErrorReasons,
|
|
||||||
setChatErrorReason,
|
|
||||||
clearChatErrorReason,
|
|
||||||
requestArchiveAgent,
|
|
||||||
requestUnarchiveAgent,
|
|
||||||
requestArchiveAndDeleteWorkspace,
|
|
||||||
isSidebarCollapsed,
|
|
||||||
onToggleSidebarCollapsed,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// When signed out and not already bootstrapping, listen for the
|
// When signed out and not already bootstrapping, listen for the
|
||||||
// postMessage from the parent frame carrying the session token.
|
// postMessage from the parent frame carrying the session token.
|
||||||
@@ -196,10 +176,10 @@ const AgentEmbedPage: FC = () => {
|
|||||||
};
|
};
|
||||||
}, [agentId, isAwaitingBootstrapMessage]);
|
}, [agentId, isAwaitingBootstrapMessage]);
|
||||||
|
|
||||||
const handleBootstrapRetry = useCallback(() => {
|
const handleBootstrapRetry = () => {
|
||||||
inFlightBootstrapRef.current = null;
|
inFlightBootstrapRef.current = null;
|
||||||
embedSessionMutation.reset();
|
embedSessionMutation.reset();
|
||||||
}, [embedSessionMutation]);
|
};
|
||||||
|
|
||||||
if (auth.isSignedIn) {
|
if (auth.isSignedIn) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,14 +20,7 @@ import type * as TypesGen from "api/typesGenerated";
|
|||||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||||
import { useAuthenticated } from "hooks";
|
import { useAuthenticated } from "hooks";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import {
|
import { type FC, useEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -57,6 +50,29 @@ const nilUUID = "00000000-0000-0000-0000-000000000000";
|
|||||||
const EMPTY_MODEL_CONFIGS: TypesGen.ChatModelConfig[] = [];
|
const EMPTY_MODEL_CONFIGS: TypesGen.ChatModelConfig[] = [];
|
||||||
|
|
||||||
// Type guard for SSE events from the chat list watch endpoint.
|
// Type guard for SSE events from the chat list watch endpoint.
|
||||||
|
// Shallow-compare two ChatDiffStatus objects by their meaningful
|
||||||
|
// fields, ignoring refreshed_at/stale_at which change on every poll.
|
||||||
|
function diffStatusEqual(
|
||||||
|
a: TypesGen.ChatDiffStatus | undefined,
|
||||||
|
b: TypesGen.ChatDiffStatus | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return (
|
||||||
|
a.url === b.url &&
|
||||||
|
a.pull_request_state === b.pull_request_state &&
|
||||||
|
a.pull_request_title === b.pull_request_title &&
|
||||||
|
a.pull_request_draft === b.pull_request_draft &&
|
||||||
|
a.changes_requested === b.changes_requested &&
|
||||||
|
a.additions === b.additions &&
|
||||||
|
a.deletions === b.deletions &&
|
||||||
|
a.changed_files === b.changed_files &&
|
||||||
|
a.pr_number === b.pr_number &&
|
||||||
|
a.approved === b.approved &&
|
||||||
|
a.commits === b.commits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isChatListSSEEvent(
|
function isChatListSSEEvent(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
): data is { kind: string; chat: TypesGen.Chat } {
|
): data is { kind: string; chat: TypesGen.Chat } {
|
||||||
@@ -188,15 +204,11 @@ const AgentsPage: FC = () => {
|
|||||||
const [chatErrorReasons, setChatErrorReasons] = useState<
|
const [chatErrorReasons, setChatErrorReasons] = useState<
|
||||||
Record<string, ChatDetailError>
|
Record<string, ChatDetailError>
|
||||||
>({});
|
>({});
|
||||||
const catalogModelOptions = useMemo(
|
const catalogModelOptions = getModelOptionsFromCatalog(
|
||||||
() =>
|
chatModelsQuery.data,
|
||||||
getModelOptionsFromCatalog(
|
chatModelConfigsQuery.data,
|
||||||
chatModelsQuery.data,
|
|
||||||
chatModelConfigsQuery.data,
|
|
||||||
),
|
|
||||||
[chatModelsQuery.data, chatModelConfigsQuery.data],
|
|
||||||
);
|
);
|
||||||
const modelConfigIDByModelID = useMemo(() => {
|
const modelConfigIDByModelID = (() => {
|
||||||
const byModelID = new Map<string, string>();
|
const byModelID = new Map<string, string>();
|
||||||
for (const config of chatModelConfigsQuery.data ?? []) {
|
for (const config of chatModelConfigsQuery.data ?? []) {
|
||||||
const { provider, model } = getNormalizedModelRef(config);
|
const { provider, model } = getNormalizedModelRef(config);
|
||||||
@@ -213,31 +225,28 @@ const AgentsPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return byModelID;
|
return byModelID;
|
||||||
}, [chatModelConfigsQuery.data]);
|
})();
|
||||||
const setChatErrorReason = useCallback(
|
const setChatErrorReason = (chatId: string, reason: ChatDetailError) => {
|
||||||
(chatId: string, reason: ChatDetailError) => {
|
const trimmedMessage = reason.message.trim();
|
||||||
const trimmedMessage = reason.message.trim();
|
if (!chatId || !trimmedMessage) {
|
||||||
if (!chatId || !trimmedMessage) {
|
return;
|
||||||
return;
|
}
|
||||||
|
setChatErrorReasons((current) => {
|
||||||
|
const existing = current[chatId];
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.kind === reason.kind &&
|
||||||
|
existing.message === trimmedMessage
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
setChatErrorReasons((current) => {
|
return {
|
||||||
const existing = current[chatId];
|
...current,
|
||||||
if (
|
[chatId]: { kind: reason.kind, message: trimmedMessage },
|
||||||
existing &&
|
};
|
||||||
existing.kind === reason.kind &&
|
});
|
||||||
existing.message === trimmedMessage
|
};
|
||||||
) {
|
const clearChatErrorReason = (chatId: string) => {
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[chatId]: { kind: reason.kind, message: trimmedMessage },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const clearChatErrorReason = useCallback((chatId: string) => {
|
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -249,11 +258,8 @@ const AgentsPage: FC = () => {
|
|||||||
delete next[chatId];
|
delete next[chatId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
const chatList = useMemo(
|
const chatList = chatsQuery.data?.pages.flat() ?? [];
|
||||||
() => chatsQuery.data?.pages.flat() ?? [],
|
|
||||||
[chatsQuery.data],
|
|
||||||
);
|
|
||||||
const isArchiving =
|
const isArchiving =
|
||||||
archiveAgentMutation.isPending || archiveAndDeleteMutation.isPending;
|
archiveAgentMutation.isPending || archiveAndDeleteMutation.isPending;
|
||||||
const archivingChatId =
|
const archivingChatId =
|
||||||
@@ -263,43 +269,40 @@ const AgentsPage: FC = () => {
|
|||||||
(archiveAndDeleteMutation.isPending
|
(archiveAndDeleteMutation.isPending
|
||||||
? archiveAndDeleteMutation.variables?.chatId
|
? archiveAndDeleteMutation.variables?.chatId
|
||||||
: undefined);
|
: undefined);
|
||||||
const requestArchiveAgent = useCallback(
|
const requestArchiveAgent = (chatId: string) => {
|
||||||
(chatId: string) => {
|
if (!isArchiving) {
|
||||||
if (!isArchiving) {
|
archiveAgentMutation.mutate(chatId);
|
||||||
archiveAgentMutation.mutate(chatId);
|
}
|
||||||
}
|
};
|
||||||
},
|
const requestArchiveAndDeleteWorkspace = async (
|
||||||
[isArchiving, archiveAgentMutation],
|
chatId: string,
|
||||||
);
|
workspaceId: string,
|
||||||
const requestArchiveAndDeleteWorkspace = useCallback(
|
) => {
|
||||||
async (chatId: string, workspaceId: string) => {
|
if (isArchiving) {
|
||||||
if (isArchiving) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
try {
|
||||||
try {
|
const action = await resolveArchiveAndDeleteAction(
|
||||||
const action = await resolveArchiveAndDeleteAction(
|
() => queryClient.fetchQuery(workspaceById(workspaceId)),
|
||||||
() => queryClient.fetchQuery(workspaceById(workspaceId)),
|
() =>
|
||||||
() =>
|
readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId)
|
||||||
readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId)
|
?.created_at,
|
||||||
?.created_at,
|
);
|
||||||
|
if (action === "proceed") {
|
||||||
|
archiveAndDeleteMutation.mutate(
|
||||||
|
{ chatId, workspaceId },
|
||||||
|
{
|
||||||
|
onSettled: () => navigate("/agents"),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (action === "proceed") {
|
} else {
|
||||||
archiveAndDeleteMutation.mutate(
|
setPendingArchiveAndDelete({ chatId, workspaceId });
|
||||||
{ chatId, workspaceId },
|
|
||||||
{
|
|
||||||
onSettled: () => navigate("/agents"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setPendingArchiveAndDelete({ chatId, workspaceId });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to look up workspace for deletion.");
|
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
[isArchiving, queryClient, archiveAndDeleteMutation, navigate],
|
toast.error("Failed to look up workspace for deletion.");
|
||||||
);
|
}
|
||||||
const handleConfirmArchiveAndDelete = useCallback(() => {
|
};
|
||||||
|
const handleConfirmArchiveAndDelete = () => {
|
||||||
if (pendingArchiveAndDelete && !isArchiving) {
|
if (pendingArchiveAndDelete && !isArchiving) {
|
||||||
archiveAndDeleteMutation.mutate(pendingArchiveAndDelete, {
|
archiveAndDeleteMutation.mutate(pendingArchiveAndDelete, {
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
@@ -308,22 +311,12 @@ const AgentsPage: FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
};
|
||||||
pendingArchiveAndDelete,
|
const requestUnarchiveAgent = (chatId: string) => {
|
||||||
isArchiving,
|
unarchiveAgentMutation.mutate(chatId);
|
||||||
archiveAndDeleteMutation,
|
};
|
||||||
navigate,
|
const handleToggleSidebarCollapsed = () =>
|
||||||
]);
|
setIsSidebarCollapsed((prev) => !prev);
|
||||||
const requestUnarchiveAgent = useCallback(
|
|
||||||
(chatId: string) => {
|
|
||||||
unarchiveAgentMutation.mutate(chatId);
|
|
||||||
},
|
|
||||||
[unarchiveAgentMutation],
|
|
||||||
);
|
|
||||||
const handleToggleSidebarCollapsed = useCallback(
|
|
||||||
() => setIsSidebarCollapsed((prev) => !prev),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const handleCreateChat = async (options: CreateChatOptions) => {
|
const handleCreateChat = async (options: CreateChatOptions) => {
|
||||||
const { message, fileIDs, workspaceId, model } = options;
|
const { message, fileIDs, workspaceId, model } = options;
|
||||||
const modelConfigID =
|
const modelConfigID =
|
||||||
@@ -368,7 +361,9 @@ const AgentsPage: FC = () => {
|
|||||||
// WebSocket handler can read it without re-subscribing on
|
// WebSocket handler can read it without re-subscribing on
|
||||||
// every navigation.
|
// every navigation.
|
||||||
const activeChatIDRef = useRef(agentId);
|
const activeChatIDRef = useRef(agentId);
|
||||||
activeChatIDRef.current = agentId;
|
useEffect(() => {
|
||||||
|
activeChatIDRef.current = agentId;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return createReconnectingWebSocket({
|
return createReconnectingWebSocket({
|
||||||
@@ -389,7 +384,6 @@ const AgentsPage: FC = () => {
|
|||||||
}
|
}
|
||||||
const chatEvent = sse.data;
|
const chatEvent = sse.data;
|
||||||
const updatedChat = chatEvent.chat;
|
const updatedChat = chatEvent.chat;
|
||||||
|
|
||||||
// Read the previous status from the infinite chat list
|
// Read the previous status from the infinite chat list
|
||||||
// cache before we write the update below. The per-chat
|
// cache before we write the update below. The per-chat
|
||||||
// query cache (chatKey) only exists for chats the user
|
// query cache (chatKey) only exists for chats the user
|
||||||
@@ -452,21 +446,35 @@ const AgentsPage: FC = () => {
|
|||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
const nextChats = chats.map((c) => {
|
const nextChats = chats.map((c) => {
|
||||||
if (c.id !== updatedChat.id) return c;
|
if (c.id !== updatedChat.id) return c;
|
||||||
|
const nextStatus = isStatusEvent
|
||||||
|
? updatedChat.status
|
||||||
|
: c.status;
|
||||||
|
const nextTitle = isTitleEvent ? updatedChat.title : c.title;
|
||||||
|
const nextDiffStatus = isDiffStatusEvent
|
||||||
|
? updatedChat.diff_status
|
||||||
|
: c.diff_status;
|
||||||
|
const nextWorkspaceId =
|
||||||
|
updatedChat.workspace_id ?? c.workspace_id;
|
||||||
|
const nextUpdatedAt =
|
||||||
|
c.updated_at > updatedChat.updated_at
|
||||||
|
? c.updated_at
|
||||||
|
: updatedChat.updated_at;
|
||||||
|
if (
|
||||||
|
nextStatus === c.status &&
|
||||||
|
nextTitle === c.title &&
|
||||||
|
diffStatusEqual(nextDiffStatus, c.diff_status) &&
|
||||||
|
nextWorkspaceId === c.workspace_id
|
||||||
|
) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
didUpdate = true;
|
didUpdate = true;
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
...(isStatusEvent && { status: updatedChat.status }),
|
status: nextStatus,
|
||||||
...(isTitleEvent && { title: updatedChat.title }),
|
title: nextTitle,
|
||||||
...(isDiffStatusEvent && {
|
diff_status: nextDiffStatus,
|
||||||
diff_status: updatedChat.diff_status,
|
workspace_id: nextWorkspaceId,
|
||||||
}),
|
updated_at: nextUpdatedAt,
|
||||||
// workspace_id can arrive on any event kind once
|
|
||||||
// the workspace is associated with the chat.
|
|
||||||
workspace_id: updatedChat.workspace_id ?? c.workspace_id,
|
|
||||||
updated_at:
|
|
||||||
c.updated_at > updatedChat.updated_at
|
|
||||||
? c.updated_at
|
|
||||||
: updatedChat.updated_at,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return didUpdate ? nextChats : chats;
|
return didUpdate ? nextChats : chats;
|
||||||
@@ -478,19 +486,43 @@ const AgentsPage: FC = () => {
|
|||||||
if (!previousChat) {
|
if (!previousChat) {
|
||||||
return previousChat;
|
return previousChat;
|
||||||
}
|
}
|
||||||
|
// Only create a new object if a field actually
|
||||||
|
// changed. Returning the same reference prevents
|
||||||
|
// react-query from notifying subscribers, avoiding
|
||||||
|
// unnecessary re-renders of AgentDetail during
|
||||||
|
// streaming when repeated status_change events
|
||||||
|
// carry the same "running" status.
|
||||||
|
const nextStatus = isStatusEvent
|
||||||
|
? updatedChat.status
|
||||||
|
: previousChat.status;
|
||||||
|
const nextTitle = isTitleEvent
|
||||||
|
? updatedChat.title
|
||||||
|
: previousChat.title;
|
||||||
|
const nextDiffStatus = isDiffStatusEvent
|
||||||
|
? updatedChat.diff_status
|
||||||
|
: previousChat.diff_status;
|
||||||
|
const nextWorkspaceId =
|
||||||
|
updatedChat.workspace_id ?? previousChat.workspace_id;
|
||||||
|
const nextUpdatedAt =
|
||||||
|
previousChat.updated_at > updatedChat.updated_at
|
||||||
|
? previousChat.updated_at
|
||||||
|
: updatedChat.updated_at;
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextStatus === previousChat.status &&
|
||||||
|
nextTitle === previousChat.title &&
|
||||||
|
diffStatusEqual(nextDiffStatus, previousChat.diff_status) &&
|
||||||
|
nextWorkspaceId === previousChat.workspace_id
|
||||||
|
) {
|
||||||
|
return previousChat;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...previousChat,
|
...previousChat,
|
||||||
...(isStatusEvent && { status: updatedChat.status }),
|
status: nextStatus,
|
||||||
...(isTitleEvent && { title: updatedChat.title }),
|
title: nextTitle,
|
||||||
...(isDiffStatusEvent && {
|
diff_status: nextDiffStatus,
|
||||||
diff_status: updatedChat.diff_status,
|
workspace_id: nextWorkspaceId,
|
||||||
}),
|
updated_at: nextUpdatedAt,
|
||||||
workspace_id:
|
|
||||||
updatedChat.workspace_id ?? previousChat.workspace_id,
|
|
||||||
updated_at:
|
|
||||||
previousChat.updated_at > updatedChat.updated_at
|
|
||||||
? previousChat.updated_at
|
|
||||||
: updatedChat.updated_at,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
|||||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import { PanelLeftIcon } from "lucide-react";
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
import { type FC, useCallback, useMemo } from "react";
|
import type { FC } from "react";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
@@ -128,24 +128,20 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const sidebarView = sidebarViewFromPath(location.pathname);
|
const sidebarView = sidebarViewFromPath(location.pathname);
|
||||||
|
|
||||||
const handleOpenAnalytics = useCallback(() => {
|
const handleOpenAnalytics = () => {
|
||||||
navigate("/agents/analytics");
|
navigate("/agents/analytics");
|
||||||
}, [navigate]);
|
};
|
||||||
|
|
||||||
// The sidebar expects plain string error messages, but the outlet
|
// The sidebar expects plain string error messages, but the outlet
|
||||||
// context now carries structured ChatDetailError objects.
|
// context now carries structured ChatDetailError objects.
|
||||||
const sidebarChatErrorReasons = useMemo(
|
const sidebarChatErrorReasons = Object.fromEntries(
|
||||||
() =>
|
Object.entries(chatErrorReasons).map(([chatId, error]) => [
|
||||||
Object.fromEntries(
|
chatId,
|
||||||
Object.entries(chatErrorReasons).map(([chatId, error]) => [
|
error.message,
|
||||||
chatId,
|
]),
|
||||||
error.message,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
[chatErrorReasons],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const modelIDByConfigID = useMemo(() => {
|
const modelIDByConfigID = (() => {
|
||||||
const byConfigID = new Map<string, string>();
|
const byConfigID = new Map<string, string>();
|
||||||
for (const [modelID, configID] of modelConfigIDByModelID.entries()) {
|
for (const [modelID, configID] of modelConfigIDByModelID.entries()) {
|
||||||
if (!byConfigID.has(configID)) {
|
if (!byConfigID.has(configID)) {
|
||||||
@@ -153,48 +149,27 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return byConfigID;
|
return byConfigID;
|
||||||
}, [modelConfigIDByModelID]);
|
})();
|
||||||
|
|
||||||
const outletContextValue: AgentsOutletContext = useMemo(
|
const outletContextValue: AgentsOutletContext = {
|
||||||
() => ({
|
chatErrorReasons,
|
||||||
chatErrorReasons,
|
setChatErrorReason,
|
||||||
setChatErrorReason,
|
clearChatErrorReason,
|
||||||
clearChatErrorReason,
|
requestArchiveAgent,
|
||||||
requestArchiveAgent,
|
requestUnarchiveAgent,
|
||||||
requestUnarchiveAgent,
|
requestArchiveAndDeleteWorkspace,
|
||||||
requestArchiveAndDeleteWorkspace,
|
isSidebarCollapsed,
|
||||||
isSidebarCollapsed,
|
onToggleSidebarCollapsed,
|
||||||
onToggleSidebarCollapsed,
|
onOpenAnalytics: handleOpenAnalytics,
|
||||||
onOpenAnalytics: handleOpenAnalytics,
|
modelOptions: catalogModelOptions,
|
||||||
modelOptions: catalogModelOptions,
|
modelConfigIDByModelID,
|
||||||
modelConfigIDByModelID,
|
modelIDByConfigID,
|
||||||
modelIDByConfigID,
|
modelConfigs,
|
||||||
modelConfigs,
|
modelCatalog,
|
||||||
modelCatalog,
|
isModelCatalogLoading,
|
||||||
isModelCatalogLoading,
|
modelCatalogError,
|
||||||
modelCatalogError,
|
desktopEnabled,
|
||||||
desktopEnabled,
|
};
|
||||||
}),
|
|
||||||
[
|
|
||||||
chatErrorReasons,
|
|
||||||
setChatErrorReason,
|
|
||||||
clearChatErrorReason,
|
|
||||||
requestArchiveAgent,
|
|
||||||
requestUnarchiveAgent,
|
|
||||||
requestArchiveAndDeleteWorkspace,
|
|
||||||
isSidebarCollapsed,
|
|
||||||
onToggleSidebarCollapsed,
|
|
||||||
handleOpenAnalytics,
|
|
||||||
catalogModelOptions,
|
|
||||||
modelConfigIDByModelID,
|
|
||||||
modelIDByConfigID,
|
|
||||||
modelConfigs,
|
|
||||||
modelCatalog,
|
|
||||||
isModelCatalogLoading,
|
|
||||||
modelCatalogError,
|
|
||||||
desktopEnabled,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
|
||||||
|
|||||||
@@ -60,10 +60,8 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
type FC,
|
type FC,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -581,7 +579,6 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ChatTreeNode.displayName = "ChatTreeNode";
|
|
||||||
|
|
||||||
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
@@ -620,39 +617,42 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
|||||||
const normalizedSearch = "";
|
const normalizedSearch = "";
|
||||||
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
|
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const chatTree = useMemo(() => buildChatTree(chats), [chats]);
|
const chatTree = buildChatTree(chats);
|
||||||
const chatById = useMemo(() => {
|
const chatById = new Map(chats.map((chat) => [chat.id, chat] as const));
|
||||||
return new Map(chats.map((chat) => [chat.id, chat] as const));
|
const visibleChatIDs = collectVisibleChatIDs({
|
||||||
}, [chats]);
|
chats,
|
||||||
const visibleChatIDs = useMemo(
|
search: normalizedSearch,
|
||||||
() =>
|
tree: chatTree,
|
||||||
collectVisibleChatIDs({
|
});
|
||||||
chats,
|
const visibleRootIDs = chatTree.rootIds.filter((chatID) =>
|
||||||
search: normalizedSearch,
|
visibleChatIDs.has(chatID),
|
||||||
tree: chatTree,
|
|
||||||
}),
|
|
||||||
[chats, 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.
|
// Auto-expand ancestors of the active chat so it's always visible.
|
||||||
|
// Only runs when activeChatId changes — not on every parentById
|
||||||
|
// recalculation — so user-initiated collapse is preserved.
|
||||||
|
const parentByIdRef = useRef(chatTree.parentById);
|
||||||
|
useEffect(() => {
|
||||||
|
parentByIdRef.current = chatTree.parentById;
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const parentById = parentByIdRef.current;
|
||||||
const toExpand: string[] = [];
|
const toExpand: string[] = [];
|
||||||
let cursor = chatTree.parentById.get(activeChatId);
|
let cursor = parentById.get(activeChatId);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
while (cursor && !seen.has(cursor)) {
|
while (cursor && !seen.has(cursor)) {
|
||||||
seen.add(cursor);
|
seen.add(cursor);
|
||||||
toExpand.push(cursor);
|
toExpand.push(cursor);
|
||||||
cursor = chatTree.parentById.get(cursor);
|
cursor = parentById.get(cursor);
|
||||||
}
|
}
|
||||||
if (toExpand.length > 0) {
|
if (toExpand.length > 0) {
|
||||||
setExpandedById((prev) => {
|
setExpandedById((prev) => {
|
||||||
|
if (toExpand.every((id) => prev[id])) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const id of toExpand) {
|
for (const id of toExpand) {
|
||||||
next[id] = true;
|
next[id] = true;
|
||||||
@@ -660,45 +660,27 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeChatId, chatTree.parentById]);
|
}, [activeChatId]);
|
||||||
|
const toggleExpanded = (chatID: string) => {
|
||||||
const toggleExpanded = useCallback((chatID: string) => {
|
|
||||||
setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] }));
|
setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] }));
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const chatTreeCtx = useMemo<ChatTreeContextValue>(
|
const chatTreeCtx: ChatTreeContextValue = {
|
||||||
() => ({
|
chatTree,
|
||||||
chatTree,
|
chatById,
|
||||||
chatById,
|
visibleChatIDs,
|
||||||
visibleChatIDs,
|
normalizedSearch,
|
||||||
normalizedSearch,
|
expandedById,
|
||||||
expandedById,
|
modelOptions,
|
||||||
modelOptions,
|
modelConfigs,
|
||||||
modelConfigs,
|
chatErrorReasons,
|
||||||
chatErrorReasons,
|
isArchiving,
|
||||||
isArchiving,
|
archivingChatId,
|
||||||
archivingChatId,
|
toggleExpanded,
|
||||||
toggleExpanded,
|
onArchiveAgent,
|
||||||
onArchiveAgent,
|
onUnarchiveAgent,
|
||||||
onUnarchiveAgent,
|
onArchiveAndDeleteWorkspace,
|
||||||
onArchiveAndDeleteWorkspace,
|
};
|
||||||
}),
|
|
||||||
[
|
|
||||||
chatTree,
|
|
||||||
chatById,
|
|
||||||
visibleChatIDs,
|
|
||||||
expandedById,
|
|
||||||
modelOptions,
|
|
||||||
modelConfigs,
|
|
||||||
chatErrorReasons,
|
|
||||||
isArchiving,
|
|
||||||
archivingChatId,
|
|
||||||
toggleExpanded,
|
|
||||||
onArchiveAgent,
|
|
||||||
onUnarchiveAgent,
|
|
||||||
onArchiveAndDeleteWorkspace,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const subNavTitle = "Settings";
|
const subNavTitle = "Settings";
|
||||||
|
|
||||||
@@ -1110,8 +1092,10 @@ const LoadMoreSentinel: FC<{
|
|||||||
// Keep refs in sync with the latest prop values so the
|
// Keep refs in sync with the latest prop values so the
|
||||||
// observer callback always reads current state without
|
// observer callback always reads current state without
|
||||||
// needing to tear down and re-create the observer.
|
// needing to tear down and re-create the observer.
|
||||||
onLoadMoreRef.current = onLoadMore;
|
useEffect(() => {
|
||||||
isFetchingNextPageRef.current = isFetchingNextPage;
|
onLoadMoreRef.current = onLoadMore;
|
||||||
|
isFetchingNextPageRef.current = isFetchingNextPage;
|
||||||
|
}, [onLoadMore, isFetchingNextPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { chatCostSummary } from "api/queries/chats";
|
|||||||
import { useAuthContext } from "contexts/auth/AuthProvider";
|
import { useAuthContext } from "contexts/auth/AuthProvider";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { BarChart3Icon } from "lucide-react";
|
import { BarChart3Icon } from "lucide-react";
|
||||||
import { type FC, useMemo } from "react";
|
import type { FC } from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
import { ChatCostSummaryView } from "./ChatCostSummaryView";
|
||||||
import { SectionHeader } from "./SectionHeader";
|
import { SectionHeader } from "./SectionHeader";
|
||||||
@@ -28,7 +28,7 @@ export const AnalyticsPageContent: FC<AnalyticsPageContentProps> = ({
|
|||||||
now,
|
now,
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const dateRange = useMemo(() => createDateRange(now), [now]);
|
const dateRange = createDateRange(now);
|
||||||
|
|
||||||
const summaryQuery = useQuery({
|
const summaryQuery = useQuery({
|
||||||
...chatCostSummary(user?.id ?? "me", {
|
...chatCostSummary(user?.id ?? "me", {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type * as TypesGen from "api/typesGenerated";
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert";
|
import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert";
|
||||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import { type FC, type ReactNode, useMemo, useState } from "react";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { formatProviderLabel } from "../modelOptions";
|
import { formatProviderLabel } from "../modelOptions";
|
||||||
@@ -98,104 +98,99 @@ const useProviderStates = (
|
|||||||
modelConfigs: readonly TypesGen.ChatModelConfig[],
|
modelConfigs: readonly TypesGen.ChatModelConfig[],
|
||||||
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
|
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
|
||||||
catalogData: TypesGen.ChatModelsResponse | null | undefined,
|
catalogData: TypesGen.ChatModelsResponse | null | undefined,
|
||||||
): readonly ProviderState[] =>
|
): readonly ProviderState[] => {
|
||||||
useMemo(() => {
|
const orderedProviders: string[] = [];
|
||||||
const orderedProviders: string[] = [];
|
const seenProviders = new Set<string>();
|
||||||
const seenProviders = new Set<string>();
|
const includeProvider = (providerValue: string) => {
|
||||||
const includeProvider = (providerValue: string) => {
|
const normalized = normalizeProvider(providerValue);
|
||||||
const normalized = normalizeProvider(providerValue);
|
if (!normalized || seenProviders.has(normalized)) return;
|
||||||
if (!normalized || seenProviders.has(normalized)) return;
|
seenProviders.add(normalized);
|
||||||
seenProviders.add(normalized);
|
orderedProviders.push(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),
|
||||||
};
|
};
|
||||||
|
});
|
||||||
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 ──────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -245,14 +240,10 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Sorted model configs ───────────────────────────────────
|
// ── Sorted model configs ───────────────────────────────────
|
||||||
const modelConfigs = useMemo(
|
const modelConfigs = (modelConfigsQuery.data ?? []).slice().sort((a, b) => {
|
||||||
() =>
|
const cmp = a.provider.localeCompare(b.provider);
|
||||||
(modelConfigsQuery.data ?? []).slice().sort((a, b) => {
|
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
|
||||||
const cmp = a.provider.localeCompare(b.provider);
|
});
|
||||||
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
|
|
||||||
}),
|
|
||||||
[modelConfigsQuery.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Provider states ────────────────────────────────────────
|
// ── Provider states ────────────────────────────────────────
|
||||||
const providerStates = useProviderStates(
|
const providerStates = useProviderStates(
|
||||||
@@ -264,25 +255,15 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
|||||||
// Derive the effective selected provider from user intent + available
|
// Derive the effective selected provider from user intent + available
|
||||||
// providers. This avoids a useEffect + setState cycle that would cause
|
// providers. This avoids a useEffect + setState cycle that would cause
|
||||||
// an extra render with a stale value.
|
// an extra render with a stale value.
|
||||||
const selectedProvider = useMemo(() => {
|
const selectedProvider =
|
||||||
if (
|
requestedProvider &&
|
||||||
requestedProvider &&
|
providerStates.some((ps) => ps.provider === requestedProvider)
|
||||||
providerStates.some((ps) => ps.provider === requestedProvider)
|
? requestedProvider
|
||||||
) {
|
: (providerStates[0]?.provider ?? null);
|
||||||
return requestedProvider;
|
|
||||||
}
|
|
||||||
return providerStates[0]?.provider ?? null;
|
|
||||||
}, [requestedProvider, providerStates]);
|
|
||||||
|
|
||||||
const selectedProviderState = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedProvider
|
|
||||||
? (providerStates.find((ps) => ps.provider === selectedProvider) ??
|
|
||||||
null)
|
|
||||||
: null,
|
|
||||||
[providerStates, selectedProvider],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const selectedProviderState = selectedProvider
|
||||||
|
? (providerStates.find((ps) => ps.provider === selectedProvider) ?? null)
|
||||||
|
: null;
|
||||||
// ── Derived state ──────────────────────────────────────────
|
// ── Derived state ──────────────────────────────────────────
|
||||||
const isLoading =
|
const isLoading =
|
||||||
providerConfigsQuery.isLoading ||
|
providerConfigsQuery.isLoading ||
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FC, useMemo, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { getFormHelpers } from "utils/formUtils";
|
import { getFormHelpers } from "utils/formUtils";
|
||||||
|
|
||||||
@@ -186,13 +186,9 @@ export const ModelForm: FC<ModelFormProps> = ({
|
|||||||
|
|
||||||
const getFieldHelpers = getFormHelpers(form);
|
const getFieldHelpers = getFormHelpers(form);
|
||||||
|
|
||||||
const modelConfigFormBuildResult = useMemo(
|
const modelConfigFormBuildResult = buildModelConfigFromForm(
|
||||||
() =>
|
selectedProviderState?.provider,
|
||||||
buildModelConfigFromForm(
|
form.values.config,
|
||||||
selectedProviderState?.provider,
|
|
||||||
form.values.config,
|
|
||||||
),
|
|
||||||
[selectedProviderState?.provider, form.values.config],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasFieldErrors =
|
const hasFieldErrors =
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
StarIcon,
|
StarIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FC, type ReactNode, useMemo } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { SectionHeader } from "../SectionHeader";
|
import { SectionHeader } from "../SectionHeader";
|
||||||
@@ -86,7 +86,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
|
|
||||||
// Derive the current view from URL search params so that
|
// Derive the current view from URL search params so that
|
||||||
// browser back/forward navigation works as expected.
|
// browser back/forward navigation works as expected.
|
||||||
const view: ModelView = useMemo(() => {
|
const view: ModelView = (() => {
|
||||||
const editModelId = searchParams.get("model");
|
const editModelId = searchParams.get("model");
|
||||||
if (editModelId) {
|
if (editModelId) {
|
||||||
const model = modelConfigs.find((m) => m.id === editModelId);
|
const model = modelConfigs.find((m) => m.id === editModelId);
|
||||||
@@ -97,7 +97,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
|||||||
return { mode: "add", provider: addProvider };
|
return { mode: "add", provider: addProvider };
|
||||||
}
|
}
|
||||||
return { mode: "list" };
|
return { mode: "list" };
|
||||||
}, [searchParams, modelConfigs]);
|
})();
|
||||||
|
|
||||||
// Clear model-related search params and return to the list.
|
// Clear model-related search params and return to the list.
|
||||||
const clearModelView = () => {
|
const clearModelView = () => {
|
||||||
|
|||||||
@@ -104,48 +104,55 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
|||||||
const trimmedDisplayName = displayName.trim();
|
const trimmedDisplayName = displayName.trim();
|
||||||
const trimmedBaseURL = baseURLValue.trim();
|
const trimmedBaseURL = baseURLValue.trim();
|
||||||
|
|
||||||
try {
|
if (providerConfig) {
|
||||||
if (providerConfig) {
|
const currentDisplayName =
|
||||||
const currentDisplayName =
|
readOptionalString(providerConfig.display_name) ?? "";
|
||||||
readOptionalString(providerConfig.display_name) ?? "";
|
const currentBaseURL = baseURL.trim();
|
||||||
const currentBaseURL = baseURL.trim();
|
const req: TypesGen.UpdateChatProviderConfigRequest = {
|
||||||
const req: TypesGen.UpdateChatProviderConfigRequest = {
|
...(trimmedDisplayName !== currentDisplayName && {
|
||||||
...(trimmedDisplayName !== currentDisplayName && {
|
display_name: trimmedDisplayName,
|
||||||
display_name: trimmedDisplayName,
|
}),
|
||||||
}),
|
...(effectiveApiKey && { api_key: effectiveApiKey }),
|
||||||
...(effectiveApiKey && { api_key: effectiveApiKey }),
|
...(trimmedBaseURL !== currentBaseURL && {
|
||||||
...(trimmedBaseURL !== currentBaseURL && {
|
base_url: trimmedBaseURL,
|
||||||
base_url: trimmedBaseURL,
|
}),
|
||||||
}),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if (!req.display_name && !req.api_key && !req.base_url) {
|
if (!req.display_name && !req.api_key && !req.base_url) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
await onUpdateProvider(providerConfig.id, req);
|
|
||||||
} else {
|
|
||||||
if (!effectiveApiKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const req: TypesGen.CreateChatProviderConfigRequest = {
|
|
||||||
provider,
|
|
||||||
api_key: effectiveApiKey,
|
|
||||||
...(trimmedDisplayName && {
|
|
||||||
display_name: trimmedDisplayName,
|
|
||||||
}),
|
|
||||||
...(trimmedBaseURL && { base_url: trimmedBaseURL }),
|
|
||||||
};
|
|
||||||
|
|
||||||
await onCreateProvider(req);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setApiKeyTouched(false);
|
try {
|
||||||
} catch {
|
await onUpdateProvider(providerConfig.id, req);
|
||||||
// Error is surfaced via the mutation's error state
|
} catch {
|
||||||
// in ChatModelAdminPanel, no toast needed.
|
// Error is surfaced via the mutation's error state
|
||||||
|
// in ChatModelAdminPanel, no toast needed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!effectiveApiKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req: TypesGen.CreateChatProviderConfigRequest = {
|
||||||
|
provider,
|
||||||
|
api_key: effectiveApiKey,
|
||||||
|
...(trimmedDisplayName && {
|
||||||
|
display_name: trimmedDisplayName,
|
||||||
|
}),
|
||||||
|
...(trimmedBaseURL && { base_url: trimmedBaseURL }),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onCreateProvider(req);
|
||||||
|
} catch {
|
||||||
|
// Error is surfaced via the mutation's error state
|
||||||
|
// in ChatModelAdminPanel, no toast needed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setApiKeyTouched(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApiKeyFocus = () => {
|
const handleApiKeyFocus = () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
import { type FC, type ReactNode, useMemo } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { SectionHeader } from "../SectionHeader";
|
import { SectionHeader } from "../SectionHeader";
|
||||||
@@ -48,7 +48,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
|||||||
|
|
||||||
// Derive the current view from URL search params so that
|
// Derive the current view from URL search params so that
|
||||||
// browser back/forward navigation works as expected.
|
// browser back/forward navigation works as expected.
|
||||||
const view: ProviderView = useMemo(() => {
|
const view: ProviderView = (() => {
|
||||||
const providerParam = searchParams.get("provider");
|
const providerParam = searchParams.get("provider");
|
||||||
if (providerParam) {
|
if (providerParam) {
|
||||||
const exists = providerStates.some((ps) => ps.provider === providerParam);
|
const exists = providerStates.some((ps) => ps.provider === providerParam);
|
||||||
@@ -57,7 +57,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
|||||||
: { mode: "list" };
|
: { mode: "list" };
|
||||||
}
|
}
|
||||||
return { mode: "list" };
|
return { mode: "list" };
|
||||||
}, [searchParams, providerStates]);
|
})();
|
||||||
|
|
||||||
// Clear provider search param and return to the list.
|
// Clear provider search param and return to the list.
|
||||||
const clearProviderView = () => {
|
const clearProviderView = () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ArrowUpIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -215,117 +214,101 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
|
|||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Line interaction callbacks
|
// Line interaction callbacks
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
const handleLineNumberClick = useCallback(
|
const handleLineNumberClick = (
|
||||||
(
|
fileName: string,
|
||||||
fileName: string,
|
props: {
|
||||||
props: {
|
lineNumber: number;
|
||||||
lineNumber: number;
|
annotationSide: "additions" | "deletions";
|
||||||
annotationSide: "additions" | "deletions";
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setActiveCommentBox({
|
|
||||||
fileName,
|
|
||||||
start: props.lineNumber,
|
|
||||||
startSide: props.annotationSide,
|
|
||||||
end: props.lineNumber,
|
|
||||||
endSide: props.annotationSide,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[],
|
) => {
|
||||||
);
|
setActiveCommentBox({
|
||||||
|
fileName,
|
||||||
|
start: props.lineNumber,
|
||||||
|
startSide: props.annotationSide,
|
||||||
|
end: props.lineNumber,
|
||||||
|
endSide: props.annotationSide,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleLineSelected = useCallback(
|
const handleLineSelected = (
|
||||||
(
|
fileName: string,
|
||||||
fileName: string,
|
range: {
|
||||||
range: {
|
start: number;
|
||||||
start: number;
|
end: number;
|
||||||
end: number;
|
side?: "additions" | "deletions";
|
||||||
side?: "additions" | "deletions";
|
endSide?: "additions" | "deletions";
|
||||||
endSide?: "additions" | "deletions";
|
} | null,
|
||||||
} | null,
|
) => {
|
||||||
) => {
|
const result = commentBoxFromRange(fileName, range);
|
||||||
const result = commentBoxFromRange(fileName, range);
|
if (result === "ignore") return;
|
||||||
if (result === "ignore") return;
|
setActiveCommentBox(result);
|
||||||
setActiveCommentBox(result);
|
};
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Annotation helpers
|
// Annotation helpers
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
const getLineAnnotations = useCallback(
|
const getLineAnnotations = (
|
||||||
(fileName: string): DiffLineAnnotation<string>[] => {
|
fileName: string,
|
||||||
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
): DiffLineAnnotation<string>[] => {
|
||||||
return [
|
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
||||||
{
|
return [
|
||||||
side: annotationSideForBox(activeCommentBox),
|
{
|
||||||
lineNumber: annotationLineForBox(activeCommentBox),
|
side: annotationSideForBox(activeCommentBox),
|
||||||
metadata: "active-input",
|
lineNumber: annotationLineForBox(activeCommentBox),
|
||||||
},
|
metadata: "active-input",
|
||||||
];
|
},
|
||||||
}
|
];
|
||||||
return [];
|
}
|
||||||
},
|
return [];
|
||||||
[activeCommentBox],
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const getSelectedLines = useCallback(
|
const getSelectedLines = (fileName: string): SelectedLineRange | null => {
|
||||||
(fileName: string): SelectedLineRange | null => {
|
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
||||||
if (activeCommentBox && activeCommentBox.fileName === fileName) {
|
return selectedLinesForBox(activeCommentBox);
|
||||||
return selectedLinesForBox(activeCommentBox);
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
};
|
||||||
},
|
|
||||||
[activeCommentBox],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancelComment = useCallback(() => {
|
const handleCancelComment = () => {
|
||||||
setActiveCommentBox(null);
|
setActiveCommentBox(null);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleSubmitComment = useCallback(
|
const handleSubmitComment = (text: string) => {
|
||||||
(text: string) => {
|
if (!activeCommentBox) return;
|
||||||
if (!activeCommentBox) return;
|
const { startLine, endLine, side } = contentRangeForBox(activeCommentBox);
|
||||||
const { startLine, endLine, side } = contentRangeForBox(activeCommentBox);
|
const content = extractDiffContent(
|
||||||
const content = extractDiffContent(
|
parsedFiles,
|
||||||
parsedFiles,
|
activeCommentBox.fileName,
|
||||||
activeCommentBox.fileName,
|
startLine,
|
||||||
startLine,
|
endLine,
|
||||||
endLine,
|
side,
|
||||||
side,
|
);
|
||||||
|
// Single imperative call -- chip inserted atomically
|
||||||
|
// in one Lexical update. No rAF hack needed.
|
||||||
|
chatInputRef?.current?.addFileReference({
|
||||||
|
fileName: activeCommentBox.fileName,
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
if (text.trim()) {
|
||||||
|
chatInputRef?.current?.insertText(text);
|
||||||
|
}
|
||||||
|
setActiveCommentBox(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAnnotation = (annotation: DiffLineAnnotation<string>) => {
|
||||||
|
if (annotation.metadata === "active-input") {
|
||||||
|
if (!activeCommentBox) return null;
|
||||||
|
return (
|
||||||
|
<InlinePromptInput
|
||||||
|
onSubmit={handleSubmitComment}
|
||||||
|
onCancel={handleCancelComment}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
// Single imperative call -- chip inserted atomically
|
}
|
||||||
// in one Lexical update. No rAF hack needed.
|
return null;
|
||||||
chatInputRef?.current?.addFileReference({
|
};
|
||||||
fileName: activeCommentBox.fileName,
|
|
||||||
startLine,
|
|
||||||
endLine,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
if (text.trim()) {
|
|
||||||
chatInputRef?.current?.insertText(text);
|
|
||||||
}
|
|
||||||
setActiveCommentBox(null);
|
|
||||||
},
|
|
||||||
[activeCommentBox, chatInputRef, parsedFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderAnnotation = useCallback(
|
|
||||||
(annotation: DiffLineAnnotation<string>) => {
|
|
||||||
if (annotation.metadata === "active-input") {
|
|
||||||
if (!activeCommentBox) return null;
|
|
||||||
return (
|
|
||||||
<InlinePromptInput
|
|
||||||
onSubmit={handleSubmitComment}
|
|
||||||
onCancel={handleCancelComment}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[activeCommentBox, handleSubmitComment, handleCancelComment],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Render
|
// Render
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "components/Button/Button";
|
import { Button } from "components/Button/Button";
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import { type FC, useCallback, useEffect, useRef } from "react";
|
import { type FC, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
type UseDesktopConnectionResult,
|
type UseDesktopConnectionResult,
|
||||||
useDesktopConnection,
|
useDesktopConnection,
|
||||||
@@ -27,15 +27,12 @@ export const DesktopPanel: FC<DesktopPanelProps> = ({
|
|||||||
connectionOverride ?? hookResult;
|
connectionOverride ?? hookResult;
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const attachToContainer = useCallback(
|
const attachToContainer = (el: HTMLDivElement | null) => {
|
||||||
(el: HTMLDivElement | null) => {
|
containerRef.current = el;
|
||||||
containerRef.current = el;
|
if (el) {
|
||||||
if (el) {
|
attach(el);
|
||||||
attach(el);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[attach],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect on mount, disconnect on unmount. This drives the
|
// Connect on mount, disconnect on unmount. This drives the
|
||||||
// visibility-based lifecycle: DesktopPanel is only rendered
|
// visibility-based lifecycle: DesktopPanel is only rendered
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ import {
|
|||||||
type FC,
|
type FC,
|
||||||
memo,
|
memo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -484,25 +482,6 @@ const LazyFileDiff = memo<{
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prev, next) => {
|
|
||||||
if (
|
|
||||||
prev.fileDiff !== next.fileDiff ||
|
|
||||||
prev.options !== next.options ||
|
|
||||||
prev.lineAnnotations !== next.lineAnnotations ||
|
|
||||||
prev.selectedLines !== next.selectedLines
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// When neither the previous nor next props include line
|
|
||||||
// annotations, a new `renderAnnotation` reference is
|
|
||||||
// irrelevant because there is nothing to render. Skip the
|
|
||||||
// re-render so that unrelated state changes (e.g.
|
|
||||||
// activeCommentBox) don't bust memo for every file.
|
|
||||||
if (prev.renderAnnotation !== next.renderAnnotation) {
|
|
||||||
return !prev.lineAnnotations && !next.lineAnnotations;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -527,7 +506,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
|
||||||
const diffOptions = useMemo(() => {
|
const diffOptions = (() => {
|
||||||
const base = getDiffViewerOptions(isDark);
|
const base = getDiffViewerOptions(isDark);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -536,70 +515,60 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
// remain visible while scrolling through long diffs.
|
// remain visible while scrolling through long diffs.
|
||||||
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
|
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
|
||||||
};
|
};
|
||||||
}, [isDark, diffStyle]);
|
})();
|
||||||
|
|
||||||
// Memoize the per-file options object so every <FileDiff>
|
const fileOptions = {
|
||||||
// receives the same reference and avoids re-highlighting
|
...diffOptions,
|
||||||
// when the parent re-renders.
|
overflow: "wrap" as const,
|
||||||
const fileOptions = useMemo(
|
enableLineSelection: true,
|
||||||
() => ({
|
enableHoverUtility: true,
|
||||||
...diffOptions,
|
onLineSelected() {
|
||||||
overflow: "wrap" as const,
|
// TODO: Make this add context to the input so the
|
||||||
enableLineSelection: true,
|
// user can type.
|
||||||
enableHoverUtility: true,
|
},
|
||||||
onLineSelected() {
|
};
|
||||||
// TODO: Make this add context to the input so the
|
|
||||||
// user can type.
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[diffOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
// When the parent provides per-file callbacks (e.g. line click
|
// When the parent provides per-file callbacks (e.g. line click
|
||||||
// handlers for comment inputs), build options per file. Otherwise
|
// handlers for comment inputs), build options per file. Otherwise
|
||||||
// share a single stable object to avoid unnecessary re-highlights.
|
// share a single stable object to avoid unnecessary re-highlights.
|
||||||
const hasPerFileCallbacks = !!(onLineNumberClick || onLineSelected);
|
const hasPerFileCallbacks = !!(onLineNumberClick || onLineSelected);
|
||||||
|
|
||||||
const getOptionsForFile = useCallback(
|
const getOptionsForFile = (fileName: string) => ({
|
||||||
(fileName: string) => ({
|
...diffOptions,
|
||||||
...diffOptions,
|
overflow: "wrap" as const,
|
||||||
overflow: "wrap" as const,
|
enableLineSelection: true,
|
||||||
enableLineSelection: true,
|
enableHoverUtility: true,
|
||||||
enableHoverUtility: true,
|
...(onLineNumberClick && {
|
||||||
...(onLineNumberClick && {
|
onLineNumberClick: (props: {
|
||||||
onLineNumberClick: (props: {
|
lineNumber: number;
|
||||||
lineNumber: number;
|
annotationSide: "additions" | "deletions";
|
||||||
annotationSide: "additions" | "deletions";
|
}) => onLineNumberClick(fileName, props),
|
||||||
}) => onLineNumberClick(fileName, props),
|
|
||||||
}),
|
|
||||||
onLineSelected: onLineSelected
|
|
||||||
? (
|
|
||||||
range: {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
side?: "additions" | "deletions";
|
|
||||||
endSide?: "additions" | "deletions";
|
|
||||||
} | null,
|
|
||||||
) => onLineSelected(fileName, range)
|
|
||||||
: () => {
|
|
||||||
// TODO: Make this add context to the input.
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[diffOptions, onLineNumberClick, onLineSelected],
|
onLineSelected: onLineSelected
|
||||||
);
|
? (
|
||||||
|
range: {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
side?: "additions" | "deletions";
|
||||||
|
endSide?: "additions" | "deletions";
|
||||||
|
} | null,
|
||||||
|
) => onLineSelected(fileName, range)
|
||||||
|
: () => {
|
||||||
|
// TODO: Make this add context to the input.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
|
const fileTree = buildFileTree(parsedFiles);
|
||||||
|
|
||||||
// Sort diff blocks in the same order the file tree displays them
|
// Sort diff blocks in the same order the file tree displays them
|
||||||
// (directories first, then alphabetical) so the rendering is
|
// (directories first, then alphabetical) so the rendering is
|
||||||
// consistent regardless of whether the sidebar is visible.
|
// consistent regardless of whether the sidebar is visible.
|
||||||
const sortedFiles = useMemo(() => {
|
const sortedFiles = (() => {
|
||||||
const order = new Map<string, number>();
|
const order = new Map<string, number>();
|
||||||
let idx = 0;
|
|
||||||
const walk = (nodes: FileTreeNode[]) => {
|
const walk = (nodes: FileTreeNode[]) => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === "file") {
|
if (node.type === "file") {
|
||||||
order.set(node.fullPath, idx++);
|
order.set(node.fullPath, order.size);
|
||||||
} else {
|
} else {
|
||||||
walk(node.children);
|
walk(node.children);
|
||||||
}
|
}
|
||||||
@@ -609,21 +578,21 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
return [...parsedFiles].sort(
|
return [...parsedFiles].sort(
|
||||||
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
|
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
|
||||||
);
|
);
|
||||||
}, [fileTree, parsedFiles]);
|
})();
|
||||||
|
|
||||||
// Pre-compute per-file options so each LazyFileDiff receives a
|
// Pre-compute per-file options so each LazyFileDiff receives a
|
||||||
// stable reference and avoids re-highlighting on parent re-render.
|
// stable reference and avoids re-highlighting on parent re-render.
|
||||||
const perFileOptions = useMemo(() => {
|
const perFileOptions = (() => {
|
||||||
if (!hasPerFileCallbacks) return null;
|
if (!hasPerFileCallbacks) return null;
|
||||||
const map = new Map<string, ComponentProps<typeof FileDiff>["options"]>();
|
const map = new Map<string, ComponentProps<typeof FileDiff>["options"]>();
|
||||||
for (const file of sortedFiles) {
|
for (const file of sortedFiles) {
|
||||||
map.set(file.name, getOptionsForFile(file.name));
|
map.set(file.name, getOptionsForFile(file.name));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [hasPerFileCallbacks, sortedFiles, getOptionsForFile]);
|
})();
|
||||||
|
|
||||||
// Pre-compute per-file line annotations for the same reason.
|
// Pre-compute per-file line annotations for the same reason.
|
||||||
const perFileAnnotations = useMemo(() => {
|
const perFileAnnotations = (() => {
|
||||||
if (!getLineAnnotations) return null;
|
if (!getLineAnnotations) return null;
|
||||||
return new Map(
|
return new Map(
|
||||||
sortedFiles
|
sortedFiles
|
||||||
@@ -633,14 +602,14 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
entry[1].length > 0,
|
entry[1].length > 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [sortedFiles, getLineAnnotations]);
|
})();
|
||||||
|
|
||||||
// Pre-compute per-file selected lines so each LazyFileDiff
|
// Pre-compute per-file selected lines so each LazyFileDiff
|
||||||
// receives a stable reference. Without this, calling
|
// receives a stable reference. Without this, calling
|
||||||
// getSelectedLines during render returns a new object every
|
// getSelectedLines during render returns a new object every
|
||||||
// time, which busts the memo comparator and forces an
|
// time, which busts the memo comparator and forces an
|
||||||
// expensive Shadow DOM + shiki re-highlight.
|
// expensive Shadow DOM + shiki re-highlight.
|
||||||
const perFileSelectedLines = useMemo(() => {
|
const perFileSelectedLines = (() => {
|
||||||
if (!getSelectedLines) return null;
|
if (!getSelectedLines) return null;
|
||||||
return new Map(
|
return new Map(
|
||||||
sortedFiles
|
sortedFiles
|
||||||
@@ -649,7 +618,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
(entry): entry is [string, SelectedLineRange] => entry[1] != null,
|
(entry): entry is [string, SelectedLineRange] => entry[1] != null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [sortedFiles, getSelectedLines]);
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Container width measurement via ResizeObserver so we can decide
|
// Container width measurement via ResizeObserver so we can decide
|
||||||
@@ -657,22 +626,17 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
// parent.
|
// parent.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
const roRef = useRef<ResizeObserver | null>(null);
|
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
|
||||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
|
||||||
if (roRef.current) {
|
useEffect(() => {
|
||||||
roRef.current.disconnect();
|
if (!containerEl) return;
|
||||||
roRef.current = null;
|
setContainerWidth(containerEl.getBoundingClientRect().width);
|
||||||
}
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setContainerWidth(el.getBoundingClientRect().width);
|
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
setContainerWidth(entry.contentRect.width);
|
setContainerWidth(entry.contentRect.width);
|
||||||
});
|
});
|
||||||
ro.observe(el);
|
ro.observe(containerEl);
|
||||||
roRef.current = ro;
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, [containerEl]);
|
||||||
|
|
||||||
const showTree =
|
const showTree =
|
||||||
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
|
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
|
||||||
@@ -686,13 +650,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
const [activeFile, setActiveFile] = useState<string | null>(null);
|
const [activeFile, setActiveFile] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keep a ref callback that sets up per-file refs.
|
// Keep a ref callback that sets up per-file refs.
|
||||||
const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => {
|
const setFileRef = (name: string, el: HTMLDivElement | null) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
fileRefs.current.set(name, el);
|
fileRefs.current.set(name, el);
|
||||||
} else {
|
} else {
|
||||||
fileRefs.current.delete(name);
|
fileRefs.current.delete(name);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Track which file is at the top of the diff scroll area by
|
// Track which file is at the top of the diff scroll area by
|
||||||
// listening to scroll events on the viewport. The active file
|
// listening to scroll events on the viewport. The active file
|
||||||
@@ -764,13 +728,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [showTree, sortedFiles.length]);
|
}, [showTree, sortedFiles.length]);
|
||||||
|
|
||||||
const handleFileClick = useCallback((name: string) => {
|
const handleFileClick = (name: string) => {
|
||||||
const el = fileRefs.current.get(name);
|
const el = fileRefs.current.get(name);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ block: "start" });
|
el.scrollIntoView({ block: "start" });
|
||||||
setActiveFile(name);
|
setActiveFile(name);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Scroll to a file programmatically when the parent sets
|
// Scroll to a file programmatically when the parent sets
|
||||||
// scrollToFile. This enables external navigation (e.g.
|
// scrollToFile. This enables external navigation (e.g.
|
||||||
@@ -795,14 +759,14 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
// lands during commit — before useEffect-based scroll logic.
|
// lands during commit — before useEffect-based scroll logic.
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
const [viewportHeight, setViewportHeight] = useState(0);
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
const scrollAreaRef = useCallback((node: HTMLElement | null) => {
|
const [scrollAreaEl, setScrollAreaEl] = useState<HTMLDivElement | null>(null);
|
||||||
const vp = node?.querySelector<HTMLElement>(
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vp = scrollAreaEl?.querySelector<HTMLElement>(
|
||||||
"[data-radix-scroll-area-viewport]",
|
"[data-radix-scroll-area-viewport]",
|
||||||
);
|
);
|
||||||
diffViewportRef.current = vp ?? null;
|
diffViewportRef.current = vp ?? null;
|
||||||
|
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
|
|
||||||
setViewportHeight(vp.clientHeight);
|
setViewportHeight(vp.clientHeight);
|
||||||
const ro = new ResizeObserver(([entry]) => {
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
setViewportHeight(entry.contentRect.height);
|
setViewportHeight(entry.contentRect.height);
|
||||||
@@ -812,7 +776,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
diffViewportRef.current = null;
|
diffViewportRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [scrollAreaEl]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Loading state
|
// Loading state
|
||||||
@@ -850,7 +814,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={setContainerEl}
|
||||||
className="flex h-full min-w-0 flex-col overflow-hidden"
|
className="flex h-full min-w-0 flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Diff contents */}
|
{/* Diff contents */}
|
||||||
@@ -889,7 +853,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
|||||||
)}
|
)}
|
||||||
scrollBarClassName="w-1.5"
|
scrollBarClassName="w-1.5"
|
||||||
viewportClassName="[&>div]:!block"
|
viewportClassName="[&>div]:!block"
|
||||||
ref={scrollAreaRef}
|
ref={setScrollAreaEl}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 text-xs">
|
<div className="min-w-0 text-xs">
|
||||||
{sortedFiles.map((fileDiff, i) => {
|
{sortedFiles.map((fileDiff, i) => {
|
||||||
|
|||||||
@@ -17,15 +17,7 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
RowsIcon,
|
RowsIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||||
import { DiffStatBadge } from "./DiffStats";
|
import { DiffStatBadge } from "./DiffStats";
|
||||||
@@ -85,7 +77,7 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
const prDraft = remoteDiffStats?.pull_request_draft;
|
const prDraft = remoteDiffStats?.pull_request_draft;
|
||||||
|
|
||||||
// Compute per-repo diff stats from unified diffs.
|
// Compute per-repo diff stats from unified diffs.
|
||||||
const repoStats = useMemo(() => {
|
const repoStats = (() => {
|
||||||
const stats = new Map<string, DiffStats>();
|
const stats = new Map<string, DiffStats>();
|
||||||
for (const [root, repo] of repositories.entries()) {
|
for (const [root, repo] of repositories.entries()) {
|
||||||
if (!repo.unified_diff) continue;
|
if (!repo.unified_diff) continue;
|
||||||
@@ -103,11 +95,10 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
}, [repositories]);
|
})();
|
||||||
|
|
||||||
const localRepos = useMemo(
|
const localRepos = Array.from(repoStats.keys()).sort((a, b) =>
|
||||||
() => Array.from(repoStats.keys()).sort((a, b) => a.localeCompare(b)),
|
a.localeCompare(b),
|
||||||
[repoStats],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default to the first local repo when there are only local
|
// Default to the first local repo when there are only local
|
||||||
@@ -138,20 +129,20 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
|
|
||||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||||
|
|
||||||
const handleDiffStyleChange = useCallback((style: DiffStyle) => {
|
const handleDiffStyleChange = (style: DiffStyle) => {
|
||||||
saveDiffStyle(style);
|
saveDiffStyle(style);
|
||||||
setDiffStyle(style);
|
setDiffStyle(style);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const [spinning, setSpinning] = useState(false);
|
const [spinning, setSpinning] = useState(false);
|
||||||
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = () => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
setSpinning(true);
|
setSpinning(true);
|
||||||
clearTimeout(spinTimerRef.current);
|
clearTimeout(spinTimerRef.current);
|
||||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||||
}, [onRefresh]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prInsights } from "api/queries/chats";
|
import { prInsights } from "api/queries/chats";
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { type FC, useCallback, useMemo, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
|
||||||
|
|
||||||
@@ -17,14 +17,12 @@ function timeRangeToDates(range: PRInsightsTimeRange) {
|
|||||||
|
|
||||||
export const InsightsContent: FC = () => {
|
export const InsightsContent: FC = () => {
|
||||||
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>("30d");
|
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>("30d");
|
||||||
const dates = useMemo(() => timeRangeToDates(timeRange), [timeRange]);
|
const dates = timeRangeToDates(timeRange);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery(prInsights(dates));
|
const { data, isLoading, error } = useQuery(prInsights(dates));
|
||||||
|
|
||||||
const handleTimeRangeChange = useCallback(
|
const handleTimeRangeChange = (range: PRInsightsTimeRange) =>
|
||||||
(range: PRInsightsTimeRange) => setTimeRange(range),
|
setTimeRange(range);
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "components/Tooltip/Tooltip";
|
} from "components/Tooltip/Tooltip";
|
||||||
import { ShieldIcon } from "lucide-react";
|
import { ShieldIcon } from "lucide-react";
|
||||||
import { type FC, type ReactNode, useMemo, useState } from "react";
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import {
|
import {
|
||||||
dollarsToMicros,
|
dollarsToMicros,
|
||||||
@@ -128,7 +128,7 @@ export const LimitsTab: FC = () => {
|
|||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [userOverrideAmount, setUserOverrideAmount] = useState("");
|
const [userOverrideAmount, setUserOverrideAmount] = useState("");
|
||||||
|
|
||||||
const defaultLimitValues = useMemo<DefaultLimitFormValues>(() => {
|
const defaultLimitValues: DefaultLimitFormValues = (() => {
|
||||||
const spendLimitMicros = configQuery.data?.spend_limit_micros;
|
const spendLimitMicros = configQuery.data?.spend_limit_micros;
|
||||||
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
|
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
|
||||||
|
|
||||||
@@ -140,27 +140,19 @@ export const LimitsTab: FC = () => {
|
|||||||
? microsToDollars(spendLimitMicros).toString()
|
? microsToDollars(spendLimitMicros).toString()
|
||||||
: "",
|
: "",
|
||||||
};
|
};
|
||||||
}, [configQuery.data?.period, configQuery.data?.spend_limit_micros]);
|
})();
|
||||||
const defaultLimitKey = useMemo(
|
const defaultLimitKey = JSON.stringify({
|
||||||
() =>
|
spend_limit_micros: configQuery.data?.spend_limit_micros ?? null,
|
||||||
JSON.stringify({
|
period: defaultLimitValues.period,
|
||||||
spend_limit_micros: configQuery.data?.spend_limit_micros ?? null,
|
});
|
||||||
period: defaultLimitValues.period,
|
const existingGroupIds = new Set(
|
||||||
}),
|
(configQuery.data?.group_overrides ?? []).map((g) => g.group_id),
|
||||||
[configQuery.data?.spend_limit_micros, defaultLimitValues.period],
|
|
||||||
);
|
);
|
||||||
const existingGroupIds = useMemo(
|
const existingUserIds = new Set(
|
||||||
() =>
|
(configQuery.data?.overrides ?? []).map((o) => o.user_id),
|
||||||
new Set((configQuery.data?.group_overrides ?? []).map((g) => g.group_id)),
|
|
||||||
[configQuery.data?.group_overrides],
|
|
||||||
);
|
);
|
||||||
const existingUserIds = useMemo(
|
const availableGroups = (groupsQuery.data ?? []).filter(
|
||||||
() => new Set((configQuery.data?.overrides ?? []).map((o) => o.user_id)),
|
(g) => !existingGroupIds.has(g.id),
|
||||||
[configQuery.data?.overrides],
|
|
||||||
);
|
|
||||||
const availableGroups = useMemo(
|
|
||||||
() => (groupsQuery.data ?? []).filter((g) => !existingGroupIds.has(g.id)),
|
|
||||||
[groupsQuery.data, existingGroupIds],
|
|
||||||
);
|
);
|
||||||
const selectedUserAlreadyOverridden = selectedUser
|
const selectedUserAlreadyOverridden = selectedUser
|
||||||
? existingUserIds.has(selectedUser.id)
|
? existingUserIds.has(selectedUser.id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { parsePatchFiles } from "@pierre/diffs";
|
import { parsePatchFiles } from "@pierre/diffs";
|
||||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||||
import { type FC, type RefObject, useMemo } from "react";
|
import type { FC, RefObject } from "react";
|
||||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||||
import { CommentableDiffViewer } from "./CommentableDiffViewer";
|
import { CommentableDiffViewer } from "./CommentableDiffViewer";
|
||||||
import type { DiffStyle } from "./DiffViewer";
|
import type { DiffStyle } from "./DiffViewer";
|
||||||
@@ -18,7 +18,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
|
|||||||
diffStyle,
|
diffStyle,
|
||||||
chatInputRef,
|
chatInputRef,
|
||||||
}) => {
|
}) => {
|
||||||
const parsedFiles = useMemo(() => {
|
const parsedFiles = (() => {
|
||||||
const diff = repo.unified_diff;
|
const diff = repo.unified_diff;
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
return [];
|
return [];
|
||||||
@@ -29,7 +29,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
|
|||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [repo.unified_diff]);
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommentableDiffViewer
|
<CommentableDiffViewer
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { type FC, type ReactNode, useId, useState } from "react";
|
||||||
type FC,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
@@ -890,60 +883,57 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
|
|||||||
const updateMut = useMutation(updateMCPServerConfigMutation(queryClient));
|
const updateMut = useMutation(updateMCPServerConfigMutation(queryClient));
|
||||||
const deleteMut = useMutation(deleteMCPServerConfigMutation(queryClient));
|
const deleteMut = useMutation(deleteMCPServerConfigMutation(queryClient));
|
||||||
|
|
||||||
const servers = useMemo(
|
const servers = (serversQuery.data ?? [])
|
||||||
() =>
|
.slice()
|
||||||
(serversQuery.data ?? [])
|
.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
||||||
[serversQuery.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editingServer = useMemo(
|
const editingServer =
|
||||||
() =>
|
serverId && serverId !== "new"
|
||||||
serverId && serverId !== "new"
|
? (servers.find((s) => s.id === serverId) ?? null)
|
||||||
? (servers.find((s) => s.id === serverId) ?? null)
|
: null;
|
||||||
: null,
|
|
||||||
[serverId, servers],
|
|
||||||
);
|
|
||||||
const isFormView = serverId !== null;
|
const isFormView = serverId !== null;
|
||||||
const isCreating = serverId === "new";
|
const isCreating = serverId === "new";
|
||||||
|
|
||||||
const handleSave = useCallback(
|
const handleSave = async (
|
||||||
async (req: TypesGen.CreateMCPServerConfigRequest, id?: string) => {
|
req: TypesGen.CreateMCPServerConfigRequest,
|
||||||
|
id?: string,
|
||||||
|
) => {
|
||||||
|
if (id) {
|
||||||
|
const updateReq: TypesGen.UpdateMCPServerConfigRequest = {
|
||||||
|
...req,
|
||||||
|
tool_allow_list: req.tool_allow_list
|
||||||
|
? [...req.tool_allow_list]
|
||||||
|
: undefined,
|
||||||
|
tool_deny_list: req.tool_deny_list
|
||||||
|
? [...req.tool_deny_list]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (id) {
|
await updateMut.mutateAsync({ id, req: updateReq });
|
||||||
const updateReq: TypesGen.UpdateMCPServerConfigRequest = {
|
|
||||||
...req,
|
|
||||||
tool_allow_list: req.tool_allow_list
|
|
||||||
? [...req.tool_allow_list]
|
|
||||||
: undefined,
|
|
||||||
tool_deny_list: req.tool_deny_list
|
|
||||||
? [...req.tool_deny_list]
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
await updateMut.mutateAsync({ id, req: updateReq });
|
|
||||||
} else {
|
|
||||||
await createMut.mutateAsync(req);
|
|
||||||
}
|
|
||||||
setSearchParams({});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Error surfaced via mutation error state.
|
// Error surfaced via mutation error state.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
[createMut, updateMut, setSearchParams],
|
try {
|
||||||
);
|
await createMut.mutateAsync(req);
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via mutation error state.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSearchParams({});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = async (id: string) => {
|
||||||
async (id: string) => {
|
try {
|
||||||
try {
|
await deleteMut.mutateAsync(id);
|
||||||
await deleteMut.mutateAsync(id);
|
} catch {
|
||||||
setSearchParams({});
|
// Error surfaced via mutation error state.
|
||||||
} catch {
|
return;
|
||||||
// Error surfaced via mutation error state.
|
}
|
||||||
}
|
setSearchParams({});
|
||||||
},
|
};
|
||||||
[deleteMut, setSearchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (serversQuery.isLoading) {
|
if (serversQuery.isLoading) {
|
||||||
return <Spinner loading className="h-4 w-4" />;
|
return <Spinner loading className="h-4 w-4" />;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
PencilIcon,
|
PencilIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
|
import { type FC, useEffect, useState } from "react";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
interface QueuedMessagesListProps {
|
interface QueuedMessagesListProps {
|
||||||
@@ -72,21 +72,17 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
|||||||
editingMessageID = null,
|
editingMessageID = null,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const items = useMemo(
|
const items = messages.map((message) => {
|
||||||
() =>
|
const { displayText, rawText, attachmentCount, fileBlocks } =
|
||||||
messages.map((message) => {
|
getQueuedMessageInfo(message);
|
||||||
const { displayText, rawText, attachmentCount, fileBlocks } =
|
return {
|
||||||
getQueuedMessageInfo(message);
|
id: message.id,
|
||||||
return {
|
displayText,
|
||||||
id: message.id,
|
rawText,
|
||||||
displayText,
|
attachmentCount,
|
||||||
rawText,
|
fileBlocks,
|
||||||
attachmentCount,
|
};
|
||||||
fileBlocks,
|
});
|
||||||
};
|
|
||||||
}),
|
|
||||||
[messages],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [hoveredID, setHoveredID] = useState<number | null>(null);
|
const [hoveredID, setHoveredID] = useState<number | null>(null);
|
||||||
// Tracks which item has an async action in flight and what kind.
|
// Tracks which item has an async action in flight and what kind.
|
||||||
@@ -98,7 +94,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
|||||||
ReadonlySet<number>
|
ReadonlySet<number>
|
||||||
>(new Set());
|
>(new Set());
|
||||||
|
|
||||||
const hideItemOptimistically = useCallback((id: number) => {
|
const hideItemOptimistically = (id: number) => {
|
||||||
setOptimisticallyHiddenIDs((current) => {
|
setOptimisticallyHiddenIDs((current) => {
|
||||||
if (current.has(id)) {
|
if (current.has(id)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -107,9 +103,9 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
|||||||
next.add(id);
|
next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const restoreHiddenItem = useCallback((id: number) => {
|
const restoreHiddenItem = (id: number) => {
|
||||||
setOptimisticallyHiddenIDs((current) => {
|
setOptimisticallyHiddenIDs((current) => {
|
||||||
if (!current.has(id)) {
|
if (!current.has(id)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -118,7 +114,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
|||||||
next.delete(id);
|
next.delete(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const liveIDs = new Set(messages.map((message) => message.id));
|
const liveIDs = new Set(messages.map((message) => message.id));
|
||||||
@@ -139,35 +135,29 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
|
|||||||
});
|
});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = async (id: number) => {
|
||||||
async (id: number) => {
|
setBusyItem({ id, action: "delete" });
|
||||||
setBusyItem({ id, action: "delete" });
|
hideItemOptimistically(id);
|
||||||
hideItemOptimistically(id);
|
try {
|
||||||
try {
|
await onDelete(id);
|
||||||
await onDelete(id);
|
setBusyItem((current) => (current?.id === id ? null : current));
|
||||||
} catch {
|
} catch {
|
||||||
restoreHiddenItem(id);
|
restoreHiddenItem(id);
|
||||||
} finally {
|
setBusyItem((current) => (current?.id === id ? null : current));
|
||||||
setBusyItem((current) => (current?.id === id ? null : current));
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[hideItemOptimistically, onDelete, restoreHiddenItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePromote = useCallback(
|
const handlePromote = async (id: number) => {
|
||||||
async (id: number) => {
|
setBusyItem({ id, action: "promote" });
|
||||||
setBusyItem({ id, action: "promote" });
|
hideItemOptimistically(id);
|
||||||
hideItemOptimistically(id);
|
try {
|
||||||
try {
|
await onPromote(id);
|
||||||
await onPromote(id);
|
setBusyItem((current) => (current?.id === id ? null : current));
|
||||||
} catch {
|
} catch {
|
||||||
restoreHiddenItem(id);
|
restoreHiddenItem(id);
|
||||||
} finally {
|
setBusyItem((current) => (current?.id === id ? null : current));
|
||||||
setBusyItem((current) => (current?.id === id ? null : current));
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[hideItemOptimistically, onPromote, restoreHiddenItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleItems = items.filter(
|
const visibleItems = items.filter(
|
||||||
(item) => !optimisticallyHiddenIDs.has(item.id),
|
(item) => !optimisticallyHiddenIDs.has(item.id),
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ import {
|
|||||||
GitPullRequestDraftIcon,
|
GitPullRequestDraftIcon,
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { type FC, type RefObject, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||||
@@ -30,18 +22,6 @@ import { parsePullRequestUrl } from "./pullRequest";
|
|||||||
|
|
||||||
export { InlinePromptInput } from "./CommentableDiffViewer";
|
export { InlinePromptInput } from "./CommentableDiffViewer";
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// Module-level counter for cache key uniqueness
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monotonic counter shared across all RemoteDiffPanel instances.
|
|
||||||
* Ensures parsePatchFiles cache keys never collide across mounts,
|
|
||||||
* since component-local refs reset to 0 on remount while the
|
|
||||||
* worker pool's LRU cache persists.
|
|
||||||
*/
|
|
||||||
let remoteDiffVersion = 0;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// PR state badge
|
// PR state badge
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -109,14 +89,16 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const diffContent = diffContentsQuery.data?.diff;
|
const diffContent = diffContentsQuery.data?.diff;
|
||||||
const diffVersionRef = useRef(0);
|
const [diffVersion, setDiffVersion] = useState(0);
|
||||||
const prevDiffRef = useRef<string | undefined>(undefined);
|
const [prevDiffContent, setPrevDiffContent] = useState<string | undefined>(
|
||||||
if (diffContent !== prevDiffRef.current) {
|
undefined,
|
||||||
prevDiffRef.current = diffContent;
|
);
|
||||||
diffVersionRef.current = ++remoteDiffVersion;
|
if (diffContent !== prevDiffContent) {
|
||||||
|
setPrevDiffContent(diffContent);
|
||||||
|
setDiffVersion((v) => v + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedFiles = useMemo(() => {
|
const parsedFiles = (() => {
|
||||||
if (!diffContent) {
|
if (!diffContent) {
|
||||||
return [] as FileDiffMetadata[];
|
return [] as FileDiffMetadata[];
|
||||||
}
|
}
|
||||||
@@ -133,13 +115,13 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
|||||||
// recomputation on refetches with identical content.
|
// recomputation on refetches with identical content.
|
||||||
const patches = parsePatchFiles(
|
const patches = parsePatchFiles(
|
||||||
diffContent,
|
diffContent,
|
||||||
`chat-${chatId}-v${diffVersionRef.current}`,
|
`chat-${chatId}-v${diffVersion}`,
|
||||||
);
|
);
|
||||||
return patches.flatMap((p) => p.files);
|
return patches.flatMap((p) => p.files);
|
||||||
} catch {
|
} catch {
|
||||||
return [] as FileDiffMetadata[];
|
return [] as FileDiffMetadata[];
|
||||||
}
|
}
|
||||||
}, [diffContent, chatId]);
|
})();
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Scroll-to-file from chat input chip clicks
|
// Scroll-to-file from chat input chip clicks
|
||||||
@@ -156,9 +138,9 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
|||||||
return () => window.removeEventListener("file-reference-click", handler);
|
return () => window.removeEventListener("file-reference-click", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleScrollComplete = useCallback(() => {
|
const handleScrollComplete = () => {
|
||||||
setScrollTarget(null);
|
setScrollTarget(null);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Header content
|
// Header content
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -85,96 +84,77 @@ function useResizableDrag({
|
|||||||
"normal" | "expanded" | "closed" | null
|
"normal" | "expanded" | "closed" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
isDragging.current = true;
|
||||||
isDragging.current = true;
|
setDragSnap(null);
|
||||||
setDragSnap(null);
|
sidebarCollapsedByDrag.current = false;
|
||||||
sidebarCollapsedByDrag.current = false;
|
startX.current = e.clientX;
|
||||||
startX.current = e.clientX;
|
startWidth.current = isExpanded
|
||||||
startWidth.current = isExpanded
|
? ((e.target as HTMLElement).closest("[data-testid='agents-right-panel']")
|
||||||
? ((e.target as HTMLElement).closest(
|
?.parentElement?.clientWidth ?? getMaxWidth())
|
||||||
"[data-testid='agents-right-panel']",
|
: width;
|
||||||
)?.parentElement?.clientWidth ?? getMaxWidth())
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
: width;
|
};
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
},
|
|
||||||
[width, isExpanded],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
if (!isDragging.current) {
|
||||||
if (!isDragging.current) {
|
return;
|
||||||
return;
|
}
|
||||||
|
const delta = startX.current - e.clientX;
|
||||||
|
const raw = startWidth.current + delta;
|
||||||
|
const maxWidth = getMaxWidth();
|
||||||
|
|
||||||
|
// Collapse/uncollapse the sidebar live when the pointer
|
||||||
|
// reaches the left edge of the viewport.
|
||||||
|
if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) {
|
||||||
|
if (!isSidebarCollapsed && onToggleSidebarCollapsed) {
|
||||||
|
onToggleSidebarCollapsed();
|
||||||
|
sidebarCollapsedByDrag.current = true;
|
||||||
}
|
}
|
||||||
const delta = startX.current - e.clientX;
|
} else if (e.clientX >= SNAP_THRESHOLD && sidebarCollapsedByDrag.current) {
|
||||||
const raw = startWidth.current + delta;
|
if (onToggleSidebarCollapsed) {
|
||||||
const maxWidth = getMaxWidth();
|
onToggleSidebarCollapsed();
|
||||||
|
sidebarCollapsedByDrag.current = false;
|
||||||
// Collapse/uncollapse the sidebar live when the pointer
|
|
||||||
// reaches the left edge of the viewport.
|
|
||||||
if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) {
|
|
||||||
if (!isSidebarCollapsed && onToggleSidebarCollapsed) {
|
|
||||||
onToggleSidebarCollapsed();
|
|
||||||
sidebarCollapsedByDrag.current = true;
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
e.clientX >= SNAP_THRESHOLD &&
|
|
||||||
sidebarCollapsedByDrag.current
|
|
||||||
) {
|
|
||||||
if (onToggleSidebarCollapsed) {
|
|
||||||
onToggleSidebarCollapsed();
|
|
||||||
sidebarCollapsedByDrag.current = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nextSnap: "normal" | "expanded" | "closed";
|
let nextSnap: "normal" | "expanded" | "closed";
|
||||||
if (raw > maxWidth + SNAP_THRESHOLD) {
|
if (raw > maxWidth + SNAP_THRESHOLD) {
|
||||||
nextSnap = "expanded";
|
nextSnap = "expanded";
|
||||||
} else if (raw < MIN_WIDTH - SNAP_THRESHOLD) {
|
} else if (raw < MIN_WIDTH - SNAP_THRESHOLD) {
|
||||||
nextSnap = "closed";
|
nextSnap = "closed";
|
||||||
} else {
|
} else {
|
||||||
nextSnap = "normal";
|
nextSnap = "normal";
|
||||||
setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw)));
|
setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw)));
|
||||||
}
|
}
|
||||||
setDragSnap(nextSnap);
|
setDragSnap(nextSnap);
|
||||||
|
|
||||||
// Notify parent of the live visual expanded state so
|
// Notify parent of the live visual expanded state so
|
||||||
// sibling content reacts during the drag.
|
// sibling content reacts during the drag.
|
||||||
const nextVisualExpanded =
|
const nextVisualExpanded =
|
||||||
nextSnap === "expanded" ||
|
nextSnap === "expanded" ||
|
||||||
(nextSnap !== "normal" && nextSnap !== "closed" && isExpanded);
|
(nextSnap !== "normal" && nextSnap !== "closed" && isExpanded);
|
||||||
onVisualExpandedChange?.(nextVisualExpanded);
|
onVisualExpandedChange?.(nextVisualExpanded);
|
||||||
},
|
};
|
||||||
[
|
|
||||||
setWidth,
|
|
||||||
isExpanded,
|
|
||||||
onVisualExpandedChange,
|
|
||||||
isSidebarCollapsed,
|
|
||||||
onToggleSidebarCollapsed,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
const handlePointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
if (!isDragging.current) {
|
||||||
if (!isDragging.current) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const snap = dragSnap;
|
||||||
const snap = dragSnap;
|
isDragging.current = false;
|
||||||
isDragging.current = false;
|
setDragSnap(null);
|
||||||
setDragSnap(null);
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
|
||||||
|
|
||||||
// Clear the drag override so parent falls back to its
|
// Clear the drag override so parent falls back to its
|
||||||
// own committed expanded state.
|
// own committed expanded state.
|
||||||
onVisualExpandedChange?.(null);
|
onVisualExpandedChange?.(null);
|
||||||
|
|
||||||
if (snap) {
|
if (snap) {
|
||||||
onSnapCommit(snap);
|
onSnapCommit(snap);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
[dragSnap, onSnapCommit, onVisualExpandedChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derive visual state: during a drag the snap overrides the
|
// Derive visual state: during a drag the snap overrides the
|
||||||
// committed parent state so the panel reacts live.
|
// committed parent state so the panel reacts live.
|
||||||
@@ -216,22 +196,19 @@ export const RightPanel = ({
|
|||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSnapCommit = useCallback(
|
const handleSnapCommit = (snap: "normal" | "expanded" | "closed") => {
|
||||||
(snap: "normal" | "expanded" | "closed") => {
|
if (snap === "expanded" && !isExpanded) {
|
||||||
if (snap === "expanded" && !isExpanded) {
|
onToggleExpanded();
|
||||||
onToggleExpanded();
|
} else if (snap === "closed") {
|
||||||
} else if (snap === "closed") {
|
setWidth(DEFAULT_WIDTH);
|
||||||
setWidth(DEFAULT_WIDTH);
|
if (isExpanded) {
|
||||||
if (isExpanded) {
|
|
||||||
onToggleExpanded();
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
} else if (snap === "normal" && isExpanded) {
|
|
||||||
onToggleExpanded();
|
onToggleExpanded();
|
||||||
}
|
}
|
||||||
},
|
onClose();
|
||||||
[isExpanded, onToggleExpanded, onClose],
|
} else if (snap === "normal" && isExpanded) {
|
||||||
);
|
onToggleExpanded();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
visualExpanded,
|
visualExpanded,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import dayjs from "dayjs";
|
|||||||
import { useDebouncedValue } from "hooks/debounce";
|
import { useDebouncedValue } from "hooks/debounce";
|
||||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||||
import { ChevronLeftIcon, ShieldIcon } from "lucide-react";
|
import { ChevronLeftIcon, ShieldIcon } from "lucide-react";
|
||||||
import { type FC, type FormEvent, useCallback, useMemo, useState } from "react";
|
import { type FC, type FormEvent, useState } from "react";
|
||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -132,7 +132,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
|||||||
const [searchFilter, setSearchFilter] = useState("");
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
const debouncedSearch = useDebouncedValue(searchFilter, 300);
|
const debouncedSearch = useDebouncedValue(searchFilter, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const dateRange = useMemo(() => {
|
const dateRange = (() => {
|
||||||
const end = now ?? dayjs();
|
const end = now ?? dayjs();
|
||||||
const start = end.subtract(30, "day");
|
const start = end.subtract(30, "day");
|
||||||
return {
|
return {
|
||||||
@@ -140,7 +140,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
|
|||||||
endDate: end.toISOString(),
|
endDate: end.toISOString(),
|
||||||
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
|
rangeLabel: `${start.format("MMM D")} – ${end.format("MMM D, YYYY")}`,
|
||||||
};
|
};
|
||||||
}, [now]);
|
})();
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
@@ -466,42 +466,32 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
|
|||||||
isSavingWorkspaceTTL;
|
isSavingWorkspaceTTL;
|
||||||
const isTTLLoading = workspaceTTLQuery.isLoading;
|
const isTTLLoading = workspaceTTLQuery.isLoading;
|
||||||
|
|
||||||
const handleSaveSystemPrompt = useCallback(
|
const handleSaveSystemPrompt = (event: FormEvent) => {
|
||||||
(event: FormEvent) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
if (!isSystemPromptDirty) return;
|
||||||
if (!isSystemPromptDirty) return;
|
saveSystemPrompt(
|
||||||
saveSystemPrompt(
|
{ system_prompt: systemPromptDraft },
|
||||||
{ system_prompt: systemPromptDraft },
|
{ onSuccess: () => setLocalEdit(null) },
|
||||||
{ onSuccess: () => setLocalEdit(null) },
|
);
|
||||||
);
|
};
|
||||||
},
|
|
||||||
[isSystemPromptDirty, systemPromptDraft, saveSystemPrompt],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSaveUserPrompt = useCallback(
|
const handleSaveUserPrompt = (event: FormEvent) => {
|
||||||
(event: FormEvent) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
if (!isUserPromptDirty) return;
|
||||||
if (!isUserPromptDirty) return;
|
saveUserPrompt(
|
||||||
saveUserPrompt(
|
{ custom_prompt: userPromptDraft },
|
||||||
{ custom_prompt: userPromptDraft },
|
{ onSuccess: () => setLocalUserEdit(null) },
|
||||||
{ onSuccess: () => setLocalUserEdit(null) },
|
);
|
||||||
);
|
};
|
||||||
},
|
|
||||||
[isUserPromptDirty, userPromptDraft, saveUserPrompt],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSaveChatWorkspaceTTL = useCallback(
|
|
||||||
(event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!isTTLDirty) return;
|
|
||||||
saveWorkspaceTTL(
|
|
||||||
{ workspace_ttl_ms: localTTLMs ?? 0 },
|
|
||||||
{ onSuccess: () => setLocalTTLMs(null) },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[isTTLDirty, localTTLMs, saveWorkspaceTTL],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const handleSaveChatWorkspaceTTL = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isTTLDirty) return;
|
||||||
|
saveWorkspaceTTL(
|
||||||
|
{ workspace_ttl_ms: localTTLMs ?? 0 },
|
||||||
|
{ onSuccess: () => setLocalTTLMs(null) },
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 pt-8 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
|
||||||
<div className="mx-auto w-full max-w-3xl">
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import { type FC, useEffect, useId, useRef, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "utils/cn";
|
import { cn } from "utils/cn";
|
||||||
import { DesktopPanel } from "./DesktopPanel";
|
import { DesktopPanel } from "./DesktopPanel";
|
||||||
import type { UseDesktopConnectionResult } from "./useDesktopConnection";
|
import type { UseDesktopConnectionResult } from "./useDesktopConnection";
|
||||||
@@ -66,17 +59,15 @@ function useTabScroll() {
|
|||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
const update = useCallback(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
setCanScrollLeft(el.scrollLeft > 0);
|
|
||||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
setCanScrollLeft(el.scrollLeft > 0);
|
||||||
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||||
|
};
|
||||||
|
|
||||||
// Initial check.
|
// Initial check.
|
||||||
update();
|
update();
|
||||||
|
|
||||||
@@ -91,21 +82,21 @@ function useTabScroll() {
|
|||||||
el.removeEventListener("scroll", update);
|
el.removeEventListener("scroll", update);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, [update]);
|
}, []);
|
||||||
|
|
||||||
const scrollLeft = useCallback(() => {
|
const scrollLeft = () => {
|
||||||
ref.current?.scrollBy({
|
ref.current?.scrollBy({
|
||||||
left: -TAB_SCROLL_AMOUNT,
|
left: -TAB_SCROLL_AMOUNT,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const scrollRight = useCallback(() => {
|
const scrollRight = () => {
|
||||||
ref.current?.scrollBy({
|
ref.current?.scrollBy({
|
||||||
left: TAB_SCROLL_AMOUNT,
|
left: TAB_SCROLL_AMOUNT,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return { ref, canScrollLeft, canScrollRight, scrollLeft, scrollRight };
|
return { ref, canScrollLeft, canScrollRight, scrollLeft, scrollRight };
|
||||||
}
|
}
|
||||||
@@ -146,7 +137,13 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
|||||||
|
|
||||||
const activeTab = tabs.find((t) => t.id === effectiveTabId) ?? null;
|
const activeTab = tabs.find((t) => t.id === effectiveTabId) ?? null;
|
||||||
|
|
||||||
const tabScroll = useTabScroll();
|
const {
|
||||||
|
ref: tabScrollRef,
|
||||||
|
canScrollLeft,
|
||||||
|
canScrollRight,
|
||||||
|
scrollLeft: scrollTabsLeft,
|
||||||
|
scrollRight: scrollTabsRight,
|
||||||
|
} = useTabScroll();
|
||||||
|
|
||||||
if (tabs.length === 0 && !desktopChatId) {
|
if (tabs.length === 0 && !desktopChatId) {
|
||||||
return (
|
return (
|
||||||
@@ -212,10 +209,10 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
{/* Scrollable tab strip with overlay chevrons */}
|
{/* Scrollable tab strip with overlay chevrons */}
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
{tabScroll.canScrollLeft && (
|
{canScrollLeft && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={tabScroll.scrollLeft}
|
onClick={scrollTabsLeft}
|
||||||
aria-label="Scroll tabs left"
|
aria-label="Scroll tabs left"
|
||||||
className="absolute left-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-start border-none p-0 pl-1 text-content-primary [background:linear-gradient(to_right,hsl(var(--surface-primary))_50%,transparent)]"
|
className="absolute left-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-start border-none p-0 pl-1 text-content-primary [background:linear-gradient(to_right,hsl(var(--surface-primary))_50%,transparent)]"
|
||||||
>
|
>
|
||||||
@@ -223,7 +220,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={tabScroll.ref}
|
ref={tabScrollRef}
|
||||||
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -277,10 +274,10 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tabScroll.canScrollRight && (
|
{canScrollRight && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={tabScroll.scrollRight}
|
onClick={scrollTabsRight}
|
||||||
aria-label="Scroll tabs right"
|
aria-label="Scroll tabs right"
|
||||||
className="absolute right-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-end border-none p-0 pr-1 text-content-primary [background:linear-gradient(to_left,hsl(var(--surface-primary))_50%,transparent)]"
|
className="absolute right-0 top-0 z-10 flex h-full w-8 cursor-pointer items-center justify-end border-none p-0 pr-1 text-content-primary [background:linear-gradient(to_left,hsl(var(--surface-primary))_50%,transparent)]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import RFB from "@novnc/novnc/lib/rfb";
|
import RFB from "@novnc/novnc/lib/rfb";
|
||||||
import { watchChatDesktop } from "api/api";
|
import { watchChatDesktop } from "api/api";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface UseDesktopConnectionOptions {
|
interface UseDesktopConnectionOptions {
|
||||||
chatId: string | undefined;
|
chatId: string | undefined;
|
||||||
@@ -47,6 +47,7 @@ export function useDesktopConnection({
|
|||||||
const [status, setStatus] = useState<DesktopConnectionStatus>("idle");
|
const [status, setStatus] = useState<DesktopConnectionStatus>("idle");
|
||||||
const [hasConnected, setHasConnected] = useState(false);
|
const [hasConnected, setHasConnected] = useState(false);
|
||||||
|
|
||||||
|
const [rfbInstance, setRfbInstance] = useState<RFB | null>(null);
|
||||||
const rfbRef = useRef<RFB | null>(null);
|
const rfbRef = useRef<RFB | null>(null);
|
||||||
const offscreenContainerRef = useRef<HTMLElement | null>(null);
|
const offscreenContainerRef = useRef<HTMLElement | null>(null);
|
||||||
const reconnectAttemptRef = useRef(0);
|
const reconnectAttemptRef = useRef(0);
|
||||||
@@ -58,23 +59,27 @@ export function useDesktopConnection({
|
|||||||
// the latest value without stale closures.
|
// the latest value without stale closures.
|
||||||
const hasConnectedRef = useRef(false);
|
const hasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const cleanupRfb = useCallback(() => {
|
const cleanupRfbRef = useRef(() => {});
|
||||||
if (rfbRef.current) {
|
useEffect(() => {
|
||||||
try {
|
cleanupRfbRef.current = () => {
|
||||||
rfbRef.current.disconnect();
|
if (rfbRef.current) {
|
||||||
} catch {
|
try {
|
||||||
// Ignore errors during disconnect.
|
rfbRef.current.disconnect();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during disconnect.
|
||||||
|
}
|
||||||
|
rfbRef.current = null;
|
||||||
|
setRfbInstance(null);
|
||||||
}
|
}
|
||||||
rfbRef.current = null;
|
};
|
||||||
}
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
const doConnect = useCallback(() => {
|
const doConnect = () => {
|
||||||
if (!chatId || disposedRef.current) {
|
if (!chatId || disposedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupRfb();
|
cleanupRfbRef.current();
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
|
|
||||||
// Temporary offscreen container for the RFB canvas; moved into
|
// Temporary offscreen container for the RFB canvas; moved into
|
||||||
@@ -109,6 +114,7 @@ export function useDesktopConnection({
|
|||||||
rfb.addEventListener("disconnect", () => {
|
rfb.addEventListener("disconnect", () => {
|
||||||
if (disposedRef.current) return;
|
if (disposedRef.current) return;
|
||||||
rfbRef.current = null;
|
rfbRef.current = null;
|
||||||
|
setRfbInstance(null);
|
||||||
|
|
||||||
if (!sessionConnected && !hasConnectedRef.current) {
|
if (!sessionConnected && !hasConnectedRef.current) {
|
||||||
// The VNC handshake never completed and the desktop
|
// The VNC handshake never completed and the desktop
|
||||||
@@ -140,41 +146,43 @@ export function useDesktopConnection({
|
|||||||
rfb.addEventListener("securityfailure", () => {
|
rfb.addEventListener("securityfailure", () => {
|
||||||
if (disposedRef.current) return;
|
if (disposedRef.current) return;
|
||||||
rfbRef.current = null;
|
rfbRef.current = null;
|
||||||
|
setRfbInstance(null);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
rfbRef.current = rfb;
|
rfbRef.current = rfb;
|
||||||
|
setRfbInstance(rfb);
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
}, [chatId, cleanupRfb]);
|
};
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = () => {
|
||||||
if (connectRequestedRef.current) {
|
if (connectRequestedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
connectRequestedRef.current = true;
|
connectRequestedRef.current = true;
|
||||||
doConnect();
|
doConnect();
|
||||||
}, [doConnect]);
|
};
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = () => {
|
||||||
if (reconnectTimerRef.current !== null) {
|
if (reconnectTimerRef.current !== null) {
|
||||||
clearTimeout(reconnectTimerRef.current);
|
clearTimeout(reconnectTimerRef.current);
|
||||||
reconnectTimerRef.current = null;
|
reconnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
cleanupRfb();
|
cleanupRfbRef.current();
|
||||||
offscreenContainerRef.current = null;
|
offscreenContainerRef.current = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
connectRequestedRef.current = false;
|
connectRequestedRef.current = false;
|
||||||
reconnectAttemptRef.current = 0;
|
reconnectAttemptRef.current = 0;
|
||||||
}, [cleanupRfb]);
|
};
|
||||||
|
|
||||||
const attach = useCallback((container: HTMLElement) => {
|
const attach = (container: HTMLElement) => {
|
||||||
const screen = offscreenContainerRef.current;
|
const screen = offscreenContainerRef.current;
|
||||||
if (screen && screen.parentElement !== container) {
|
if (screen && screen.parentElement !== container) {
|
||||||
container.appendChild(screen);
|
container.appendChild(screen);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Cleanup on unmount or chatId change.
|
// Cleanup on unmount or chatId change.
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: chatId is an intentional trigger to reset state for a new conversation
|
// biome-ignore lint/correctness/useExhaustiveDependencies: chatId is an intentional trigger to reset state for a new conversation
|
||||||
@@ -187,7 +195,7 @@ export function useDesktopConnection({
|
|||||||
clearTimeout(reconnectTimerRef.current);
|
clearTimeout(reconnectTimerRef.current);
|
||||||
reconnectTimerRef.current = null;
|
reconnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
cleanupRfb();
|
cleanupRfbRef.current();
|
||||||
offscreenContainerRef.current = null;
|
offscreenContainerRef.current = null;
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setHasConnected(false);
|
setHasConnected(false);
|
||||||
@@ -195,7 +203,7 @@ export function useDesktopConnection({
|
|||||||
connectRequestedRef.current = false;
|
connectRequestedRef.current = false;
|
||||||
reconnectAttemptRef.current = 0;
|
reconnectAttemptRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [chatId, cleanupRfb]);
|
}, [chatId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
@@ -203,6 +211,6 @@ export function useDesktopConnection({
|
|||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
attach,
|
attach,
|
||||||
rfb: rfbRef.current,
|
rfb: rfbInstance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { getErrorDetail, getErrorMessage } from "api/errors";
|
|||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -34,7 +33,9 @@ export function useFileAttachments(
|
|||||||
|
|
||||||
// Revoke blob URLs on unmount to prevent memory leaks.
|
// Revoke blob URLs on unmount to prevent memory leaks.
|
||||||
const previewUrlsRef = useRef(previewUrls);
|
const previewUrlsRef = useRef(previewUrls);
|
||||||
previewUrlsRef.current = previewUrls;
|
useEffect(() => {
|
||||||
|
previewUrlsRef.current = previewUrls;
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
for (const [, url] of previewUrlsRef.current) {
|
for (const [, url] of previewUrlsRef.current) {
|
||||||
@@ -43,79 +44,71 @@ export function useFileAttachments(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startUpload = useCallback(
|
const startUpload = (file: File) => {
|
||||||
(file: File) => {
|
if (!organizationId) {
|
||||||
if (!organizationId) {
|
setUploadStates((prev) =>
|
||||||
|
new Map(prev).set(file, {
|
||||||
|
status: "error",
|
||||||
|
error: "Unable to upload: no organization context.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadStates((prev) => new Map(prev).set(file, { status: "uploading" }));
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await API.uploadChatFile(file, organizationId);
|
||||||
|
setUploadStates((prev) =>
|
||||||
|
new Map(prev).set(file, {
|
||||||
|
status: "uploaded",
|
||||||
|
fileId: result.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Pre-warm the browser HTTP cache so the timeline
|
||||||
|
// can render this image instantly after send. The
|
||||||
|
// server responds with Cache-Control: private,
|
||||||
|
// immutable, so the <img src> never hits the
|
||||||
|
// network again.
|
||||||
|
void fetch(`/api/experimental/chats/files/${result.id}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err, "Upload failed");
|
||||||
|
const detail = getErrorDetail(err);
|
||||||
|
const errorMessage = detail ? `${message} ${detail}` : message;
|
||||||
setUploadStates((prev) =>
|
setUploadStates((prev) =>
|
||||||
new Map(prev).set(file, {
|
new Map(prev).set(file, {
|
||||||
status: "error",
|
status: "error",
|
||||||
error: "Unable to upload: no organization context.",
|
error: errorMessage,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setUploadStates((prev) =>
|
})();
|
||||||
new Map(prev).set(file, { status: "uploading" }),
|
};
|
||||||
);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const result = await API.uploadChatFile(file, organizationId);
|
|
||||||
setUploadStates((prev) =>
|
|
||||||
new Map(prev).set(file, {
|
|
||||||
status: "uploaded",
|
|
||||||
fileId: result.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Pre-warm the browser HTTP cache so the timeline
|
|
||||||
// can render this image instantly after send. The
|
|
||||||
// server responds with Cache-Control: private,
|
|
||||||
// immutable, so the <img src> never hits the
|
|
||||||
// network again.
|
|
||||||
void fetch(`/api/experimental/chats/files/${result.id}`);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err, "Upload failed");
|
|
||||||
const detail = getErrorDetail(err);
|
|
||||||
const errorMessage = detail ? `${message} ${detail}` : message;
|
|
||||||
setUploadStates((prev) =>
|
|
||||||
new Map(prev).set(file, {
|
|
||||||
status: "error",
|
|
||||||
error: errorMessage,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[organizationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAttach = useCallback(
|
const handleAttach = (files: File[]) => {
|
||||||
(files: File[]) => {
|
const maxSize = 10 * 1024 * 1024; // 10 MB
|
||||||
const maxSize = 10 * 1024 * 1024; // 10 MB
|
setAttachments((prev) => [...prev, ...files]);
|
||||||
setAttachments((prev) => [...prev, ...files]);
|
setPreviewUrls((prev) => {
|
||||||
setPreviewUrls((prev) => {
|
const next = new Map(prev);
|
||||||
const next = new Map(prev);
|
|
||||||
for (const file of files) {
|
|
||||||
next.set(file, URL.createObjectURL(file));
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > maxSize) {
|
next.set(file, URL.createObjectURL(file));
|
||||||
setUploadStates((prev) =>
|
|
||||||
new Map(prev).set(file, {
|
|
||||||
status: "error" as const,
|
|
||||||
error: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
startUpload(file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
return next;
|
||||||
[startUpload],
|
});
|
||||||
);
|
for (const file of files) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setUploadStates((prev) =>
|
||||||
|
new Map(prev).set(file, {
|
||||||
|
status: "error" as const,
|
||||||
|
error: `File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is 10 MB.`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
startUpload(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveAttachment = useCallback((index: number) => {
|
const handleRemoveAttachment = (index: number) => {
|
||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
const removed = prev[index];
|
const removed = prev[index];
|
||||||
if (removed) {
|
if (removed) {
|
||||||
@@ -134,16 +127,16 @@ export function useFileAttachments(
|
|||||||
}
|
}
|
||||||
return prev.filter((_, i) => i !== index);
|
return prev.filter((_, i) => i !== index);
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const resetAttachments = useCallback(() => {
|
const resetAttachments = () => {
|
||||||
for (const [, url] of previewUrlsRef.current) {
|
for (const [, url] of previewUrlsRef.current) {
|
||||||
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
setPreviewUrls(new Map());
|
setPreviewUrls(new Map());
|
||||||
setUploadStates(new Map());
|
setUploadStates(new Map());
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachments,
|
attachments,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
WorkspaceAgentRepoChanges,
|
WorkspaceAgentRepoChanges,
|
||||||
WorkspaceAgentStatus,
|
WorkspaceAgentStatus,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
// Compile-time guard: ensures the bailout comparison in setRepositories
|
// Compile-time guard: ensures the bailout comparison in setRepositories
|
||||||
// covers every data field. If WorkspaceAgentRepoChanges gains a new
|
// covers every data field. If WorkspaceAgentRepoChanges gains a new
|
||||||
@@ -51,16 +51,16 @@ export function useGitWatcher({
|
|||||||
// Track whether we've been disposed to avoid reconnecting after unmount.
|
// Track whether we've been disposed to avoid reconnecting after unmount.
|
||||||
const disposedRef = useRef(false);
|
const disposedRef = useRef(false);
|
||||||
|
|
||||||
const sendMessage = useCallback((msg: WorkspaceAgentGitClientMessage) => {
|
const sendMessage = (msg: WorkspaceAgentGitClientMessage) => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify(msg));
|
socket.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = () => {
|
||||||
sendMessage({ type: "refresh" });
|
sendMessage({ type: "refresh" });
|
||||||
}, [sendMessage]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || agentStatus !== "connected") {
|
if (!chatId || agentStatus !== "connected") {
|
||||||
@@ -91,41 +91,43 @@ export function useGitWatcher({
|
|||||||
if (socketRef.current !== socket) {
|
if (socketRef.current !== socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let data: WorkspaceAgentGitServerMessage;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(
|
data = JSON.parse(
|
||||||
String(event.data),
|
String(event.data),
|
||||||
) as WorkspaceAgentGitServerMessage;
|
) as WorkspaceAgentGitServerMessage;
|
||||||
|
|
||||||
if (data.type === "changes" && data.repositories) {
|
|
||||||
setRepositories((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const repo of data.repositories!) {
|
|
||||||
if (repo.removed) {
|
|
||||||
if (next.has(repo.repo_root)) {
|
|
||||||
next.delete(repo.repo_root);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const existing = next.get(repo.repo_root);
|
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
existing.branch !== repo.branch ||
|
|
||||||
existing.remote_origin !== repo.remote_origin ||
|
|
||||||
existing.unified_diff !== repo.unified_diff
|
|
||||||
) {
|
|
||||||
next.set(repo.repo_root, repo);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
} else if (data.type === "error") {
|
|
||||||
console.warn("[useGitWatcher] server error:", data.message);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore unparsable messages.
|
// Ignore unparsable messages.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "changes" && data.repositories) {
|
||||||
|
setRepositories((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const repo of data.repositories!) {
|
||||||
|
if (repo.removed) {
|
||||||
|
if (next.has(repo.repo_root)) {
|
||||||
|
next.delete(repo.repo_root);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existing = next.get(repo.repo_root);
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
existing.branch !== repo.branch ||
|
||||||
|
existing.remote_origin !== repo.remote_origin ||
|
||||||
|
existing.unified_diff !== repo.unified_diff
|
||||||
|
) {
|
||||||
|
next.set(repo.repo_root, repo);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
console.warn("[useGitWatcher] server error:", data.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -8,7 +8,17 @@ import checker from "vite-plugin-checker";
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
const plugins: PluginOption[] = [
|
const plugins: PluginOption[] = [
|
||||||
react(),
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
test: /src\/(pages\/AgentsPage|components\/ai-elements)\//,
|
||||||
|
plugins: ["babel-plugin-react-compiler"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
checker({
|
checker({
|
||||||
typescript: true,
|
typescript: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user