refactor(webui): unify floating controls

This commit is contained in:
Xubin Ren 2026-08-04 15:32:29 +08:00
parent 4e8702a47b
commit 003a853edc
16 changed files with 777 additions and 257 deletions

View File

@ -8,6 +8,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
@ -213,7 +214,7 @@
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@ -237,7 +238,9 @@
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@ -263,13 +266,13 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
@ -1319,18 +1322,60 @@
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
"@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
"@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="],
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg=="],
"@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="],
"@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA=="],
"@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="],
"@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw=="],
"@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
"@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
"@radix-ui/react-popover/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="],
"@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
"@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-popper/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-use-size/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@ -1389,6 +1434,40 @@
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
"@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="],
"@radix-ui/react-popover/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-popover/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-popover/@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-popover/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="],
"@radix-ui/react-popover/@radix-ui/react-use-controllable-state/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
"@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
@ -1415,6 +1494,8 @@
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],

View File

@ -15,6 +15,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",

View File

@ -46,7 +46,6 @@ import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160; const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160; const VISIBLE_SESSIONS_INCREMENT = 160;
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]"; const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
const ACTION_MENU_ITEM_CLASS = "grid w-[7.75rem] grid-cols-[1rem_minmax(0,1fr)] items-center gap-2";
interface ChatListProps { interface ChatListProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
@ -337,7 +336,6 @@ export const ChatList = memo(function ChatList({
> >
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onTogglePin(s.key)} onSelect={() => onTogglePin(s.key)}
className={ACTION_MENU_ITEM_CLASS}
> >
{isPinned ? ( {isPinned ? (
<PinOff className="h-4 w-4 shrink-0" /> <PinOff className="h-4 w-4 shrink-0" />
@ -348,14 +346,12 @@ export const ChatList = memo(function ChatList({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)} onSelect={() => onRequestRename(s.key, title)}
className={ACTION_MENU_ITEM_CLASS}
> >
<Pencil className="h-4 w-4 shrink-0" /> <Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")} {t("chat.rename")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onToggleArchive(s.key)} onSelect={() => onToggleArchive(s.key)}
className={ACTION_MENU_ITEM_CLASS}
> >
{isArchived ? ( {isArchived ? (
<ArchiveRestore className="h-4 w-4 shrink-0" /> <ArchiveRestore className="h-4 w-4 shrink-0" />
@ -365,13 +361,10 @@ export const ChatList = memo(function ChatList({
{isArchived ? t("chat.unarchive") : t("chat.archive")} {isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
tone="destructive"
onSelect={() => { onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0); window.setTimeout(() => onRequestDelete(s.key, title), 0);
}} }}
className={cn(
ACTION_MENU_ITEM_CLASS,
"text-destructive focus:text-destructive",
)}
> >
<Trash2 className="h-4 w-4 shrink-0" /> <Trash2 className="h-4 w-4 shrink-0" />
{t("chat.delete")} {t("chat.delete")}
@ -472,7 +465,7 @@ function ProjectGroupHeader({
portalContainer={actionMenuPortalContainer} portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}
> >
<DropdownMenuItem onSelect={onRequestRename} className={ACTION_MENU_ITEM_CLASS}> <DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="h-4 w-4 shrink-0" /> <Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")} {t("chat.rename")}
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -84,10 +84,16 @@ import {
ChannelSetupPanel, ChannelSetupPanel,
} from "@/components/settings/channels/ChannelSetupPanel"; } from "@/components/settings/channels/ChannelSetupPanel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
ComboboxOption,
useComboboxNavigation,
} from "@/components/ui/combobox";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
@ -100,6 +106,11 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { isLoopbackHost } from "@/lib/network"; import { isLoopbackHost } from "@/lib/network";
import { import {
@ -2537,7 +2548,7 @@ function SettingsSidebar({
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
sideOffset={6} sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)] rounded-[16px] p-1.5" className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
> >
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
@ -2547,7 +2558,7 @@ function SettingsSidebar({
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
onSelect={() => onSelectSection(key)} onSelect={() => onSelectSection(key)}
className={cn( className={cn(
"flex h-10 cursor-default items-center gap-2.5 rounded-[11px] px-2.5 text-[13px] font-medium", "flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent", active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
)} )}
> >
@ -3988,14 +3999,26 @@ function ProviderAdvancedOptions({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]"> <DropdownMenuContent align="start" className="min-w-[220px]">
<DropdownMenuRadioGroup
value={form.apiType}
onValueChange={(value) => {
const option = OPENAI_API_TYPE_OPTIONS.find(
(candidate) => candidate.value === value,
);
if (option) onChange({ apiType: option.value });
}}
>
{OPENAI_API_TYPE_OPTIONS.map((option) => ( {OPENAI_API_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem <DropdownMenuRadioItem
key={option.value} key={option.value}
onSelect={() => onChange({ apiType: option.value })} value={option.value}
indicator="check"
indicatorPosition="end"
> >
{option.label} {option.label}
</DropdownMenuItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</label> </label>
@ -4024,15 +4047,22 @@ function ProviderAdvancedOptions({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]"> <DropdownMenuContent align="start" className="min-w-[220px]">
<DropdownMenuRadioGroup
value={form.thinkingStyle}
onValueChange={(value) => onChange({ thinkingStyle: value })}
>
{thinkingStyleOptions.map((option) => ( {thinkingStyleOptions.map((option) => (
<DropdownMenuItem <DropdownMenuRadioItem
key={option.value || "default"} key={option.value || "default"}
onSelect={() => onChange({ thinkingStyle: option.value })} value={option.value}
indicator="check"
indicatorPosition="end"
className="font-mono text-[12px]" className="font-mono text-[12px]"
> >
{option.label} {option.label}
</DropdownMenuItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</label> </label>
@ -4748,11 +4778,11 @@ function ProvidersSettings({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={8} sideOffset={8}
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto rounded-[20px] border-border bg-popover p-1.5 shadow-none scrollbar-thin scrollbar-track-transparent" className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
> >
<DropdownMenuItem <DropdownMenuItem
onSelect={beginCustomProviderCreation} onSelect={beginCustomProviderCreation}
className="flex min-h-[54px] cursor-default items-center gap-3 rounded-[14px] px-2.5 py-2 focus:bg-muted/85 focus:text-foreground" className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
> >
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} /> <ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
<span className="truncate text-[13px] font-medium"> <span className="truncate text-[13px] font-medium">
@ -4769,7 +4799,7 @@ function ProvidersSettings({
onToggleProvider(provider.name); onToggleProvider(provider.name);
} }
}} }}
className="flex min-h-[54px] cursor-default items-center gap-3 rounded-[14px] px-2.5 py-2 focus:bg-muted/85 focus:text-foreground" className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
> >
<ProviderIcon <ProviderIcon
provider={provider.name} provider={provider.name}
@ -5494,12 +5524,25 @@ function AutomationsSettings({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuRadioGroup
value={sort}
onValueChange={(value) => {
const option = (Object.keys(sortLabel) as AutomationSort[])
.find((candidate) => candidate === value);
if (option) onSortChange(option);
}}
>
{(Object.keys(sortLabel) as AutomationSort[]).map((value) => ( {(Object.keys(sortLabel) as AutomationSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => onSortChange(value)}> <DropdownMenuRadioItem
<span>{sortLabel[value]}</span> key={value}
{sort === value ? <Check className="ml-auto h-3.5 w-3.5" aria-hidden /> : null} value={value}
</DropdownMenuItem> indicator="check"
indicatorPosition="end"
>
{sortLabel[value]}
</DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -6112,27 +6155,51 @@ function AutomationEditDialog({
className="h-10 rounded-[12px]" className="h-10 rounded-[12px]"
/> />
</label> </label>
<label className="block space-y-1.5"> <div className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground"> <span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.unit", "Unit")} {tx("settings.automations.fields.unit", "Unit")}
</span> </span>
<select <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
aria-label={tx("settings.automations.fields.unit", "Unit")}
className="h-10 w-full justify-between rounded-[12px] px-3 text-[13px] font-normal"
>
<span>{unitLabels[draft.everyUnit]}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
>
<DropdownMenuRadioGroup
value={draft.everyUnit} value={draft.everyUnit}
onChange={(event) => onValueChange={(value) => {
setDraft((prev) => ({ const unit = AUTOMATION_EVERY_UNITS.find(
...prev, (candidate) => candidate.value === value,
everyUnit: event.target.value as AutomationEveryUnit, );
})) if (unit) {
setDraft((prev) => ({ ...prev, everyUnit: unit.value }));
} }
className="h-10 w-full rounded-[12px] border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring" }}
> >
{AUTOMATION_EVERY_UNITS.map((unit) => ( {AUTOMATION_EVERY_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}> <DropdownMenuRadioItem
key={unit.value}
value={unit.value}
indicator="check"
indicatorPosition="end"
>
{unitLabels[unit.value]} {unitLabels[unit.value]}
</option> </DropdownMenuRadioItem>
))} ))}
</select> </DropdownMenuRadioGroup>
</label> </DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
) : null} ) : null}
@ -7472,15 +7539,19 @@ function CliAppsCatalogRow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}> <DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden /> <PlayCircle aria-hidden />
{tx("settings.cliApps.test", "Test CLI")} {tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}> <DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden /> <RotateCcw aria-hidden />
{tx("settings.cliApps.update", "Update CLI")} {tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}> <DropdownMenuItem
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden /> tone="destructive"
disabled={busy}
onClick={() => onAction("uninstall", app.name)}
>
<Trash2 aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")} {tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -7604,17 +7675,21 @@ function McpAppsCatalogRow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}> <DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden /> <PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")} {tx("settings.mcp.test", "Test")}
</DropdownMenuItem> </DropdownMenuItem>
{toolNames.length ? ( {toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}> <DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal className="mr-2 h-3.5 w-3.5" aria-hidden /> <SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")} {tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem disabled={busy} onClick={() => onAction("remove", preset.name)}> <DropdownMenuItem
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden /> tone="destructive"
disabled={busy}
onClick={() => onAction("remove", preset.name)}
>
<Trash2 aria-hidden />
{tx("settings.mcp.remove", "Remove")} {tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -8821,13 +8896,35 @@ function TimezonePicker({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const options = useMemo(() => timezoneOptions(value), [value]); const options = useMemo(() => timezoneOptions(value), [value]);
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]); const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
const optionValues = useMemo(
() => filteredOptions.map((option) => option.name),
[filteredOptions],
);
const chooseTimezone = (timezone: string) => {
onChange(timezone);
setOpen(false);
};
const navigation = useComboboxNavigation({
open,
values: optionValues,
selectedValue: value,
onSelect: chooseTimezone,
onClose: () => setOpen(false),
});
return ( return (
<DropdownMenu onOpenChange={(open) => !open && setQuery("")}> <Popover
<DropdownMenuTrigger asChild> open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -8839,8 +8936,8 @@ function TimezonePicker({
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span> <span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> <ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
className="w-[340px] max-w-[calc(100vw-2rem)]" className="w-[340px] max-w-[calc(100vw-2rem)]"
> >
@ -8851,13 +8948,16 @@ function TimezonePicker({
autoFocus autoFocus
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()} {...navigation.inputProps}
placeholder={tx("settings.timezone.search", "Search timezone")} placeholder={tx("settings.timezone.search", "Search timezone")}
aria-label={tx("settings.timezone.search", "Search timezone")}
className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0" className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
/> />
</div> </div>
</div> </div>
<div <div
{...navigation.listProps}
aria-label={tx("settings.timezone.select", "Select timezone")}
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent" className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list" data-testid="timezone-picker-list"
> >
@ -8865,13 +8965,12 @@ function TimezonePicker({
filteredOptions.map((option) => { filteredOptions.map((option) => {
const selected = option.name === value; const selected = option.name === value;
return ( return (
<DropdownMenuItem <ComboboxOption
key={option.name} key={option.name}
onSelect={() => onChange(option.name)} {...navigation.getOptionProps(option.name)}
className={cn( className={cn(
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]", "flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
"focus:bg-muted/85 focus:text-foreground", selected && "text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span> <span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
@ -8881,7 +8980,7 @@ function TimezonePicker({
</span> </span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null} {selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</span> </span>
</DropdownMenuItem> </ComboboxOption>
); );
}) })
) : ( ) : (
@ -8890,8 +8989,8 @@ function TimezonePicker({
</div> </div>
)} )}
</div> </div>
</DropdownMenuContent> </PopoverContent>
</DropdownMenu> </Popover>
); );
} }
@ -8940,16 +9039,16 @@ function ProviderPicker({
align="end" align="end"
className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent" className="max-h-[18rem] w-[240px] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
> >
{providers.map((provider) => { <DropdownMenuRadioGroup value={value} onValueChange={onChange}>
const selected = provider.name === value; {providers.map((provider) => (
return ( <DropdownMenuRadioItem
<DropdownMenuItem
key={provider.name} key={provider.name}
onSelect={() => onChange(provider.name)} value={provider.name}
indicator="check"
indicatorPosition="end"
className={cn( className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]", "flex cursor-default items-center gap-2 rounded-[12px] py-2 text-[13px]",
"focus:bg-muted/85 focus:text-foreground", "focus:bg-muted/85 focus:text-foreground data-[state=checked]:bg-muted/80",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
@ -8961,10 +9060,9 @@ function ProviderPicker({
) : null} ) : null}
<span className="truncate">{provider.label}</span> <span className="truncate">{provider.label}</span>
</span> </span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null} </DropdownMenuRadioItem>
</DropdownMenuItem> ))}
); </DropdownMenuRadioGroup>
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@ -9021,16 +9119,22 @@ function ModelIdPicker({
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const providerModels: ProviderModelsPayload["models"] = hasStaticModels const providerModels: ProviderModelsPayload["models"] = useMemo(
() => hasStaticModels
? (models?.map((id) => ({ id })) ?? []) ? (models?.map((id) => ({ id })) ?? [])
: (payload?.models ?? []); : (payload?.models ?? []),
const visibleModels = providerModels [hasStaticModels, models, payload?.models],
);
const visibleModels = useMemo(
() => providerModels
.filter((model) => { .filter((model) => {
if (!normalizedQuery) return true; if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""] return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery)); .some((field) => field.toLowerCase().includes(normalizedQuery));
}) })
.slice(0, 80); .slice(0, 80),
[normalizedQuery, providerModels],
);
const isCatalog = payload?.catalog_kind === "catalog"; const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider); const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery = const hasDeferredSearchQuery =
@ -9046,6 +9150,9 @@ function ModelIdPicker({
const customCandidate = query.trim(); const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration; const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate); const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const showCustomModel = Boolean(
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
);
const providerModelCount = payload?.model_count ?? providerModels.length; const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured; const modelUnconfigured = !value.trim() || !providerConfigured;
@ -9084,18 +9191,31 @@ function ModelIdPicker({
onChange(model); onChange(model);
setOpen(false); setOpen(false);
}; };
const navigationValues = useMemo(
() => [
...(showModels ? visibleModels.map((model) => model.id) : []),
...(showCustomModel ? [customCandidate] : []),
],
[customCandidate, showCustomModel, showModels, visibleModels],
);
const navigation = useComboboxNavigation({
open,
values: navigationValues,
selectedValue: value,
onSelect: selectModel,
onClose: () => setOpen(false),
});
const renderModelRow = ( const renderModelRow = (
model: ProviderModelsPayload["models"][number], model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {}, options: { selected?: boolean } = {},
) => ( ) => (
<DropdownMenuItem <ComboboxOption
key={model.id} key={model.id}
onSelect={() => selectModel(model.id)} {...navigation.getOptionProps(model.id)}
className={cn( className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]", "flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
"focus:bg-muted/85 focus:text-foreground", options.selected && "text-foreground",
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
@ -9121,12 +9241,12 @@ function ModelIdPicker({
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null} {model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null} {options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span> </span>
</DropdownMenuItem> </ComboboxOption>
); );
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -9152,8 +9272,8 @@ function ModelIdPicker({
</span> </span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> <ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5" className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
> >
@ -9166,13 +9286,7 @@ function ModelIdPicker({
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => { {...navigation.inputProps}
event.stopPropagation();
if (event.key === "Enter" && allowCustomModel && customCandidate) {
event.preventDefault();
selectModel(customCandidate);
}
}}
placeholder={ placeholder={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
} }
@ -9228,24 +9342,25 @@ function ModelIdPicker({
</div> </div>
) : null} ) : null}
{showModels && visibleModels.length ? ( {navigationValues.length ? (
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"> <div
{visibleModels.map((model) => {...navigation.listProps}
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
>
{showModels
? visibleModels.map((model) =>
renderModelRow(model, { selected: model.id === value }), renderModelRow(model, { selected: model.id === value }),
)} )
</div> : null}
) : showModels ? ( {showCustomModel ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<> <>
{showModels ? <DropdownMenuSeparator /> : null} {showModels && visibleModels.length ? (
<DropdownMenuItem <div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
onSelect={() => selectModel(customCandidate)} ) : null}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85" <ComboboxOption
{...navigation.getOptionProps(customCandidate)}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
> >
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground"> <span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
<Pencil className="h-3 w-3" aria-hidden /> <Pencil className="h-3 w-3" aria-hidden />
@ -9254,11 +9369,18 @@ function ModelIdPicker({
{tx("settings.models.useCustomModel", "Use")}{" "} {tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span> <span className="font-medium text-foreground">{customCandidate}</span>
</span> </span>
</DropdownMenuItem> </ComboboxOption>
</> </>
) : null} ) : null}
</DropdownMenuContent> </div>
</DropdownMenu> ) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
</PopoverContent>
</Popover>
); );
} }

View File

@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, Popover,
DropdownMenuContent, PopoverContent,
DropdownMenuTrigger, PopoverTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/popover";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs"; import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n"; import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format"; import { fmtDateTime } from "@/lib/format";
@ -63,8 +63,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
); );
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -76,11 +76,11 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
> >
<ListTodo className="h-4 w-4 stroke-[1.75]" /> <ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
sideOffset={8} sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0" className="w-[min(23rem,calc(100vw-1.5rem))] p-0"
> >
<div className="space-y-3 px-4 py-3.5"> <div className="space-y-3 px-4 py-3.5">
<div className="min-w-0"> <div className="min-w-0">
@ -108,8 +108,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
{automationContent} {automationContent}
</div> </div>
</DropdownMenuContent> </PopoverContent>
</DropdownMenu> </Popover>
); );
} }

View File

@ -50,6 +50,10 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
floatingItemClassName,
floatingSurfaceVisualClassName,
} from "@/components/ui/floating-surface";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -2688,10 +2692,9 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")} aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }} style={{ maxHeight: layout.maxHeight }}
className={cn( className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border", floatingSurfaceVisualClassName,
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2", layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]", isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)} )}
> >
@ -2728,7 +2731,8 @@ function CliAppMentionPalette({
onChoose(candidate); onChoose(candidate);
}} }}
className={cn( className={cn(
"flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors", floatingItemClassName,
"flex min-h-10 w-full items-center gap-2.5 px-2.5 py-1.5 text-left transition-colors",
selected selected
? "bg-foreground/[0.055] text-foreground" ? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]", : "text-foreground/90 hover:bg-foreground/[0.04]",
@ -2836,10 +2840,9 @@ function SlashCommandPalette({
aria-label={t("thread.composer.slash.ariaLabel")} aria-label={t("thread.composer.slash.ariaLabel")}
style={{ maxHeight: layout.maxHeight }} style={{ maxHeight: layout.maxHeight }}
className={cn( className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border", floatingSurfaceVisualClassName,
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2", layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]", isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)} )}
> >
@ -2868,7 +2871,8 @@ function SlashCommandPalette({
onChoose(command); onChoose(command);
}} }}
className={cn( className={cn(
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors", floatingItemClassName,
"flex min-h-[44px] w-full items-center gap-3 px-3 py-2 text-left transition-colors",
selected selected
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]" ? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]", : "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",

View File

@ -6,10 +6,20 @@ import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
floatingItemClassName,
floatingItemFocusClassName,
} from "@/components/ui/floating-surface";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { import type {
WorkspaceAccessMode, WorkspaceAccessMode,
WorkspaceScopePayload, WorkspaceScopePayload,
@ -134,8 +144,8 @@ export function WorkspaceProjectPicker({
return ( return (
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4"> <div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
<DropdownMenu open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
@ -151,16 +161,21 @@ export function WorkspaceProjectPicker({
<span className="truncate">{projectLabel}</span> <span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button> </button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="start" align="start"
side="bottom" side="bottom"
sideOffset={8} sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]" className="w-[min(25rem,calc(100vw-2rem))]"
> >
<DropdownMenuItem <button
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)} type="button"
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55" onClick={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
className={cn(
floatingItemClassName,
floatingItemFocusClassName,
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55",
)}
> >
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80"> <span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
<Folder className="h-4 w-4" /> <Folder className="h-4 w-4" />
@ -174,14 +189,9 @@ export function WorkspaceProjectPicker({
</span> </span>
</span> </span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null} {!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem> </button>
<div className="my-1 h-px bg-border/45" /> <div className="my-1 h-px bg-border/45" />
<div <div className="space-y-1.5 px-1.5 py-1.5">
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form <form
className="flex items-center gap-2" className="flex items-center gap-2"
onSubmit={(event) => { onSubmit={(event) => {
@ -217,8 +227,8 @@ export function WorkspaceProjectPicker({
</p> </p>
) : null} ) : null}
</div> </div>
</DropdownMenuContent> </PopoverContent>
</DropdownMenu> </Popover>
</div> </div>
); );
} }
@ -284,46 +294,51 @@ export function WorkspaceAccessMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className="w-56">
<DropdownMenuRadioGroup
value={mode}
onValueChange={(value) => {
if (value === "restricted" || value === "full") setMode(value);
}}
>
<AccessMenuItem <AccessMenuItem
value="restricted"
icon={<Hand className="h-4 w-4" />} icon={<Hand className="h-4 w-4" />}
label={t("thread.composer.workspace.default")} label={t("thread.composer.workspace.default")}
selected={mode === "restricted"}
onSelect={() => setMode("restricted")}
/> />
<AccessMenuItem <AccessMenuItem
value="full"
icon={<AlertTriangle className="h-4 w-4" />} icon={<AlertTriangle className="h-4 w-4" />}
label={t("thread.composer.workspace.full")} label={t("thread.composer.workspace.full")}
selected={mode === "full"}
disabled={!canUseFullAccess} disabled={!canUseFullAccess}
warning warning
onSelect={() => setMode("full")}
/> />
</DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
} }
function AccessMenuItem({ function AccessMenuItem({
value,
icon, icon,
label, label,
selected,
disabled, disabled,
warning, warning,
onSelect,
}: { }: {
value: WorkspaceAccessMode;
icon: ReactNode; icon: ReactNode;
label: string; label: string;
selected: boolean;
disabled?: boolean; disabled?: boolean;
warning?: boolean; warning?: boolean;
onSelect: () => void;
}) { }) {
return ( return (
<DropdownMenuItem <DropdownMenuRadioItem
value={value}
indicator="check"
indicatorPosition="end"
disabled={disabled} disabled={disabled}
onSelect={onSelect}
className={cn( className={cn(
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold", "flex h-10 items-center gap-3 rounded-xl pl-3 text-[13.5px] font-semibold",
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300", warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)} )}
> >
@ -331,7 +346,6 @@ function AccessMenuItem({
{icon} {icon}
</span> </span>
<span className="min-w-0 flex-1 truncate">{label}</span> <span className="min-w-0 flex-1 truncate">{label}</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null} </DropdownMenuRadioItem>
</DropdownMenuItem>
); );
} }

View File

@ -0,0 +1,137 @@
import * as React from "react";
import {
floatingItemClassName,
floatingItemFocusClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
interface ComboboxNavigationOptions {
open: boolean;
values: readonly string[];
selectedValue?: string;
onSelect: (value: string) => void;
onClose: () => void;
}
export function useComboboxNavigation({
open,
values,
selectedValue,
onSelect,
onClose,
}: ComboboxNavigationOptions) {
const listboxId = React.useId();
const [activeValue, setActiveValue] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
setActiveValue((current) => {
if (current && values.includes(current)) return current;
if (selectedValue && values.includes(selectedValue)) return selectedValue;
return values[0] ?? null;
});
}, [open, selectedValue, values]);
const activeIndex = activeValue ? values.indexOf(activeValue) : -1;
const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
React.useEffect(() => {
if (!activeOptionId) return;
const option = document.getElementById(activeOptionId);
option?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const move = (offset: number) => {
if (!values.length) return;
const nextIndex = activeIndex < 0
? offset > 0 ? 0 : values.length - 1
: (activeIndex + offset + values.length) % values.length;
setActiveValue(values[nextIndex]);
};
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Home":
if (values.length) {
event.preventDefault();
setActiveValue(values[0]);
}
break;
case "End":
if (values.length) {
event.preventDefault();
setActiveValue(values[values.length - 1]);
}
break;
case "Enter":
if (activeValue) {
event.preventDefault();
onSelect(activeValue);
}
break;
case "Escape":
event.preventDefault();
onClose();
break;
}
};
const inputProps = {
role: "combobox" as const,
"aria-autocomplete": "list" as const,
"aria-controls": listboxId,
"aria-expanded": open,
"aria-activedescendant": activeOptionId,
onKeyDown: onInputKeyDown,
};
const listProps = {
id: listboxId,
role: "listbox" as const,
};
const getOptionProps = (value: string) => {
const index = values.indexOf(value);
return {
id: `${listboxId}-option-${index}`,
role: "option" as const,
"aria-selected": value === selectedValue,
"data-highlighted": value === activeValue ? "" : undefined,
tabIndex: -1,
onPointerMove: () => setActiveValue(value),
onClick: () => onSelect(value),
};
};
return { inputProps, listProps, getOptionProps };
}
const ComboboxOption = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, type = "button", ...props }, ref) => (
<button
ref={ref}
type={type}
className={cn(
floatingItemClassName,
floatingItemFocusClassName,
"w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground aria-selected:bg-muted/80",
className,
)}
{...props}
/>
));
ComboboxOption.displayName = "ComboboxOption";
export { ComboboxOption };

View File

@ -2,6 +2,12 @@ import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react"; import { Check, ChevronRight, Circle } from "lucide-react";
import {
floatingItemClassName,
floatingItemFocusClassName,
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@ -11,11 +17,8 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuContentClassName =
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
const menuItemClassName = const menuItemClassName =
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]"; `${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
@ -46,7 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
menuContentClassName, floatingSurfaceClassName,
"min-w-[10rem]",
className, className,
)} )}
{...props} {...props}
@ -68,8 +72,9 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
menuContentClassName, floatingSurfaceClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", floatingSurfaceMotionClassName,
"min-w-[10rem]",
className, className,
)} )}
{...props} {...props}
@ -82,13 +87,15 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
tone?: "default" | "destructive";
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, tone = "default", ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
menuItemClassName, menuItemClassName,
inset && "pl-8", inset && "pl-8",
tone === "destructive" && "text-destructive focus:text-destructive",
className, className,
)} )}
{...props} {...props}
@ -123,20 +130,32 @@ DropdownMenuCheckboxItem.displayName =
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
>(({ className, children, ...props }, ref) => ( indicator?: "dot" | "check";
indicatorPosition?: "start" | "end";
}
>(({ className, children, indicator = "dot", indicatorPosition = "start", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
menuItemClassName, menuItemClassName,
"pl-8 pr-2.5", indicatorPosition === "start" ? "pl-8 pr-2.5" : "pl-2.5 pr-8",
className, className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center"> <span
className={cn(
"absolute flex h-3.5 w-3.5 items-center justify-center",
indicatorPosition === "start" ? "left-2.5" : "right-2.5",
)}
>
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
{indicator === "check" ? (
<Check className="h-3.5 w-3.5" />
) : (
<Circle className="h-2 w-2 fill-current" /> <Circle className="h-2 w-2 fill-current" />
)}
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}

View File

@ -0,0 +1,14 @@
export const floatingSurfaceVisualClassName =
"rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
export const floatingSurfaceClassName =
`${floatingSurfaceVisualClassName} z-50 max-h-[min(var(--radix-popper-available-height),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
export const floatingSurfaceMotionClassName =
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
export const floatingItemClassName =
"relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
export const floatingItemFocusClassName =
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";

View File

@ -0,0 +1,41 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
interface PopoverContentProps
extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
portalContainer?: HTMLElement | null;
}
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
<PopoverPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export {
Popover,
PopoverContent,
PopoverTrigger,
};

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n"; import i18n from "@/i18n";
@ -1715,6 +1716,7 @@ describe("App layout", () => {
}); });
it("opens the settings view from the sidebar footer", async () => { it("opens the settings view from the sidebar footer", async () => {
const user = userEvent.setup();
mockSessions = [ mockSessions = [
{ {
key: "websocket:chat-a", key: "websocket:chat-a",
@ -1729,6 +1731,18 @@ describe("App layout", () => {
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
const href = String(input); const href = String(input);
if (href === "/api/settings/api-service") {
return jsonResponse({
installed: false,
running: false,
managed: false,
host: "127.0.0.1",
port: 8900,
timeout: 120,
endpoint: "http://127.0.0.1:8900/v1",
command: "nanobot serve",
});
}
if (href === "/api/settings/provider-models?provider=openai") { if (href === "/api/settings/provider-models?provider=openai") {
return jsonResponse({ return jsonResponse({
provider: "openai", provider: "openai",
@ -1996,8 +2010,8 @@ describe("App layout", () => {
.getAllByRole("button", { name: /OpenAI/ }) .getAllByRole("button", { name: /OpenAI/ })
.some((button) => button.getAttribute("aria-haspopup") === "menu"), .some((button) => button.getAttribute("aria-haspopup") === "menu"),
).toBe(true); ).toBe(true);
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" })); await user.click(screen.getByRole("button", { name: "Select model" }));
fireEvent.click(await screen.findByText("openai/gpt-4o-mini")); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument(); expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
@ -2006,14 +2020,13 @@ describe("App layout", () => {
); );
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0); expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ })); fireEvent.click(screen.getByRole("menuitemradio", { name: /Auto/ }));
const openModelPicker = () => { const openModelPicker = async () => {
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ }); const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]); await user.click(modelButtons[modelButtons.length - 1]);
}; };
openModelPicker(); await openModelPicker();
await screen.findByText("openai/gpt-4o-mini"); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument(); expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
expect(screen.getByText("Model providers")).toBeInTheDocument(); expect(screen.getByText("Model providers")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
@ -2078,9 +2091,9 @@ describe("App layout", () => {
target: { value: "unsaved-brave-key" }, target: { value: "unsaved-brave-key" },
}); });
fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Tavily" }));
fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Brave Search" }));
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument(); expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
@ -2095,12 +2108,13 @@ describe("App layout", () => {
expect(screen.queryByText("Unified session")).not.toBeInTheDocument(); expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument(); expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" })); fireEvent.click(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument(); const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
fireEvent.change(screen.getByPlaceholderText("Search timezone"), { expect(timezoneSearch).toBeInTheDocument();
fireEvent.change(timezoneSearch, {
target: { value: "Shanghai" }, target: { value: "Shanghai" },
}); });
fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ })); await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ }));
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
}); });

View File

@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import {
ComboboxOption,
useComboboxNavigation,
} from "@/components/ui/combobox";
const OPTIONS = ["Alpha", "Beta", "Gamma"];
function ComboboxHarness() {
const [open, setOpen] = useState(true);
const [selected, setSelected] = useState("Beta");
const navigation = useComboboxNavigation({
open,
values: OPTIONS,
selectedValue: selected,
onSelect: setSelected,
onClose: () => setOpen(false),
});
return (
<>
<input aria-label="Options" {...navigation.inputProps} />
{open ? (
<div {...navigation.listProps} aria-label="Available options">
{OPTIONS.map((option) => (
<ComboboxOption key={option} {...navigation.getOptionProps(option)}>
{option}
</ComboboxOption>
))}
</div>
) : null}
<output aria-label="Selection">{selected}</output>
</>
);
}
describe("combobox navigation", () => {
it("exposes listbox semantics and selects the active option from the keyboard", () => {
render(<ComboboxHarness />);
const input = screen.getByRole("combobox", { name: "Options" });
expect(input).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input).toHaveAttribute(
"aria-activedescendant",
screen.getByRole("option", { name: "Gamma" }).id,
);
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma");
});
it("closes the listbox on Escape", () => {
render(<ComboboxHarness />);
fireEvent.keyDown(screen.getByRole("combobox", { name: "Options" }), {
key: "Escape",
});
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
});
});

View File

@ -56,6 +56,8 @@ describe("SessionInfoPopover", () => {
await user.click(screen.getByRole("button", { name: "Session details" })); await user.click(screen.getByRole("button", { name: "Session details" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations", "/api/sessions/websocket%3Achat-1/automations",

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView"; import { SettingsView } from "@/components/settings/SettingsView";
@ -372,6 +373,10 @@ async function togglePresetEditor(name = "primary") {
fireEvent.click(within(row).getAllByRole("button")[0]); fireEvent.click(within(row).getAllByRole("button")[0]);
} }
async function openPopover(trigger: HTMLElement) {
await userEvent.setup().click(trigger);
}
async function chooseProviderToConfigure(label: string) { async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown( fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }), await screen.findByRole("button", { name: "Add your own model provider" }),
@ -2460,8 +2465,8 @@ describe("SettingsView Apps catalog", () => {
fireEvent.change(screen.getByPlaceholderText("Fast writing"), { fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
target: { value: "Writer" }, target: { value: "Writer" },
}); });
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" })); await openPopover(screen.getByRole("button", { name: "Select model" }));
const modelSearch = await screen.findByRole("textbox", { const modelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID", name: "Search or type model ID",
}); });
fireEvent.change(modelSearch, { fireEvent.change(modelSearch, {
@ -3206,7 +3211,7 @@ describe("SettingsView Apps catalog", () => {
target: { value: "http://127.0.0.1:7890" }, target: { value: "http://127.0.0.1:7890" },
}); });
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" })); fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "enable_thinking" }));
fireEvent.click(screen.getByRole("button", { name: "Save provider" })); fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
await waitFor(() => { await waitFor(() => {
@ -3272,27 +3277,27 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" })); fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" })); await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" })); fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(), expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
); );
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-4.0-generate-001" })); await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
const modelInput = await screen.findByRole("textbox", { name: "Search or type model ID" }); const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } }); fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
fireEvent.click(await screen.findByRole("menuitem", { name: "Use “imagen-5-preview”" })); fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" })); fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-5-preview" })); await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
const customProviderInput = await screen.findByRole("textbox", { const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID", name: "Search or type model ID",
}); });
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } }); fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
@ -3572,9 +3577,9 @@ describe("SettingsView Apps catalog", () => {
if (!providerPicker) throw new Error("provider picker was not found"); if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker); fireEvent.pointerDown(providerPicker);
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument(); expect(await screen.findByRole("menuitemradio", { name: /DeepSeek/ })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument(); expect(screen.queryByRole("menuitemradio", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument(); expect(screen.queryByRole("menuitemradio", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
}); });
it("does not fetch model lists for unsigned OAuth providers", async () => { it("does not fetch model lists for unsigned OAuth providers", async () => {
@ -3638,7 +3643,7 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "models" }); renderSettingsView({ initialSection: "models" });
await togglePresetEditor(); await togglePresetEditor();
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i })); await openPopover(await screen.findByRole("button", { name: /Select model/i }));
expect( expect(
await screen.findByText("Configure this provider before loading models."), await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument(); ).toBeInTheDocument();
@ -3698,7 +3703,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor(); await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i }); const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]); await openPopover(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement; const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5"); expect(input.value).toBe("open-codex/gpt-5.5");
@ -3776,7 +3781,7 @@ describe("SettingsView Apps catalog", () => {
const modelButtons = await screen.findAllByRole("button", { const modelButtons = await screen.findAllByRole("button", {
name: /openai-codex\/gpt-5\.5/i, name: /openai-codex\/gpt-5\.5/i,
}); });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]); await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument(); expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument(); expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
@ -3900,7 +3905,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor(); await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i }); const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]); await openPopover(modelButtons[modelButtons.length - 1]);
await screen.findByText("deepseek-reasoner"); await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]); fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ })); fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
@ -4023,7 +4028,7 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "browser" }); renderSettingsView({ initialSection: "browser" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ })); fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Keenable" }));
const saveButton = screen const saveButton = screen
.getAllByRole("button", { name: "Save" }) .getAllByRole("button", { name: "Save" })
.find((button) => !(button as HTMLButtonElement).disabled); .find((button) => !(button as HTMLButtonElement).disabled);

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@ -967,7 +968,7 @@ describe("ThreadComposer", () => {
); );
fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ })); fireEvent.click(await screen.findByRole("menuitemradio", { name: /Full Access/ }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith( expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -1007,6 +1008,7 @@ describe("ThreadComposer", () => {
}); });
it("keeps project selection as a compact composer dropdown", async () => { it("keeps project selection as a compact composer dropdown", async () => {
const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn(); const onWorkspaceScopeChange = vi.fn();
const defaultScope = { const defaultScope = {
project_path: "/Users/test/.nanobot/workspace", project_path: "/Users/test/.nanobot/workspace",
@ -1030,10 +1032,10 @@ describe("ThreadComposer", () => {
/>, />,
); );
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); await user.click(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
const input = screen.getByLabelText("Paste path"); const input = screen.getByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } }); fireEvent.change(input, { target: { value: "relative/project" } });
@ -1054,7 +1056,7 @@ describe("ThreadComposer", () => {
restrict_to_workspace: false, restrict_to_workspace: false,
})); }));
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); await user.click(screen.getByRole("button", { name: "Choose project" }));
const reopenedInput = await screen.findByLabelText("Paste path"); const reopenedInput = await screen.findByLabelText("Paste path");
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } }); fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" })); fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
@ -1102,7 +1104,7 @@ describe("ThreadComposer", () => {
fireEvent.click(screen.getByRole("button", { name: "Choose project" })); fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled()); await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Default workspace/ })).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({ expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/native-project", project_path: "/Users/test/native-project",
project_name: "native-project", project_name: "native-project",
@ -1112,6 +1114,7 @@ describe("ThreadComposer", () => {
}); });
it("uses the web path menu when no native host picker is available", async () => { it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup();
const defaultScope = { const defaultScope = {
project_path: "/Users/test/.nanobot/workspace", project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace", project_name: "workspace",
@ -1131,9 +1134,9 @@ describe("ThreadComposer", () => {
/>, />,
); );
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); await user.click(screen.getByRole("button", { name: "Choose project" }));
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.getByLabelText("Paste path")).toBeInTheDocument(); expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
}); });