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"],
"ignoreBinaries": ["protoc"],
"ignoreDependencies": [
"@babel/plugin-syntax-typescript",
"@types/react-virtualized-auto-sizer",
"babel-plugin-react-compiler",
"jest_workaround",
"ts-proto"
],
+8
View File
@@ -158,6 +158,14 @@ When investigating or editing TypeScript/React code, always use the TypeScript l
## 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,
animation frame), **extract the state and its dependent UI into a child
component** rather than keeping it in a parent that renders a large
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "//",
"files": {
"includes": ["!e2e/**/*Generated.ts"]
"includes": ["!e2e/**/*Generated.ts", "!scripts/*.mjs"]
},
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
}
+5 -1
View File
@@ -14,7 +14,8 @@
"dev": "vite",
"format": "biome format --write .",
"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:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
"lint:knip": "knip",
@@ -132,6 +133,8 @@
"yup": "1.7.1"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/plugin-syntax-typescript": "7.28.6",
"@biomejs/biome": "2.2.4",
"@chromatic-com/storybook": "5.0.1",
"@octokit/types": "12.6.0",
@@ -171,6 +174,7 @@
"@vitejs/plugin-react": "5.1.1",
"@vitest/browser-playwright": "4.0.14",
"autoprefixer": "10.4.22",
"babel-plugin-react-compiler": "1.0.0",
"chromatic": "11.29.0",
"dpdm": "3.14.0",
"express": "4.21.2",
+194 -105
View File
@@ -302,6 +302,12 @@ importers:
specifier: 1.7.1
version: 1.7.1
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':
specifier: 2.2.4
version: 2.2.4
@@ -419,6 +425,9 @@ importers:
autoprefixer:
specifier: 10.4.22
version: 10.4.22(postcss@8.5.6)
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
chromatic:
specifier: 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}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.5':
resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz}
'@babel/compat-data@7.29.0':
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'}
'@babel/core@7.28.5':
resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz}
'@babel/core@7.29.0':
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'}
'@babel/generator@7.28.5':
resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz}
'@babel/generator@7.29.1':
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'}
'@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}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.3':
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz}
'@babel/helper-module-imports@7.28.6':
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'}
peerDependencies:
'@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}
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':
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'}
@@ -590,6 +611,11 @@ packages:
engines: {node: '>=6.0.0'}
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':
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz}
peerDependencies:
@@ -675,8 +701,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.24.7':
resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==, tarball: https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz}
'@babel/plugin-syntax-typescript@7.28.6':
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'}
peerDependencies:
'@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}
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':
resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz}
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':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz}
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':
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}
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:
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:
@@ -6837,19 +6878,19 @@ snapshots:
js-tokens: 4.0.0
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:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.5
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.26.10
'@babel/parser': 7.28.5
'@babel/template': 7.27.2
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
@@ -6867,9 +6908,17 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2':
'@babel/generator@7.29.1':
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
browserslist: 4.28.1
lru-cache: 5.1.1
@@ -6884,17 +6933,26 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/core': 7.28.5
'@babel/helper-module-imports': 7.27.1
'@babel/traverse': 7.29.0
'@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/traverse': 7.28.5
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-plugin-utils@7.28.6': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
@@ -6905,106 +6963,110 @@ snapshots:
'@babel/helpers@7.26.10':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.5
'@babel/template': 7.28.6
'@babel/types': 7.29.0
'@babel/parser@7.28.5':
dependencies:
'@babel/types': 7.28.5
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)':
'@babel/parser@7.29.2':
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/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)':
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@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:
'@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/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:
'@babel/core': 7.28.5
'@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/core': 7.29.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.26.10':
@@ -7017,6 +7079,12 @@ snapshots:
'@babel/parser': 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':
dependencies:
'@babel/code-frame': 7.27.1
@@ -7029,11 +7097,28 @@ snapshots:
transitivePeerDependencies:
- 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':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@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': {}
'@biomejs/biome@2.2.4':
@@ -7624,7 +7709,7 @@ snapshots:
'@jest/transform@29.7.0':
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.25
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))':
dependencies:
'@babel/core': 7.28.5
'@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.28.5)
'@babel/core': 7.29.0
'@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.29.0)
'@rolldown/pluginutils': 1.0.0-beta.47
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
@@ -9648,13 +9733,13 @@ snapshots:
transitivePeerDependencies:
- debug
babel-jest@29.7.0(@babel/core@7.28.5):
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.5
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
graceful-fs: 4.2.11
slash: 3.0.0
@@ -9684,30 +9769,34 @@ snapshots:
cosmiconfig: 7.1.0
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:
'@babel/core': 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/types': 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:
'@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-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: {}
@@ -11142,7 +11231,7 @@ snapshots:
istanbul-lib-instrument@5.2.1:
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@babel/parser': 7.28.5
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
@@ -11152,7 +11241,7 @@ snapshots:
istanbul-lib-instrument@6.0.3:
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@babel/parser': 7.28.5
'@istanbuljs/schema': 0.1.3
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)):
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
'@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
ci-info: 3.9.0
deepmerge: 4.3.1
@@ -11479,15 +11568,15 @@ snapshots:
jest-snapshot@29.7.0:
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@babel/generator': 7.28.5
'@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.28.5)
'@babel/plugin-syntax-typescript': 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.28.6(@babel/core@7.29.0)
'@babel/types': 7.28.5
'@jest/expect-utils': 29.7.0
'@jest/transform': 29.7.0
'@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
expect: 29.7.0
graceful-fs: 4.2.11
@@ -12678,7 +12767,7 @@ snapshots:
react-docgen@8.0.2:
dependencies:
'@babel/core': 7.28.5
'@babel/core': 7.29.0
'@babel/traverse': 7.28.5
'@babel/types': 7.28.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,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { type FC, useMemo } from "react";
import type { FC } from "react";
import { cn } from "utils/cn";
export interface ModelSelectorOption {
@@ -67,11 +67,8 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
dropdownAlign = "start",
contentClassName,
}) => {
const selectedModel = useMemo(
() => options.find((option) => option.id === value),
[options, value],
);
const optionsByProvider = useMemo(() => {
const selectedModel = options.find((option) => option.id === value);
const optionsByProvider = (() => {
const grouped = new Map<string, ModelSelectorOption[]>();
for (const option of options) {
@@ -84,7 +81,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
}
return Array.from(grouped.entries());
}, [options]);
})();
const isDisabled = disabled || options.length === 0;
return (
+1 -5
View File
@@ -4,7 +4,6 @@ import {
type SupportedLanguages,
} from "@pierre/diffs/react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { useMemo } from "react";
import {
type Components,
defaultRehypePlugins,
@@ -238,10 +237,7 @@ export const Response = ({
const fileViewerThemeType: FileViewerThemeType =
theme.palette.mode === "dark" ? "dark" : "light";
const viewerTheme = fileViewerTheme[fileViewerThemeType];
const components = useMemo(
() => createComponents(fileViewerThemeType, viewerTheme),
[fileViewerThemeType, viewerTheme],
);
const components = createComponents(fileViewerThemeType, viewerTheme);
return (
<div
+3 -6
View File
@@ -1,7 +1,7 @@
import type { MotionProps } from "motion/react";
import { MotionConfig, motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
import { cn } from "utils/cn";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
@@ -40,10 +40,7 @@ const ShimmerComponent = ({
Component as keyof JSX.IntrinsicElements,
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
);
const dynamicSpread = (children?.length ?? 0) * spread;
return (
<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 { FileDiff, File as FileViewer } from "@pierre/diffs/react";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import type { ComponentPropsWithRef, FC } from "react";
import { memo } from "react";
import { type ComponentPropsWithRef, type FC, memo } from "react";
import { cn } from "utils/cn";
import { ChatSummarizedTool } from "./ChatSummarizedTool";
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 { type FC, useMemo } from "react";
import type { FC } from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
@@ -14,7 +14,7 @@ interface WebSearchSourcesProps {
*/
const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
// Deduplicate sources by URL, keeping the first occurrence.
const unique = useMemo(() => {
const unique = (() => {
const seen = new Set<string>();
return sources.filter((s) => {
if (!s.url || seen.has(s.url)) {
@@ -23,7 +23,7 @@ const WebSearchSources: FC<WebSearchSourcesProps> = ({ sources }) => {
seen.add(s.url);
return true;
});
}, [sources]);
})();
if (unique.length === 0) {
return null;
+45 -93
View File
@@ -27,10 +27,10 @@ import {
} from "lucide-react";
import type React from "react";
import {
memo,
type FC,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
@@ -152,8 +152,9 @@ const RING_STROKE = 2.5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
({ usage }) => {
const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
usage,
}) => {
const usedTokens = hasFiniteTokenValue(usage?.usedTokens)
? usage.usedTokens
: undefined;
@@ -232,16 +233,14 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
</TooltipContent>
</Tooltip>
);
},
);
ContextUsageIndicator.displayName = "ContextUsageIndicator";
};
/** Renders an image thumbnail from a pre-created preview URL. */
export const ImageThumbnail = memo<{
export const ImageThumbnail: FC<{
previewUrl: string;
name: string;
className?: string;
}>(({ previewUrl, name, className }) => (
}> = ({ previewUrl, name, className }) => (
<img
src={previewUrl}
alt={name}
@@ -250,17 +249,16 @@ export const ImageThumbnail = memo<{
className,
)}
/>
));
ImageThumbnail.displayName = "ImageThumbnail";
);
/** Renders a horizontal strip of attachment thumbnails above the input. */
export const AttachmentPreview = memo<{
export const AttachmentPreview: FC<{
attachments: readonly File[];
onRemove: (index: number) => void;
uploadStates?: Map<File, UploadState>;
previewUrls?: Map<File, string>;
onPreview?: (url: string) => void;
}>(({ attachments, onRemove, uploadStates, previewUrls, onPreview }) => {
}> = ({ attachments, onRemove, uploadStates, previewUrls, onPreview }) => {
if (attachments.length === 0) return null;
return (
@@ -324,11 +322,9 @@ export const AttachmentPreview = memo<{
})}
</div>
);
});
AttachmentPreview.displayName = "AttachmentPreview";
};
export const AgentChatInput = memo<AgentChatInputProps>(
({
export const AgentChatInput: FC<AgentChatInputProps> = ({
onSend,
placeholder = "Type a message...",
isDisabled,
@@ -383,62 +379,41 @@ export const AgentChatInput = memo<AgentChatInputProps>(
}
}, [speech.transcript, speech.isRecording, preRecordingValue]);
// Merge the external inputRef with our internal ref so both
// point to the same ChatMessageInputRef instance.
const setRef = useCallback(
(instance: ChatMessageInputRef | null) => {
(
internalRef as React.MutableRefObject<ChatMessageInputRef | null>
).current = instance;
if (typeof inputRef === "function") {
inputRef(instance);
} else if (inputRef && typeof inputRef === "object") {
(
inputRef as React.MutableRefObject<ChatMessageInputRef | null>
).current = instance;
}
},
[inputRef],
);
// Forward the internal ref to the parent-supplied inputRef
// so both point to the same ChatMessageInputRef instance.
useImperativeHandle(inputRef, () => internalRef.current!, []);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && onAttach) {
onAttach(Array.from(e.target.files));
}
// Reset so the same file can be selected again.
e.target.value = "";
},
[onAttach],
);
};
const handleFilePaste = useCallback(
(file: File) => {
const handleFilePaste = (file: File) => {
onAttach?.([file]);
},
[onAttach],
);
};
// Drag-and-drop support for image files.
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
};
const handleDragLeave = useCallback((e: React.DragEvent) => {
const handleDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
}, []);
};
const handleDrop = useCallback(
(e: React.DragEvent) => {
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (!onAttach || !e.dataTransfer.files.length) return;
@@ -448,9 +423,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
if (images.length > 0) {
onAttach(images);
}
},
[onAttach],
);
};
// Track whether the editor has content so we can gate the
// send button without a controlled value prop.
@@ -458,29 +431,22 @@ export const AgentChatInput = memo<AgentChatInputProps>(
Boolean(initialValue?.trim()),
);
const handleContentChange = useCallback(
(content: string, hasRefs: boolean) => {
const handleContentChange = (content: string, hasRefs: boolean) => {
setHasContent(Boolean(content.trim()));
setHasFileReferences(hasRefs);
onContentChange?.(content);
},
[onContentChange],
);
};
// Re-focus the editor after a send completes (isLoading goes
// from true → false) so the user can immediately type again.
// Uses the "store previous value in state" pattern recommended
// by React for responding to prop changes during render.
const [prevIsLoading, setPrevIsLoading] = useState(isLoading);
if (prevIsLoading !== isLoading) {
setPrevIsLoading(isLoading);
if (prevIsLoading && !isLoading) {
if (!isMobileViewport()) {
const prevIsLoadingRef = useRef(isLoading);
useEffect(() => {
const wasLoading = prevIsLoadingRef.current;
prevIsLoadingRef.current = isLoading;
if (wasLoading && !isLoading && !isMobileViewport()) {
internalRef.current?.focus();
}
}
}
}, [isLoading]);
const isUploading = attachments.some(
(f) => uploadStates?.get(f)?.status === "uploading",
);
@@ -493,7 +459,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
hasModelOptions &&
(hasContent || hasUploadedAttachments || hasFileReferences) &&
!isUploading;
const handleSubmit = useCallback(() => {
const handleSubmit = () => {
const text = internalRef.current?.getValue()?.trim() ?? "";
// If the input is empty and there are queued messages,
@@ -526,27 +492,17 @@ export const AgentChatInput = memo<AgentChatInputProps>(
if (!isMobileViewport()) {
internalRef.current?.focus();
}
}, [
isDisabled,
isLoading,
isUploading,
hasModelOptions,
hasUploadedAttachments,
hasFileReferences,
onSend,
queuedMessages,
onPromoteQueuedMessage,
]);
const handleStartRecording = useCallback(() => {
};
const handleStartRecording = () => {
setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? "");
speech.start();
}, [speech]);
};
const handleAcceptRecording = useCallback(() => {
const handleAcceptRecording = () => {
speech.stop();
}, [speech]);
};
const handleCancelRecording = useCallback(() => {
const handleCancelRecording = () => {
const original = preRecordingValue;
speech.cancel();
const editor = internalRef.current;
@@ -557,7 +513,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
}
}
setPreRecordingValue("");
}, [speech, preRecordingValue]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@@ -667,7 +623,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
/>
)}
<ChatMessageInput
ref={setRef}
ref={internalRef}
onFilePaste={onAttach ? handleFilePaste : undefined}
aria-label="Chat message"
className="min-h-[60px] sm:min-h-24 w-full resize-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
@@ -787,9 +743,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
<ArrowUpIcon />
)}
<span className="sr-only">
{speech.isRecording
? "Accept voice input"
: sendButtonLabel}
{speech.isRecording ? "Accept voice input" : sendButtonLabel}
</span>
</Button>
)}
@@ -820,6 +774,4 @@ export const AgentChatInput = memo<AgentChatInputProps>(
)}
</>
);
},
);
AgentChatInput.displayName = "AgentChatInput";
};
+26 -39
View File
@@ -21,14 +21,7 @@ import {
} from "components/Popover/Popover";
import { Check, MonitorIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useEffect, useRef, useState } from "react";
import { useQuery } from "react-query";
import { toast } from "sonner";
import { AgentChatInput } from "./AgentChatInput";
@@ -76,7 +69,7 @@ export function useEmptyStateDraft() {
const inputValueRef = useRef(initialInputValue);
const sentRef = useRef(false);
const handleContentChange = useCallback((content: string) => {
const handleContentChange = (content: string) => {
inputValueRef.current = content;
if (typeof window !== "undefined" && !sentRef.current) {
if (content) {
@@ -85,20 +78,20 @@ export function useEmptyStateDraft() {
localStorage.removeItem(emptyInputStorageKey);
}
}
}, []);
};
const submitDraft = useCallback(() => {
const submitDraft = () => {
// Mark as sent so that editor change events firing during
// the async gap cannot re-persist the draft.
sentRef.current = true;
localStorage.removeItem(emptyInputStorageKey);
}, []);
};
const resetDraft = useCallback(() => {
const resetDraft = () => {
sentRef.current = false;
}, []);
};
const getCurrentContent = useCallback(() => inputValueRef.current, []);
const getCurrentContent = () => inputValueRef.current;
return {
initialInputValue,
@@ -143,7 +136,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
});
const modelIDByConfigID = useMemo(() => {
const modelIDByConfigID = (() => {
const optionIDByRef = new Map<string, string>();
for (const option of modelOptions) {
const provider = option.provider.trim().toLowerCase();
@@ -170,20 +163,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
byConfigID.set(config.id, modelID);
}
return byConfigID;
}, [modelConfigs, modelOptions]);
const lastUsedModelID = useMemo(() => {
if (!initialLastModelConfigID) {
return "";
}
return modelIDByConfigID.get(initialLastModelConfigID) ?? "";
}, [initialLastModelConfigID, modelIDByConfigID]);
const defaultModelID = useMemo(() => {
})();
const lastUsedModelID = initialLastModelConfigID
? (modelIDByConfigID.get(initialLastModelConfigID) ?? "")
: "";
const defaultModelID = (() => {
const defaultModelConfig = modelConfigs.find((config) => config.is_default);
if (!defaultModelConfig) {
return "";
}
return modelIDByConfigID.get(defaultModelConfig.id) ?? "";
}, [modelConfigs, modelIDByConfigID]);
})();
const preferredModelID =
lastUsedModelID || defaultModelID || (modelOptions[0]?.id ?? "");
const [userSelectedModel, setUserSelectedModel] = useState("");
@@ -249,9 +239,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// that the onSend callback always sees the latest values without
// the shared input component re-rendering on every change.
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
useEffect(() => {
selectedWorkspaceIdRef.current = selectedWorkspaceId;
selectedModelRef.current = selectedModel;
});
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
@@ -267,13 +259,12 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
};
const handleModelChange = useCallback((value: string) => {
const handleModelChange = (value: string) => {
setHasUserSelectedModel(true);
setUserSelectedModel(value);
}, []);
};
const handleSend = useCallback(
async (message: string, fileIDs?: string[]) => {
const handleSend = async (message: string, fileIDs?: string[]) => {
submitDraft();
await onCreateChat({
message,
@@ -285,9 +276,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// and retry after a failed send attempt.
resetDraft();
});
},
[submitDraft, resetDraft, onCreateChat],
);
};
const selectedWorkspace = selectedWorkspaceId
? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId)
@@ -305,8 +294,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
resetAttachments,
} = useFileAttachments(organizations[0]?.id);
const handleSendWithAttachments = useCallback(
async (message: string) => {
const handleSendWithAttachments = async (message: string) => {
const fileIds: string[] = [];
let skippedErrors = 0;
for (const file of attachments) {
@@ -324,15 +312,14 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
);
}
const fileArg = fileIds.length > 0 ? fileIds : undefined;
try {
await handleSend(message, fileIds.length > 0 ? fileIds : undefined);
await handleSend(message, fileArg);
resetAttachments();
} catch {
// Attachments preserved for retry on failure.
}
},
[attachments, handleSend, resetAttachments, uploadStates],
);
};
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">
+103 -110
View File
@@ -1,4 +1,5 @@
import { API, watchWorkspace } from "api/api";
import { isApiError } from "api/errors";
import {
chat,
@@ -19,14 +20,7 @@ import {
getVSCodeHref,
openAppInNewWindow,
} from "modules/apps/apps";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useMutation,
@@ -88,13 +82,21 @@ export function useConversationEditingState(deps: {
if (typeof window === "undefined" || !draftStorageKey) {
return "";
}
const saved = localStorage.getItem(draftStorageKey);
if (saved) {
inputValueRef.current = saved;
}
return saved ?? "";
return localStorage.getItem(draftStorageKey) ?? "";
});
// 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 --
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
@@ -104,8 +106,7 @@ export function useConversationEditingState(deps: {
readonly ChatMessagePart[]
>([]);
const handleEditUserMessage = useCallback(
(
const handleEditUserMessage = (
messageId: number,
text: string,
fileBlocks?: readonly ChatMessagePart[],
@@ -117,11 +118,9 @@ export function useConversationEditingState(deps: {
setEditorInitialValue(text);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks ?? []);
},
[editingMessageId, inputValueRef],
);
};
const handleCancelHistoryEdit = useCallback(() => {
const handleCancelHistoryEdit = () => {
setEditorInitialValue(draftBeforeHistoryEdit ?? "");
inputValueRef.current = draftBeforeHistoryEdit ?? "";
setEditingMessageId(null);
@@ -131,7 +130,7 @@ export function useConversationEditingState(deps: {
if (draftBeforeHistoryEdit) {
chatInputRef.current?.insertText(draftBeforeHistoryEdit);
}
}, [draftBeforeHistoryEdit, inputValueRef, chatInputRef]);
};
// -- Queue editing state --
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
@@ -141,8 +140,11 @@ export function useConversationEditingState(deps: {
string | null
>(null);
const handleStartQueueEdit = useCallback(
(id: number, text: string, fileBlocks: readonly ChatMessagePart[]) => {
const handleStartQueueEdit = (
id: number,
text: string,
fileBlocks: readonly ChatMessagePart[],
) => {
setDraftBeforeQueueEdit((prev) =>
editingQueuedMessageID === null ? inputValueRef.current : prev,
);
@@ -150,22 +152,19 @@ export function useConversationEditingState(deps: {
setEditorInitialValue(text);
inputValueRef.current = text;
setEditingFileBlocks(fileBlocks);
},
[editingQueuedMessageID, inputValueRef],
);
};
const handleCancelQueueEdit = useCallback(() => {
const handleCancelQueueEdit = () => {
setEditorInitialValue(draftBeforeQueueEdit ?? "");
inputValueRef.current = draftBeforeQueueEdit ?? "";
setEditingQueuedMessageID(null);
setDraftBeforeQueueEdit(null);
setEditingFileBlocks([]);
}, [draftBeforeQueueEdit, inputValueRef]);
};
// Wraps the parent onSend to clear local input/editing state
// and handle queue-edit deletion.
const handleSendFromInput = useCallback(
async (message: string, fileIds?: string[]) => {
const handleSendFromInput = async (message: string, fileIds?: string[]) => {
const editedMessageID =
editingMessageId !== null ? editingMessageId : undefined;
const queueEditID = editingQueuedMessageID;
@@ -191,20 +190,9 @@ export function useConversationEditingState(deps: {
setEditingFileBlocks([]);
void onDeleteQueuedMessage(queueEditID);
}
},
[
chatInputRef,
editingMessageId,
editingQueuedMessageID,
onDeleteQueuedMessage,
onSend,
draftStorageKey,
inputValueRef,
],
);
};
const handleContentChange = useCallback(
(content: string) => {
const handleContentChange = (content: string) => {
inputValueRef.current = content;
if (typeof window !== "undefined" && draftStorageKey) {
if (content) {
@@ -213,9 +201,7 @@ export function useConversationEditingState(deps: {
localStorage.removeItem(draftStorageKey);
}
}
},
[draftStorageKey, inputValueRef],
);
};
return {
inputValueRef,
@@ -263,7 +249,11 @@ const AgentDetail: FC = () => {
} = outletContext;
const scrollContainerRef = useRef<HTMLDivElement | 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
// skeleton and the loaded view share the same layout, preventing
@@ -272,8 +262,9 @@ const AgentDetail: FC = () => {
if (typeof window === "undefined") return false;
return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true";
});
const handleSetShowSidebarPanel = useCallback(
(next: boolean | ((prev: boolean) => boolean)) => {
const handleSetShowSidebarPanel = (
next: boolean | ((prev: boolean) => boolean),
) => {
setShowSidebarPanel((prev) => {
const value = typeof next === "function" ? next(prev) : next;
if (typeof window !== "undefined") {
@@ -281,9 +272,7 @@ const AgentDetail: FC = () => {
}
return value;
});
},
[],
);
};
const chatQuery = useQuery({
...chat(agentId ?? ""),
@@ -316,9 +305,25 @@ const AgentDetail: FC = () => {
return;
}
if (event.parsedMessage.type === "data") {
queryClient.setQueryData(
const next = event.parsedMessage.data as TypesGen.Workspace;
queryClient.setQueryData<TypesGen.Workspace | undefined>(
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,10 +334,17 @@ const AgentDetail: FC = () => {
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
const { proxy } = useProxy();
const urlTransform = useCallback<UrlTransform>(
(url) => {
const host = proxy.preferredWildcardHostname;
if (!host || !workspaceAgent || !workspace) {
// Extract the primitive fields used by the transform so the
// compiler can see the real dependencies and avoid invalidating
// the closure when the workspace object reference changes but
// 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 {
@@ -341,11 +353,11 @@ const AgentDetail: FC = () => {
return url;
}
return portForwardURL(
host,
proxyHost,
Number.parseInt(parsed.port, 10),
workspaceAgent.name,
workspace.name,
workspace.owner_name,
agentName,
wsName,
wsOwner,
"http",
parsed.pathname,
parsed.search,
@@ -353,15 +365,13 @@ const AgentDetail: FC = () => {
} catch {
return url;
}
},
[proxy.preferredWildcardHostname, workspaceAgent, workspace],
);
};
const chatRecord = chatQuery.data;
// Flatten paginated messages into chronological order.
// Pages arrive newest-first per page, and pages[0] is the
// most recent page.
const chatMessagesList = useMemo(() => {
const chatMessagesList = (() => {
const pages = chatMessagesQuery.data?.pages;
if (!pages || pages.length === 0) return undefined;
// Collect all messages, then sort chronologically by ID.
@@ -369,7 +379,7 @@ const AgentDetail: FC = () => {
// Sort ascending by ID for chronological order.
all.sort((a, b) => a.id - b.id);
return all;
}, [chatMessagesQuery.data]);
})();
// Queued messages are only in the first page (most recent).
const chatQueuedMessages = chatMessagesQuery.data?.pages[0]?.queued_messages;
@@ -377,14 +387,13 @@ const AgentDetail: FC = () => {
// Build a synthetic ChatMessagesResponse from the flattened
// data for backward compat with useChatStore.
const chatMessagesData: TypesGen.ChatMessagesResponse | undefined =
useMemo(() => {
if (!chatMessagesList) return undefined;
return {
chatMessagesList
? {
messages: chatMessagesList,
queued_messages: chatQueuedMessages ?? [],
has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false,
};
}, [chatMessagesList, chatQueuedMessages, chatMessagesQuery.data]);
}
: undefined;
const isArchived = chatRecord?.archived ?? false;
const chatLastModelConfigID = chatRecord?.last_model_config_id;
@@ -427,7 +436,7 @@ const AgentDetail: FC = () => {
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 current = inputValueRef.current;
if (current.includes(commitPrompt)) {
@@ -436,7 +445,7 @@ const AgentDetail: FC = () => {
const prefix = current.trim() ? "\n\n" : "";
chatInputRef.current?.insertText(prefix + commitPrompt);
chatInputRef.current?.focus();
}, []);
};
// Prefer the explicit PR number from the API, and only fall back to URL
// 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
// explicit choice against the current model options, falling
// back to the chat's last model or the first available option.
const effectiveSelectedModel = useMemo(() => {
const effectiveSelectedModel = (() => {
if (
selectedModel &&
modelOptions.some((model) => model.id === selectedModel)
@@ -462,15 +471,12 @@ const AgentDetail: FC = () => {
}
}
return modelOptions[0]?.id ?? "";
}, [selectedModel, chatLastModelConfigID, modelIDByConfigID, modelOptions]);
})();
const compressionThreshold = useMemo(() => {
if (!chatLastModelConfigID) {
return undefined;
}
const config = modelConfigs.find((c) => c.id === chatLastModelConfigID);
return config?.compression_threshold;
}, [chatLastModelConfigID, modelConfigs]);
const compressionThreshold = chatLastModelConfigID
? modelConfigs.find((c) => c.id === chatLastModelConfigID)
?.compression_threshold
: undefined;
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
@@ -495,8 +501,7 @@ const AgentDetail: FC = () => {
interruptMutation.isPending;
const isInputDisabled = !hasModelOptions || isArchived;
const handleUsageLimitError = useCallback(
(error: unknown): void => {
const handleUsageLimitError = (error: unknown): void => {
if (!agentId) {
return;
}
@@ -515,9 +520,7 @@ const AgentDetail: FC = () => {
message: error.message || "An unexpected error occurred.",
});
}
},
[agentId, setChatErrorReason],
);
};
const handleSend = async (
message: string,
@@ -584,11 +587,11 @@ const AgentDetail: FC = () => {
messageId: editedMessageID,
req: request,
});
setPendingEditMessageId(null);
} catch (error) {
setPendingEditMessageId(null);
handleUsageLimitError(error);
throw error;
} finally {
setPendingEditMessageId(null);
}
return;
}
@@ -610,8 +613,13 @@ const AgentDetail: FC = () => {
// timeline when the server confirms via the POST response or
// via the SSE stream.
store.clearStreamState();
let response: Awaited<ReturnType<typeof sendMutation.mutateAsync>>;
try {
const response = await sendMutation.mutateAsync(request);
response = await sendMutation.mutateAsync(request);
} catch (error) {
handleUsageLimitError(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.
@@ -628,10 +636,6 @@ const AgentDetail: FC = () => {
localStorage.removeItem(lastModelConfigIDStorageKey);
}
}
} catch (error) {
handleUsageLimitError(error);
throw error;
}
};
const handleInterrupt = () => {
@@ -641,8 +645,7 @@ const AgentDetail: FC = () => {
void interruptMutation.mutateAsync();
};
const handleDeleteQueuedMessage = useCallback(
async (id: number) => {
const handleDeleteQueuedMessage = async (id: number) => {
const previousQueuedMessages = store.getSnapshot().queuedMessages;
store.setQueuedMessages(
previousQueuedMessages.filter((message) => message.id !== id),
@@ -653,12 +656,9 @@ const AgentDetail: FC = () => {
store.setQueuedMessages(previousQueuedMessages);
throw error;
}
},
[deleteQueuedMutation, store],
);
};
const handlePromoteQueuedMessage = useCallback(
async (id: number) => {
const handlePromoteQueuedMessage = async (id: number) => {
const previousSnapshot = store.getSnapshot();
const previousQueuedMessages = previousSnapshot.queuedMessages;
const previousChatStatus = previousSnapshot.chatStatus;
@@ -683,15 +683,7 @@ const AgentDetail: FC = () => {
handleUsageLimitError(error);
throw error;
}
},
[
agentId,
clearChatErrorReason,
handleUsageLimitError,
promoteQueuedMutation,
store,
],
);
};
const editing = useConversationEditingState({
chatID: agentId,
@@ -821,6 +813,7 @@ const AgentDetail: FC = () => {
/>
);
}
return (
<AgentDetailView
agentId={agentId}
@@ -2,13 +2,7 @@ import { watchChat } from "api/api";
import { chatMessagesKey, updateInfiniteChatsCache } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import {
startTransition,
useCallback,
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { type InfiniteData, useQueryClient } from "react-query";
import type { OneWayMessageEvent } from "utils/OneWayWebSocket";
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
@@ -135,6 +129,7 @@ type ChatStoreState = {
type ChatStore = {
getSnapshot: () => ChatStoreState;
subscribe: (listener: () => void) => () => void;
batch: (fn: () => void) => void;
replaceMessages: (
messages: readonly TypesGen.ChatMessage[] | undefined,
) => void;
@@ -142,6 +137,7 @@ type ChatStore = {
isDuplicate: boolean;
changed: boolean;
};
upsertDurableMessages: (messages: readonly TypesGen.ChatMessage[]) => void;
applyMessagePart: (part: TypesGen.ChatMessagePart) => void;
applyMessageParts: (parts: readonly TypesGen.ChatMessagePart[]) => void;
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 = (
updater: (current: ChatStoreState) => ChatStoreState,
): void => {
@@ -189,7 +204,11 @@ export const createChatStore = (): ChatStore => {
return;
}
state = next;
if (batchDepth > 0) {
batchDirty = true;
} else {
emit();
}
};
const replaceMessages = (
@@ -265,6 +284,43 @@ export const createChatStore = (): ChatStore => {
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[]) => {
if (parts.length === 0) {
return;
@@ -293,8 +349,10 @@ export const createChatStore = (): ChatStore => {
listeners.delete(listener);
};
},
batch,
replaceMessages,
upsertDurableMessage,
upsertDurableMessages,
applyMessagePart: (part) => applyMessageParts([part]),
applyMessageParts,
setQueuedMessages: (queuedMessages) => {
@@ -436,7 +494,7 @@ export const useChatStore = (
} = options;
const queryClient = useQueryClient();
const storeRef = useRef<ChatStore>(createChatStore());
const [store] = useState(createChatStore);
const streamResetFrameRef = useRef<number | null>(null);
const queuedMessagesHydratedChatIDRef = useRef<string | null>(null);
// Tracks whether the WebSocket has delivered a queue_update for the
@@ -459,18 +517,29 @@ export const useChatStore = (
// server.
const lastSyncedMessagesRef = useRef<readonly TypesGen.ChatMessage[]>([]);
const store = storeRef.current;
// Compute the last REST-fetched message ID so the stream can
// skip messages the client already has. We use a ref so the
// socket effect can read the latest value without including
// chatMessages in its dependency array (which would cause
// unnecessary reconnections).
const lastMessageIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
lastMessageIdRef.current =
chatMessages && chatMessages.length > 0
? 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
// chat. The WebSocket effect gates on this so that
@@ -479,99 +548,27 @@ export const useChatStore = (
// its snapshot, defeating pagination.
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(() => {
// When the active chat changes, clear stale messages immediately
// so the previous chat's messages aren't briefly visible while
// the new chat's query resolves.
store.batch(() => {
// When the active chat changes, clear stale messages
// immediately so the previous chat's messages aren't
// briefly visible while the new chat's query resolves.
if (prevChatIDRef.current !== chatID) {
prevChatIDRef.current = chatID;
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
// WebSocket delivered via upsertDurableMessage that haven't
// Merge REST-fetched messages into the store, preserving
// any messages the WebSocket delivered that haven't
// appeared in a REST page yet.
//
// However, if the fetched set is missing message IDs the store
// already has (e.g. after an edit truncation), a full replace
// is needed because upsert can only add/update, not remove.
// We must only do this when the fetched messages actually
// changed (new elements from a refetch), not when an
// unrelated field like queued_messages caused the query
// data reference to update. Without this guard, a
// queue_update WebSocket event would trigger
// replaceMessages with the stale REST data, wiping any
// message the WebSocket just delivered.
// If the fetched set is missing message IDs the store
// already has (e.g. after an edit truncation), a full
// replace is needed. We must only do this when the
// fetched messages actually changed (new elements from
// a refetch), not when an unrelated field like
// queued_messages caused the query data reference to
// update.
if (chatMessages) {
const prev = lastSyncedMessagesRef.current;
const contentChanged =
@@ -587,11 +584,10 @@ export const useChatStore = (
if (hasStaleEntries) {
store.replaceMessages(chatMessages);
} else {
for (const message of chatMessages) {
store.upsertDurableMessage(message);
}
store.upsertDurableMessages(chatMessages);
}
}
});
}, [chatID, chatMessages, store]);
useEffect(() => {
@@ -627,6 +623,75 @@ export const useChatStore = (
}, [chatMessagesData, chatID, chatQueuedMessages, store]);
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();
store.resetTransientState();
activeChatIDRef.current = chatID ?? null;
@@ -641,6 +706,54 @@ export const useChatStore = (
// outside the utility) can bail out after cleanup.
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 = (
payload: OneWayMessageEvent<TypesGen.ServerSentEvent>,
) => {
@@ -659,34 +772,15 @@ export const useChatStore = (
if (streamEvents.length === 0) {
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 => {
const currentStatus = store.getSnapshot().chatStatus;
return currentStatus !== "pending" && currentStatus !== "waiting";
};
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);
});
};
// Wrap all store mutations in a batch so subscribers
// are notified exactly once at the end, not per event.
store.batch(() => {
for (const streamEvent of streamEvents) {
if (streamEvent.type === "message_part") {
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
@@ -698,7 +792,7 @@ export const useChatStore = (
const part = streamEvent.message_part?.part;
if (part) {
cancelScheduledStreamReset();
pendingMessageParts.push(part);
partsBuf.push(part);
}
continue;
}
@@ -713,11 +807,7 @@ export const useChatStore = (
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
continue;
}
const { changed } = store.upsertDurableMessage(message);
// Keep lastMessageIdRef in sync with
// stream-delivered messages so reconnections use
// the correct after_id and don't re-fetch or
// miss events.
pendingMessages.push(message);
if (
message.id !== undefined &&
(lastMessageIdRef.current === undefined ||
@@ -725,14 +815,9 @@ export const useChatStore = (
) {
lastMessageIdRef.current = message.id;
}
if (changed && message.role === "assistant") {
scheduleStreamReset();
if (message.role === "assistant") {
needsStreamReset = true;
}
// 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;
}
case "queue_update":
@@ -748,12 +833,13 @@ export const useChatStore = (
if (!nextStatus) {
continue;
}
if (streamEvent.chat_id && streamEvent.chat_id !== chatID) {
store.setSubagentStatusOverride(streamEvent.chat_id, nextStatus);
store.setSubagentStatusOverride(
streamEvent.chat_id,
nextStatus,
);
continue;
}
store.setChatStatus(nextStatus);
if (nextStatus === "pending" || nextStatus === "waiting") {
store.clearStreamState();
@@ -763,12 +849,13 @@ export const useChatStore = (
store.clearRetryState();
}
if (nextStatus !== "error") {
clearChatErrorReason(chatID);
clearChatErrorReasonRef.current(chatID);
}
updateSidebarChat((chat) => ({
...chat,
status: nextStatus,
}));
updateSidebarChat((chat) =>
chat.status === nextStatus
? chat
: { ...chat, status: nextStatus },
);
continue;
}
case "error": {
@@ -780,14 +867,13 @@ export const useChatStore = (
store.setChatStatus("error");
store.setStreamError(reason);
store.clearRetryState();
setChatErrorReason(chatID, {
setChatErrorReasonRef.current(chatID, {
kind: "generic",
message: reason,
});
updateSidebarChat((chat) => ({
...chat,
status: "error",
}));
updateSidebarChat((chat) =>
chat.status === "error" ? chat : { ...chat, status: "error" },
);
continue;
}
case "retry": {
@@ -808,9 +894,22 @@ export const useChatStore = (
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({
connect() {
// Use the latest known message ID so the server only
@@ -848,25 +947,17 @@ export const useChatStore = (
disposed = true;
disposeSocket();
cancelScheduledStreamReset();
if (partsFlushTimer !== null) {
clearTimeout(partsFlushTimer);
}
activeChatIDRef.current = null;
};
}, [
cancelScheduledStreamReset,
chatID,
clearChatErrorReason,
initialDataLoaded,
scheduleStreamReset,
setChatErrorReason,
store,
updateChatQueuedMessages,
updateSidebarChat,
]);
}, [chatID, initialDataLoaded, queryClient, store]);
return {
store,
clearStreamError: useCallback(() => {
clearStreamError: () => {
store.clearStreamError();
}, [store]),
},
};
};
@@ -874,9 +965,6 @@ export const useChatSelector = <T>(
store: ChatStore,
selector: (state: ChatStoreState) => T,
): T => {
const getSnapshot = useCallback(
() => selector(store.getSnapshot()),
[selector, store],
);
const getSnapshot = () => selector(store.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;
streamTools: readonly MergedTool[];
subagentTitles?: Map<string, string>;
@@ -501,8 +500,7 @@ export const StreamingOutput = memo<{
showInitialPlaceholder?: boolean;
retryState?: { attempt: number; error: string } | null;
urlTransform?: UrlTransform;
}>(
({
}> = ({
streamState,
streamTools,
subagentTitles,
@@ -570,9 +568,7 @@ export const StreamingOutput = memo<{
</Message>
</ConversationItem>
);
},
);
StreamingOutput.displayName = "StreamingOutput";
};
const StickyUserMessage: FC<{
message: TypesGen.ChatMessage;
@@ -1,13 +1,19 @@
import { chatKey } from "api/queries/chats";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useQueryClient } from "react-query";
import { useChatSelector } from "./ChatContext";
import type { StreamState } from "./types";
type ChatStoreHandle = Parameters<typeof useChatSelector>[0];
const selectStreamState = (state: { streamState: StreamState | null }) =>
state.streamState;
// Only extract the toolResults record from the stream state.
// 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 {
store: ChatStoreHandle;
@@ -28,27 +34,26 @@ export function useWorkspaceCreationWatcher({
chatID,
}: UseWorkspaceCreationWatcherOptions): void {
const queryClient = useQueryClient();
const streamState = useChatSelector(store, selectStreamState);
const toolResults = useChatSelector(store, selectStreamToolResults);
const processedToolCallIdsRef = useRef<Set<string>>(new Set());
// 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();
}
const chatIDRef = useRef(chatID);
// Watch stream tool results for create_workspace completions.
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();
return;
}
let shouldInvalidateChat = false;
for (const toolResult of Object.values(streamState.toolResults)) {
for (const toolResult of Object.values(toolResults)) {
if (processedToolCallIdsRef.current.has(toolResult.id)) {
continue;
}
@@ -67,5 +72,5 @@ export function useWorkspaceCreationWatcher({
queryKey: chatKey(chatID),
});
}
}, [chatID, streamState, queryClient]);
}, [toolResults, queryClient, chatID]);
}
@@ -1,7 +1,7 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, useEffect, useMemo } from "react";
import { type FC, useEffect } from "react";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import {
@@ -29,6 +29,7 @@ import {
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState";
import type { ParsedMessageEntry } from "./AgentDetail/types";
import type { ChatDetailError } from "./usageLimitMessage";
import { useFileAttachments } from "./useFileAttachments";
@@ -52,7 +53,10 @@ interface AgentDetailTimelineProps {
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,
persistedErrorReason,
onOpenAnalytics,
@@ -63,7 +67,6 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
const streamState = useChatSelector(store, selectStreamState);
const chatStatus = useChatSelector(store, selectChatStatus);
const streamError = useChatSelector(store, selectStreamError);
const subagentStatusOverrides = useChatSelector(
@@ -72,25 +75,11 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
);
const retryState = useChatSelector(store, selectRetryState);
const messages = useMemo(
() =>
orderedMessageIDs
const messages = orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
.filter(isChatMessage),
[messagesByID, orderedMessageIDs],
);
const streamTools = useMemo(
() => buildStreamTools(streamState),
[streamState],
);
const parsedMessages = useMemo(
() => parseMessagesWithMergedTools(messages),
[messages],
);
const subagentTitles = useMemo(
() => buildSubagentTitles(parsedMessages),
[parsedMessages],
);
.filter(isChatMessage);
const parsedMessages = parseMessagesWithMergedTools(messages);
const subagentTitles = buildSubagentTitles(parsedMessages);
const detailError: ChatDetailError | undefined =
(persistedErrorReason?.kind === "usage-limit" || chatStatus === "error"
? persistedErrorReason
@@ -101,6 +90,66 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
const latestMessage = messages[messages.length - 1];
const latestMessageNeedsAssistantResponse =
!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 =
!streamState &&
(chatStatus === "running" || chatStatus === "pending") &&
@@ -109,7 +158,7 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
return (
<ConversationTimeline
isEmpty={messages.length === 0}
isEmpty={isEmpty}
parsedMessages={parsedMessages}
hasStreamOutput={hasStreamOutput}
streamState={streamState}
@@ -128,6 +177,10 @@ export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
);
};
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = (props) => {
return <MessageListProvider {...props} />;
};
interface AgentDetailInputProps {
store: ChatStoreHandle;
compressionThreshold: number | undefined;
@@ -197,22 +250,18 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
const chatStatus = useChatSelector(store, selectChatStatus);
const queuedMessages = useChatSelector(store, selectQueuedMessages);
const messages = useMemo(
() =>
orderedMessageIDs
const messages = orderedMessageIDs
.map((messageID) => messagesByID.get(messageID))
.filter(isChatMessage),
[messagesByID, orderedMessageIDs],
);
.filter(isChatMessage);
const { organizations } = useDashboard();
const organizationId = organizations[0]?.id;
const latestContextUsage = useMemo(() => {
const latestContextUsage = (() => {
const usage = getLatestContextUsage(messages);
if (!usage) {
return usage;
}
return { ...usage, compressionThreshold };
}, [messages, compressionThreshold]);
})();
const {
attachments,
uploadStates,
@@ -273,7 +322,6 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
<AgentChatInput
onSend={(message) => {
void (async () => {
try {
// Collect file IDs from already-uploaded attachments.
// Skip files in error state (e.g. too large).
const fileIds: string[] = [];
@@ -293,11 +341,14 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
`${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`,
);
}
await onSend(message, fileIds.length > 0 ? fileIds : undefined);
resetAttachments();
const fileArg = fileIds.length > 0 ? fileIds : undefined;
try {
await onSend(message, fileArg);
} catch {
// Attachments preserved for retry on failure.
return;
}
resetAttachments();
})();
}}
attachments={attachments}
+10 -13
View File
@@ -3,14 +3,7 @@ import type { ChatDiffStatus, ChatMessagePart } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import { ArchiveIcon, ArrowDownIcon } from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
import type { UrlTransform } from "streamdown";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
@@ -529,9 +522,11 @@ const ScrollAnchoredContainer: FC<{
const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const isFetchingRef = useRef(isFetchingMoreMessages);
isFetchingRef.current = isFetchingMoreMessages;
const onFetchRef = useRef(onFetchMoreMessages);
useEffect(() => {
isFetchingRef.current = isFetchingMoreMessages;
onFetchRef.current = onFetchMoreMessages;
}, [isFetchingMoreMessages, onFetchMoreMessages]);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
// Sentinel observer — triggers loading older messages.
@@ -593,8 +588,10 @@ const ScrollAnchoredContainer: FC<{
const handleScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
const isAtBottom = Math.abs(container.scrollTop) < SCROLL_THRESHOLD;
setShowScrollToBottom(!isAtBottom);
const shouldShow = Math.abs(container.scrollTop) >= SCROLL_THRESHOLD;
setShowScrollToBottom((prev) =>
prev === shouldShow ? prev : shouldShow,
);
rafId = null;
});
};
@@ -608,7 +605,7 @@ const ScrollAnchoredContainer: FC<{
};
}, [scrollContainerRef]);
const handleScrollToBottom = useCallback(() => {
const handleScrollToBottom = () => {
const container = scrollContainerRef.current;
if (!container) return;
container.scrollTo({ top: 0, behavior: "smooth" });
@@ -617,7 +614,7 @@ const ScrollAnchoredContainer: FC<{
// before it reaches the bottom, the scroll handler will
// re-show the button.
setShowScrollToBottom(false);
}, [scrollContainerRef]);
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
+19 -39
View File
@@ -5,14 +5,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
import { ProxyProvider } from "contexts/ProxyContext";
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
import { permissionChecks } from "modules/permissions";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Outlet, useParams } from "react-router";
import type { AgentsOutletContext } from "./AgentsPage";
@@ -61,7 +54,9 @@ const AgentEmbedPage: FC = () => {
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
);
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
useEffect(() => {
latestEmbedSessionMutationRef.current = embedSessionMutation;
});
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
const [chatErrorReasons, setChatErrorReasons] = useState<
@@ -69,8 +64,7 @@ const AgentEmbedPage: FC = () => {
>({});
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const setChatErrorReason = useCallback(
(chatId: string, reason: ChatDetailError) => {
const setChatErrorReason = (chatId: string, reason: ChatDetailError) => {
const trimmedMessage = reason.message.trim();
if (!chatId || !trimmedMessage) {
return;
@@ -89,11 +83,9 @@ const AgentEmbedPage: FC = () => {
[chatId]: { kind: reason.kind, message: trimmedMessage },
};
});
},
[],
);
};
const clearChatErrorReason = useCallback((chatId: string) => {
const clearChatErrorReason = (chatId: string) => {
if (!chatId) {
return;
}
@@ -105,23 +97,22 @@ const AgentEmbedPage: FC = () => {
delete next[chatId];
return next;
});
}, []);
};
const requestArchiveAgent = useCallback((_chatId: string) => {}, []);
const requestArchiveAgent = (_chatId: string) => {};
const requestUnarchiveAgent = useCallback((_chatId: string) => {}, []);
const requestUnarchiveAgent = (_chatId: string) => {};
const requestArchiveAndDeleteWorkspace = useCallback(
(_chatId: string, _workspaceId: string) => {},
[],
);
const requestArchiveAndDeleteWorkspace = (
_chatId: string,
_workspaceId: string,
) => {};
const onToggleSidebarCollapsed = useCallback(() => {
const onToggleSidebarCollapsed = () => {
setIsSidebarCollapsed((current) => !current);
}, []);
};
const outletContext = useMemo<AgentsOutletContext>(
() => ({
const outletContext: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
@@ -138,18 +129,7 @@ const AgentEmbedPage: FC = () => {
isModelCatalogLoading: false,
modelCatalogError: null,
desktopEnabled: false,
}),
[
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
isSidebarCollapsed,
onToggleSidebarCollapsed,
],
);
};
// When signed out and not already bootstrapping, listen for the
// postMessage from the parent frame carrying the session token.
@@ -196,10 +176,10 @@ const AgentEmbedPage: FC = () => {
};
}, [agentId, isAwaitingBootstrapMessage]);
const handleBootstrapRetry = useCallback(() => {
const handleBootstrapRetry = () => {
inFlightBootstrapRef.current = null;
embedSessionMutation.reset();
}, [embedSessionMutation]);
};
if (auth.isSignedIn) {
return (
+108 -76
View File
@@ -20,14 +20,7 @@ import type * as TypesGen from "api/typesGenerated";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "hooks";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, useEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useMutation,
@@ -57,6 +50,29 @@ const nilUUID = "00000000-0000-0000-0000-000000000000";
const EMPTY_MODEL_CONFIGS: TypesGen.ChatModelConfig[] = [];
// 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(
data: unknown,
): data is { kind: string; chat: TypesGen.Chat } {
@@ -188,15 +204,11 @@ const AgentsPage: FC = () => {
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, ChatDetailError>
>({});
const catalogModelOptions = useMemo(
() =>
getModelOptionsFromCatalog(
const catalogModelOptions = getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
),
[chatModelsQuery.data, chatModelConfigsQuery.data],
);
const modelConfigIDByModelID = useMemo(() => {
const modelConfigIDByModelID = (() => {
const byModelID = new Map<string, string>();
for (const config of chatModelConfigsQuery.data ?? []) {
const { provider, model } = getNormalizedModelRef(config);
@@ -213,9 +225,8 @@ const AgentsPage: FC = () => {
}
}
return byModelID;
}, [chatModelConfigsQuery.data]);
const setChatErrorReason = useCallback(
(chatId: string, reason: ChatDetailError) => {
})();
const setChatErrorReason = (chatId: string, reason: ChatDetailError) => {
const trimmedMessage = reason.message.trim();
if (!chatId || !trimmedMessage) {
return;
@@ -234,10 +245,8 @@ const AgentsPage: FC = () => {
[chatId]: { kind: reason.kind, message: trimmedMessage },
};
});
},
[],
);
const clearChatErrorReason = useCallback((chatId: string) => {
};
const clearChatErrorReason = (chatId: string) => {
if (!chatId) {
return;
}
@@ -249,11 +258,8 @@ const AgentsPage: FC = () => {
delete next[chatId];
return next;
});
}, []);
const chatList = useMemo(
() => chatsQuery.data?.pages.flat() ?? [],
[chatsQuery.data],
);
};
const chatList = chatsQuery.data?.pages.flat() ?? [];
const isArchiving =
archiveAgentMutation.isPending || archiveAndDeleteMutation.isPending;
const archivingChatId =
@@ -263,16 +269,15 @@ const AgentsPage: FC = () => {
(archiveAndDeleteMutation.isPending
? archiveAndDeleteMutation.variables?.chatId
: undefined);
const requestArchiveAgent = useCallback(
(chatId: string) => {
const requestArchiveAgent = (chatId: string) => {
if (!isArchiving) {
archiveAgentMutation.mutate(chatId);
}
},
[isArchiving, archiveAgentMutation],
);
const requestArchiveAndDeleteWorkspace = useCallback(
async (chatId: string, workspaceId: string) => {
};
const requestArchiveAndDeleteWorkspace = async (
chatId: string,
workspaceId: string,
) => {
if (isArchiving) {
return;
}
@@ -296,10 +301,8 @@ const AgentsPage: FC = () => {
} catch {
toast.error("Failed to look up workspace for deletion.");
}
},
[isArchiving, queryClient, archiveAndDeleteMutation, navigate],
);
const handleConfirmArchiveAndDelete = useCallback(() => {
};
const handleConfirmArchiveAndDelete = () => {
if (pendingArchiveAndDelete && !isArchiving) {
archiveAndDeleteMutation.mutate(pendingArchiveAndDelete, {
onSettled: () => {
@@ -308,22 +311,12 @@ const AgentsPage: FC = () => {
},
});
}
}, [
pendingArchiveAndDelete,
isArchiving,
archiveAndDeleteMutation,
navigate,
]);
const requestUnarchiveAgent = useCallback(
(chatId: string) => {
};
const requestUnarchiveAgent = (chatId: string) => {
unarchiveAgentMutation.mutate(chatId);
},
[unarchiveAgentMutation],
);
const handleToggleSidebarCollapsed = useCallback(
() => setIsSidebarCollapsed((prev) => !prev),
[],
);
};
const handleToggleSidebarCollapsed = () =>
setIsSidebarCollapsed((prev) => !prev);
const handleCreateChat = async (options: CreateChatOptions) => {
const { message, fileIDs, workspaceId, model } = options;
const modelConfigID =
@@ -368,7 +361,9 @@ const AgentsPage: FC = () => {
// WebSocket handler can read it without re-subscribing on
// every navigation.
const activeChatIDRef = useRef(agentId);
useEffect(() => {
activeChatIDRef.current = agentId;
});
useEffect(() => {
return createReconnectingWebSocket({
@@ -389,7 +384,6 @@ const AgentsPage: FC = () => {
}
const chatEvent = sse.data;
const updatedChat = chatEvent.chat;
// Read the previous status from the infinite chat list
// cache before we write the update below. The per-chat
// query cache (chatKey) only exists for chats the user
@@ -452,21 +446,35 @@ const AgentsPage: FC = () => {
let didUpdate = false;
const nextChats = chats.map((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;
return {
...c,
...(isStatusEvent && { status: updatedChat.status }),
...(isTitleEvent && { title: updatedChat.title }),
...(isDiffStatusEvent && {
diff_status: updatedChat.diff_status,
}),
// 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,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
updated_at: nextUpdatedAt,
};
});
return didUpdate ? nextChats : chats;
@@ -478,19 +486,43 @@ const AgentsPage: FC = () => {
if (!previousChat) {
return previousChat;
}
return {
...previousChat,
...(isStatusEvent && { status: updatedChat.status }),
...(isTitleEvent && { title: updatedChat.title }),
...(isDiffStatusEvent && {
diff_status: updatedChat.diff_status,
}),
workspace_id:
updatedChat.workspace_id ?? previousChat.workspace_id,
updated_at:
// 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,
: updatedChat.updated_at;
if (
nextStatus === previousChat.status &&
nextTitle === previousChat.title &&
diffStatusEqual(nextDiffStatus, previousChat.diff_status) &&
nextWorkspaceId === previousChat.workspace_id
) {
return previousChat;
}
return {
...previousChat,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
updated_at: nextUpdatedAt,
};
},
);
+8 -33
View File
@@ -5,7 +5,7 @@ import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { Dayjs } from "dayjs";
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 { cn } from "utils/cn";
import { pageTitle } from "utils/page";
@@ -128,24 +128,20 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
const navigate = useNavigate();
const sidebarView = sidebarViewFromPath(location.pathname);
const handleOpenAnalytics = useCallback(() => {
const handleOpenAnalytics = () => {
navigate("/agents/analytics");
}, [navigate]);
};
// The sidebar expects plain string error messages, but the outlet
// context now carries structured ChatDetailError objects.
const sidebarChatErrorReasons = useMemo(
() =>
Object.fromEntries(
const sidebarChatErrorReasons = Object.fromEntries(
Object.entries(chatErrorReasons).map(([chatId, error]) => [
chatId,
error.message,
]),
),
[chatErrorReasons],
);
const modelIDByConfigID = useMemo(() => {
const modelIDByConfigID = (() => {
const byConfigID = new Map<string, string>();
for (const [modelID, configID] of modelConfigIDByModelID.entries()) {
if (!byConfigID.has(configID)) {
@@ -153,10 +149,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
}
}
return byConfigID;
}, [modelConfigIDByModelID]);
})();
const outletContextValue: AgentsOutletContext = useMemo(
() => ({
const outletContextValue: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
@@ -174,27 +169,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isModelCatalogLoading,
modelCatalogError,
desktopEnabled,
}),
[
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
isSidebarCollapsed,
onToggleSidebarCollapsed,
handleOpenAnalytics,
catalogModelOptions,
modelConfigIDByModelID,
modelIDByConfigID,
modelConfigs,
modelCatalog,
isModelCatalogLoading,
modelCatalogError,
desktopEnabled,
],
);
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
+25 -41
View File
@@ -60,10 +60,8 @@ import {
createContext,
type FC,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -581,7 +579,6 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
</div>
);
});
ChatTreeNode.displayName = "ChatTreeNode";
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const {
@@ -620,39 +617,42 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const normalizedSearch = "";
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const chatTree = useMemo(() => buildChatTree(chats), [chats]);
const chatById = useMemo(() => {
return new Map(chats.map((chat) => [chat.id, chat] as const));
}, [chats]);
const visibleChatIDs = useMemo(
() =>
collectVisibleChatIDs({
const chatTree = buildChatTree(chats);
const chatById = new Map(chats.map((chat) => [chat.id, chat] as const));
const visibleChatIDs = collectVisibleChatIDs({
chats,
search: normalizedSearch,
tree: chatTree,
}),
[chats, chatTree],
);
const visibleRootIDs = useMemo(
() => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)),
[chatTree.rootIds, visibleChatIDs],
});
const visibleRootIDs = chatTree.rootIds.filter((chatID) =>
visibleChatIDs.has(chatID),
);
// 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(() => {
if (!activeChatId) {
return;
}
const parentById = parentByIdRef.current;
const toExpand: string[] = [];
let cursor = chatTree.parentById.get(activeChatId);
let cursor = parentById.get(activeChatId);
const seen = new Set<string>();
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
toExpand.push(cursor);
cursor = chatTree.parentById.get(cursor);
cursor = parentById.get(cursor);
}
if (toExpand.length > 0) {
setExpandedById((prev) => {
if (toExpand.every((id) => prev[id])) {
return prev;
}
const next = { ...prev };
for (const id of toExpand) {
next[id] = true;
@@ -660,14 +660,12 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
return next;
});
}
}, [activeChatId, chatTree.parentById]);
const toggleExpanded = useCallback((chatID: string) => {
}, [activeChatId]);
const toggleExpanded = (chatID: string) => {
setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] }));
}, []);
};
const chatTreeCtx = useMemo<ChatTreeContextValue>(
() => ({
const chatTreeCtx: ChatTreeContextValue = {
chatTree,
chatById,
visibleChatIDs,
@@ -682,23 +680,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
}),
[
chatTree,
chatById,
visibleChatIDs,
expandedById,
modelOptions,
modelConfigs,
chatErrorReasons,
isArchiving,
archivingChatId,
toggleExpanded,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
],
);
};
const subNavTitle = "Settings";
@@ -1110,8 +1092,10 @@ const LoadMoreSentinel: FC<{
// Keep refs in sync with the latest prop values so the
// observer callback always reads current state without
// needing to tear down and re-create the observer.
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
isFetchingNextPageRef.current = isFetchingNextPage;
}, [onLoadMore, isFetchingNextPage]);
useEffect(() => {
const el = sentinelRef.current;
@@ -2,7 +2,7 @@ import { chatCostSummary } from "api/queries/chats";
import { useAuthContext } from "contexts/auth/AuthProvider";
import dayjs from "dayjs";
import { BarChart3Icon } from "lucide-react";
import { type FC, useMemo } from "react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SectionHeader } from "./SectionHeader";
@@ -28,7 +28,7 @@ export const AnalyticsPageContent: FC<AnalyticsPageContentProps> = ({
now,
}) => {
const { user } = useAuthContext();
const dateRange = useMemo(() => createDateRange(now), [now]);
const dateRange = createDateRange(now);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
@@ -13,7 +13,7 @@ import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
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 { cn } from "utils/cn";
import { formatProviderLabel } from "../modelOptions";
@@ -98,8 +98,7 @@ const useProviderStates = (
modelConfigs: readonly TypesGen.ChatModelConfig[],
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
catalogData: TypesGen.ChatModelsResponse | null | undefined,
): readonly ProviderState[] =>
useMemo(() => {
): readonly ProviderState[] => {
const orderedProviders: string[] = [];
const seenProviders = new Set<string>();
const includeProvider = (providerValue: string) => {
@@ -135,10 +134,7 @@ const useProviderStates = (
providerConfigsByProvider.set(normalized, pc);
}
const modelConfigsByProvider = new Map<
string,
TypesGen.ChatModelConfig[]
>();
const modelConfigsByProvider = new Map<string, TypesGen.ChatModelConfig[]>();
for (const mc of modelConfigs) {
const normalized = normalizeProvider(mc.provider);
if (!normalized) continue;
@@ -171,8 +167,7 @@ const useProviderStates = (
const label =
readOptionalString(providerConfigEntry?.display_name) ??
formatProviderLabel(provider);
const modelConfigsForProvider =
modelConfigsByProvider.get(provider) ?? [];
const modelConfigsForProvider = modelConfigsByProvider.get(provider) ?? [];
const isCatalogEnvPreset =
!providerConfig &&
envPresetProviders.has(provider) &&
@@ -195,7 +190,7 @@ const useProviderStates = (
baseURL: getProviderBaseURL(providerConfigEntry),
};
});
}, [modelConfigs, catalogData, providerConfigsData]);
};
// ── Component ──────────────────────────────────────────────────
@@ -245,14 +240,10 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
);
// ── Sorted model configs ───────────────────────────────────
const modelConfigs = useMemo(
() =>
(modelConfigsQuery.data ?? []).slice().sort((a, b) => {
const modelConfigs = (modelConfigsQuery.data ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider);
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
}),
[modelConfigsQuery.data],
);
});
// ── Provider states ────────────────────────────────────────
const providerStates = useProviderStates(
@@ -264,25 +255,15 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
// Derive the effective selected provider from user intent + available
// providers. This avoids a useEffect + setState cycle that would cause
// an extra render with a stale value.
const selectedProvider = useMemo(() => {
if (
const selectedProvider =
requestedProvider &&
providerStates.some((ps) => ps.provider === requestedProvider)
) {
return requestedProvider;
}
return providerStates[0]?.provider ?? null;
}, [requestedProvider, providerStates]);
const selectedProviderState = useMemo(
() =>
selectedProvider
? (providerStates.find((ps) => ps.provider === selectedProvider) ??
null)
: null,
[providerStates, selectedProvider],
);
? requestedProvider
: (providerStates[0]?.provider ?? null);
const selectedProviderState = selectedProvider
? (providerStates.find((ps) => ps.provider === selectedProvider) ?? null)
: null;
// ── Derived state ──────────────────────────────────────────
const isLoading =
providerConfigsQuery.isLoading ||
@@ -16,7 +16,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { type FC, useState } from "react";
import { cn } from "utils/cn";
import { getFormHelpers } from "utils/formUtils";
@@ -186,13 +186,9 @@ export const ModelForm: FC<ModelFormProps> = ({
const getFieldHelpers = getFormHelpers(form);
const modelConfigFormBuildResult = useMemo(
() =>
buildModelConfigFromForm(
const modelConfigFormBuildResult = buildModelConfigFromForm(
selectedProviderState?.provider,
form.values.config,
),
[selectedProviderState?.provider, form.values.config],
);
const hasFieldErrors =
@@ -19,7 +19,7 @@ import {
StarIcon,
TriangleAlertIcon,
} 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 { cn } from "utils/cn";
import { SectionHeader } from "../SectionHeader";
@@ -86,7 +86,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
// Derive the current view from URL search params so that
// browser back/forward navigation works as expected.
const view: ModelView = useMemo(() => {
const view: ModelView = (() => {
const editModelId = searchParams.get("model");
if (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: "list" };
}, [searchParams, modelConfigs]);
})();
// Clear model-related search params and return to the list.
const clearModelView = () => {
@@ -104,7 +104,6 @@ export const ProviderForm: FC<ProviderFormProps> = ({
const trimmedDisplayName = displayName.trim();
const trimmedBaseURL = baseURLValue.trim();
try {
if (providerConfig) {
const currentDisplayName =
readOptionalString(providerConfig.display_name) ?? "";
@@ -123,7 +122,13 @@ export const ProviderForm: FC<ProviderFormProps> = ({
return;
}
try {
await onUpdateProvider(providerConfig.id, req);
} catch {
// Error is surfaced via the mutation's error state
// in ChatModelAdminPanel, no toast needed.
return;
}
} else {
if (!effectiveApiKey) {
return;
@@ -138,14 +143,16 @@ export const ProviderForm: FC<ProviderFormProps> = ({
...(trimmedBaseURL && { base_url: trimmedBaseURL }),
};
try {
await onCreateProvider(req);
}
setApiKeyTouched(false);
} catch {
// Error is surfaced via the mutation's error state
// in ChatModelAdminPanel, no toast needed.
return;
}
}
setApiKeyTouched(false);
};
const handleApiKeyFocus = () => {
@@ -1,6 +1,6 @@
import type * as TypesGen from "api/typesGenerated";
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 { cn } from "utils/cn";
import { SectionHeader } from "../SectionHeader";
@@ -48,7 +48,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
// Derive the current view from URL search params so that
// browser back/forward navigation works as expected.
const view: ProviderView = useMemo(() => {
const view: ProviderView = (() => {
const providerParam = searchParams.get("provider");
if (providerParam) {
const exists = providerStates.some((ps) => ps.provider === providerParam);
@@ -57,7 +57,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
: { mode: "list" };
}
return { mode: "list" };
}, [searchParams, providerStates]);
})();
// Clear provider search param and return to the list.
const clearProviderView = () => {
@@ -8,7 +8,6 @@ import { ArrowUpIcon } from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useLayoutEffect,
useRef,
useState,
@@ -215,8 +214,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
// ---------------------------------------------------------------
// Line interaction callbacks
// ---------------------------------------------------------------
const handleLineNumberClick = useCallback(
(
const handleLineNumberClick = (
fileName: string,
props: {
lineNumber: number;
@@ -230,12 +228,9 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
end: props.lineNumber,
endSide: props.annotationSide,
});
},
[],
);
};
const handleLineSelected = useCallback(
(
const handleLineSelected = (
fileName: string,
range: {
start: number;
@@ -247,15 +242,14 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
const result = commentBoxFromRange(fileName, range);
if (result === "ignore") return;
setActiveCommentBox(result);
},
[],
);
};
// ---------------------------------------------------------------
// Annotation helpers
// ---------------------------------------------------------------
const getLineAnnotations = useCallback(
(fileName: string): DiffLineAnnotation<string>[] => {
const getLineAnnotations = (
fileName: string,
): DiffLineAnnotation<string>[] => {
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return [
{
@@ -266,26 +260,20 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
];
}
return [];
},
[activeCommentBox],
);
};
const getSelectedLines = useCallback(
(fileName: string): SelectedLineRange | null => {
const getSelectedLines = (fileName: string): SelectedLineRange | null => {
if (activeCommentBox && activeCommentBox.fileName === fileName) {
return selectedLinesForBox(activeCommentBox);
}
return null;
},
[activeCommentBox],
);
};
const handleCancelComment = useCallback(() => {
const handleCancelComment = () => {
setActiveCommentBox(null);
}, []);
};
const handleSubmitComment = useCallback(
(text: string) => {
const handleSubmitComment = (text: string) => {
if (!activeCommentBox) return;
const { startLine, endLine, side } = contentRangeForBox(activeCommentBox);
const content = extractDiffContent(
@@ -307,12 +295,9 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
chatInputRef?.current?.insertText(text);
}
setActiveCommentBox(null);
},
[activeCommentBox, chatInputRef, parsedFiles],
);
};
const renderAnnotation = useCallback(
(annotation: DiffLineAnnotation<string>) => {
const renderAnnotation = (annotation: DiffLineAnnotation<string>) => {
if (annotation.metadata === "active-input") {
if (!activeCommentBox) return null;
return (
@@ -323,9 +308,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
);
}
return null;
},
[activeCommentBox, handleSubmitComment, handleCancelComment],
);
};
// ---------------------------------------------------------------
// Render
+3 -6
View File
@@ -1,6 +1,6 @@
import { Button } from "components/Button/Button";
import { Spinner } from "components/Spinner/Spinner";
import { type FC, useCallback, useEffect, useRef } from "react";
import { type FC, useEffect, useRef } from "react";
import {
type UseDesktopConnectionResult,
useDesktopConnection,
@@ -27,15 +27,12 @@ export const DesktopPanel: FC<DesktopPanelProps> = ({
connectionOverride ?? hookResult;
const containerRef = useRef<HTMLDivElement | null>(null);
const attachToContainer = useCallback(
(el: HTMLDivElement | null) => {
const attachToContainer = (el: HTMLDivElement | null) => {
containerRef.current = el;
if (el) {
attach(el);
}
},
[attach],
);
};
// Connect on mount, disconnect on unmount. This drives the
// visibility-based lifecycle: DesktopPanel is only rendered
+35 -71
View File
@@ -19,9 +19,7 @@ import {
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} 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 isDark = theme.palette.mode === "dark";
const diffOptions = useMemo(() => {
const diffOptions = (() => {
const base = getDiffViewerOptions(isDark);
return {
...base,
@@ -536,13 +515,9 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// remain visible while scrolling through long diffs.
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
};
}, [isDark, diffStyle]);
})();
// Memoize the per-file options object so every <FileDiff>
// receives the same reference and avoids re-highlighting
// when the parent re-renders.
const fileOptions = useMemo(
() => ({
const fileOptions = {
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
@@ -551,17 +526,14 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// 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
// handlers for comment inputs), build options per file. Otherwise
// share a single stable object to avoid unnecessary re-highlights.
const hasPerFileCallbacks = !!(onLineNumberClick || onLineSelected);
const getOptionsForFile = useCallback(
(fileName: string) => ({
const getOptionsForFile = (fileName: string) => ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
@@ -584,22 +556,19 @@ export const DiffViewer: FC<DiffViewerProps> = ({
: () => {
// TODO: Make this add context to the input.
},
}),
[diffOptions, onLineNumberClick, onLineSelected],
);
});
const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]);
const fileTree = buildFileTree(parsedFiles);
// Sort diff blocks in the same order the file tree displays them
// (directories first, then alphabetical) so the rendering is
// consistent regardless of whether the sidebar is visible.
const sortedFiles = useMemo(() => {
const sortedFiles = (() => {
const order = new Map<string, number>();
let idx = 0;
const walk = (nodes: FileTreeNode[]) => {
for (const node of nodes) {
if (node.type === "file") {
order.set(node.fullPath, idx++);
order.set(node.fullPath, order.size);
} else {
walk(node.children);
}
@@ -609,21 +578,21 @@ export const DiffViewer: FC<DiffViewerProps> = ({
return [...parsedFiles].sort(
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
);
}, [fileTree, parsedFiles]);
})();
// Pre-compute per-file options so each LazyFileDiff receives a
// stable reference and avoids re-highlighting on parent re-render.
const perFileOptions = useMemo(() => {
const perFileOptions = (() => {
if (!hasPerFileCallbacks) return null;
const map = new Map<string, ComponentProps<typeof FileDiff>["options"]>();
for (const file of sortedFiles) {
map.set(file.name, getOptionsForFile(file.name));
}
return map;
}, [hasPerFileCallbacks, sortedFiles, getOptionsForFile]);
})();
// Pre-compute per-file line annotations for the same reason.
const perFileAnnotations = useMemo(() => {
const perFileAnnotations = (() => {
if (!getLineAnnotations) return null;
return new Map(
sortedFiles
@@ -633,14 +602,14 @@ export const DiffViewer: FC<DiffViewerProps> = ({
entry[1].length > 0,
),
);
}, [sortedFiles, getLineAnnotations]);
})();
// Pre-compute per-file selected lines so each LazyFileDiff
// receives a stable reference. Without this, calling
// getSelectedLines during render returns a new object every
// time, which busts the memo comparator and forces an
// expensive Shadow DOM + shiki re-highlight.
const perFileSelectedLines = useMemo(() => {
const perFileSelectedLines = (() => {
if (!getSelectedLines) return null;
return new Map(
sortedFiles
@@ -649,7 +618,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
(entry): entry is [string, SelectedLineRange] => entry[1] != null,
),
);
}, [sortedFiles, getSelectedLines]);
})();
// ---------------------------------------------------------------
// Container width measurement via ResizeObserver so we can decide
@@ -657,22 +626,17 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// parent.
// ---------------------------------------------------------------
const [containerWidth, setContainerWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const containerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
if (!el) {
return;
}
setContainerWidth(el.getBoundingClientRect().width);
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerEl) return;
setContainerWidth(containerEl.getBoundingClientRect().width);
const ro = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
ro.observe(el);
roRef.current = ro;
}, []);
ro.observe(containerEl);
return () => ro.disconnect();
}, [containerEl]);
const showTree =
(isExpanded || containerWidth >= FILE_TREE_THRESHOLD) &&
@@ -686,13 +650,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
const [activeFile, setActiveFile] = useState<string | null>(null);
// 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) {
fileRefs.current.set(name, el);
} else {
fileRefs.current.delete(name);
}
}, []);
};
// Track which file is at the top of the diff scroll area by
// listening to scroll events on the viewport. The active file
@@ -764,13 +728,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
};
}, [showTree, sortedFiles.length]);
const handleFileClick = useCallback((name: string) => {
const handleFileClick = (name: string) => {
const el = fileRefs.current.get(name);
if (el) {
el.scrollIntoView({ block: "start" });
setActiveFile(name);
}
}, []);
};
// Scroll to a file programmatically when the parent sets
// scrollToFile. This enables external navigation (e.g.
@@ -795,14 +759,14 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// lands during commit — before useEffect-based scroll logic.
// ---------------------------------------------------------------
const [viewportHeight, setViewportHeight] = useState(0);
const scrollAreaRef = useCallback((node: HTMLElement | null) => {
const vp = node?.querySelector<HTMLElement>(
const [scrollAreaEl, setScrollAreaEl] = useState<HTMLDivElement | null>(null);
useEffect(() => {
const vp = scrollAreaEl?.querySelector<HTMLElement>(
"[data-radix-scroll-area-viewport]",
);
diffViewportRef.current = vp ?? null;
if (!vp) return;
setViewportHeight(vp.clientHeight);
const ro = new ResizeObserver(([entry]) => {
setViewportHeight(entry.contentRect.height);
@@ -812,7 +776,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
ro.disconnect();
diffViewportRef.current = null;
};
}, []);
}, [scrollAreaEl]);
// ---------------------------------------------------------------
// Loading state
@@ -850,7 +814,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
// ---------------------------------------------------------------
return (
<div
ref={containerRef}
ref={setContainerEl}
className="flex h-full min-w-0 flex-col overflow-hidden"
>
{/* Diff contents */}
@@ -889,7 +853,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
)}
scrollBarClassName="w-1.5"
viewportClassName="[&>div]:!block"
ref={scrollAreaRef}
ref={setScrollAreaEl}
>
<div className="min-w-0 text-xs">
{sortedFiles.map((fileDiff, i) => {
+9 -18
View File
@@ -17,15 +17,7 @@ import {
RefreshCwIcon,
RowsIcon,
} from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, type RefObject, useEffect, useRef, useState } from "react";
import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
import { DiffStatBadge } from "./DiffStats";
@@ -85,7 +77,7 @@ export const GitPanel: FC<GitPanelProps> = ({
const prDraft = remoteDiffStats?.pull_request_draft;
// Compute per-repo diff stats from unified diffs.
const repoStats = useMemo(() => {
const repoStats = (() => {
const stats = new Map<string, DiffStats>();
for (const [root, repo] of repositories.entries()) {
if (!repo.unified_diff) continue;
@@ -103,11 +95,10 @@ export const GitPanel: FC<GitPanelProps> = ({
}
}
return stats;
}, [repositories]);
})();
const localRepos = useMemo(
() => Array.from(repoStats.keys()).sort((a, b) => a.localeCompare(b)),
[repoStats],
const localRepos = Array.from(repoStats.keys()).sort((a, b) =>
a.localeCompare(b),
);
// 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 handleDiffStyleChange = useCallback((style: DiffStyle) => {
const handleDiffStyleChange = (style: DiffStyle) => {
saveDiffStyle(style);
setDiffStyle(style);
}, []);
};
const [spinning, setSpinning] = useState(false);
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
const handleRefresh = useCallback(() => {
const handleRefresh = () => {
onRefresh();
setSpinning(true);
clearTimeout(spinTimerRef.current);
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
}, [onRefresh]);
};
return (
<div className="flex h-full flex-col">
@@ -1,7 +1,7 @@
import { prInsights } from "api/queries/chats";
import { Spinner } from "components/Spinner/Spinner";
import dayjs from "dayjs";
import { type FC, useCallback, useMemo, useState } from "react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
@@ -17,14 +17,12 @@ function timeRangeToDates(range: PRInsightsTimeRange) {
export const InsightsContent: FC = () => {
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>("30d");
const dates = useMemo(() => timeRangeToDates(timeRange), [timeRange]);
const dates = timeRangeToDates(timeRange);
const { data, isLoading, error } = useQuery(prInsights(dates));
const handleTimeRangeChange = useCallback(
(range: PRInsightsTimeRange) => setTimeRange(range),
[],
);
const handleTimeRangeChange = (range: PRInsightsTimeRange) =>
setTimeRange(range);
if (isLoading) {
return (
@@ -18,7 +18,7 @@ import {
TooltipTrigger,
} from "components/Tooltip/Tooltip";
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 {
dollarsToMicros,
@@ -128,7 +128,7 @@ export const LimitsTab: FC = () => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userOverrideAmount, setUserOverrideAmount] = useState("");
const defaultLimitValues = useMemo<DefaultLimitFormValues>(() => {
const defaultLimitValues: DefaultLimitFormValues = (() => {
const spendLimitMicros = configQuery.data?.spend_limit_micros;
const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined;
@@ -140,27 +140,19 @@ export const LimitsTab: FC = () => {
? microsToDollars(spendLimitMicros).toString()
: "",
};
}, [configQuery.data?.period, configQuery.data?.spend_limit_micros]);
const defaultLimitKey = useMemo(
() =>
JSON.stringify({
})();
const defaultLimitKey = JSON.stringify({
spend_limit_micros: configQuery.data?.spend_limit_micros ?? null,
period: defaultLimitValues.period,
}),
[configQuery.data?.spend_limit_micros, defaultLimitValues.period],
});
const existingGroupIds = new Set(
(configQuery.data?.group_overrides ?? []).map((g) => g.group_id),
);
const existingGroupIds = useMemo(
() =>
new Set((configQuery.data?.group_overrides ?? []).map((g) => g.group_id)),
[configQuery.data?.group_overrides],
const existingUserIds = new Set(
(configQuery.data?.overrides ?? []).map((o) => o.user_id),
);
const existingUserIds = useMemo(
() => new Set((configQuery.data?.overrides ?? []).map((o) => o.user_id)),
[configQuery.data?.overrides],
);
const availableGroups = useMemo(
() => (groupsQuery.data ?? []).filter((g) => !existingGroupIds.has(g.id)),
[groupsQuery.data, existingGroupIds],
const availableGroups = (groupsQuery.data ?? []).filter(
(g) => !existingGroupIds.has(g.id),
);
const selectedUserAlreadyOverridden = selectedUser
? existingUserIds.has(selectedUser.id)
+3 -3
View File
@@ -1,6 +1,6 @@
import { parsePatchFiles } from "@pierre/diffs";
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 { CommentableDiffViewer } from "./CommentableDiffViewer";
import type { DiffStyle } from "./DiffViewer";
@@ -18,7 +18,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
diffStyle,
chatInputRef,
}) => {
const parsedFiles = useMemo(() => {
const parsedFiles = (() => {
const diff = repo.unified_diff;
if (!diff) {
return [];
@@ -29,7 +29,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
} catch {
return [];
}
}, [repo.unified_diff]);
})();
return (
<CommentableDiffViewer
@@ -34,14 +34,7 @@ import {
ServerIcon,
XIcon,
} from "lucide-react";
import {
type FC,
type ReactNode,
useCallback,
useId,
useMemo,
useState,
} from "react";
import { type FC, type ReactNode, useId, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import { cn } from "utils/cn";
@@ -890,27 +883,21 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
const updateMut = useMutation(updateMCPServerConfigMutation(queryClient));
const deleteMut = useMutation(deleteMCPServerConfigMutation(queryClient));
const servers = useMemo(
() =>
(serversQuery.data ?? [])
const servers = (serversQuery.data ?? [])
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name)),
[serversQuery.data],
);
.sort((a, b) => a.display_name.localeCompare(b.display_name));
const editingServer = useMemo(
() =>
const editingServer =
serverId && serverId !== "new"
? (servers.find((s) => s.id === serverId) ?? null)
: null,
[serverId, servers],
);
: null;
const isFormView = serverId !== null;
const isCreating = serverId === "new";
const handleSave = useCallback(
async (req: TypesGen.CreateMCPServerConfigRequest, id?: string) => {
try {
const handleSave = async (
req: TypesGen.CreateMCPServerConfigRequest,
id?: string,
) => {
if (id) {
const updateReq: TypesGen.UpdateMCPServerConfigRequest = {
...req,
@@ -921,29 +908,32 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
? [...req.tool_deny_list]
: undefined,
};
try {
await updateMut.mutateAsync({ id, req: updateReq });
} else {
await createMut.mutateAsync(req);
}
setSearchParams({});
} catch {
// Error surfaced via mutation error state.
return;
}
},
[createMut, updateMut, setSearchParams],
);
} else {
try {
await createMut.mutateAsync(req);
} catch {
// Error surfaced via mutation error state.
return;
}
}
setSearchParams({});
};
const handleDelete = useCallback(
async (id: string) => {
const handleDelete = async (id: string) => {
try {
await deleteMut.mutateAsync(id);
setSearchParams({});
} catch {
// Error surfaced via mutation error state.
return;
}
},
[deleteMut, setSearchParams],
);
setSearchParams({});
};
if (serversQuery.isLoading) {
return <Spinner loading className="h-4 w-4" />;
@@ -13,7 +13,7 @@ import {
PencilIcon,
Trash2Icon,
} from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { cn } from "utils/cn";
interface QueuedMessagesListProps {
@@ -72,9 +72,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
editingMessageID = null,
className,
}) => {
const items = useMemo(
() =>
messages.map((message) => {
const items = messages.map((message) => {
const { displayText, rawText, attachmentCount, fileBlocks } =
getQueuedMessageInfo(message);
return {
@@ -84,9 +82,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
attachmentCount,
fileBlocks,
};
}),
[messages],
);
});
const [hoveredID, setHoveredID] = useState<number | null>(null);
// Tracks which item has an async action in flight and what kind.
@@ -98,7 +94,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
ReadonlySet<number>
>(new Set());
const hideItemOptimistically = useCallback((id: number) => {
const hideItemOptimistically = (id: number) => {
setOptimisticallyHiddenIDs((current) => {
if (current.has(id)) {
return current;
@@ -107,9 +103,9 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
next.add(id);
return next;
});
}, []);
};
const restoreHiddenItem = useCallback((id: number) => {
const restoreHiddenItem = (id: number) => {
setOptimisticallyHiddenIDs((current) => {
if (!current.has(id)) {
return current;
@@ -118,7 +114,7 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
next.delete(id);
return next;
});
}, []);
};
useEffect(() => {
const liveIDs = new Set(messages.map((message) => message.id));
@@ -139,35 +135,29 @@ export const QueuedMessagesList: FC<QueuedMessagesListProps> = ({
});
}, [messages]);
const handleDelete = useCallback(
async (id: number) => {
const handleDelete = async (id: number) => {
setBusyItem({ id, action: "delete" });
hideItemOptimistically(id);
try {
await onDelete(id);
setBusyItem((current) => (current?.id === id ? null : current));
} catch {
restoreHiddenItem(id);
} finally {
setBusyItem((current) => (current?.id === id ? null : current));
}
},
[hideItemOptimistically, onDelete, restoreHiddenItem],
);
};
const handlePromote = useCallback(
async (id: number) => {
const handlePromote = async (id: number) => {
setBusyItem({ id, action: "promote" });
hideItemOptimistically(id);
try {
await onPromote(id);
setBusyItem((current) => (current?.id === id ? null : current));
} catch {
restoreHiddenItem(id);
} finally {
setBusyItem((current) => (current?.id === id ? null : current));
}
},
[hideItemOptimistically, onPromote, restoreHiddenItem],
);
};
const visibleItems = items.filter(
(item) => !optimisticallyHiddenIDs.has(item.id),
+13 -31
View File
@@ -11,15 +11,7 @@ import {
GitPullRequestDraftIcon,
GitPullRequestIcon,
} from "lucide-react";
import {
type FC,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, type RefObject, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { cn } from "utils/cn";
import type { ChatMessageInputRef } from "./AgentChatInput";
@@ -30,18 +22,6 @@ import { parsePullRequestUrl } from "./pullRequest";
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
// -------------------------------------------------------------------
@@ -109,14 +89,16 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
});
const diffContent = diffContentsQuery.data?.diff;
const diffVersionRef = useRef(0);
const prevDiffRef = useRef<string | undefined>(undefined);
if (diffContent !== prevDiffRef.current) {
prevDiffRef.current = diffContent;
diffVersionRef.current = ++remoteDiffVersion;
const [diffVersion, setDiffVersion] = useState(0);
const [prevDiffContent, setPrevDiffContent] = useState<string | undefined>(
undefined,
);
if (diffContent !== prevDiffContent) {
setPrevDiffContent(diffContent);
setDiffVersion((v) => v + 1);
}
const parsedFiles = useMemo(() => {
const parsedFiles = (() => {
if (!diffContent) {
return [] as FileDiffMetadata[];
}
@@ -133,13 +115,13 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
// recomputation on refetches with identical content.
const patches = parsePatchFiles(
diffContent,
`chat-${chatId}-v${diffVersionRef.current}`,
`chat-${chatId}-v${diffVersion}`,
);
return patches.flatMap((p) => p.files);
} catch {
return [] as FileDiffMetadata[];
}
}, [diffContent, chatId]);
})();
// ---------------------------------------------------------------
// Scroll-to-file from chat input chip clicks
@@ -156,9 +138,9 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
return () => window.removeEventListener("file-reference-click", handler);
}, []);
const handleScrollComplete = useCallback(() => {
const handleScrollComplete = () => {
setScrollTarget(null);
}, []);
};
// ---------------------------------------------------------------
// Header content
+11 -34
View File
@@ -1,7 +1,6 @@
import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
@@ -85,25 +84,20 @@ function useResizableDrag({
"normal" | "expanded" | "closed" | null
>(null);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
setDragSnap(null);
sidebarCollapsedByDrag.current = false;
startX.current = e.clientX;
startWidth.current = isExpanded
? ((e.target as HTMLElement).closest(
"[data-testid='agents-right-panel']",
)?.parentElement?.clientWidth ?? getMaxWidth())
? ((e.target as HTMLElement).closest("[data-testid='agents-right-panel']")
?.parentElement?.clientWidth ?? getMaxWidth())
: width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width, isExpanded],
);
};
const handlePointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
@@ -118,10 +112,7 @@ function useResizableDrag({
onToggleSidebarCollapsed();
sidebarCollapsedByDrag.current = true;
}
} else if (
e.clientX >= SNAP_THRESHOLD &&
sidebarCollapsedByDrag.current
) {
} else if (e.clientX >= SNAP_THRESHOLD && sidebarCollapsedByDrag.current) {
if (onToggleSidebarCollapsed) {
onToggleSidebarCollapsed();
sidebarCollapsedByDrag.current = false;
@@ -145,18 +136,9 @@ function useResizableDrag({
nextSnap === "expanded" ||
(nextSnap !== "normal" && nextSnap !== "closed" && isExpanded);
onVisualExpandedChange?.(nextVisualExpanded);
},
[
setWidth,
isExpanded,
onVisualExpandedChange,
isSidebarCollapsed,
onToggleSidebarCollapsed,
],
);
};
const handlePointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const handlePointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
@@ -172,9 +154,7 @@ function useResizableDrag({
if (snap) {
onSnapCommit(snap);
}
},
[dragSnap, onSnapCommit, onVisualExpandedChange],
);
};
// Derive visual state: during a drag the snap overrides the
// committed parent state so the panel reacts live.
@@ -216,8 +196,7 @@ export const RightPanel = ({
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleSnapCommit = useCallback(
(snap: "normal" | "expanded" | "closed") => {
const handleSnapCommit = (snap: "normal" | "expanded" | "closed") => {
if (snap === "expanded" && !isExpanded) {
onToggleExpanded();
} else if (snap === "closed") {
@@ -229,9 +208,7 @@ export const RightPanel = ({
} else if (snap === "normal" && isExpanded) {
onToggleExpanded();
}
},
[isExpanded, onToggleExpanded, onClose],
);
};
const {
visualExpanded,
@@ -40,7 +40,7 @@ import dayjs from "dayjs";
import { useDebouncedValue } from "hooks/debounce";
import { useClickableTableRow } from "hooks/useClickableTableRow";
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 {
keepPreviousData,
useMutation,
@@ -132,7 +132,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
const [searchFilter, setSearchFilter] = useState("");
const debouncedSearch = useDebouncedValue(searchFilter, 300);
const [page, setPage] = useState(1);
const dateRange = useMemo(() => {
const dateRange = (() => {
const end = now ?? dayjs();
const start = end.subtract(30, "day");
return {
@@ -140,7 +140,7 @@ const UsageContent: FC<UsageContentProps> = ({ now }) => {
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} ${end.format("MMM D, YYYY")}`,
};
}, [now]);
})();
const offset = (page - 1) * pageSize;
const usersQuery = useQuery({
@@ -466,42 +466,32 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
isSavingWorkspaceTTL;
const isTTLLoading = workspaceTTLQuery.isLoading;
const handleSaveSystemPrompt = useCallback(
(event: FormEvent) => {
const handleSaveSystemPrompt = (event: FormEvent) => {
event.preventDefault();
if (!isSystemPromptDirty) return;
saveSystemPrompt(
{ system_prompt: systemPromptDraft },
{ onSuccess: () => setLocalEdit(null) },
);
},
[isSystemPromptDirty, systemPromptDraft, saveSystemPrompt],
);
};
const handleSaveUserPrompt = useCallback(
(event: FormEvent) => {
const handleSaveUserPrompt = (event: FormEvent) => {
event.preventDefault();
if (!isUserPromptDirty) return;
saveUserPrompt(
{ custom_prompt: userPromptDraft },
{ onSuccess: () => setLocalUserEdit(null) },
);
},
[isUserPromptDirty, userPromptDraft, saveUserPrompt],
);
};
const handleSaveChatWorkspaceTTL = useCallback(
(event: FormEvent) => {
const handleSaveChatWorkspaceTTL = (event: FormEvent) => {
event.preventDefault();
if (!isTTLDirty) return;
saveWorkspaceTTL(
{ workspace_ttl_ms: localTTLMs ?? 0 },
{ onSuccess: () => setLocalTTLMs(null) },
);
},
[isTTLDirty, localTTLMs, saveWorkspaceTTL],
);
};
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="mx-auto w-full max-w-3xl">
+23 -26
View File
@@ -8,14 +8,7 @@ import {
XIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import {
type FC,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { type FC, useEffect, useId, useRef, useState } from "react";
import { cn } from "utils/cn";
import { DesktopPanel } from "./DesktopPanel";
import type { UseDesktopConnectionResult } from "./useDesktopConnection";
@@ -66,17 +59,15 @@ function useTabScroll() {
const [canScrollLeft, setCanScrollLeft] = 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(() => {
const el = ref.current;
if (!el) return;
const update = () => {
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
// Initial check.
update();
@@ -91,21 +82,21 @@ function useTabScroll() {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, [update]);
}, []);
const scrollLeft = useCallback(() => {
const scrollLeft = () => {
ref.current?.scrollBy({
left: -TAB_SCROLL_AMOUNT,
behavior: "smooth",
});
}, []);
};
const scrollRight = useCallback(() => {
const scrollRight = () => {
ref.current?.scrollBy({
left: TAB_SCROLL_AMOUNT,
behavior: "smooth",
});
}, []);
};
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 tabScroll = useTabScroll();
const {
ref: tabScrollRef,
canScrollLeft,
canScrollRight,
scrollLeft: scrollTabsLeft,
scrollRight: scrollTabsRight,
} = useTabScroll();
if (tabs.length === 0 && !desktopChatId) {
return (
@@ -212,10 +209,10 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
)}
{/* Scrollable tab strip with overlay chevrons */}
<div className="relative min-w-0 flex-1">
{tabScroll.canScrollLeft && (
{canScrollLeft && (
<button
type="button"
onClick={tabScroll.scrollLeft}
onClick={scrollTabsLeft}
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)]"
>
@@ -223,7 +220,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</button>
)}
<div
ref={tabScroll.ref}
ref={tabScrollRef}
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
@@ -277,10 +274,10 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</Button>
)}
</div>
{tabScroll.canScrollRight && (
{canScrollRight && (
<button
type="button"
onClick={tabScroll.scrollRight}
onClick={scrollTabsRight}
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)]"
>
@@ -1,6 +1,6 @@
import RFB from "@novnc/novnc/lib/rfb";
import { watchChatDesktop } from "api/api";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
interface UseDesktopConnectionOptions {
chatId: string | undefined;
@@ -47,6 +47,7 @@ export function useDesktopConnection({
const [status, setStatus] = useState<DesktopConnectionStatus>("idle");
const [hasConnected, setHasConnected] = useState(false);
const [rfbInstance, setRfbInstance] = useState<RFB | null>(null);
const rfbRef = useRef<RFB | null>(null);
const offscreenContainerRef = useRef<HTMLElement | null>(null);
const reconnectAttemptRef = useRef(0);
@@ -58,7 +59,9 @@ export function useDesktopConnection({
// the latest value without stale closures.
const hasConnectedRef = useRef(false);
const cleanupRfb = useCallback(() => {
const cleanupRfbRef = useRef(() => {});
useEffect(() => {
cleanupRfbRef.current = () => {
if (rfbRef.current) {
try {
rfbRef.current.disconnect();
@@ -66,15 +69,17 @@ export function useDesktopConnection({
// Ignore errors during disconnect.
}
rfbRef.current = null;
setRfbInstance(null);
}
}, []);
};
});
const doConnect = useCallback(() => {
const doConnect = () => {
if (!chatId || disposedRef.current) {
return;
}
cleanupRfb();
cleanupRfbRef.current();
setStatus("connecting");
// Temporary offscreen container for the RFB canvas; moved into
@@ -109,6 +114,7 @@ export function useDesktopConnection({
rfb.addEventListener("disconnect", () => {
if (disposedRef.current) return;
rfbRef.current = null;
setRfbInstance(null);
if (!sessionConnected && !hasConnectedRef.current) {
// The VNC handshake never completed and the desktop
@@ -140,41 +146,43 @@ export function useDesktopConnection({
rfb.addEventListener("securityfailure", () => {
if (disposedRef.current) return;
rfbRef.current = null;
setRfbInstance(null);
setStatus("error");
});
rfbRef.current = rfb;
setRfbInstance(rfb);
} catch {
setStatus("error");
}
}, [chatId, cleanupRfb]);
};
const connect = useCallback(() => {
const connect = () => {
if (connectRequestedRef.current) {
return;
}
connectRequestedRef.current = true;
doConnect();
}, [doConnect]);
};
const disconnect = useCallback(() => {
const disconnect = () => {
if (reconnectTimerRef.current !== null) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
cleanupRfb();
cleanupRfbRef.current();
offscreenContainerRef.current = null;
setStatus("idle");
connectRequestedRef.current = false;
reconnectAttemptRef.current = 0;
}, [cleanupRfb]);
};
const attach = useCallback((container: HTMLElement) => {
const attach = (container: HTMLElement) => {
const screen = offscreenContainerRef.current;
if (screen && screen.parentElement !== container) {
container.appendChild(screen);
}
}, []);
};
// Cleanup on unmount or chatId change.
// 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);
reconnectTimerRef.current = null;
}
cleanupRfb();
cleanupRfbRef.current();
offscreenContainerRef.current = null;
setStatus("idle");
setHasConnected(false);
@@ -195,7 +203,7 @@ export function useDesktopConnection({
connectRequestedRef.current = false;
reconnectAttemptRef.current = 0;
};
}, [chatId, cleanupRfb]);
}, [chatId]);
return {
status,
@@ -203,6 +211,6 @@ export function useDesktopConnection({
connect,
disconnect,
attach,
rfb: rfbRef.current,
rfb: rfbInstance,
};
}
+11 -18
View File
@@ -3,7 +3,6 @@ import { getErrorDetail, getErrorMessage } from "api/errors";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
@@ -34,7 +33,9 @@ export function useFileAttachments(
// Revoke blob URLs on unmount to prevent memory leaks.
const previewUrlsRef = useRef(previewUrls);
useEffect(() => {
previewUrlsRef.current = previewUrls;
});
useEffect(() => {
return () => {
for (const [, url] of previewUrlsRef.current) {
@@ -43,8 +44,7 @@ export function useFileAttachments(
};
}, []);
const startUpload = useCallback(
(file: File) => {
const startUpload = (file: File) => {
if (!organizationId) {
setUploadStates((prev) =>
new Map(prev).set(file, {
@@ -54,9 +54,7 @@ export function useFileAttachments(
);
return;
}
setUploadStates((prev) =>
new Map(prev).set(file, { status: "uploading" }),
);
setUploadStates((prev) => new Map(prev).set(file, { status: "uploading" }));
void (async () => {
try {
const result = await API.uploadChatFile(file, organizationId);
@@ -84,12 +82,9 @@ export function useFileAttachments(
);
}
})();
},
[organizationId],
);
};
const handleAttach = useCallback(
(files: File[]) => {
const handleAttach = (files: File[]) => {
const maxSize = 10 * 1024 * 1024; // 10 MB
setAttachments((prev) => [...prev, ...files]);
setPreviewUrls((prev) => {
@@ -111,11 +106,9 @@ export function useFileAttachments(
startUpload(file);
}
}
},
[startUpload],
);
};
const handleRemoveAttachment = useCallback((index: number) => {
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => {
const removed = prev[index];
if (removed) {
@@ -134,16 +127,16 @@ export function useFileAttachments(
}
return prev.filter((_, i) => i !== index);
});
}, []);
};
const resetAttachments = useCallback(() => {
const resetAttachments = () => {
for (const [, url] of previewUrlsRef.current) {
if (url.startsWith("blob:")) URL.revokeObjectURL(url);
}
setPreviewUrls(new Map());
setUploadStates(new Map());
setAttachments([]);
}, []);
};
return {
attachments,
+11 -9
View File
@@ -5,7 +5,7 @@ import type {
WorkspaceAgentRepoChanges,
WorkspaceAgentStatus,
} 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
// 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.
const disposedRef = useRef(false);
const sendMessage = useCallback((msg: WorkspaceAgentGitClientMessage) => {
const sendMessage = (msg: WorkspaceAgentGitClientMessage) => {
const socket = socketRef.current;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(msg));
}
}, []);
};
const refresh = useCallback(() => {
const refresh = () => {
sendMessage({ type: "refresh" });
}, [sendMessage]);
};
useEffect(() => {
if (!chatId || agentStatus !== "connected") {
@@ -91,10 +91,15 @@ export function useGitWatcher({
if (socketRef.current !== socket) {
return;
}
let data: WorkspaceAgentGitServerMessage;
try {
const data = JSON.parse(
data = JSON.parse(
String(event.data),
) as WorkspaceAgentGitServerMessage;
} catch {
// Ignore unparsable messages.
return;
}
if (data.type === "changes" && data.repositories) {
setRepositories((prev) => {
@@ -124,9 +129,6 @@ export function useGitWatcher({
} else if (data.type === "error") {
console.warn("[useGitWatcher] server error:", data.message);
}
} catch {
// Ignore unparsable messages.
}
});
// Note: WebSocket "error" events are always followed by a "close"
+11 -1
View File
@@ -8,7 +8,17 @@ import checker from "vite-plugin-checker";
import { defineConfig } from "vitest/config";
const plugins: PluginOption[] = [
react(),
react({
babel: {
plugins: [],
overrides: [
{
test: /src\/(pages\/AgentsPage|components\/ai-elements)\//,
plugins: ["babel-plugin-react-compiler"],
},
],
},
}),
checker({
typescript: true,
}),