feat(site): opt AgentsPage and ai-elements into React Compiler (#23371)

This commit is contained in:
Danielle Maywood
2026-03-20 19:55:35 +00:00
committed by GitHub
parent c60a3568d7
commit 599f21afa3
46 changed files with 2683 additions and 2608 deletions
+2
View File
@@ -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"
], ],
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+194 -105
View File
@@ -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
+97
View File
@@ -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 (
+1 -5
View File
@@ -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
+3 -6
View File
@@ -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
+58 -71
View File
@@ -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">
+240 -247
View File
@@ -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]);
} }
+104 -53
View File
@@ -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}
+11 -14
View File
@@ -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">
+53 -73
View File
@@ -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 (
+153 -121
View File
@@ -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,
}; };
}, },
); );
+29 -54
View File
@@ -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">
+44 -60
View File
@@ -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
+7 -10
View File
@@ -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
+64 -100
View File
@@ -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) => {
+9 -18
View File
@@ -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)
+3 -3
View File
@@ -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),
+13 -31
View File
@@ -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
+74 -97
View File
@@ -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">
+23 -26
View File
@@ -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,
}; };
} }
+61 -68
View File
@@ -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,
+37 -35
View File
@@ -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
View File
@@ -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,
}), }),