diff --git a/site/.knip.jsonc b/site/.knip.jsonc index 49453de4d0..a99b1fda10 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -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" ], diff --git a/site/AGENTS.md b/site/AGENTS.md index 0a355160d7..6784afb607 100644 --- a/site/AGENTS.md +++ b/site/AGENTS.md @@ -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 diff --git a/site/biome.jsonc b/site/biome.jsonc index be24c66617..1721a0853c 100644 --- a/site/biome.jsonc +++ b/site/biome.jsonc @@ -1,7 +1,7 @@ { "extends": "//", "files": { - "includes": ["!e2e/**/*Generated.ts"] + "includes": ["!e2e/**/*Generated.ts", "!scripts/*.mjs"] }, "$schema": "./node_modules/@biomejs/biome/configuration_schema.json" } diff --git a/site/package.json b/site/package.json index 392ecdf1f9..a671dbc46d 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f1047a26b7..2060397e37 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/scripts/check-compiler.mjs b/site/scripts/check-compiler.mjs new file mode 100644 index 0000000000..e1c2577101 --- /dev/null +++ b/site/scripts/check-compiler.mjs @@ -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; +} diff --git a/site/src/components/ai-elements/model-selector.tsx b/site/src/components/ai-elements/model-selector.tsx index 69846a9586..8ae4032c7d 100644 --- a/site/src/components/ai-elements/model-selector.tsx +++ b/site/src/components/ai-elements/model-selector.tsx @@ -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 = ({ 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(); for (const option of options) { @@ -84,7 +81,7 @@ export const ModelSelector: FC = ({ } return Array.from(grouped.entries()); - }, [options]); + })(); const isDisabled = disabled || options.length === 0; return ( diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx index fea04d1064..2709cdccde 100644 --- a/site/src/components/ai-elements/response.tsx +++ b/site/src/components/ai-elements/response.tsx @@ -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 (
; @@ -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 ( @@ -75,4 +72,4 @@ const ShimmerComponent = ({ ); }; -export const Shimmer = memo(ShimmerComponent); +export const Shimmer = ShimmerComponent; diff --git a/site/src/components/ai-elements/tool/Tool.tsx b/site/src/components/ai-elements/tool/Tool.tsx index 36dbc5e6ed..e62e4a3f3d 100644 --- a/site/src/components/ai-elements/tool/Tool.tsx +++ b/site/src/components/ai-elements/tool/Tool.tsx @@ -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"; diff --git a/site/src/components/ai-elements/tool/WebSearchSources.tsx b/site/src/components/ai-elements/tool/WebSearchSources.tsx index 681fa3662b..672fa88edd 100644 --- a/site/src/components/ai-elements/tool/WebSearchSources.tsx +++ b/site/src/components/ai-elements/tool/WebSearchSources.tsx @@ -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 = ({ sources }) => { // Deduplicate sources by URL, keeping the first occurrence. - const unique = useMemo(() => { + const unique = (() => { const seen = new Set(); return sources.filter((s) => { if (!s.url || seen.has(s.url)) { @@ -23,7 +23,7 @@ const WebSearchSources: FC = ({ sources }) => { seen.add(s.url); return true; }); - }, [sources]); + })(); if (unique.length === 0) { return null; diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index 6c0d7bf08d..10b1092572 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -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,96 +152,95 @@ const RING_STROKE = 2.5; const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; -const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>( - ({ usage }) => { - const usedTokens = hasFiniteTokenValue(usage?.usedTokens) - ? usage.usedTokens - : undefined; - const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens) - ? usage.contextLimitTokens - : undefined; - const percentUsed = - usedTokens !== undefined && - contextLimitTokens !== undefined && - contextLimitTokens > 0 - ? (usedTokens / contextLimitTokens) * 100 - : null; - const hasPercent = percentUsed !== null; - const percentLabel = - percentUsed === null ? "--" : `${Math.round(percentUsed)}%`; - const clampedPercent = hasPercent - ? Math.min(Math.max(percentUsed, 0), 100) - : 100; - const dashOffset = - RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE; - const toneClassName = getIndicatorToneClassName(percentUsed); - const ariaLabel = hasPercent - ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.` - : "Context usage"; +const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ + usage, +}) => { + const usedTokens = hasFiniteTokenValue(usage?.usedTokens) + ? usage.usedTokens + : undefined; + const contextLimitTokens = hasFiniteTokenValue(usage?.contextLimitTokens) + ? usage.contextLimitTokens + : undefined; + const percentUsed = + usedTokens !== undefined && + contextLimitTokens !== undefined && + contextLimitTokens > 0 + ? (usedTokens / contextLimitTokens) * 100 + : null; + const hasPercent = percentUsed !== null; + const percentLabel = + percentUsed === null ? "--" : `${Math.round(percentUsed)}%`; + const clampedPercent = hasPercent + ? Math.min(Math.max(percentUsed, 0), 100) + : 100; + const dashOffset = + RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE; + const toneClassName = getIndicatorToneClassName(percentUsed); + const ariaLabel = hasPercent + ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.` + : "Context usage"; - return ( - - - - - -
- {hasPercent - ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` - : "Context usage unavailable"} - {hasPercent && - usage?.compressionThreshold !== undefined && - usage.compressionThreshold > 0 && ( -
- Compacts at {usage.compressionThreshold}% -
- )} -
-
-
- ); - }, -); -ContextUsageIndicator.displayName = "ContextUsageIndicator"; + + + + + + +
+ {hasPercent + ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` + : "Context usage unavailable"} + {hasPercent && + usage?.compressionThreshold !== undefined && + usage.compressionThreshold > 0 && ( +
+ Compacts at {usage.compressionThreshold}% +
+ )} +
+
+ + ); +}; /** 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 }) => ( {name} -)); -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; previewUrls?: Map; onPreview?: (url: string) => void; -}>(({ attachments, onRemove, uploadStates, previewUrls, onPreview }) => { +}> = ({ attachments, onRemove, uploadStates, previewUrls, onPreview }) => { if (attachments.length === 0) return null; return ( @@ -324,502 +322,456 @@ export const AttachmentPreview = memo<{ })}
); -}); -AttachmentPreview.displayName = "AttachmentPreview"; +}; -export const AgentChatInput = memo( - ({ - onSend, - placeholder = "Type a message...", - isDisabled, - isLoading, - inputRef, - initialValue, - onContentChange, - selectedModel, - onModelChange, - modelOptions, - modelSelectorPlaceholder, - hasModelOptions, - inputStatusText, - modelCatalogStatusMessage, - isStreaming = false, - onInterrupt, - isInterruptPending = false, - leftActions, - queuedMessages = [], - onDeleteQueuedMessage, - onPromoteQueuedMessage, - editingQueuedMessageID = null, - onStartQueueEdit, - onCancelQueueEdit, - isEditingHistoryMessage = false, - onCancelHistoryEdit, - contextUsage, - attachments = [], - onAttach, - onRemoveAttachment, - uploadStates, - previewUrls, - }) => { - const internalRef = useRef(null); - const [previewImage, setPreviewImage] = useState(null); +export const AgentChatInput: FC = ({ + onSend, + placeholder = "Type a message...", + isDisabled, + isLoading, + inputRef, + initialValue, + onContentChange, + selectedModel, + onModelChange, + modelOptions, + modelSelectorPlaceholder, + hasModelOptions, + inputStatusText, + modelCatalogStatusMessage, + isStreaming = false, + onInterrupt, + isInterruptPending = false, + leftActions, + queuedMessages = [], + onDeleteQueuedMessage, + onPromoteQueuedMessage, + editingQueuedMessageID = null, + onStartQueueEdit, + onCancelQueueEdit, + isEditingHistoryMessage = false, + onCancelHistoryEdit, + contextUsage, + attachments = [], + onAttach, + onRemoveAttachment, + uploadStates, + previewUrls, +}) => { + const internalRef = useRef(null); + const [previewImage, setPreviewImage] = useState(null); - const [hasFileReferences, setHasFileReferences] = useState(false); + const [hasFileReferences, setHasFileReferences] = useState(false); - const speech = useSpeechRecognition(); - const [preRecordingValue, setPreRecordingValue] = useState(""); + const speech = useSpeechRecognition(); + const [preRecordingValue, setPreRecordingValue] = useState(""); - useEffect(() => { - if (!speech.isRecording) return; - const editor = internalRef.current; - if (!editor) return; - editor.clear(); - const combined = preRecordingValue - ? `${preRecordingValue} ${speech.transcript}` - : speech.transcript; - if (combined) { - editor.insertText(combined); - } - }, [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 - ).current = instance; - if (typeof inputRef === "function") { - inputRef(instance); - } else if (inputRef && typeof inputRef === "object") { - ( - inputRef as React.MutableRefObject - ).current = instance; - } - }, - [inputRef], - ); - - const fileInputRef = useRef(null); - - const handleFileSelect = useCallback( - (e: React.ChangeEvent) => { - 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) => { - onAttach?.([file]); - }, - [onAttach], - ); - - // Drag-and-drop support for image files. - const [isDragging, setIsDragging] = useState(false); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - if (e.dataTransfer.types.includes("Files")) { - setIsDragging(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setIsDragging(false); - } - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - if (!onAttach || !e.dataTransfer.files.length) return; - const images = Array.from(e.dataTransfer.files).filter((f) => - f.type.startsWith("image/"), - ); - 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. - const [hasContent, setHasContent] = useState(() => - Boolean(initialValue?.trim()), - ); - - const handleContentChange = useCallback( - (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()) { - internalRef.current?.focus(); - } - } + useEffect(() => { + if (!speech.isRecording) return; + const editor = internalRef.current; + if (!editor) return; + editor.clear(); + const combined = preRecordingValue + ? `${preRecordingValue} ${speech.transcript}` + : speech.transcript; + if (combined) { + editor.insertText(combined); } + }, [speech.transcript, speech.isRecording, preRecordingValue]); - const isUploading = attachments.some( - (f) => uploadStates?.get(f)?.status === "uploading", + // Forward the internal ref to the parent-supplied inputRef + // so both point to the same ChatMessageInputRef instance. + useImperativeHandle(inputRef, () => internalRef.current!, []); + + const fileInputRef = useRef(null); + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files && onAttach) { + onAttach(Array.from(e.target.files)); + } + // Reset so the same file can be selected again. + e.target.value = ""; + }; + + const handleFilePaste = (file: File) => { + onAttach?.([file]); + }; + + // Drag-and-drop support for image files. + const [isDragging, setIsDragging] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.types.includes("Files")) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragging(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (!onAttach || !e.dataTransfer.files.length) return; + const images = Array.from(e.dataTransfer.files).filter((f) => + f.type.startsWith("image/"), ); - const hasUploadedAttachments = attachments.some( - (f) => uploadStates?.get(f)?.status === "uploaded", - ); - const canSend = + if (images.length > 0) { + onAttach(images); + } + }; + + // Track whether the editor has content so we can gate the + // send button without a controlled value prop. + const [hasContent, setHasContent] = useState(() => + Boolean(initialValue?.trim()), + ); + + const handleContentChange = (content: string, hasRefs: boolean) => { + setHasContent(Boolean(content.trim())); + setHasFileReferences(hasRefs); + onContentChange?.(content); + }; + + // Re-focus the editor after a send completes (isLoading goes + // from true → false) so the user can immediately type again. + 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", + ); + const hasUploadedAttachments = attachments.some( + (f) => uploadStates?.get(f)?.status === "uploaded", + ); + const canSend = + !isDisabled && + !isLoading && + hasModelOptions && + (hasContent || hasUploadedAttachments || hasFileReferences) && + !isUploading; + const handleSubmit = () => { + const text = internalRef.current?.getValue()?.trim() ?? ""; + + // If the input is empty and there are queued messages, + // promote the first one instead of submitting. + if ( + !text && + !hasUploadedAttachments && + !hasFileReferences && !isDisabled && !isLoading && - hasModelOptions && - (hasContent || hasUploadedAttachments || hasFileReferences) && - !isUploading; - const handleSubmit = useCallback(() => { - const text = internalRef.current?.getValue()?.trim() ?? ""; + !isUploading && + queuedMessages.length > 0 && + onPromoteQueuedMessage + ) { + void onPromoteQueuedMessage(queuedMessages[0].id); + return; + } - // If the input is empty and there are queued messages, - // promote the first one instead of submitting. - if ( - !text && - !hasUploadedAttachments && - !hasFileReferences && - !isDisabled && - !isLoading && - !isUploading && - queuedMessages.length > 0 && - onPromoteQueuedMessage - ) { - void onPromoteQueuedMessage(queuedMessages[0].id); - return; + if ( + (!text && !hasUploadedAttachments && !hasFileReferences) || + isDisabled || + isLoading || + isUploading || + !hasModelOptions + ) { + return; + } + + onSend(text); + if (!isMobileViewport()) { + internalRef.current?.focus(); + } + }; + const handleStartRecording = () => { + setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? ""); + speech.start(); + }; + + const handleAcceptRecording = () => { + speech.stop(); + }; + + const handleCancelRecording = () => { + const original = preRecordingValue; + speech.cancel(); + const editor = internalRef.current; + if (editor) { + editor.clear(); + if (original) { + editor.insertText(original); } + } + setPreRecordingValue(""); + }; - if ( - (!text && !hasUploadedAttachments && !hasFileReferences) || - isDisabled || - isLoading || - isUploading || - !hasModelOptions - ) { - return; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (editingQueuedMessageID !== null) { + e.preventDefault(); + onCancelQueueEdit?.(); + } else if (isEditingHistoryMessage) { + e.preventDefault(); + onCancelHistoryEdit?.(); + } else if (isStreaming && onInterrupt && !isInterruptPending) { + e.preventDefault(); + onInterrupt(); } + } + }; - onSend(text); - if (!isMobileViewport()) { - internalRef.current?.focus(); - } - }, [ - isDisabled, - isLoading, - isUploading, - hasModelOptions, - hasUploadedAttachments, - hasFileReferences, - onSend, - queuedMessages, - onPromoteQueuedMessage, - ]); - const handleStartRecording = useCallback(() => { - setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? ""); - speech.start(); - }, [speech]); + const sendButtonLabel = + editingQueuedMessageID !== null + ? "Save" + : isEditingHistoryMessage + ? "Save Edit" + : "Send"; - const handleAcceptRecording = useCallback(() => { - speech.stop(); - }, [speech]); - - const handleCancelRecording = useCallback(() => { - const original = preRecordingValue; - speech.cancel(); - const editor = internalRef.current; - if (editor) { - editor.clear(); - if (original) { - editor.insertText(original); - } - } - setPreRecordingValue(""); - }, [speech, preRecordingValue]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (editingQueuedMessageID !== null) { - e.preventDefault(); - onCancelQueueEdit?.(); - } else if (isEditingHistoryMessage) { - e.preventDefault(); - onCancelHistoryEdit?.(); - } else if (isStreaming && onInterrupt && !isInterruptPending) { - e.preventDefault(); - onInterrupt(); - } - } - }; - - const sendButtonLabel = - editingQueuedMessageID !== null - ? "Save" - : isEditingHistoryMessage - ? "Save Edit" - : "Send"; - - const content = ( + const content = ( +
+ {queuedMessages.length > 0 && ( + { + if (id === editingQueuedMessageID) { + onCancelQueueEdit?.(); + } + void onDeleteQueuedMessage?.(id); + }} + onPromote={(id) => { + if (id === editingQueuedMessageID) { + onCancelQueueEdit?.(); + } + void onPromoteQueuedMessage?.(id); + }} + onEdit={onStartQueueEdit} + editingMessageID={editingQueuedMessageID} + className="mb-2" + /> + )}
- {queuedMessages.length > 0 && ( - { - if (id === editingQueuedMessageID) { - onCancelQueueEdit?.(); - } - void onDeleteQueuedMessage?.(id); - }} - onPromote={(id) => { - if (id === editingQueuedMessageID) { - onCancelQueueEdit?.(); - } - void onPromoteQueuedMessage?.(id); - }} - onEdit={onStartQueueEdit} - editingMessageID={editingQueuedMessageID} - className="mb-2" - /> - )} -
- {editingQueuedMessageID !== null && ( -
- - Editing queued message - - -
- )} - {isEditingHistoryMessage && editingQueuedMessageID === null && ( -
- - - {isLoading - ? "Saving edit..." - : "Editing will delete all subsequent messages and restart the conversation here."} - - -
- )} - {onRemoveAttachment && ( - - )} - - -
-
- {" "} - - {leftActions} - {inputStatusText && ( - - {inputStatusText} - - )} -
-
- {onAttach && ( - <> - - - - )} - {speech.isSupported && !isStreaming && ( - <> - - {speech.error && !speech.isRecording && ( - - {speech.error === "not-allowed" - ? "Mic access denied" - : "Voice input failed"} - - )} - - )} - {contextUsage !== undefined && ( - - )} - {isStreaming && onInterrupt && ( - - )} - {!(isStreaming && editingQueuedMessageID === null) && ( - - )} -
+ {editingQueuedMessageID !== null && ( +
+ + Editing queued message + +
- {inputStatusText && ( -
- {inputStatusText} -
- )} - {modelCatalogStatusMessage && ( -
- {modelCatalogStatusMessage} -
- )} -
-
- ); - - return ( - <> - {content} - {previewImage && ( - setPreviewImage(null)} + )} + {isEditingHistoryMessage && editingQueuedMessageID === null && ( +
+ + + {isLoading + ? "Saving edit..." + : "Editing will delete all subsequent messages and restart the conversation here."} + + +
+ )} + {onRemoveAttachment && ( + )} - - ); - }, -); -AgentChatInput.displayName = "AgentChatInput"; + + +
+
+ {" "} + + {leftActions} + {inputStatusText && ( + + {inputStatusText} + + )} +
+
+ {onAttach && ( + <> + + + + )} + {speech.isSupported && !isStreaming && ( + <> + + {speech.error && !speech.isRecording && ( + + {speech.error === "not-allowed" + ? "Mic access denied" + : "Voice input failed"} + + )} + + )} + {contextUsage !== undefined && ( + + )} + {isStreaming && onInterrupt && ( + + )} + {!(isStreaming && editingQueuedMessageID === null) && ( + + )} +
+
+ {inputStatusText && ( +
+ {inputStatusText} +
+ )} + {modelCatalogStatusMessage && ( +
+ {modelCatalogStatusMessage} +
+ )} +
+
+ ); + + return ( + <> + {content} + {previewImage && ( + setPreviewImage(null)} + /> + )} + + ); +}; diff --git a/site/src/pages/AgentsPage/AgentCreateForm.tsx b/site/src/pages/AgentsPage/AgentCreateForm.tsx index a8be2c980c..f9538497b2 100644 --- a/site/src/pages/AgentsPage/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/AgentCreateForm.tsx @@ -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 = ({ } return localStorage.getItem(lastModelConfigIDStorageKey) ?? ""; }); - const modelIDByConfigID = useMemo(() => { + const modelIDByConfigID = (() => { const optionIDByRef = new Map(); for (const option of modelOptions) { const provider = option.provider.trim().toLowerCase(); @@ -170,20 +163,17 @@ export const AgentCreateForm: FC = ({ 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 = ({ // that the onSend callback always sees the latest values without // the shared input component re-rendering on every change. const selectedWorkspaceIdRef = useRef(selectedWorkspaceId); - selectedWorkspaceIdRef.current = selectedWorkspaceId; const selectedModelRef = useRef(selectedModel); - selectedModelRef.current = selectedModel; + useEffect(() => { + selectedWorkspaceIdRef.current = selectedWorkspaceId; + selectedModelRef.current = selectedModel; + }); const handleWorkspaceChange = (value: string) => { if (value === autoCreateWorkspaceValue) { @@ -267,27 +259,24 @@ export const AgentCreateForm: FC = ({ } }; - const handleModelChange = useCallback((value: string) => { + const handleModelChange = (value: string) => { setHasUserSelectedModel(true); setUserSelectedModel(value); - }, []); + }; - const handleSend = useCallback( - async (message: string, fileIDs?: string[]) => { - submitDraft(); - await onCreateChat({ - message, - fileIDs, - workspaceId: selectedWorkspaceIdRef.current ?? undefined, - model: selectedModelRef.current || undefined, - }).catch(() => { - // Re-enable draft persistence so the user can edit - // and retry after a failed send attempt. - resetDraft(); - }); - }, - [submitDraft, resetDraft, onCreateChat], - ); + const handleSend = async (message: string, fileIDs?: string[]) => { + submitDraft(); + await onCreateChat({ + message, + fileIDs, + workspaceId: selectedWorkspaceIdRef.current ?? undefined, + model: selectedModelRef.current || undefined, + }).catch(() => { + // Re-enable draft persistence so the user can edit + // and retry after a failed send attempt. + resetDraft(); + }); + }; const selectedWorkspace = selectedWorkspaceId ? workspaceOptions.find((ws) => ws.id === selectedWorkspaceId) @@ -305,34 +294,32 @@ export const AgentCreateForm: FC = ({ resetAttachments, } = useFileAttachments(organizations[0]?.id); - const handleSendWithAttachments = useCallback( - async (message: string) => { - const fileIds: string[] = []; - let skippedErrors = 0; - for (const file of attachments) { - const state = uploadStates.get(file); - if (state?.status === "error") { - skippedErrors++; - continue; - } - if (state?.status === "uploaded" && state.fileId) { - fileIds.push(state.fileId); - } + const handleSendWithAttachments = async (message: string) => { + const fileIds: string[] = []; + let skippedErrors = 0; + for (const file of attachments) { + const state = uploadStates.get(file); + if (state?.status === "error") { + skippedErrors++; + continue; } - if (skippedErrors > 0) { - toast.warning( - `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, - ); + if (state?.status === "uploaded" && state.fileId) { + fileIds.push(state.fileId); } - try { - await handleSend(message, fileIds.length > 0 ? fileIds : undefined); - resetAttachments(); - } catch { - // Attachments preserved for retry on failure. - } - }, - [attachments, handleSend, resetAttachments, uploadStates], - ); + } + if (skippedErrors > 0) { + toast.warning( + `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, + ); + } + const fileArg = fileIds.length > 0 ? fileIds : undefined; + try { + await handleSend(message, fileArg); + resetAttachments(); + } catch { + // Attachments preserved for retry on failure. + } + }; return (
diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 75bc585e0e..64f1c8f6a4 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -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).current = + editorInitialValue; + } + }, [editorInitialValue, inputValueRef]); + // -- History editing state -- const [editingMessageId, setEditingMessageId] = useState(null); const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState< @@ -104,24 +106,21 @@ export function useConversationEditingState(deps: { readonly ChatMessagePart[] >([]); - const handleEditUserMessage = useCallback( - ( - messageId: number, - text: string, - fileBlocks?: readonly ChatMessagePart[], - ) => { - setDraftBeforeHistoryEdit((prev) => - editingMessageId !== null ? prev : inputValueRef.current, - ); - setEditingMessageId(messageId); - setEditorInitialValue(text); - inputValueRef.current = text; - setEditingFileBlocks(fileBlocks ?? []); - }, - [editingMessageId, inputValueRef], - ); + const handleEditUserMessage = ( + messageId: number, + text: string, + fileBlocks?: readonly ChatMessagePart[], + ) => { + setDraftBeforeHistoryEdit((prev) => + editingMessageId !== null ? prev : inputValueRef.current, + ); + setEditingMessageId(messageId); + setEditorInitialValue(text); + inputValueRef.current = text; + setEditingFileBlocks(fileBlocks ?? []); + }; - 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,81 +140,68 @@ export function useConversationEditingState(deps: { string | null >(null); - const handleStartQueueEdit = useCallback( - (id: number, text: string, fileBlocks: readonly ChatMessagePart[]) => { - setDraftBeforeQueueEdit((prev) => - editingQueuedMessageID === null ? inputValueRef.current : prev, - ); - setEditingQueuedMessageID(id); - setEditorInitialValue(text); - inputValueRef.current = text; - setEditingFileBlocks(fileBlocks); - }, - [editingQueuedMessageID, inputValueRef], - ); + const handleStartQueueEdit = ( + id: number, + text: string, + fileBlocks: readonly ChatMessagePart[], + ) => { + setDraftBeforeQueueEdit((prev) => + editingQueuedMessageID === null ? inputValueRef.current : prev, + ); + setEditingQueuedMessageID(id); + setEditorInitialValue(text); + inputValueRef.current = text; + setEditingFileBlocks(fileBlocks); + }; - 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 editedMessageID = - editingMessageId !== null ? editingMessageId : undefined; - const queueEditID = editingQueuedMessageID; + const handleSendFromInput = async (message: string, fileIds?: string[]) => { + const editedMessageID = + editingMessageId !== null ? editingMessageId : undefined; + const queueEditID = editingQueuedMessageID; - await onSend(message, fileIds, editedMessageID); - // Clear input and editing state on success. - chatInputRef.current?.clear(); - if (!isMobileViewport()) { - chatInputRef.current?.focus(); - } - inputValueRef.current = ""; - if (typeof window !== "undefined" && draftStorageKey) { + await onSend(message, fileIds, editedMessageID); + // Clear input and editing state on success. + chatInputRef.current?.clear(); + if (!isMobileViewport()) { + chatInputRef.current?.focus(); + } + inputValueRef.current = ""; + if (typeof window !== "undefined" && draftStorageKey) { + localStorage.removeItem(draftStorageKey); + } + if (editingMessageId !== null) { + setEditingMessageId(null); + setDraftBeforeHistoryEdit(null); + setEditingFileBlocks([]); + } + if (queueEditID !== null) { + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + setEditingFileBlocks([]); + void onDeleteQueuedMessage(queueEditID); + } + }; + + const handleContentChange = (content: string) => { + inputValueRef.current = content; + if (typeof window !== "undefined" && draftStorageKey) { + if (content) { + localStorage.setItem(draftStorageKey, content); + } else { localStorage.removeItem(draftStorageKey); } - if (editingMessageId !== null) { - setEditingMessageId(null); - setDraftBeforeHistoryEdit(null); - setEditingFileBlocks([]); - } - if (queueEditID !== null) { - setEditingQueuedMessageID(null); - setDraftBeforeQueueEdit(null); - setEditingFileBlocks([]); - void onDeleteQueuedMessage(queueEditID); - } - }, - [ - chatInputRef, - editingMessageId, - editingQueuedMessageID, - onDeleteQueuedMessage, - onSend, - draftStorageKey, - inputValueRef, - ], - ); - - const handleContentChange = useCallback( - (content: string) => { - inputValueRef.current = content; - if (typeof window !== "undefined" && draftStorageKey) { - if (content) { - localStorage.setItem(draftStorageKey, content); - } else { - localStorage.removeItem(draftStorageKey); - } - } - }, - [draftStorageKey, inputValueRef], - ); + } + }; return { inputValueRef, @@ -263,7 +249,11 @@ const AgentDetail: FC = () => { } = outletContext; const scrollContainerRef = useRef(null); const chatInputRef = useRef(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,18 +262,17 @@ 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)) => { - setShowSidebarPanel((prev) => { - const value = typeof next === "function" ? next(prev) : next; - if (typeof window !== "undefined") { - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value)); - } - return value; - }); - }, - [], - ); + const handleSetShowSidebarPanel = ( + next: boolean | ((prev: boolean) => boolean), + ) => { + setShowSidebarPanel((prev) => { + const value = typeof next === "function" ? next(prev) : next; + if (typeof window !== "undefined") { + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value)); + } + 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( workspaceByIdKey(workspaceId), - event.parsedMessage.data as TypesGen.Workspace, + (prev) => { + // Return the same reference when nothing the UI + // reads has changed. This prevents react-query + // from notifying subscribers and avoids a full + // AgentDetail re-render on every heartbeat. + if ( + prev && + prev.latest_build.status === next.latest_build.status && + prev.latest_build.resources === next.latest_build.resources && + prev.name === next.name && + prev.owner_name === next.owner_name + ) { + return prev; + } + return next; + }, ); } }); @@ -329,39 +334,44 @@ const AgentDetail: FC = () => { const workspaceAgent = getWorkspaceAgent(workspace, undefined); const { proxy } = useProxy(); - const urlTransform = useCallback( - (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 { + const parsed = new URL(url); + if (!localHosts.has(parsed.hostname)) { return url; } - try { - const parsed = new URL(url); - if (!localHosts.has(parsed.hostname)) { - return url; - } - return portForwardURL( - host, - Number.parseInt(parsed.port, 10), - workspaceAgent.name, - workspace.name, - workspace.owner_name, - "http", - parsed.pathname, - parsed.search, - ); - } catch { - return url; - } - }, - [proxy.preferredWildcardHostname, workspaceAgent, workspace], - ); + return portForwardURL( + proxyHost, + Number.parseInt(parsed.port, 10), + agentName, + wsName, + wsOwner, + "http", + parsed.pathname, + parsed.search, + ); + } catch { + return url; + } + }; 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 { - messages: chatMessagesList, - queued_messages: chatQueuedMessages ?? [], - has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false, - }; - }, [chatMessagesList, chatQueuedMessages, chatMessagesQuery.data]); + chatMessagesList + ? { + messages: chatMessagesList, + queued_messages: chatQueuedMessages ?? [], + has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false, + } + : 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,29 +501,26 @@ const AgentDetail: FC = () => { interruptMutation.isPending; const isInputDisabled = !hasModelOptions || isArchived; - const handleUsageLimitError = useCallback( - (error: unknown): void => { - if (!agentId) { - return; - } - if ( - isApiError(error) && - error.response?.status === 409 && - isUsageLimitData(error.response.data) - ) { - setChatErrorReason(agentId, { - kind: "usage-limit", - message: formatUsageLimitMessage(error.response.data), - }); - } else if (isApiError(error)) { - setChatErrorReason(agentId, { - kind: "generic", - message: error.message || "An unexpected error occurred.", - }); - } - }, - [agentId, setChatErrorReason], - ); + const handleUsageLimitError = (error: unknown): void => { + if (!agentId) { + return; + } + if ( + isApiError(error) && + error.response?.status === 409 && + isUsageLimitData(error.response.data) + ) { + setChatErrorReason(agentId, { + kind: "usage-limit", + message: formatUsageLimitMessage(error.response.data), + }); + } else if (isApiError(error)) { + setChatErrorReason(agentId, { + kind: "generic", + message: error.message || "An unexpected error occurred.", + }); + } + }; 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,28 +613,29 @@ const AgentDetail: FC = () => { // timeline when the server confirms via the POST response or // via the SSE stream. store.clearStreamState(); + let response: Awaited>; try { - const response = await sendMutation.mutateAsync(request); - // When the server accepts the message immediately (not - // queued), insert it into the store so it appears in the - // timeline without waiting for the SSE stream. - if (!response.queued && response.message) { - store.upsertDurableMessage(response.message); - } - if (typeof window !== "undefined") { - if (selectedModelConfigID) { - localStorage.setItem( - lastModelConfigIDStorageKey, - selectedModelConfigID, - ); - } else { - localStorage.removeItem(lastModelConfigIDStorageKey); - } - } + 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. + if (!response.queued && response.message) { + store.upsertDurableMessage(response.message); + } + if (typeof window !== "undefined") { + if (selectedModelConfigID) { + localStorage.setItem( + lastModelConfigIDStorageKey, + selectedModelConfigID, + ); + } else { + localStorage.removeItem(lastModelConfigIDStorageKey); + } + } }; const handleInterrupt = () => { @@ -641,57 +645,45 @@ const AgentDetail: FC = () => { void interruptMutation.mutateAsync(); }; - const handleDeleteQueuedMessage = useCallback( - async (id: number) => { - const previousQueuedMessages = store.getSnapshot().queuedMessages; - store.setQueuedMessages( - previousQueuedMessages.filter((message) => message.id !== id), - ); - try { - await deleteQueuedMutation.mutateAsync(id); - } catch (error) { - store.setQueuedMessages(previousQueuedMessages); - throw error; - } - }, - [deleteQueuedMutation, store], - ); + const handleDeleteQueuedMessage = async (id: number) => { + const previousQueuedMessages = store.getSnapshot().queuedMessages; + store.setQueuedMessages( + previousQueuedMessages.filter((message) => message.id !== id), + ); + try { + await deleteQueuedMutation.mutateAsync(id); + } catch (error) { + store.setQueuedMessages(previousQueuedMessages); + throw error; + } + }; - const handlePromoteQueuedMessage = useCallback( - async (id: number) => { - const previousSnapshot = store.getSnapshot(); - const previousQueuedMessages = previousSnapshot.queuedMessages; - const previousChatStatus = previousSnapshot.chatStatus; - store.setQueuedMessages( - previousQueuedMessages.filter((message) => message.id !== id), - ); - store.clearStreamState(); - if (agentId) { - clearChatErrorReason(agentId); - } - store.clearStreamError(); - store.setChatStatus("pending"); - try { - const promotedMessage = await promoteQueuedMutation.mutateAsync(id); - // Insert the promoted message into the store immediately - // so it appears in the timeline without waiting for the - // WebSocket to deliver it. - store.upsertDurableMessage(promotedMessage); - } catch (error) { - store.setQueuedMessages(previousQueuedMessages); - store.setChatStatus(previousChatStatus); - handleUsageLimitError(error); - throw error; - } - }, - [ - agentId, - clearChatErrorReason, - handleUsageLimitError, - promoteQueuedMutation, - store, - ], - ); + const handlePromoteQueuedMessage = async (id: number) => { + const previousSnapshot = store.getSnapshot(); + const previousQueuedMessages = previousSnapshot.queuedMessages; + const previousChatStatus = previousSnapshot.chatStatus; + store.setQueuedMessages( + previousQueuedMessages.filter((message) => message.id !== id), + ); + store.clearStreamState(); + if (agentId) { + clearChatErrorReason(agentId); + } + store.clearStreamError(); + store.setChatStatus("pending"); + try { + const promotedMessage = await promoteQueuedMutation.mutateAsync(id); + // Insert the promoted message into the store immediately + // so it appears in the timeline without waiting for the + // WebSocket to deliver it. + store.upsertDurableMessage(promotedMessage); + } catch (error) { + store.setQueuedMessages(previousQueuedMessages); + store.setChatStatus(previousChatStatus); + handleUsageLimitError(error); + throw error; + } + }; const editing = useConversationEditingState({ chatID: agentId, @@ -821,6 +813,7 @@ const AgentDetail: FC = () => { /> ); } + return ( 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; - emit(); + 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 | 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(createChatStore()); + const [store] = useState(createChatStore); const streamResetFrameRef = useRef(null); const queuedMessagesHydratedChatIDRef = useRef(null); // Tracks whether the WebSocket has delivered a queue_update for the @@ -459,18 +517,29 @@ export const useChatStore = ( // server. const lastSyncedMessagesRef = useRef([]); - 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(undefined); - lastMessageIdRef.current = - chatMessages && chatMessages.length > 0 - ? chatMessages[chatMessages.length - 1].id - : 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,119 +548,46 @@ 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 | 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. - 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 - // 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 (chatMessages) { - const prev = lastSyncedMessagesRef.current; - const contentChanged = - chatMessages.length !== prev.length || - chatMessages.some((m, i) => m !== prev[i]); - lastSyncedMessagesRef.current = chatMessages; + 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, preserving + // any messages the WebSocket delivered that haven't + // appeared in a REST page yet. + // + // 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 = + chatMessages.length !== prev.length || + chatMessages.some((m, i) => m !== prev[i]); + lastSyncedMessagesRef.current = chatMessages; - const storeSnap = store.getSnapshot(); - const fetchedIDs = new Set(chatMessages.map((m) => m.id)); - const hasStaleEntries = - contentChanged && - storeSnap.orderedMessageIDs.some((id) => !fetchedIDs.has(id)); - if (hasStaleEntries) { - store.replaceMessages(chatMessages); - } else { - for (const message of chatMessages) { - store.upsertDurableMessage(message); + const storeSnap = store.getSnapshot(); + const fetchedIDs = new Set(chatMessages.map((m) => m.id)); + const hasStaleEntries = + contentChanged && + storeSnap.orderedMessageIDs.some((id) => !fetchedIDs.has(id)); + if (hasStaleEntries) { + store.replaceMessages(chatMessages); + } else { + 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 | 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 | 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, ) => { @@ -659,158 +772,144 @@ 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); - }); - }; - - for (const streamEvent of streamEvents) { - if (streamEvent.type === "message_part") { - if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { - continue; - } - if (!shouldApplyMessagePart()) { - continue; - } - const part = streamEvent.message_part?.part; - if (part) { - cancelScheduledStreamReset(); - pendingMessageParts.push(part); - } - continue; - } - flushMessageParts(); - - switch (streamEvent.type) { - case "message": { - const message = streamEvent.message; - if (!message) { - continue; - } + // 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) { 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. - if ( - message.id !== undefined && - (lastMessageIdRef.current === undefined || - message.id > lastMessageIdRef.current) - ) { - lastMessageIdRef.current = message.id; + if (!shouldApplyMessagePart()) { + continue; } - if (changed && message.role === "assistant") { - scheduleStreamReset(); + const part = streamEvent.message_part?.part; + if (part) { + cancelScheduledStreamReset(); + partsBuf.push(part); } - // Do not update updated_at here. The global - // chat-list WebSocket delivers the authoritative - // server timestamp; fabricating a client-side - // value causes the chat to flicker between time - // groups when the two sources race. continue; } - case "queue_update": - if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { - continue; - } - wsQueueUpdateReceivedRef.current = true; - store.setQueuedMessages(streamEvent.queued_messages); - updateChatQueuedMessages(streamEvent.queued_messages); - continue; - case "status": { - const nextStatus = streamEvent.status?.status; - if (!nextStatus) { - continue; - } + flushMessageParts(); - if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { - store.setSubagentStatusOverride(streamEvent.chat_id, nextStatus); + switch (streamEvent.type) { + case "message": { + const message = streamEvent.message; + if (!message) { + continue; + } + if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { + continue; + } + pendingMessages.push(message); + if ( + message.id !== undefined && + (lastMessageIdRef.current === undefined || + message.id > lastMessageIdRef.current) + ) { + lastMessageIdRef.current = message.id; + } + if (message.role === "assistant") { + needsStreamReset = true; + } continue; } - - store.setChatStatus(nextStatus); - if (nextStatus === "pending" || nextStatus === "waiting") { - store.clearStreamState(); + case "queue_update": + if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { + continue; + } + wsQueueUpdateReceivedRef.current = true; + store.setQueuedMessages(streamEvent.queued_messages); + updateChatQueuedMessages(streamEvent.queued_messages); + continue; + case "status": { + const nextStatus = streamEvent.status?.status; + if (!nextStatus) { + continue; + } + if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { + store.setSubagentStatusOverride( + streamEvent.chat_id, + nextStatus, + ); + continue; + } + store.setChatStatus(nextStatus); + if (nextStatus === "pending" || nextStatus === "waiting") { + store.clearStreamState(); + store.clearRetryState(); + } + if (nextStatus === "running") { + store.clearRetryState(); + } + if (nextStatus !== "error") { + clearChatErrorReasonRef.current(chatID); + } + updateSidebarChat((chat) => + chat.status === nextStatus + ? chat + : { ...chat, status: nextStatus }, + ); + continue; + } + case "error": { + if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { + continue; + } + const reason = + streamEvent.error?.message.trim() || "Chat processing failed."; + store.setChatStatus("error"); + store.setStreamError(reason); store.clearRetryState(); - } - if (nextStatus === "running") { - store.clearRetryState(); - } - if (nextStatus !== "error") { - clearChatErrorReason(chatID); - } - updateSidebarChat((chat) => ({ - ...chat, - status: nextStatus, - })); - continue; - } - case "error": { - if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { - continue; - } - const reason = - streamEvent.error?.message.trim() || "Chat processing failed."; - store.setChatStatus("error"); - store.setStreamError(reason); - store.clearRetryState(); - setChatErrorReason(chatID, { - kind: "generic", - message: reason, - }); - updateSidebarChat((chat) => ({ - ...chat, - status: "error", - })); - continue; - } - case "retry": { - if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { - continue; - } - const retry = streamEvent.retry; - if (retry) { - store.clearStreamState(); - store.setRetryState({ - attempt: retry.attempt, - error: retry.error, + setChatErrorReasonRef.current(chatID, { + kind: "generic", + message: reason, }); + updateSidebarChat((chat) => + chat.status === "error" ? chat : { ...chat, status: "error" }, + ); + continue; } - continue; + case "retry": { + if (streamEvent.chat_id && streamEvent.chat_id !== chatID) { + continue; + } + const retry = streamEvent.retry; + if (retry) { + store.clearStreamState(); + store.setRetryState({ + attempt: retry.attempt, + error: retry.error, + }); + } + continue; + } + default: + continue; } - default: - continue; } - } - flushMessageParts(); - }; + // Schedule a rAF-coalesced flush for any remaining + // parts. If parts were already flushed by a + // non-message_part event above, this is a no-op. + schedulePartsFlush(); + + // Bulk-upsert all collected durable messages in one + // pass: one Map copy + one sort instead of N each. + if (pendingMessages.length > 0) { + store.upsertDurableMessages(pendingMessages); + } + }); + if (needsStreamReset) { + scheduleStreamReset(); + } + }; const disposeSocket = createReconnectingWebSocket({ 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 = ( 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); }; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index d7a6b804d3..f40092be64 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -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; @@ -501,78 +500,75 @@ export const StreamingOutput = memo<{ showInitialPlaceholder?: boolean; retryState?: { attempt: number; error: string } | null; urlTransform?: UrlTransform; -}>( - ({ - streamState, - streamTools, +}> = ({ + streamState, + streamTools, + subagentTitles, + subagentStatusOverrides, + showInitialPlaceholder = false, + retryState, + urlTransform, +}) => { + const conversationItemProps = { role: "assistant" as const }; + const toolByID = new Map(streamTools.map((tool) => [tool.id, tool])); + const blocks = streamState?.blocks ?? []; + const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({ + blocks, + toolByID, + keyPrefix: "stream", + isStreaming: true, subagentTitles, subagentStatusOverrides, - showInitialPlaceholder = false, - retryState, urlTransform, - }) => { - const conversationItemProps = { role: "assistant" as const }; - const toolByID = new Map(streamTools.map((tool) => [tool.id, tool])); - const blocks = streamState?.blocks ?? []; - const { elements: orderedBlocks, renderedToolIDs } = renderBlockList({ - blocks, - toolByID, - keyPrefix: "stream", - isStreaming: true, - subagentTitles, - subagentStatusOverrides, - urlTransform, - }); - const remainingTools = streamTools.filter( - (tool) => !renderedToolIDs.has(tool.id), - ); + }); + const remainingTools = streamTools.filter( + (tool) => !renderedToolIDs.has(tool.id), + ); - return ( - - - -
- {orderedBlocks} - {showInitialPlaceholder || - (streamState && - orderedBlocks.length === 0 && - streamTools.length === 0) ? ( -
- - {`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`} - -
- - Thinking... - - {retryState && ( - - attempt {retryState.attempt} - - )} -
+ return ( + + + +
+ {orderedBlocks} + {showInitialPlaceholder || + (streamState && + orderedBlocks.length === 0 && + streamTools.length === 0) ? ( +
+ + {`Thinking...${retryState ? ` attempt ${retryState.attempt}` : ""}`} + +
+ + Thinking... + + {retryState && ( + + attempt {retryState.attempt} + + )}
- ) : null} - {remainingTools.map((tool) => ( - - ))} -
- - - - ); - }, -); -StreamingOutput.displayName = "StreamingOutput"; +
+ ) : null} + {remainingTools.map((tool) => ( + + ))} +
+ + + + ); +}; const StickyUserMessage: FC<{ message: TypesGen.ChatMessage; diff --git a/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.ts b/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.ts index 05d25ae541..9b0ad717f7 100644 --- a/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.ts +++ b/site/src/pages/AgentsPage/AgentDetail/useWorkspaceCreationWatcher.ts @@ -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[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 | 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>(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]); } diff --git a/site/src/pages/AgentsPage/AgentDetailContent.tsx b/site/src/pages/AgentsPage/AgentDetailContent.tsx index 21914fa7e6..b486278176 100644 --- a/site/src/pages/AgentsPage/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/AgentDetailContent.tsx @@ -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 = ({ +// 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 = ({ store, persistedErrorReason, onOpenAnalytics, @@ -63,7 +67,6 @@ export const AgentDetailTimeline: FC = ({ }) => { 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 = ({ ); const retryState = useChatSelector(store, selectRetryState); - const messages = useMemo( - () => - 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], - ); + const messages = orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .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 = ({ const latestMessage = messages[messages.length - 1]; const latestMessageNeedsAssistantResponse = !latestMessage || latestMessage.role !== "assistant"; + + return ( + + ); +}; + +// 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; + subagentStatusOverrides: Map; + 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 = ({ return ( = ({ ); }; +export const AgentDetailTimeline: FC = (props) => { + return ; +}; + interface AgentDetailInputProps { store: ChatStoreHandle; compressionThreshold: number | undefined; @@ -197,22 +250,18 @@ export const AgentDetailInput: FC = ({ const chatStatus = useChatSelector(store, selectChatStatus); const queuedMessages = useChatSelector(store, selectQueuedMessages); - const messages = useMemo( - () => - orderedMessageIDs - .map((messageID) => messagesByID.get(messageID)) - .filter(isChatMessage), - [messagesByID, orderedMessageIDs], - ); + const messages = orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .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,31 +322,33 @@ export const AgentDetailInput: FC = ({ { void (async () => { + // Collect file IDs from already-uploaded attachments. + // Skip files in error state (e.g. too large). + const fileIds: string[] = []; + let skippedErrors = 0; + for (const file of attachments) { + const state = uploadStates.get(file); + if (state?.status === "error") { + skippedErrors++; + continue; + } + if (state?.status === "uploaded" && state.fileId) { + fileIds.push(state.fileId); + } + } + if (skippedErrors > 0) { + toast.warning( + `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, + ); + } + const fileArg = fileIds.length > 0 ? fileIds : undefined; try { - // Collect file IDs from already-uploaded attachments. - // Skip files in error state (e.g. too large). - const fileIds: string[] = []; - let skippedErrors = 0; - for (const file of attachments) { - const state = uploadStates.get(file); - if (state?.status === "error") { - skippedErrors++; - continue; - } - if (state?.status === "uploaded" && state.fileId) { - fileIds.push(state.fileId); - } - } - if (skippedErrors > 0) { - toast.warning( - `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, - ); - } - await onSend(message, fileIds.length > 0 ? fileIds : undefined); - resetAttachments(); + await onSend(message, fileArg); } catch { // Attachments preserved for retry on failure. + return; } + resetAttachments(); })(); }} attachments={attachments} diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index 1670166464..3c983150cb 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -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(null); const observerRef = useRef(null); const isFetchingRef = useRef(isFetchingMoreMessages); - isFetchingRef.current = isFetchingMoreMessages; const onFetchRef = useRef(onFetchMoreMessages); - onFetchRef.current = 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 (
diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index c9f7235c6d..7e173a9d5f 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -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); - latestEmbedSessionMutationRef.current = embedSessionMutation; + useEffect(() => { + latestEmbedSessionMutationRef.current = embedSessionMutation; + }); const inFlightBootstrapRef = useRef | null>(null); const [chatErrorReasons, setChatErrorReasons] = useState< @@ -69,31 +64,28 @@ const AgentEmbedPage: FC = () => { >({}); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const setChatErrorReason = useCallback( - (chatId: string, reason: ChatDetailError) => { - const trimmedMessage = reason.message.trim(); - if (!chatId || !trimmedMessage) { - return; + const setChatErrorReason = (chatId: string, reason: ChatDetailError) => { + const trimmedMessage = reason.message.trim(); + if (!chatId || !trimmedMessage) { + return; + } + setChatErrorReasons((current) => { + const existing = current[chatId]; + if ( + existing && + existing.kind === reason.kind && + existing.message === trimmedMessage + ) { + return current; } - setChatErrorReasons((current) => { - const existing = current[chatId]; - if ( - existing && - existing.kind === reason.kind && - existing.message === trimmedMessage - ) { - return current; - } - return { - ...current, - [chatId]: { kind: reason.kind, message: trimmedMessage }, - }; - }); - }, - [], - ); + return { + ...current, + [chatId]: { kind: reason.kind, message: trimmedMessage }, + }; + }); + }; - const clearChatErrorReason = useCallback((chatId: string) => { + const clearChatErrorReason = (chatId: string) => { if (!chatId) { return; } @@ -105,51 +97,39 @@ 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( - () => ({ - chatErrorReasons, - setChatErrorReason, - clearChatErrorReason, - requestArchiveAgent, - requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, - isSidebarCollapsed, - onToggleSidebarCollapsed, - modelOptions: [], - modelConfigIDByModelID: new Map(), - modelIDByConfigID: new Map(), - modelConfigs: [], - modelCatalog: undefined, - isModelCatalogLoading: false, - modelCatalogError: null, - desktopEnabled: false, - }), - [ - chatErrorReasons, - setChatErrorReason, - clearChatErrorReason, - requestArchiveAgent, - requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, - isSidebarCollapsed, - onToggleSidebarCollapsed, - ], - ); + const outletContext: AgentsOutletContext = { + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + requestArchiveAgent, + requestUnarchiveAgent, + requestArchiveAndDeleteWorkspace, + isSidebarCollapsed, + onToggleSidebarCollapsed, + modelOptions: [], + modelConfigIDByModelID: new Map(), + modelIDByConfigID: new Map(), + modelConfigs: [], + modelCatalog: undefined, + isModelCatalogLoading: false, + modelCatalogError: null, + desktopEnabled: false, + }; // 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 ( diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 9f5243275f..03aee13a02 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -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 >({}); - const catalogModelOptions = useMemo( - () => - getModelOptionsFromCatalog( - chatModelsQuery.data, - chatModelConfigsQuery.data, - ), - [chatModelsQuery.data, chatModelConfigsQuery.data], + const catalogModelOptions = getModelOptionsFromCatalog( + chatModelsQuery.data, + chatModelConfigsQuery.data, ); - const modelConfigIDByModelID = useMemo(() => { + const modelConfigIDByModelID = (() => { const byModelID = new Map(); for (const config of chatModelConfigsQuery.data ?? []) { const { provider, model } = getNormalizedModelRef(config); @@ -213,31 +225,28 @@ const AgentsPage: FC = () => { } } return byModelID; - }, [chatModelConfigsQuery.data]); - const setChatErrorReason = useCallback( - (chatId: string, reason: ChatDetailError) => { - const trimmedMessage = reason.message.trim(); - if (!chatId || !trimmedMessage) { - return; + })(); + const setChatErrorReason = (chatId: string, reason: ChatDetailError) => { + const trimmedMessage = reason.message.trim(); + if (!chatId || !trimmedMessage) { + return; + } + setChatErrorReasons((current) => { + const existing = current[chatId]; + if ( + existing && + existing.kind === reason.kind && + existing.message === trimmedMessage + ) { + return current; } - setChatErrorReasons((current) => { - const existing = current[chatId]; - if ( - existing && - existing.kind === reason.kind && - existing.message === trimmedMessage - ) { - return current; - } - return { - ...current, - [chatId]: { kind: reason.kind, message: trimmedMessage }, - }; - }); - }, - [], - ); - const clearChatErrorReason = useCallback((chatId: string) => { + return { + ...current, + [chatId]: { kind: reason.kind, message: trimmedMessage }, + }; + }); + }; + 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,43 +269,40 @@ const AgentsPage: FC = () => { (archiveAndDeleteMutation.isPending ? archiveAndDeleteMutation.variables?.chatId : undefined); - const requestArchiveAgent = useCallback( - (chatId: string) => { - if (!isArchiving) { - archiveAgentMutation.mutate(chatId); - } - }, - [isArchiving, archiveAgentMutation], - ); - const requestArchiveAndDeleteWorkspace = useCallback( - async (chatId: string, workspaceId: string) => { - if (isArchiving) { - return; - } - try { - const action = await resolveArchiveAndDeleteAction( - () => queryClient.fetchQuery(workspaceById(workspaceId)), - () => - readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId) - ?.created_at, + const requestArchiveAgent = (chatId: string) => { + if (!isArchiving) { + archiveAgentMutation.mutate(chatId); + } + }; + const requestArchiveAndDeleteWorkspace = async ( + chatId: string, + workspaceId: string, + ) => { + if (isArchiving) { + return; + } + try { + const action = await resolveArchiveAndDeleteAction( + () => queryClient.fetchQuery(workspaceById(workspaceId)), + () => + readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId) + ?.created_at, + ); + if (action === "proceed") { + archiveAndDeleteMutation.mutate( + { chatId, workspaceId }, + { + onSettled: () => navigate("/agents"), + }, ); - if (action === "proceed") { - archiveAndDeleteMutation.mutate( - { chatId, workspaceId }, - { - onSettled: () => navigate("/agents"), - }, - ); - } else { - setPendingArchiveAndDelete({ chatId, workspaceId }); - } - } catch { - toast.error("Failed to look up workspace for deletion."); + } else { + setPendingArchiveAndDelete({ chatId, workspaceId }); } - }, - [isArchiving, queryClient, archiveAndDeleteMutation, navigate], - ); - const handleConfirmArchiveAndDelete = useCallback(() => { + } catch { + toast.error("Failed to look up workspace for deletion."); + } + }; + 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) => { - unarchiveAgentMutation.mutate(chatId); - }, - [unarchiveAgentMutation], - ); - const handleToggleSidebarCollapsed = useCallback( - () => setIsSidebarCollapsed((prev) => !prev), - [], - ); + }; + const requestUnarchiveAgent = (chatId: string) => { + unarchiveAgentMutation.mutate(chatId); + }; + 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); - activeChatIDRef.current = 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; } + // Only create a new object if a field actually + // changed. Returning the same reference prevents + // react-query from notifying subscribers, avoiding + // unnecessary re-renders of AgentDetail during + // streaming when repeated status_change events + // carry the same "running" status. + const nextStatus = isStatusEvent + ? updatedChat.status + : previousChat.status; + const nextTitle = isTitleEvent + ? updatedChat.title + : previousChat.title; + const nextDiffStatus = isDiffStatusEvent + ? updatedChat.diff_status + : previousChat.diff_status; + const nextWorkspaceId = + updatedChat.workspace_id ?? previousChat.workspace_id; + const nextUpdatedAt = + previousChat.updated_at > updatedChat.updated_at + ? previousChat.updated_at + : updatedChat.updated_at; + + if ( + nextStatus === previousChat.status && + nextTitle === previousChat.title && + diffStatusEqual(nextDiffStatus, previousChat.diff_status) && + nextWorkspaceId === previousChat.workspace_id + ) { + return previousChat; + } return { ...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: - previousChat.updated_at > updatedChat.updated_at - ? previousChat.updated_at - : updatedChat.updated_at, + status: nextStatus, + title: nextTitle, + diff_status: nextDiffStatus, + workspace_id: nextWorkspaceId, + updated_at: nextUpdatedAt, }; }, ); diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 7078860e9e..9261213a19 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -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 = ({ 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( - Object.entries(chatErrorReasons).map(([chatId, error]) => [ - chatId, - error.message, - ]), - ), - [chatErrorReasons], + const sidebarChatErrorReasons = Object.fromEntries( + Object.entries(chatErrorReasons).map(([chatId, error]) => [ + chatId, + error.message, + ]), ); - const modelIDByConfigID = useMemo(() => { + const modelIDByConfigID = (() => { const byConfigID = new Map(); for (const [modelID, configID] of modelConfigIDByModelID.entries()) { if (!byConfigID.has(configID)) { @@ -153,48 +149,27 @@ export const AgentsPageView: FC = ({ } } return byConfigID; - }, [modelConfigIDByModelID]); + })(); - const outletContextValue: AgentsOutletContext = useMemo( - () => ({ - chatErrorReasons, - setChatErrorReason, - clearChatErrorReason, - requestArchiveAgent, - requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, - isSidebarCollapsed, - onToggleSidebarCollapsed, - onOpenAnalytics: handleOpenAnalytics, - modelOptions: catalogModelOptions, - modelConfigIDByModelID, - modelIDByConfigID, - modelConfigs, - modelCatalog, - isModelCatalogLoading, - modelCatalogError, - desktopEnabled, - }), - [ - chatErrorReasons, - setChatErrorReason, - clearChatErrorReason, - requestArchiveAgent, - requestUnarchiveAgent, - requestArchiveAndDeleteWorkspace, - isSidebarCollapsed, - onToggleSidebarCollapsed, - handleOpenAnalytics, - catalogModelOptions, - modelConfigIDByModelID, - modelIDByConfigID, - modelConfigs, - modelCatalog, - isModelCatalogLoading, - modelCatalogError, - desktopEnabled, - ], - ); + const outletContextValue: AgentsOutletContext = { + chatErrorReasons, + setChatErrorReason, + clearChatErrorReason, + requestArchiveAgent, + requestUnarchiveAgent, + requestArchiveAndDeleteWorkspace, + isSidebarCollapsed, + onToggleSidebarCollapsed, + onOpenAnalytics: handleOpenAnalytics, + modelOptions: catalogModelOptions, + modelConfigIDByModelID, + modelIDByConfigID, + modelConfigs, + modelCatalog, + isModelCatalogLoading, + modelCatalogError, + desktopEnabled, + }; return (
diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index 72956f8ed6..ac64605b53 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -60,10 +60,8 @@ import { createContext, type FC, memo, - useCallback, useContext, useEffect, - useMemo, useRef, useState, } from "react"; @@ -581,7 +579,6 @@ const ChatTreeNode = memo(({ chat, isChildNode }) => {
); }); -ChatTreeNode.displayName = "ChatTreeNode"; export const AgentsSidebar: FC = (props) => { const { @@ -620,39 +617,42 @@ export const AgentsSidebar: FC = (props) => { const normalizedSearch = ""; const [expandedById, setExpandedById] = useState>({}); - const chatTree = useMemo(() => buildChatTree(chats), [chats]); - const chatById = useMemo(() => { - return new Map(chats.map((chat) => [chat.id, chat] as const)); - }, [chats]); - const visibleChatIDs = useMemo( - () => - collectVisibleChatIDs({ - chats, - search: normalizedSearch, - tree: chatTree, - }), - [chats, chatTree], - ); - const visibleRootIDs = useMemo( - () => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)), - [chatTree.rootIds, visibleChatIDs], + const chatTree = buildChatTree(chats); + const chatById = new Map(chats.map((chat) => [chat.id, chat] as const)); + const visibleChatIDs = collectVisibleChatIDs({ + chats, + search: normalizedSearch, + tree: chatTree, + }); + 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(); 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,45 +660,27 @@ export const AgentsSidebar: FC = (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( - () => ({ - chatTree, - chatById, - visibleChatIDs, - normalizedSearch, - expandedById, - modelOptions, - modelConfigs, - chatErrorReasons, - isArchiving, - archivingChatId, - toggleExpanded, - onArchiveAgent, - onUnarchiveAgent, - onArchiveAndDeleteWorkspace, - }), - [ - chatTree, - chatById, - visibleChatIDs, - expandedById, - modelOptions, - modelConfigs, - chatErrorReasons, - isArchiving, - archivingChatId, - toggleExpanded, - onArchiveAgent, - onUnarchiveAgent, - onArchiveAndDeleteWorkspace, - ], - ); + const chatTreeCtx: ChatTreeContextValue = { + chatTree, + chatById, + visibleChatIDs, + normalizedSearch, + 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. - onLoadMoreRef.current = onLoadMore; - isFetchingNextPageRef.current = isFetchingNextPage; + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + isFetchingNextPageRef.current = isFetchingNextPage; + }, [onLoadMore, isFetchingNextPage]); useEffect(() => { const el = sentinelRef.current; diff --git a/site/src/pages/AgentsPage/AnalyticsPageContent.tsx b/site/src/pages/AgentsPage/AnalyticsPageContent.tsx index 10b802d36a..8c6e98ade0 100644 --- a/site/src/pages/AgentsPage/AnalyticsPageContent.tsx +++ b/site/src/pages/AgentsPage/AnalyticsPageContent.tsx @@ -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 = ({ now, }) => { const { user } = useAuthContext(); - const dateRange = useMemo(() => createDateRange(now), [now]); + const dateRange = createDateRange(now); const summaryQuery = useQuery({ ...chatCostSummary(user?.id ?? "me", { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 9c7d03816c..60ee9b0743 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -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,104 +98,99 @@ const useProviderStates = ( modelConfigs: readonly TypesGen.ChatModelConfig[], providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined, catalogData: TypesGen.ChatModelsResponse | null | undefined, -): readonly ProviderState[] => - useMemo(() => { - const orderedProviders: string[] = []; - const seenProviders = new Set(); - const includeProvider = (providerValue: string) => { - const normalized = normalizeProvider(providerValue); - if (!normalized || seenProviders.has(normalized)) return; - seenProviders.add(normalized); - orderedProviders.push(normalized); +): readonly ProviderState[] => { + const orderedProviders: string[] = []; + const seenProviders = new Set(); + const includeProvider = (providerValue: string) => { + const normalized = normalizeProvider(providerValue); + if (!normalized || seenProviders.has(normalized)) return; + seenProviders.add(normalized); + orderedProviders.push(normalized); + }; + + const catalogProviders = getCatalogProviders(catalogData); + const catalogProvidersByProvider = new Map(); + for (const cp of catalogProviders) { + const normalized = normalizeProvider(cp.provider); + if (!normalized) continue; + includeProvider(normalized); + catalogProvidersByProvider.set(normalized, cp); + } + + for (const pc of providerConfigsData ?? []) { + includeProvider(pc.provider); + } + for (const mc of modelConfigs) { + includeProvider(mc.provider); + } + + const providerConfigsByProvider = new Map< + string, + TypesGen.ChatProviderConfig + >(); + for (const pc of providerConfigsData ?? []) { + const normalized = normalizeProvider(pc.provider); + if (!normalized) continue; + providerConfigsByProvider.set(normalized, pc); + } + + const modelConfigsByProvider = new Map(); + for (const mc of modelConfigs) { + const normalized = normalizeProvider(mc.provider); + if (!normalized) continue; + const existing = modelConfigsByProvider.get(normalized); + if (existing) { + existing.push(mc); + } else { + modelConfigsByProvider.set(normalized, [mc]); + } + } + + return orderedProviders.map((provider) => { + const providerConfigEntry = providerConfigsByProvider.get(provider); + const providerConfigSource = getProviderConfigSource(providerConfigEntry); + const providerConfig = isDatabaseProviderConfig( + providerConfigEntry, + providerConfigSource, + ) + ? providerConfigEntry + : undefined; + const catalogProvider = catalogProvidersByProvider.get(provider); + const catalogProviderSource = readOptionalString( + (catalogProvider as CatalogProvider & { source?: string })?.source, + ); + const hasManagedAPIKey = hasProviderAPIKey(providerConfig); + const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry); + const hasCatalogAPIKey = catalogProvider + ? providerHasCatalogAPIKey(catalogProvider) + : false; + const label = + readOptionalString(providerConfigEntry?.display_name) ?? + formatProviderLabel(provider); + const modelConfigsForProvider = modelConfigsByProvider.get(provider) ?? []; + const isCatalogEnvPreset = + !providerConfig && + envPresetProviders.has(provider) && + (catalogProviderSource === "env" || hasCatalogAPIKey); + const isEnvPreset = + providerConfigSource === "env_preset" || isCatalogEnvPreset; + + return { + provider, + label, + providerConfig, + modelConfigs: modelConfigsForProvider, + catalogModelCount: getProviderModels(catalogProvider).length, + hasManagedAPIKey, + hasCatalogAPIKey, + hasEffectiveAPIKey: providerConfigEntry + ? hasProviderEntryAPIKey + : hasManagedAPIKey || hasCatalogAPIKey, + isEnvPreset, + baseURL: getProviderBaseURL(providerConfigEntry), }; - - const catalogProviders = getCatalogProviders(catalogData); - const catalogProvidersByProvider = new Map(); - for (const cp of catalogProviders) { - const normalized = normalizeProvider(cp.provider); - if (!normalized) continue; - includeProvider(normalized); - catalogProvidersByProvider.set(normalized, cp); - } - - for (const pc of providerConfigsData ?? []) { - includeProvider(pc.provider); - } - for (const mc of modelConfigs) { - includeProvider(mc.provider); - } - - const providerConfigsByProvider = new Map< - string, - TypesGen.ChatProviderConfig - >(); - for (const pc of providerConfigsData ?? []) { - const normalized = normalizeProvider(pc.provider); - if (!normalized) continue; - providerConfigsByProvider.set(normalized, pc); - } - - const modelConfigsByProvider = new Map< - string, - TypesGen.ChatModelConfig[] - >(); - for (const mc of modelConfigs) { - const normalized = normalizeProvider(mc.provider); - if (!normalized) continue; - const existing = modelConfigsByProvider.get(normalized); - if (existing) { - existing.push(mc); - } else { - modelConfigsByProvider.set(normalized, [mc]); - } - } - - return orderedProviders.map((provider) => { - const providerConfigEntry = providerConfigsByProvider.get(provider); - const providerConfigSource = getProviderConfigSource(providerConfigEntry); - const providerConfig = isDatabaseProviderConfig( - providerConfigEntry, - providerConfigSource, - ) - ? providerConfigEntry - : undefined; - const catalogProvider = catalogProvidersByProvider.get(provider); - const catalogProviderSource = readOptionalString( - (catalogProvider as CatalogProvider & { source?: string })?.source, - ); - const hasManagedAPIKey = hasProviderAPIKey(providerConfig); - const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry); - const hasCatalogAPIKey = catalogProvider - ? providerHasCatalogAPIKey(catalogProvider) - : false; - const label = - readOptionalString(providerConfigEntry?.display_name) ?? - formatProviderLabel(provider); - const modelConfigsForProvider = - modelConfigsByProvider.get(provider) ?? []; - const isCatalogEnvPreset = - !providerConfig && - envPresetProviders.has(provider) && - (catalogProviderSource === "env" || hasCatalogAPIKey); - const isEnvPreset = - providerConfigSource === "env_preset" || isCatalogEnvPreset; - - return { - provider, - label, - providerConfig, - modelConfigs: modelConfigsForProvider, - catalogModelCount: getProviderModels(catalogProvider).length, - hasManagedAPIKey, - hasCatalogAPIKey, - hasEffectiveAPIKey: providerConfigEntry - ? hasProviderEntryAPIKey - : hasManagedAPIKey || hasCatalogAPIKey, - isEnvPreset, - baseURL: getProviderBaseURL(providerConfigEntry), - }; - }); - }, [modelConfigs, catalogData, providerConfigsData]); + }); +}; // ── Component ────────────────────────────────────────────────── @@ -245,14 +240,10 @@ export const ChatModelAdminPanel: FC = ({ ); // ── Sorted model configs ─────────────────────────────────── - const modelConfigs = useMemo( - () => - (modelConfigsQuery.data ?? []).slice().sort((a, b) => { - const cmp = a.provider.localeCompare(b.provider); - return cmp !== 0 ? cmp : a.model.localeCompare(b.model); - }), - [modelConfigsQuery.data], - ); + const modelConfigs = (modelConfigsQuery.data ?? []).slice().sort((a, b) => { + const cmp = a.provider.localeCompare(b.provider); + return cmp !== 0 ? cmp : a.model.localeCompare(b.model); + }); // ── Provider states ──────────────────────────────────────── const providerStates = useProviderStates( @@ -264,25 +255,15 @@ export const ChatModelAdminPanel: FC = ({ // Derive the effective selected provider from user intent + available // providers. This avoids a useEffect + setState cycle that would cause // an extra render with a stale value. - const selectedProvider = useMemo(() => { - if ( - requestedProvider && - providerStates.some((ps) => ps.provider === requestedProvider) - ) { - return requestedProvider; - } - return providerStates[0]?.provider ?? null; - }, [requestedProvider, providerStates]); - - const selectedProviderState = useMemo( - () => - selectedProvider - ? (providerStates.find((ps) => ps.provider === selectedProvider) ?? - null) - : null, - [providerStates, selectedProvider], - ); + const selectedProvider = + requestedProvider && + providerStates.some((ps) => ps.provider === requestedProvider) + ? requestedProvider + : (providerStates[0]?.provider ?? null); + const selectedProviderState = selectedProvider + ? (providerStates.find((ps) => ps.provider === selectedProvider) ?? null) + : null; // ── Derived state ────────────────────────────────────────── const isLoading = providerConfigsQuery.isLoading || diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx index 146599a31d..e22e581fa4 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx @@ -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 = ({ const getFieldHelpers = getFormHelpers(form); - const modelConfigFormBuildResult = useMemo( - () => - buildModelConfigFromForm( - selectedProviderState?.provider, - form.values.config, - ), - [selectedProviderState?.provider, form.values.config], + const modelConfigFormBuildResult = buildModelConfigFromForm( + selectedProviderState?.provider, + form.values.config, ); const hasFieldErrors = diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx index 3ea00aaeff..dd4964459c 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx @@ -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 = ({ // 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 = ({ return { mode: "add", provider: addProvider }; } return { mode: "list" }; - }, [searchParams, modelConfigs]); + })(); // Clear model-related search params and return to the list. const clearModelView = () => { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx index 12438b1e76..5f96e0bf4c 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx @@ -104,48 +104,55 @@ export const ProviderForm: FC = ({ const trimmedDisplayName = displayName.trim(); const trimmedBaseURL = baseURLValue.trim(); - try { - if (providerConfig) { - const currentDisplayName = - readOptionalString(providerConfig.display_name) ?? ""; - const currentBaseURL = baseURL.trim(); - const req: TypesGen.UpdateChatProviderConfigRequest = { - ...(trimmedDisplayName !== currentDisplayName && { - display_name: trimmedDisplayName, - }), - ...(effectiveApiKey && { api_key: effectiveApiKey }), - ...(trimmedBaseURL !== currentBaseURL && { - base_url: trimmedBaseURL, - }), - }; + if (providerConfig) { + const currentDisplayName = + readOptionalString(providerConfig.display_name) ?? ""; + const currentBaseURL = baseURL.trim(); + const req: TypesGen.UpdateChatProviderConfigRequest = { + ...(trimmedDisplayName !== currentDisplayName && { + display_name: trimmedDisplayName, + }), + ...(effectiveApiKey && { api_key: effectiveApiKey }), + ...(trimmedBaseURL !== currentBaseURL && { + base_url: trimmedBaseURL, + }), + }; - if (!req.display_name && !req.api_key && !req.base_url) { - return; - } - - await onUpdateProvider(providerConfig.id, req); - } else { - if (!effectiveApiKey) { - return; - } - - const req: TypesGen.CreateChatProviderConfigRequest = { - provider, - api_key: effectiveApiKey, - ...(trimmedDisplayName && { - display_name: trimmedDisplayName, - }), - ...(trimmedBaseURL && { base_url: trimmedBaseURL }), - }; - - await onCreateProvider(req); + if (!req.display_name && !req.api_key && !req.base_url) { + return; } - setApiKeyTouched(false); - } catch { - // Error is surfaced via the mutation's error state - // in ChatModelAdminPanel, no toast needed. + 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; + } + + const req: TypesGen.CreateChatProviderConfigRequest = { + provider, + api_key: effectiveApiKey, + ...(trimmedDisplayName && { + display_name: trimmedDisplayName, + }), + ...(trimmedBaseURL && { base_url: trimmedBaseURL }), + }; + + try { + await onCreateProvider(req); + } catch { + // Error is surfaced via the mutation's error state + // in ChatModelAdminPanel, no toast needed. + return; + } } + + setApiKeyTouched(false); }; const handleApiKeyFocus = () => { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProvidersSection.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProvidersSection.tsx index 5570f29689..1345ae07ee 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProvidersSection.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProvidersSection.tsx @@ -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 = ({ // 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 = ({ : { mode: "list" }; } return { mode: "list" }; - }, [searchParams, providerStates]); + })(); // Clear provider search param and return to the list. const clearProviderView = () => { diff --git a/site/src/pages/AgentsPage/CommentableDiffViewer.tsx b/site/src/pages/AgentsPage/CommentableDiffViewer.tsx index 2dd8ca2c89..14974d8d46 100644 --- a/site/src/pages/AgentsPage/CommentableDiffViewer.tsx +++ b/site/src/pages/AgentsPage/CommentableDiffViewer.tsx @@ -8,7 +8,6 @@ import { ArrowUpIcon } from "lucide-react"; import { type FC, type RefObject, - useCallback, useLayoutEffect, useRef, useState, @@ -215,117 +214,101 @@ export const CommentableDiffViewer: FC = ({ // --------------------------------------------------------------- // Line interaction callbacks // --------------------------------------------------------------- - const handleLineNumberClick = useCallback( - ( - fileName: string, - props: { - lineNumber: number; - annotationSide: "additions" | "deletions"; - }, - ) => { - setActiveCommentBox({ - fileName, - start: props.lineNumber, - startSide: props.annotationSide, - end: props.lineNumber, - endSide: props.annotationSide, - }); + const handleLineNumberClick = ( + fileName: string, + props: { + lineNumber: number; + annotationSide: "additions" | "deletions"; }, - [], - ); + ) => { + setActiveCommentBox({ + fileName, + start: props.lineNumber, + startSide: props.annotationSide, + end: props.lineNumber, + endSide: props.annotationSide, + }); + }; - const handleLineSelected = useCallback( - ( - fileName: string, - range: { - start: number; - end: number; - side?: "additions" | "deletions"; - endSide?: "additions" | "deletions"; - } | null, - ) => { - const result = commentBoxFromRange(fileName, range); - if (result === "ignore") return; - setActiveCommentBox(result); - }, - [], - ); + const handleLineSelected = ( + fileName: string, + range: { + start: number; + end: number; + side?: "additions" | "deletions"; + endSide?: "additions" | "deletions"; + } | null, + ) => { + const result = commentBoxFromRange(fileName, range); + if (result === "ignore") return; + setActiveCommentBox(result); + }; // --------------------------------------------------------------- // Annotation helpers // --------------------------------------------------------------- - const getLineAnnotations = useCallback( - (fileName: string): DiffLineAnnotation[] => { - if (activeCommentBox && activeCommentBox.fileName === fileName) { - return [ - { - side: annotationSideForBox(activeCommentBox), - lineNumber: annotationLineForBox(activeCommentBox), - metadata: "active-input", - }, - ]; - } - return []; - }, - [activeCommentBox], - ); + const getLineAnnotations = ( + fileName: string, + ): DiffLineAnnotation[] => { + if (activeCommentBox && activeCommentBox.fileName === fileName) { + return [ + { + side: annotationSideForBox(activeCommentBox), + lineNumber: annotationLineForBox(activeCommentBox), + metadata: "active-input", + }, + ]; + } + return []; + }; - const getSelectedLines = useCallback( - (fileName: string): SelectedLineRange | null => { - if (activeCommentBox && activeCommentBox.fileName === fileName) { - return selectedLinesForBox(activeCommentBox); - } - return null; - }, - [activeCommentBox], - ); + const getSelectedLines = (fileName: string): SelectedLineRange | null => { + if (activeCommentBox && activeCommentBox.fileName === fileName) { + return selectedLinesForBox(activeCommentBox); + } + return null; + }; - const handleCancelComment = useCallback(() => { + const handleCancelComment = () => { setActiveCommentBox(null); - }, []); + }; - const handleSubmitComment = useCallback( - (text: string) => { - if (!activeCommentBox) return; - const { startLine, endLine, side } = contentRangeForBox(activeCommentBox); - const content = extractDiffContent( - parsedFiles, - activeCommentBox.fileName, - startLine, - endLine, - side, + const handleSubmitComment = (text: string) => { + if (!activeCommentBox) return; + const { startLine, endLine, side } = contentRangeForBox(activeCommentBox); + const content = extractDiffContent( + parsedFiles, + activeCommentBox.fileName, + startLine, + endLine, + side, + ); + // Single imperative call -- chip inserted atomically + // in one Lexical update. No rAF hack needed. + chatInputRef?.current?.addFileReference({ + fileName: activeCommentBox.fileName, + startLine, + endLine, + content, + }); + if (text.trim()) { + chatInputRef?.current?.insertText(text); + } + setActiveCommentBox(null); + }; + + const renderAnnotation = (annotation: DiffLineAnnotation) => { + if (annotation.metadata === "active-input") { + if (!activeCommentBox) return null; + return ( + ); - // Single imperative call -- chip inserted atomically - // in one Lexical update. No rAF hack needed. - chatInputRef?.current?.addFileReference({ - fileName: activeCommentBox.fileName, - startLine, - endLine, - content, - }); - if (text.trim()) { - chatInputRef?.current?.insertText(text); - } - setActiveCommentBox(null); - }, - [activeCommentBox, chatInputRef, parsedFiles], - ); - - const renderAnnotation = useCallback( - (annotation: DiffLineAnnotation) => { - if (annotation.metadata === "active-input") { - if (!activeCommentBox) return null; - return ( - - ); - } - return null; - }, - [activeCommentBox, handleSubmitComment, handleCancelComment], - ); + } + return null; + }; // --------------------------------------------------------------- // Render diff --git a/site/src/pages/AgentsPage/DesktopPanel.tsx b/site/src/pages/AgentsPage/DesktopPanel.tsx index 3bdfaeefe8..53ba6b4a2c 100644 --- a/site/src/pages/AgentsPage/DesktopPanel.tsx +++ b/site/src/pages/AgentsPage/DesktopPanel.tsx @@ -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 = ({ connectionOverride ?? hookResult; const containerRef = useRef(null); - const attachToContainer = useCallback( - (el: HTMLDivElement | null) => { - containerRef.current = el; - if (el) { - attach(el); - } - }, - [attach], - ); + const attachToContainer = (el: HTMLDivElement | null) => { + containerRef.current = el; + if (el) { + attach(el); + } + }; // Connect on mount, disconnect on unmount. This drives the // visibility-based lifecycle: DesktopPanel is only rendered diff --git a/site/src/pages/AgentsPage/DiffViewer.tsx b/site/src/pages/AgentsPage/DiffViewer.tsx index 1c4c3bc78c..67cef3dc65 100644 --- a/site/src/pages/AgentsPage/DiffViewer.tsx +++ b/site/src/pages/AgentsPage/DiffViewer.tsx @@ -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 = ({ const theme = useTheme(); const isDark = theme.palette.mode === "dark"; - const diffOptions = useMemo(() => { + const diffOptions = (() => { const base = getDiffViewerOptions(isDark); return { ...base, @@ -536,70 +515,60 @@ export const DiffViewer: FC = ({ // remain visible while scrolling through long diffs. unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`, }; - }, [isDark, diffStyle]); + })(); - // Memoize the per-file options object so every - // receives the same reference and avoids re-highlighting - // when the parent re-renders. - const fileOptions = useMemo( - () => ({ - ...diffOptions, - overflow: "wrap" as const, - enableLineSelection: true, - enableHoverUtility: true, - onLineSelected() { - // TODO: Make this add context to the input so the - // user can type. - }, - }), - [diffOptions], - ); + const fileOptions = { + ...diffOptions, + overflow: "wrap" as const, + enableLineSelection: true, + enableHoverUtility: true, + onLineSelected() { + // TODO: Make this add context to the input so the + // user can type. + }, + }; // 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) => ({ - ...diffOptions, - overflow: "wrap" as const, - enableLineSelection: true, - enableHoverUtility: true, - ...(onLineNumberClick && { - onLineNumberClick: (props: { - lineNumber: number; - annotationSide: "additions" | "deletions"; - }) => onLineNumberClick(fileName, props), - }), - onLineSelected: onLineSelected - ? ( - range: { - start: number; - end: number; - side?: "additions" | "deletions"; - endSide?: "additions" | "deletions"; - } | null, - ) => onLineSelected(fileName, range) - : () => { - // TODO: Make this add context to the input. - }, + const getOptionsForFile = (fileName: string) => ({ + ...diffOptions, + overflow: "wrap" as const, + enableLineSelection: true, + enableHoverUtility: true, + ...(onLineNumberClick && { + onLineNumberClick: (props: { + lineNumber: number; + annotationSide: "additions" | "deletions"; + }) => onLineNumberClick(fileName, props), }), - [diffOptions, onLineNumberClick, onLineSelected], - ); + onLineSelected: onLineSelected + ? ( + range: { + start: number; + end: number; + side?: "additions" | "deletions"; + endSide?: "additions" | "deletions"; + } | null, + ) => onLineSelected(fileName, range) + : () => { + // TODO: Make this add context to the input. + }, + }); - const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]); + const fileTree = buildFileTree(parsedFiles); // Sort diff blocks in the same order the file tree displays them // (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(); - 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 = ({ 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["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 = ({ 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 = ({ (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 = ({ // parent. // --------------------------------------------------------------- const [containerWidth, setContainerWidth] = useState(0); - const roRef = useRef(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(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 = ({ const [activeFile, setActiveFile] = useState(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 = ({ }; }, [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 = ({ // lands during commit — before useEffect-based scroll logic. // --------------------------------------------------------------- const [viewportHeight, setViewportHeight] = useState(0); - const scrollAreaRef = useCallback((node: HTMLElement | null) => { - const vp = node?.querySelector( + const [scrollAreaEl, setScrollAreaEl] = useState(null); + + useEffect(() => { + const vp = scrollAreaEl?.querySelector( "[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 = ({ ro.disconnect(); diffViewportRef.current = null; }; - }, []); + }, [scrollAreaEl]); // --------------------------------------------------------------- // Loading state @@ -850,7 +814,7 @@ export const DiffViewer: FC = ({ // --------------------------------------------------------------- return (
{/* Diff contents */} @@ -889,7 +853,7 @@ export const DiffViewer: FC = ({ )} scrollBarClassName="w-1.5" viewportClassName="[&>div]:!block" - ref={scrollAreaRef} + ref={setScrollAreaEl} >
{sortedFiles.map((fileDiff, i) => { diff --git a/site/src/pages/AgentsPage/GitPanel.tsx b/site/src/pages/AgentsPage/GitPanel.tsx index f4038b0aa5..274d6ce292 100644 --- a/site/src/pages/AgentsPage/GitPanel.tsx +++ b/site/src/pages/AgentsPage/GitPanel.tsx @@ -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 = ({ const prDraft = remoteDiffStats?.pull_request_draft; // Compute per-repo diff stats from unified diffs. - const repoStats = useMemo(() => { + const repoStats = (() => { const stats = new Map(); for (const [root, repo] of repositories.entries()) { if (!repo.unified_diff) continue; @@ -103,11 +95,10 @@ export const GitPanel: FC = ({ } } 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 = ({ const [diffStyle, setDiffStyle] = useState(loadDiffStyle); - const handleDiffStyleChange = useCallback((style: DiffStyle) => { + const handleDiffStyleChange = (style: DiffStyle) => { saveDiffStyle(style); setDiffStyle(style); - }, []); + }; const [spinning, setSpinning] = useState(false); const spinTimerRef = useRef>(undefined); useEffect(() => () => clearTimeout(spinTimerRef.current), []); - const handleRefresh = useCallback(() => { + const handleRefresh = () => { onRefresh(); setSpinning(true); clearTimeout(spinTimerRef.current); spinTimerRef.current = setTimeout(() => setSpinning(false), 1000); - }, [onRefresh]); + }; return (
diff --git a/site/src/pages/AgentsPage/InsightsContent.tsx b/site/src/pages/AgentsPage/InsightsContent.tsx index 522b563fd2..20ec53f5fa 100644 --- a/site/src/pages/AgentsPage/InsightsContent.tsx +++ b/site/src/pages/AgentsPage/InsightsContent.tsx @@ -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("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 ( diff --git a/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx index 5f20025b86..a8804ef211 100644 --- a/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx +++ b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx @@ -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(null); const [userOverrideAmount, setUserOverrideAmount] = useState(""); - const defaultLimitValues = useMemo(() => { + 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({ - spend_limit_micros: configQuery.data?.spend_limit_micros ?? null, - period: defaultLimitValues.period, - }), - [configQuery.data?.spend_limit_micros, defaultLimitValues.period], + })(); + const defaultLimitKey = JSON.stringify({ + spend_limit_micros: configQuery.data?.spend_limit_micros ?? null, + period: defaultLimitValues.period, + }); + const existingGroupIds = new Set( + (configQuery.data?.group_overrides ?? []).map((g) => g.group_id), ); - 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) diff --git a/site/src/pages/AgentsPage/LocalDiffPanel.tsx b/site/src/pages/AgentsPage/LocalDiffPanel.tsx index d0afd7138b..b7dbaea357 100644 --- a/site/src/pages/AgentsPage/LocalDiffPanel.tsx +++ b/site/src/pages/AgentsPage/LocalDiffPanel.tsx @@ -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 = ({ diffStyle, chatInputRef, }) => { - const parsedFiles = useMemo(() => { + const parsedFiles = (() => { const diff = repo.unified_diff; if (!diff) { return []; @@ -29,7 +29,7 @@ export const LocalDiffPanel: FC = ({ } catch { return []; } - }, [repo.unified_diff]); + })(); return ( = ({ const updateMut = useMutation(updateMCPServerConfigMutation(queryClient)); const deleteMut = useMutation(deleteMCPServerConfigMutation(queryClient)); - const servers = useMemo( - () => - (serversQuery.data ?? []) - .slice() - .sort((a, b) => a.display_name.localeCompare(b.display_name)), - [serversQuery.data], - ); + const servers = (serversQuery.data ?? []) + .slice() + .sort((a, b) => a.display_name.localeCompare(b.display_name)); - const editingServer = useMemo( - () => - serverId && serverId !== "new" - ? (servers.find((s) => s.id === serverId) ?? null) - : null, - [serverId, servers], - ); + const editingServer = + serverId && serverId !== "new" + ? (servers.find((s) => s.id === serverId) ?? null) + : null; const isFormView = serverId !== null; const isCreating = serverId === "new"; - const handleSave = useCallback( - async (req: TypesGen.CreateMCPServerConfigRequest, id?: string) => { + const handleSave = async ( + req: TypesGen.CreateMCPServerConfigRequest, + id?: string, + ) => { + if (id) { + const updateReq: TypesGen.UpdateMCPServerConfigRequest = { + ...req, + tool_allow_list: req.tool_allow_list + ? [...req.tool_allow_list] + : undefined, + tool_deny_list: req.tool_deny_list + ? [...req.tool_deny_list] + : undefined, + }; try { - if (id) { - const updateReq: TypesGen.UpdateMCPServerConfigRequest = { - ...req, - tool_allow_list: req.tool_allow_list - ? [...req.tool_allow_list] - : undefined, - tool_deny_list: req.tool_deny_list - ? [...req.tool_deny_list] - : undefined, - }; - await updateMut.mutateAsync({ id, req: updateReq }); - } else { - await createMut.mutateAsync(req); - } - setSearchParams({}); + await updateMut.mutateAsync({ id, req: updateReq }); } 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) => { - try { - await deleteMut.mutateAsync(id); - setSearchParams({}); - } catch { - // Error surfaced via mutation error state. - } - }, - [deleteMut, setSearchParams], - ); + const handleDelete = async (id: string) => { + try { + await deleteMut.mutateAsync(id); + } catch { + // Error surfaced via mutation error state. + return; + } + setSearchParams({}); + }; if (serversQuery.isLoading) { return ; diff --git a/site/src/pages/AgentsPage/QueuedMessagesList.tsx b/site/src/pages/AgentsPage/QueuedMessagesList.tsx index 1f9d48d00d..bb7bd2e29f 100644 --- a/site/src/pages/AgentsPage/QueuedMessagesList.tsx +++ b/site/src/pages/AgentsPage/QueuedMessagesList.tsx @@ -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,21 +72,17 @@ export const QueuedMessagesList: FC = ({ editingMessageID = null, className, }) => { - const items = useMemo( - () => - messages.map((message) => { - const { displayText, rawText, attachmentCount, fileBlocks } = - getQueuedMessageInfo(message); - return { - id: message.id, - displayText, - rawText, - attachmentCount, - fileBlocks, - }; - }), - [messages], - ); + const items = messages.map((message) => { + const { displayText, rawText, attachmentCount, fileBlocks } = + getQueuedMessageInfo(message); + return { + id: message.id, + displayText, + rawText, + attachmentCount, + fileBlocks, + }; + }); const [hoveredID, setHoveredID] = useState(null); // Tracks which item has an async action in flight and what kind. @@ -98,7 +94,7 @@ export const QueuedMessagesList: FC = ({ ReadonlySet >(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 = ({ 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 = ({ next.delete(id); return next; }); - }, []); + }; useEffect(() => { const liveIDs = new Set(messages.map((message) => message.id)); @@ -139,35 +135,29 @@ export const QueuedMessagesList: FC = ({ }); }, [messages]); - const handleDelete = useCallback( - async (id: number) => { - setBusyItem({ id, action: "delete" }); - hideItemOptimistically(id); - try { - await onDelete(id); - } catch { - restoreHiddenItem(id); - } finally { - setBusyItem((current) => (current?.id === id ? null : current)); - } - }, - [hideItemOptimistically, onDelete, restoreHiddenItem], - ); + const handleDelete = async (id: number) => { + setBusyItem({ id, action: "delete" }); + hideItemOptimistically(id); + try { + await onDelete(id); + setBusyItem((current) => (current?.id === id ? null : current)); + } catch { + restoreHiddenItem(id); + setBusyItem((current) => (current?.id === id ? null : current)); + } + }; - const handlePromote = useCallback( - async (id: number) => { - setBusyItem({ id, action: "promote" }); - hideItemOptimistically(id); - try { - await onPromote(id); - } catch { - restoreHiddenItem(id); - } finally { - setBusyItem((current) => (current?.id === id ? null : current)); - } - }, - [hideItemOptimistically, onPromote, restoreHiddenItem], - ); + const handlePromote = async (id: number) => { + setBusyItem({ id, action: "promote" }); + hideItemOptimistically(id); + try { + await onPromote(id); + setBusyItem((current) => (current?.id === id ? null : current)); + } catch { + restoreHiddenItem(id); + setBusyItem((current) => (current?.id === id ? null : current)); + } + }; const visibleItems = items.filter( (item) => !optimisticallyHiddenIDs.has(item.id), diff --git a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx index cf1e6e9d27..db6e2fd015 100644 --- a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx +++ b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx @@ -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 = ({ }); const diffContent = diffContentsQuery.data?.diff; - const diffVersionRef = useRef(0); - const prevDiffRef = useRef(undefined); - if (diffContent !== prevDiffRef.current) { - prevDiffRef.current = diffContent; - diffVersionRef.current = ++remoteDiffVersion; + const [diffVersion, setDiffVersion] = useState(0); + const [prevDiffContent, setPrevDiffContent] = useState( + 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 = ({ // 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 = ({ return () => window.removeEventListener("file-reference-click", handler); }, []); - const handleScrollComplete = useCallback(() => { + const handleScrollComplete = () => { setScrollTarget(null); - }, []); + }; // --------------------------------------------------------------- // Header content diff --git a/site/src/pages/AgentsPage/RightPanel.tsx b/site/src/pages/AgentsPage/RightPanel.tsx index 79d2e4dc0d..60e4dace77 100644 --- a/site/src/pages/AgentsPage/RightPanel.tsx +++ b/site/src/pages/AgentsPage/RightPanel.tsx @@ -1,7 +1,6 @@ import { type ReactNode, type PointerEvent as ReactPointerEvent, - useCallback, useEffect, useRef, useState, @@ -85,96 +84,77 @@ function useResizableDrag({ "normal" | "expanded" | "closed" | null >(null); - const handlePointerDown = useCallback( - (e: ReactPointerEvent) => { - 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()) - : width; - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [width, isExpanded], - ); + const handlePointerDown = (e: ReactPointerEvent) => { + 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()) + : width; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }; - const handlePointerMove = useCallback( - (e: ReactPointerEvent) => { - if (!isDragging.current) { - return; + const handlePointerMove = (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + const delta = startX.current - e.clientX; + const raw = startWidth.current + delta; + const maxWidth = getMaxWidth(); + + // Collapse/uncollapse the sidebar live when the pointer + // reaches the left edge of the viewport. + if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) { + if (!isSidebarCollapsed && onToggleSidebarCollapsed) { + onToggleSidebarCollapsed(); + sidebarCollapsedByDrag.current = true; } - const delta = startX.current - e.clientX; - const raw = startWidth.current + delta; - const maxWidth = getMaxWidth(); - - // Collapse/uncollapse the sidebar live when the pointer - // reaches the left edge of the viewport. - if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) { - if (!isSidebarCollapsed && onToggleSidebarCollapsed) { - onToggleSidebarCollapsed(); - sidebarCollapsedByDrag.current = true; - } - } else if ( - e.clientX >= SNAP_THRESHOLD && - sidebarCollapsedByDrag.current - ) { - if (onToggleSidebarCollapsed) { - onToggleSidebarCollapsed(); - sidebarCollapsedByDrag.current = false; - } + } else if (e.clientX >= SNAP_THRESHOLD && sidebarCollapsedByDrag.current) { + if (onToggleSidebarCollapsed) { + onToggleSidebarCollapsed(); + sidebarCollapsedByDrag.current = false; } + } - let nextSnap: "normal" | "expanded" | "closed"; - if (raw > maxWidth + SNAP_THRESHOLD) { - nextSnap = "expanded"; - } else if (raw < MIN_WIDTH - SNAP_THRESHOLD) { - nextSnap = "closed"; - } else { - nextSnap = "normal"; - setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw))); - } - setDragSnap(nextSnap); + let nextSnap: "normal" | "expanded" | "closed"; + if (raw > maxWidth + SNAP_THRESHOLD) { + nextSnap = "expanded"; + } else if (raw < MIN_WIDTH - SNAP_THRESHOLD) { + nextSnap = "closed"; + } else { + nextSnap = "normal"; + setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw))); + } + setDragSnap(nextSnap); - // Notify parent of the live visual expanded state so - // sibling content reacts during the drag. - const nextVisualExpanded = - nextSnap === "expanded" || - (nextSnap !== "normal" && nextSnap !== "closed" && isExpanded); - onVisualExpandedChange?.(nextVisualExpanded); - }, - [ - setWidth, - isExpanded, - onVisualExpandedChange, - isSidebarCollapsed, - onToggleSidebarCollapsed, - ], - ); + // Notify parent of the live visual expanded state so + // sibling content reacts during the drag. + const nextVisualExpanded = + nextSnap === "expanded" || + (nextSnap !== "normal" && nextSnap !== "closed" && isExpanded); + onVisualExpandedChange?.(nextVisualExpanded); + }; - const handlePointerUp = useCallback( - (e: ReactPointerEvent) => { - if (!isDragging.current) { - return; - } - const snap = dragSnap; - isDragging.current = false; - setDragSnap(null); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); + const handlePointerUp = (e: ReactPointerEvent) => { + if (!isDragging.current) { + return; + } + const snap = dragSnap; + isDragging.current = false; + setDragSnap(null); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); - // Clear the drag override so parent falls back to its - // own committed expanded state. - onVisualExpandedChange?.(null); + // Clear the drag override so parent falls back to its + // own committed expanded state. + onVisualExpandedChange?.(null); - if (snap) { - onSnapCommit(snap); - } - }, - [dragSnap, onSnapCommit, onVisualExpandedChange], - ); + if (snap) { + onSnapCommit(snap); + } + }; // Derive visual state: during a drag the snap overrides the // committed parent state so the panel reacts live. @@ -216,22 +196,19 @@ export const RightPanel = ({ return () => window.removeEventListener("resize", handleResize); }, []); - const handleSnapCommit = useCallback( - (snap: "normal" | "expanded" | "closed") => { - if (snap === "expanded" && !isExpanded) { - onToggleExpanded(); - } else if (snap === "closed") { - setWidth(DEFAULT_WIDTH); - if (isExpanded) { - onToggleExpanded(); - } - onClose(); - } else if (snap === "normal" && isExpanded) { + const handleSnapCommit = (snap: "normal" | "expanded" | "closed") => { + if (snap === "expanded" && !isExpanded) { + onToggleExpanded(); + } else if (snap === "closed") { + setWidth(DEFAULT_WIDTH); + if (isExpanded) { onToggleExpanded(); } - }, - [isExpanded, onToggleExpanded, onClose], - ); + onClose(); + } else if (snap === "normal" && isExpanded) { + onToggleExpanded(); + } + }; const { visualExpanded, diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index de4203f667..d169be4b10 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -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 = ({ 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 = ({ 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 = ({ isSavingWorkspaceTTL; const isTTLLoading = workspaceTTLQuery.isLoading; - const handleSaveSystemPrompt = useCallback( - (event: FormEvent) => { - event.preventDefault(); - if (!isSystemPromptDirty) return; - saveSystemPrompt( - { system_prompt: systemPromptDraft }, - { onSuccess: () => setLocalEdit(null) }, - ); - }, - [isSystemPromptDirty, systemPromptDraft, saveSystemPrompt], - ); + const handleSaveSystemPrompt = (event: FormEvent) => { + event.preventDefault(); + if (!isSystemPromptDirty) return; + saveSystemPrompt( + { system_prompt: systemPromptDraft }, + { onSuccess: () => setLocalEdit(null) }, + ); + }; - const handleSaveUserPrompt = useCallback( - (event: FormEvent) => { - event.preventDefault(); - if (!isUserPromptDirty) return; - saveUserPrompt( - { custom_prompt: userPromptDraft }, - { onSuccess: () => setLocalUserEdit(null) }, - ); - }, - [isUserPromptDirty, userPromptDraft, saveUserPrompt], - ); - - const handleSaveChatWorkspaceTTL = useCallback( - (event: FormEvent) => { - event.preventDefault(); - if (!isTTLDirty) return; - saveWorkspaceTTL( - { workspace_ttl_ms: localTTLMs ?? 0 }, - { onSuccess: () => setLocalTTLMs(null) }, - ); - }, - [isTTLDirty, localTTLMs, saveWorkspaceTTL], - ); + const handleSaveUserPrompt = (event: FormEvent) => { + event.preventDefault(); + if (!isUserPromptDirty) return; + saveUserPrompt( + { custom_prompt: userPromptDraft }, + { onSuccess: () => setLocalUserEdit(null) }, + ); + }; + const handleSaveChatWorkspaceTTL = (event: FormEvent) => { + event.preventDefault(); + if (!isTTLDirty) return; + saveWorkspaceTTL( + { workspace_ttl_ms: localTTLMs ?? 0 }, + { onSuccess: () => setLocalTTLMs(null) }, + ); + }; return (
diff --git a/site/src/pages/AgentsPage/SidebarTabView.tsx b/site/src/pages/AgentsPage/SidebarTabView.tsx index 58b58de025..f1f647bd90 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.tsx @@ -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 = ({ 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 = ({ )} {/* Scrollable tab strip with overlay chevrons */}
- {tabScroll.canScrollLeft && ( + {canScrollLeft && ( )}
{tabs.map((tab) => { @@ -277,10 +274,10 @@ export const SidebarTabView: FC = ({ )}
- {tabScroll.canScrollRight && ( + {canScrollRight && (