mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): opt AgentsPage and ai-elements into React Compiler (#23371)
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
"ignore": ["**/*Generated.ts", "src/api/chatModelOptions.ts"],
|
||||
"ignoreBinaries": ["protoc"],
|
||||
"ignoreDependencies": [
|
||||
"@babel/plugin-syntax-typescript",
|
||||
"@types/react-virtualized-auto-sizer",
|
||||
"babel-plugin-react-compiler",
|
||||
"jest_workaround",
|
||||
"ts-proto"
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+194
-105
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user