From 4fafa855e87cf4effc3dee9366e04b9fe04d618d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:41:29 -0700 Subject: [PATCH 1/4] chore: bump @clickhouse/click-ui to 0.9.1 Inline @clickhouse/click-ui in vitest server deps and exclude .claude/** from test discovery. click-ui 0.9.1 ships ESM that imports .css files, which Node's loader cannot parse, so Vitest must process the package through Vite. --- bun.lock | 22 +++++----------------- package.json | 2 +- vitest.config.ts | 7 ++++++- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index 08d047a6..ec864745 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tanstack-start-app", "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -160,7 +160,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@clickhouse/click-ui": ["@clickhouse/click-ui@0.2.0-rc.4", "", { "dependencies": { "@h6s/calendar": "2.0.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-WcPjvS7W/InvHP/JVeMIHA/30jl3Ks6CN+zch7kfqogjcbTc4byOzSUSkOJlYblzLxfdins0z0oeBF+jVsBMDA=="], + "@clickhouse/click-ui": ["@clickhouse/click-ui@0.9.1", "", { "dependencies": { "@h6s/calendar": "2.2.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gAyIlzIDYqnXcmHM2rOWNpGAoC0UAeacT5yJpDgu09I+TjmuOdzh9tYaJlS938SmUyLsg2U6t8oMA7do108Hgg=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], @@ -188,8 +188,6 @@ "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -270,7 +268,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@h6s/calendar": ["@h6s/calendar@2.0.1", "", { "peerDependencies": { "date-fns": ">= 2", "react": ">= 18" } }, "sha512-9q5ksdnUsLDeuSm5arXzkxHyS22u7yi5TtVr4DZgW514AjdL+76kx7d+ScX9sKusasRQVxrhM2LTVmd8A/u42Q=="], + "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -644,8 +642,6 @@ "@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], - "@types/stylis": ["@types/stylis@4.2.7", "", {}, "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA=="], - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -824,6 +820,8 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.3.1", "", {}, "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1454,8 +1452,6 @@ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1654,10 +1650,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@clickhouse/click-ui/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "@clickhouse/click-ui/styled-components": ["styled-components@6.3.11", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.6", "tslib": "2.8.1" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["react-dom"] }, "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], @@ -2020,8 +2012,6 @@ "xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@clickhouse/click-ui/styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -2136,8 +2126,6 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@clickhouse/click-ui/styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@humanwhocodes/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 78d3e020..f46add31 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'" }, "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", diff --git a/vitest.config.ts b/vitest.config.ts index c7e8e233..25a36ac3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - exclude: ['e2e/**', 'node_modules/**', 'tools/**'], + exclude: ['e2e/**', 'node_modules/**', 'tools/**', '.claude/**'], + server: { + deps: { + inline: ['@clickhouse/click-ui'], + }, + }, }, }) From 385730ed68206b56d368e6c15fecfecd4a0545ff Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:34:37 -0700 Subject: [PATCH 2/4] fix: make scope selector operable with the Enter key The whole selector rendered inside cmdk's Command.Dialog, whose root keydown handler calls preventDefault on every Enter press and redispatches it to the aria-selected item. preventDefault cancels native button activation, so Enter did nothing on any button, and in the list view the redispatch activated the auto-highlighted first item. Since that item is Base configuration, pressing Enter on the Create button or in the empty search input silently switched the active scope to Base and closed the dialog, discarding the user's scope context. The creation and delete confirmation views contain no cmdk items or input, so they now render in a plain click-ui Dialog with Dialog.Content, dropping the VisuallyHidden title and description shims and letting buttons activate natively. The delete confirmation focuses the Delete button on open and keeps Escape and overlay dismissal closing the whole selector as before, while Cancel returns to the list view. The list view keeps cmdk for search and arrow navigation. The Create button and the per-scope delete buttons stop propagation of Enter and Space in their own keydown handlers, so native activation proceeds and the key never reaches the Command root. Enter in the search input is only forwarded to cmdk after the user has typed a query or navigated with the keyboard, which removes the silent switch to Base while preserving Enter-to-select on real list items. Adds jsdom regression tests covering all four Enter paths plus arrow navigation, using the real click-ui and cmdk packages with only the server layer mocked, and adds @testing-library/user-event as a dev dependency. --- bun.lock | 3 + package.json | 1 + .../configuration/ScopeSelector.test.tsx | 197 +++++++++++ .../configuration/ScopeSelector.tsx | 309 +++++++++--------- src/locales/en/translation.json | 1 + 5 files changed, 360 insertions(+), 151 deletions(-) create mode 100644 src/components/configuration/ScopeSelector.test.tsx diff --git a/bun.lock b/bun.lock index ec864745..1b467c87 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.20.0", "@types/react": "^19.2.17", @@ -604,6 +605,8 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "7.28.6" }, "optionalDependencies": { "@types/react": "19.2.14", "@types/react-dom": "19.2.3" }, "peerDependencies": { "@testing-library/dom": "10.4.1", "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/package.json b/package.json index f46add31..7b931eff 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.20.0", "@types/react": "^19.2.17", diff --git a/src/components/configuration/ScopeSelector.test.tsx b/src/components/configuration/ScopeSelector.test.tsx new file mode 100644 index 00000000..cea5d8e4 --- /dev/null +++ b/src/components/configuration/ScopeSelector.test.tsx @@ -0,0 +1,197 @@ +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { PrincipalType, PermissionTypes } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { ScopeSelector } from './ScopeSelector'; + +const mocks = vi.hoisted(() => ({ + scopesQueryFn: vi.fn(), + rolesQueryFn: vi.fn(), + groupsQueryFn: vi.fn(), + createScopeFn: vi.fn(), + deleteScopeFn: vi.fn(), +})); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: mocks.scopesQueryFn }, + allRolesQueryOptions: { queryKey: ['allRoles'], queryFn: mocks.rolesQueryFn }, + allGroupsQueryOptions: { queryKey: ['allGroups'], queryFn: mocks.groupsQueryFn }, + createScopeFn: mocks.createScopeFn, + deleteScopeFn: mocks.deleteScopeFn, +})); + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, +); +Element.prototype.scrollIntoView = vi.fn(); + +const emptyPermissions = Object.fromEntries( + Object.values(PermissionTypes).map((type) => [type, {}]), +) as t.RolePermissions; + +const engineeringScope: t.ConfigScope = { + principalType: PrincipalType.ROLE, + principalId: 'role-1', + name: 'Engineering', + priority: 10, + isActive: true, +}; + +const engineeringRole: t.Role = { + id: 'role-1', + name: 'Engineering', + description: 'Engineering team', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: emptyPermissions, +}; + +const marketingRole: t.Role = { + id: 'role-2', + name: 'Marketing', + description: 'Growth team', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: emptyPermissions, +}; + +function renderSelector() { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return { onSelect, onOpenChange }; +} + +async function renderListView() { + const handlers = renderSelector(); + await screen.findByText('Engineering'); + await waitFor(() => + expect(document.querySelector('[cmdk-item][aria-selected="true"]')).not.toBeNull(), + ); + return handlers; +} + +async function openDeleteConfirmation(user: ReturnType) { + const handlers = await renderListView(); + const deleteButton = screen.getByRole('button', { name: 'com_scope_delete' }); + deleteButton.focus(); + await user.keyboard('{Enter}'); + await screen.findByText('com_scope_delete_confirm'); + return handlers; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.scopesQueryFn.mockResolvedValue([engineeringScope]); + mocks.rolesQueryFn.mockResolvedValue([engineeringRole, marketingRole]); + mocks.groupsQueryFn.mockResolvedValue([]); + mocks.createScopeFn.mockResolvedValue({}); + mocks.deleteScopeFn.mockResolvedValue({}); +}); + +describe('ScopeSelector Enter key handling', () => { + it('opens the creation view when Enter is pressed on the focused Create button', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + const createButton = screen.getByRole('button', { name: 'com_scope_create' }); + createButton.focus(); + await user.keyboard('{Enter}'); + expect(await screen.findByText('com_scope_create_new')).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not close the dialog or select the Base scope when Enter is pressed in the empty search input', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + screen.getByRole('combobox').focus(); + await user.keyboard('{Enter}'); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('creates a configuration when Enter is pressed on a focused role button in the creation view', async () => { + const user = userEvent.setup(); + await renderListView(); + screen.getByRole('button', { name: 'com_scope_create' }).focus(); + await user.keyboard('{Enter}'); + const roleButton = await screen.findByRole('button', { name: /Marketing/ }); + roleButton.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => + expect(mocks.createScopeFn).toHaveBeenCalledWith({ + data: { + principalType: PrincipalType.ROLE, + name: 'Marketing', + priority: 10, + principalId: 'role-2', + }, + }), + ); + }); + + it('opens the delete confirmation without selecting the scope when Enter is pressed on a delete button', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await openDeleteConfirmation(user); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('deletes the scope once when Enter is pressed on the focused Delete button in the confirmation view', async () => { + const user = userEvent.setup(); + await openDeleteConfirmation(user); + const confirmButton = screen.getByRole('button', { name: 'com_scope_delete' }); + await waitFor(() => expect(confirmButton).toHaveFocus()); + await user.keyboard('{Enter}'); + await waitFor(() => expect(mocks.deleteScopeFn).toHaveBeenCalledTimes(1)); + expect(mocks.deleteScopeFn).toHaveBeenCalledWith({ + data: { principalType: PrincipalType.ROLE, principalId: 'role-1' }, + }); + }); + + it('returns to the list without deleting when Enter is pressed on the focused Cancel button', async () => { + const user = userEvent.setup(); + const { onOpenChange } = await openDeleteConfirmation(user); + const cancelButton = screen.getByRole('button', { name: 'com_ui_cancel' }); + cancelButton.focus(); + await user.keyboard('{Enter}'); + expect(await screen.findByRole('combobox')).toBeInTheDocument(); + expect(mocks.deleteScopeFn).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('still selects a scope with arrow navigation followed by Enter', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + screen.getByRole('combobox').focus(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onSelect).toHaveBeenCalledWith({ type: 'SCOPE', scope: engineeringScope }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx index 9af2d913..d31f0b53 100644 --- a/src/components/configuration/ScopeSelector.tsx +++ b/src/components/configuration/ScopeSelector.tsx @@ -1,11 +1,12 @@ import { Command } from 'cmdk'; -import { Button, Icon } from '@clickhouse/click-ui'; import { PrincipalType } from 'librechat-data-provider'; +import { Button, Dialog, Icon } from '@clickhouse/click-ui'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Title as DialogTitle, Description as DialogDescription } from '@radix-ui/react-dialog'; import type { AdminGroup } from '@librechat/data-schemas'; +import type { KeyboardEvent } from 'react'; import type * as t from '@/types'; import { availableScopesOptions, @@ -18,6 +19,14 @@ import { getScopeTypeConfig } from '@/constants'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; +const NAVIGATION_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End']); +const VIM_NAVIGATION_KEYS = new Set(['n', 'j', 'p', 'k']); + +/** cmdk's root keydown preventDefaults Enter to redispatch it to the highlighted item, which cancels native button activation; keep the key from reaching the root. */ +function stopActivationKeys(e: KeyboardEvent) { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); +} + // ── Main selector ─────────────────────────────────────────────────── export function ScopeSelector({ @@ -36,6 +45,8 @@ export function ScopeSelector({ const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const listRef = useRef(null); + const navigatedRef = useRef(false); + const deleteButtonRef = useRef(null); const { data: scopes = [], isLoading: loading } = useQuery({ ...availableScopesOptions, @@ -57,11 +68,29 @@ export function ScopeSelector({ if (listRef.current) listRef.current.scrollTop = 0; }, []); + /** Without an explicit highlight, Enter in the empty input would silently activate the auto-highlighted first item (Base configuration) and close the dialog. */ + const handleSearchKeyDown = useCallback( + (e: KeyboardEvent) => { + if (NAVIGATION_KEYS.has(e.key) || (e.ctrlKey && VIM_NAVIGATION_KEYS.has(e.key))) { + navigatedRef.current = true; + return; + } + if (e.key === 'Enter' && search === '' && !navigatedRef.current) e.stopPropagation(); + }, + [search], + ); + + const handleDeleteAutoFocus = useCallback((e: Event) => { + e.preventDefault(); + deleteButtonRef.current?.focus(); + }, []); + const resetState = useCallback(() => { setShowCreate(false); setCreating(false); setDeleteTarget(null); setDeleting(false); + navigatedRef.current = false; }, []); const close = useCallback(() => { @@ -205,53 +234,35 @@ export function ScopeSelector({ if (deleteTarget) { return ( - - - {localize('com_scope_delete')} - {localize('com_scope_delete')} - - - - {localize('com_scope_delete_confirm', { name: deleteTarget.name })} - - - setDeleteTarget(null)} - disabled={deleting} - className="cursor-pointer rounded-md px-3 py-1.5 text-sm text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)" - > - {localize('com_ui_cancel')} - - - {deleting ? ( - <> - - {localize('com_scope_deleting')} - > - ) : ( - localize('com_scope_delete') - )} - + + + + + {localize('com_scope_delete_confirm', { name: deleteTarget.name })} + + + setDeleteTarget(null)} + disabled={deleting} + /> + + - - + + ); } @@ -262,118 +273,111 @@ export function ScopeSelector({ const noGroups = availableGroups.length === 0; return ( - - - {localize('com_scope_create_new')} - {localize('com_scope_create_new')} - - - setShowCreate(false)} - className="flex cursor-pointer items-center text-(--cui-color-text-muted) hover:text-(--cui-color-text-default)" - > - - - - {localize('com_scope_create_new')} - - {creating && ( - - - - )} - - - {/* Roles section */} - - {localize('com_scope_roles')} - {noRoles ? ( - - {localize('com_scope_no_available_roles')} - - ) : ( - availableRoles.map((role) => ( - handleCreateForRole(role)} - disabled={creating} - className={cn( - 'scope-item w-full text-left', - creating && 'pointer-events-none opacity-50', - )} - > - + + + setShowCreate(false)} + aria-label={localize('com_ui_back')} + className="flex cursor-pointer items-center text-(--cui-color-text-muted) hover:text-(--cui-color-text-default)" + > + + + {creating && ( + + + + )} + + + {/* Roles section */} + + {localize('com_scope_roles')} + {noRoles ? ( + + {localize('com_scope_no_available_roles')} + + ) : ( + availableRoles.map((role) => ( + handleCreateForRole(role)} + disabled={creating} + className={cn( + 'scope-item w-full text-left', + creating && 'pointer-events-none opacity-50', + )} > - - - - - {role.name} + + - {role.description && ( - - {role.description} + + + {role.name} + {role.description && ( + + {role.description} + + )} + + + )) + )} + + + {/* Groups section */} + + {localize('com_scope_groups')} + {noGroups ? ( + + {localize('com_scope_no_available_groups')} + + ) : ( + availableGroups.map((group) => ( + handleCreateForGroup(group)} + disabled={creating} + className={cn( + 'scope-item w-full text-left', + creating && 'pointer-events-none opacity-50', )} - - - )) - )} - - - {/* Groups section */} - - {localize('com_scope_groups')} - {noGroups ? ( - - {localize('com_scope_no_available_groups')} - - ) : ( - availableGroups.map((group) => ( - handleCreateForGroup(group)} - disabled={creating} - className={cn( - 'scope-item w-full text-left', - creating && 'pointer-events-none opacity-50', - )} - > - - - - - - {group.name} + + - {group.description && ( - - {group.description} + + + {group.name} - )} - - - )) - )} + {group.description && ( + + {group.description} + + )} + + + )) + )} + - - + + ); } @@ -395,6 +399,7 @@ export function ScopeSelector({ @@ -404,6 +409,7 @@ export function ScopeSelector({ iconLeft="plus" label={localize('com_scope_create')} onClick={() => setShowCreate(true)} + onKeyDown={stopActivationKeys} /> )} @@ -553,6 +559,7 @@ function ScopeItem({ scope, isSelected, onSelect, onDelete, localize }: ScopeIte e.stopPropagation(); onDelete(scope); }} + onKeyDown={stopActivationKeys} className="shrink-0 cursor-pointer rounded-sm p-0.5 text-(--cui-color-text-muted) opacity-0 transition-opacity group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[selected=true]:opacity-100 hover:text-(--cui-color-accent-danger)" aria-label={localize('com_scope_delete')} title={localize('com_scope_delete')} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a00cd996..0df53e33 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -9,6 +9,7 @@ "com_ui_remove_item": "Remove {{name}}", "com_ui_save": "Save", "com_ui_add": "Add", + "com_ui_back": "Back", "com_ui_cancel": "Cancel", "com_ui_create": "Create", "com_ui_retry": "Retry", From 284dadab48be9c29e447f471425bdc19fe1cb7fc Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:27:54 -0700 Subject: [PATCH 3/4] fix: let pointer-highlighted scopes be selected with Enter cmdk moves its highlight on pointer movement over an item, not just on keyboard navigation, so hovering a scope and pressing Enter in the empty search was swallowed by the empty-input guard and never selected anything. Track pointer movement over items alongside keyboard navigation so any explicit highlight lets Enter through. The guard still blocks Enter when nothing was explicitly highlighted, preserving the original fix for the auto-highlighted first item closing the dialog on a stray Enter. --- .../configuration/ScopeSelector.test.tsx | 10 ++++++++ .../configuration/ScopeSelector.tsx | 23 ++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/components/configuration/ScopeSelector.test.tsx b/src/components/configuration/ScopeSelector.test.tsx index cea5d8e4..0fad14c8 100644 --- a/src/components/configuration/ScopeSelector.test.tsx +++ b/src/components/configuration/ScopeSelector.test.tsx @@ -186,6 +186,16 @@ describe('ScopeSelector Enter key handling', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + it('selects a scope with Enter after hovering it with the mouse', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + await user.hover(screen.getByText('Engineering')); + screen.getByRole('combobox').focus(); + await user.keyboard('{Enter}'); + expect(onSelect).toHaveBeenCalledWith({ type: 'SCOPE', scope: engineeringScope }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + it('still selects a scope with arrow navigation followed by Enter', async () => { const user = userEvent.setup(); const { onSelect, onOpenChange } = await renderListView(); diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx index d31f0b53..e3affccc 100644 --- a/src/components/configuration/ScopeSelector.tsx +++ b/src/components/configuration/ScopeSelector.tsx @@ -6,7 +6,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Title as DialogTitle, Description as DialogDescription } from '@radix-ui/react-dialog'; import type { AdminGroup } from '@librechat/data-schemas'; -import type { KeyboardEvent } from 'react'; +import type { KeyboardEvent, PointerEvent } from 'react'; import type * as t from '@/types'; import { availableScopesOptions, @@ -45,7 +45,7 @@ export function ScopeSelector({ const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const listRef = useRef(null); - const navigatedRef = useRef(false); + const highlightedRef = useRef(false); const deleteButtonRef = useRef(null); const { data: scopes = [], isLoading: loading } = useQuery({ @@ -72,14 +72,21 @@ export function ScopeSelector({ const handleSearchKeyDown = useCallback( (e: KeyboardEvent) => { if (NAVIGATION_KEYS.has(e.key) || (e.ctrlKey && VIM_NAVIGATION_KEYS.has(e.key))) { - navigatedRef.current = true; + highlightedRef.current = true; return; } - if (e.key === 'Enter' && search === '' && !navigatedRef.current) e.stopPropagation(); + if (e.key === 'Enter' && search === '' && !highlightedRef.current) e.stopPropagation(); }, [search], ); + /** cmdk also moves the highlight when the pointer travels over an item, so hover counts as an explicit highlight too. */ + const handleListPointerMove = useCallback((e: PointerEvent) => { + if (e.target instanceof Element && e.target.closest('[cmdk-item]')) { + highlightedRef.current = true; + } + }, []); + const handleDeleteAutoFocus = useCallback((e: Event) => { e.preventDefault(); deleteButtonRef.current?.focus(); @@ -90,7 +97,7 @@ export function ScopeSelector({ setCreating(false); setDeleteTarget(null); setDeleting(false); - navigatedRef.current = false; + highlightedRef.current = false; }, []); const close = useCallback(() => { @@ -414,7 +421,11 @@ export function ScopeSelector({ )} - + {loading ? ( From e45d03330866c3e2d16cd83cb375ad13d9a47c94 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:51:31 -0700 Subject: [PATCH 4/4] fix: clear the pointer highlight when returning to the scope list Cancelling a delete, finishing a delete, or backing out of the create view remounts the cmdk list with the first item auto-highlighted while the hover flag from the previous view survived, so a stray Enter in the empty search selected Base. Every path back to the list now clears the flag through a shared returnToList handler. --- .../configuration/ScopeSelector.test.tsx | 23 ++++++++++++++++-- .../configuration/ScopeSelector.tsx | 24 +++++++++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/components/configuration/ScopeSelector.test.tsx b/src/components/configuration/ScopeSelector.test.tsx index 0fad14c8..3697a90c 100644 --- a/src/components/configuration/ScopeSelector.test.tsx +++ b/src/components/configuration/ScopeSelector.test.tsx @@ -96,12 +96,16 @@ async function renderListView() { return handlers; } -async function openDeleteConfirmation(user: ReturnType) { - const handlers = await renderListView(); +async function openDeleteConfirmationFromList(user: ReturnType) { const deleteButton = screen.getByRole('button', { name: 'com_scope_delete' }); deleteButton.focus(); await user.keyboard('{Enter}'); await screen.findByText('com_scope_delete_confirm'); +} + +async function openDeleteConfirmation(user: ReturnType) { + const handlers = await renderListView(); + await openDeleteConfirmationFromList(user); return handlers; } @@ -186,6 +190,21 @@ describe('ScopeSelector Enter key handling', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + it('does not select the auto-highlighted Base scope with Enter after cancelling a delete reached by hovering', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + await user.hover(screen.getByText('Engineering')); + await openDeleteConfirmationFromList(user); + const cancelButton = screen.getByRole('button', { name: 'com_ui_cancel' }); + cancelButton.focus(); + await user.keyboard('{Enter}'); + const searchInput = await screen.findByRole('combobox'); + searchInput.focus(); + await user.keyboard('{Enter}'); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + it('selects a scope with Enter after hovering it with the mouse', async () => { const user = userEvent.setup(); const { onSelect, onOpenChange } = await renderListView(); diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx index e3affccc..b1e79474 100644 --- a/src/components/configuration/ScopeSelector.tsx +++ b/src/components/configuration/ScopeSelector.tsx @@ -100,6 +100,13 @@ export function ScopeSelector({ highlightedRef.current = false; }, []); + /** Returning to the list remounts the cmdk view with the first item auto-highlighted, so a highlight carried over from the previous view must not let Enter select it. */ + const returnToList = useCallback(() => { + setShowCreate(false); + setDeleteTarget(null); + highlightedRef.current = false; + }, []); + const close = useCallback(() => { onOpenChange(false); resetState(); @@ -208,13 +215,22 @@ export function ScopeSelector({ ) { onSelect({ type: 'BASE' }); } - setDeleteTarget(null); + returnToList(); setDeleting(false); } catch (err) { setDeleting(false); onError?.(err instanceof Error ? err.message : localize('com_scope_delete_error')); } - }, [deleteTarget, deleting, queryClient, currentSelection, onSelect, onError, localize]); + }, [ + deleteTarget, + deleting, + queryClient, + currentSelection, + onSelect, + onError, + localize, + returnToList, + ]); const roleScopes = useMemo( () => scopes.filter((s) => s.principalType === PrincipalType.ROLE), @@ -255,7 +271,7 @@ export function ScopeSelector({ setDeleteTarget(null)} + onClick={returnToList} disabled={deleting} /> setShowCreate(false)} + onClick={returnToList} aria-label={localize('com_ui_back')} className="flex cursor-pointer items-center text-(--cui-color-text-muted) hover:text-(--cui-color-text-default)" >
- {localize('com_scope_delete_confirm', { name: deleteTarget.name })} -
+ {localize('com_scope_delete_confirm', { name: deleteTarget.name })} +
- {localize('com_scope_no_available_roles')} -
+ {localize('com_scope_no_available_roles')} +
+ {localize('com_scope_no_available_groups')} +
- {localize('com_scope_no_available_groups')} -