diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44d7c726aa6d..d60a44899fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1315,7 +1315,7 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - 'extensions/onepassword': + extensions/onepassword: devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -2104,12 +2104,12 @@ importers: packages/terminal-core: dependencies: - '@openclaw/normalization-core': - specifier: workspace:* - version: link:../normalization-core '@clack/prompts': specifier: 1.6.0 version: 1.6.0 + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core chalk: specifier: 5.6.2 version: 5.6.2 @@ -2124,9 +2124,9 @@ importers: ui: dependencies: - '@openclaw/gateway-client': - specifier: workspace:* - version: link:../packages/gateway-client + '@awesome.me/webawesome': + specifier: 3.10.0 + version: 3.10.0(@floating-ui/utils@0.2.12)(@types/node@24.13.2)(@types/react@19.2.17) '@codemirror/commands': specifier: 6.10.4 version: 6.10.4 @@ -2160,6 +2160,9 @@ importers: '@noble/ed25519': specifier: 3.1.0 version: 3.1.0 + '@openclaw/gateway-client': + specifier: workspace:* + version: link:../packages/gateway-client '@openclaw/libterminal': specifier: 0.3.1 version: 0.3.1 @@ -2205,7 +2208,7 @@ importers: version: 14.1.2 '@vitest/browser-playwright': specifier: 4.1.9 - version: 4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + version: 4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) jsdom: specifier: 29.1.1 version: 29.1.1(@noble/hashes@2.2.0) @@ -2214,7 +2217,7 @@ importers: version: 1.61.1 vite: specifier: 8.1.3 - version: 8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -2326,6 +2329,10 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@awesome.me/webawesome@3.10.0': + resolution: {integrity: sha512-QrVKGTiz9OhtIoDic7RF6o1x5ShnJI7jgxK93uoSBh3INYlUyejKYXohXqpJ2cgjIJqfo0jMSQygd1Ty96QOMA==} + engines: {node: '>=14.17.0'} + '@aws-sdk/checksums@3.1000.11': resolution: {integrity: sha512-nW5kNBJBwVxkvBqygBYksS8WUFDO8Ad8OSfsVa+f5KFRRTrHL1rnWZNsWBwc5fC9YvZbET/57+X4hym/jKfV+g==} engines: {node: '>=20.0.0'} @@ -2729,6 +2736,10 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.1.0': + resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} + engines: {node: '>=14'} + '@d-fischer/cache-decorators@4.0.1': resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==} @@ -2947,6 +2958,15 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@gar/promise-retry@1.0.3': resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3108,6 +3128,9 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@konnorr/qr-creator@1.0.1': + resolution: {integrity: sha512-EmRR9rny1ENBtQy7TLOguO/79h1EpzXUmqIdMTGp2BW8NkZtRRPr5hmQcE+n9QXYo4T8NjcaJ14hnZg+s/+K+A==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -3229,12 +3252,29 @@ packages: '@lit-labs/signals@0.3.0': resolution: {integrity: sha512-PfB5FLtorVfkhwTWXOPNT/rvrVdSDY8siwPyR17ztrViRbMqxy1TQBcHqJoA4303QT8yXPGxYtGKJGj7fEfEwg==} + '@lit-labs/ssr-client@1.1.8': + resolution: {integrity: sha512-PjGh81oKsoI64m3IDjTqqjhC7dr2uC/o0jrllUb5gRAyp/RlAHxapgJrjq9kWz97faCHLQ8jUlTi6tGm+8fgyA==} + '@lit-labs/ssr-dom-shim@1.6.0': resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==} + '@lit-labs/ssr@4.1.0': + resolution: {integrity: sha512-m0zymVVlHB1ddJQ1lastsV8ROW3whFOiHJhVPQWd04MnGTkTlUUVLQctux1QlyD9BtLXNN6iASxv388vhgKMFg==} + engines: {node: '>=13.9.0'} + peerDependencies: + '@types/node': '>=20.0.0 <25.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + '@lit/context@1.1.6': resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==} + '@lit/react@1.0.8': + resolution: {integrity: sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==} + peerDependencies: + '@types/react': 17 || 18 || 19 + '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} @@ -4011,6 +4051,9 @@ packages: cpu: [x64] os: [win32] + '@parse5/tools@0.3.0': + resolution: {integrity: sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==} + '@pierre/diffs@1.2.12': resolution: {integrity: sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==} peerDependencies: @@ -4284,6 +4327,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shoelace-style/animations@1.2.0': + resolution: {integrity: sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==} + + '@shoelace-style/localize@3.2.2': + resolution: {integrity: sha512-h3+2/cFWGaw3KQUwintkP4Cy3PtrVW//ysr9DM5nOfIXYekgrHwsELk/nyxc8hmjVP/Kcon7KCzvUtSwUBipfQ==} + '@sigstore/bundle@4.0.0': resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -4666,6 +4715,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -5295,6 +5347,11 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + composed-offset-position@0.0.6: + resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==} + peerDependencies: + '@floating-ui/utils': ^0.2.5 + constantinople@4.0.1: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} @@ -5356,6 +5413,9 @@ packages: cssom@0.5.0: resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} @@ -5496,10 +5556,18 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.24.2: + resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -6405,6 +6473,11 @@ packages: markdown-table@2.0.0: resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marked@11.2.0: + resolution: {integrity: sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -6911,6 +6984,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -7599,6 +7675,10 @@ packages: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -8238,6 +8318,26 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@awesome.me/webawesome@3.10.0(@floating-ui/utils@0.2.12)(@types/node@24.13.2)(@types/react@19.2.17)': + dependencies: + '@ctrl/tinycolor': 4.1.0 + '@floating-ui/dom': 1.8.0 + '@konnorr/qr-creator': 1.0.1 + '@lit-labs/ssr': 4.1.0(@types/node@24.13.2) + '@lit-labs/ssr-client': 1.1.8 + '@lit/context': 1.1.6 + '@lit/react': 1.0.8(@types/react@19.2.17) + '@shoelace-style/animations': 1.2.0 + '@shoelace-style/localize': 3.2.2 + composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12) + lit: 3.3.3 + marked: 11.2.0 + nanoid: 5.1.16 + transitivePeerDependencies: + - '@floating-ui/utils' + - '@types/node' + - '@types/react' + '@aws-sdk/checksums@3.1000.11': dependencies: '@aws-sdk/core': 3.974.27 @@ -8971,6 +9071,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@ctrl/tinycolor@4.1.0': {} + '@d-fischer/cache-decorators@4.0.1': dependencies: '@d-fischer/shared-utils': 3.6.4 @@ -9143,6 +9245,17 @@ snapshots: optionalDependencies: '@noble/hashes': 2.2.0 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + '@gar/promise-retry@1.0.3': {} '@github/copilot-darwin-arm64@1.0.68': @@ -9309,6 +9422,8 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@konnorr/qr-creator@1.0.1': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -9469,12 +9584,37 @@ snapshots: lit: 3.3.3 signal-polyfill: 0.2.2 + '@lit-labs/ssr-client@1.1.8': + dependencies: + '@lit/reactive-element': 2.1.2 + lit: 3.3.3 + lit-html: 3.3.3 + '@lit-labs/ssr-dom-shim@1.6.0': {} + '@lit-labs/ssr@4.1.0(@types/node@24.13.2)': + dependencies: + '@lit-labs/ssr-client': 1.1.8 + '@lit-labs/ssr-dom-shim': 1.6.0 + '@lit/reactive-element': 2.1.2 + '@parse5/tools': 0.3.0 + enhanced-resolve: 5.24.2 + lit: 3.3.3 + lit-element: 4.2.2 + lit-html: 3.3.3 + node-fetch: 3.3.2 + parse5: 7.3.0 + optionalDependencies: + '@types/node': 24.13.2 + '@lit/context@1.1.6': dependencies: '@lit/reactive-element': 2.1.2 + '@lit/react@1.0.8(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + '@lit/reactive-element@2.1.2': dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 @@ -10164,6 +10304,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.73.0': optional: true + '@parse5/tools@0.3.0': + dependencies: + parse5: 7.3.0 + '@pierre/diffs@1.2.12(@shikijs/themes@4.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@pierre/theme': 1.1.0 @@ -10367,6 +10511,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@shoelace-style/animations@1.2.0': {} + + '@shoelace-style/localize@3.2.2': {} + '@sigstore/bundle@4.0.0': dependencies: '@sigstore/protobuf-specs': 0.5.1 @@ -10819,6 +10967,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/retry@0.12.0': {} '@types/retry@0.12.5': {} @@ -10889,6 +11041,19 @@ snapshots: '@urbit/aura@3.0.0': {} + '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)': + dependencies: + '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) @@ -10901,6 +11066,24 @@ snapshots: - msw - utf-8-validate - vite + optional: true + + '@vitest/browser@4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite '@vitest/browser@4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9)': dependencies: @@ -10918,6 +11101,7 @@ snapshots: - msw - utf-8-validate - vite + optional: true '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: @@ -10933,7 +11117,7 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) '@vitest/expect@4.1.9': dependencies: @@ -10944,6 +11128,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 @@ -11455,6 +11647,10 @@ snapshots: commander@5.1.0: {} + composed-offset-position@0.0.6(@floating-ui/utils@0.2.12): + dependencies: + '@floating-ui/utils': 0.2.12 + constantinople@4.0.1: dependencies: '@babel/parser': 7.29.7 @@ -11508,6 +11704,8 @@ snapshots: cssom@0.5.0: {} + csstype@3.2.3: {} + curve25519-js@0.0.4: {} data-uri-to-buffer@4.0.1: {} @@ -11618,8 +11816,15 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.24.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} entities@8.0.0: {} @@ -12680,6 +12885,8 @@ snapshots: dependencies: repeat-string: 1.6.1 + marked@11.2.0: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -13117,8 +13324,7 @@ snapshots: nanoid@3.3.15: {} - nanoid@5.1.16: - optional: true + nanoid@5.1.16: {} negotiator@1.0.0: {} @@ -13434,6 +13640,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -14215,6 +14425,8 @@ snapshots: array-back: 6.2.3 wordwrapjs: 5.1.1 + tapable@2.3.3: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -14468,6 +14680,21 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + yaml: 2.9.0 + vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -14483,6 +14710,37 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.2 + '@vitest/browser-playwright': 4.1.9(playwright@1.61.1)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + jsdom: 29.1.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs index f5def45600a1..4470653e932a 100644 --- a/scripts/check-control-ui-performance.mjs +++ b/scripts/check-control-ui-performance.mjs @@ -10,11 +10,11 @@ const KIB = 1024; // Small, explicit headroom over the optimized baseline. Budget changes should // accompany an intentional loading or chunking decision. export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({ - startupJsRequests: 25, + startupJsRequests: 28, startupCssRequests: 1, - startupJsGzipBytes: 320 * KIB, + startupJsGzipBytes: 370 * KIB, startupCssGzipBytes: 42 * KIB, - largestJsGzipBytes: 205 * KIB, + largestJsGzipBytes: 215 * KIB, largestCssGzipBytes: 42 * KIB, }); diff --git a/ui/package.json b/ui/package.json index f9b55011e975..45aee650ff4a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,6 +9,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@awesome.me/webawesome": "3.10.0", "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", "@codemirror/language-data": "6.5.2", diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 7ca5193c4126..c953abd5fced 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -74,6 +74,8 @@ function applyStartupPresentation(settings: ReturnType): vo const resolvedTheme = resolveTheme(settings.theme, settings.themeMode); root.dataset.theme = resolvedTheme; root.dataset.themeMode = resolvedTheme.endsWith("light") ? "light" : "dark"; + root.classList.toggle("wa-light", root.dataset.themeMode === "light"); + root.classList.toggle("wa-dark", root.dataset.themeMode === "dark"); root.style.colorScheme = root.dataset.themeMode; root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`); syncCustomThemeStyleTag(settings.customTheme); diff --git a/ui/src/app/native-link-routing.test.ts b/ui/src/app/native-link-routing.test.ts index b935ff944670..c8d718942d6c 100644 --- a/ui/src/app/native-link-routing.test.ts +++ b/ui/src/app/native-link-routing.test.ts @@ -194,6 +194,32 @@ describe("native link routing", () => { ); }); + it("ignores a stale hide event after replacing the context menu", async () => { + installBridge(); + routing = startNativeLinkRouting(); + const firstAnchor = appendLink("https://example.com/first"); + const secondAnchor = appendLink("https://example.com/second"); + + contextMenu(firstAnchor); + const firstMenu = document.querySelector }>( + "openclaw-native-link-menu", + ); + expect(firstMenu).not.toBeNull(); + await firstMenu?.updateComplete; + const firstDropdown = firstMenu?.querySelector("wa-dropdown"); + expect(firstDropdown).not.toBeNull(); + + contextMenu(secondAnchor); + const secondMenu = document.querySelector("openclaw-native-link-menu"); + expect(secondMenu).not.toBe(firstMenu); + + firstDropdown?.dispatchEvent( + new CustomEvent("wa-after-hide", { bubbles: true, composed: true }), + ); + + expect(document.querySelector("openclaw-native-link-menu")).toBe(secondMenu); + }); + it("mounts a fallback menu inside an active dialog", async () => { installBridge(); routing = startNativeLinkRouting(); diff --git a/ui/src/app/native-link-routing.ts b/ui/src/app/native-link-routing.ts index 9aa789f43456..52a99798699b 100644 --- a/ui/src/app/native-link-routing.ts +++ b/ui/src/app/native-link-routing.ts @@ -136,7 +136,10 @@ export function startNativeLinkRouting(): NativeLinkRouting { } let menu: NativeLinkMenu | null = null; - const closeMenu = () => { + const closeMenu = (expected?: NativeLinkMenu) => { + if (expected && menu !== expected) { + return; + } menu?.remove(); menu = null; }; @@ -152,7 +155,7 @@ export function startNativeLinkRouting(): NativeLinkRouting { nextMenu.x = x; nextMenu.y = y; nextMenu.trigger = anchor; - nextMenu.onClose = closeMenu; + nextMenu.onClose = () => closeMenu(nextMenu); nextMenu.onAction = (action: NativeLinkMenuAction) => { if (action === "copy") { void copyToClipboard(url.href); diff --git a/ui/src/components/agent-select.test.ts b/ui/src/components/agent-select.test.ts index 17e45b54eddb..8872fa6b827e 100644 --- a/ui/src/components/agent-select.test.ts +++ b/ui/src/components/agent-select.test.ts @@ -186,79 +186,44 @@ it("renders a local avatar image when token auth is not active", async () => { } }); -it("opens a listbox with selection state and a default badge", async () => { +it("renders the agent picker as a Web Awesome dropdown", async () => { const element = await createAgentSelect({ defaultId: "beta" }); try { - element.querySelector(".agent-select__trigger")?.click(); - await element.updateComplete; - - const listbox = element.querySelector('[role="listbox"]'); + const dropdown = element.querySelector("wa-dropdown"); const options = Array.from( - element.querySelectorAll('.agent-select__option[role="option"]'), + element.querySelectorAll( + "wa-dropdown-item", + ), ); - expect(listbox).not.toBeNull(); + expect(dropdown).not.toBeNull(); expect(options).toHaveLength(2); - expect(options[0]?.getAttribute("aria-selected")).toBe("true"); - expect(options[1]?.getAttribute("aria-selected")).toBe("false"); + expect(options[0]?.checked).toBe(true); + expect(options[1]?.checked).toBe(false); + expect(options[0]?.value).toBe("alpha"); + expect(options[1]?.value).toBe("beta"); expect(options[1]?.querySelector(".agent-select__badge")?.textContent?.trim()).toBe("default"); - expect(document.activeElement).toBe(options[0]); + expect(dropdown?.shadowRoot?.querySelector('[role="menu"]')).not.toBeNull(); } finally { element.remove(); } }); -it("supports trigger and listbox keyboard navigation", async () => { +it("uses Web Awesome to open and dismiss the dropdown", async () => { const element = await createAgentSelect(); try { const trigger = element.querySelector(".agent-select__trigger"); - trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - await element.updateComplete; + const dropdown = element.querySelector< + HTMLElement & { open: boolean; updateComplete: Promise } + >("wa-dropdown"); + trigger?.click(); + await dropdown?.updateComplete; + expect(dropdown?.open).toBe(true); - const options = Array.from( - element.querySelectorAll(".agent-select__option"), - ); - // Options are focused programmatically, never sequential tab stops. - expect(options.every((option) => option.tabIndex === -1)).toBe(true); - expect(document.activeElement).toBe(options[0]); - - options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); - expect(document.activeElement).toBe(options[1]); - - options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); - expect(document.activeElement).toBe(options[0]); - - options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); - expect(document.activeElement).toBe(options[1]); - - options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); - await element.updateComplete; - expect(element.querySelector('[role="listbox"]')).toBeNull(); - // Tab hands focus back to the trigger so sequential navigation continues. - expect(document.activeElement).toBe(trigger); - } finally { - element.remove(); - } -}); - -it("jumps focus to a matching agent via printable-key type-ahead", async () => { - const element = await createAgentSelect(); - - try { - element.querySelector(".agent-select__trigger")?.click(); - await element.updateComplete; - const options = Array.from( - element.querySelectorAll(".agent-select__option"), - ); - expect(document.activeElement).toBe(options[0]); - - options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "b", bubbles: true })); - expect(document.activeElement).toBe(options[1]); - - // Accumulated prefix keeps matching the same agent instead of cycling. - options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "e", bubbles: true })); - expect(document.activeElement).toBe(options[1]); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); + await dropdown?.updateComplete; + expect(dropdown?.open).toBe(false); } finally { element.remove(); } @@ -269,49 +234,28 @@ it("selects a different agent and ignores the already-selected agent", async () const element = await createAgentSelect({ onSelect }); try { - const trigger = element.querySelector(".agent-select__trigger"); - trigger?.click(); - await element.updateComplete; - element.querySelector('[data-agent-id="beta"]')?.click(); - await element.updateComplete; + const beta = element.querySelector('[data-agent-id="beta"]'); + const dropdown = element.querySelector("wa-dropdown"); + dropdown?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: beta }, bubbles: true }), + ); expect(onSelect).toHaveBeenCalledOnce(); expect(onSelect).toHaveBeenCalledWith("beta"); - expect(element.querySelector('[role="listbox"]')).toBeNull(); - trigger?.click(); - await element.updateComplete; - element.querySelector('[data-agent-id="alpha"]')?.click(); - await element.updateComplete; + const alpha = element.querySelector('[data-agent-id="alpha"]'); + const repeatedSelection = new CustomEvent("wa-select", { + detail: { item: alpha }, + bubbles: true, + cancelable: true, + }); + (alpha as HTMLElement).focus(); + dropdown?.dispatchEvent(repeatedSelection); expect(onSelect).toHaveBeenCalledOnce(); - expect(element.querySelector('[role="listbox"]')).toBeNull(); - } finally { - element.remove(); - } -}); - -it("closes on Escape or outside pointerdown and refocuses the trigger on Escape", async () => { - const element = await createAgentSelect(); - - try { - const trigger = element.querySelector(".agent-select__trigger"); - trigger?.click(); - await element.updateComplete; - element - .querySelector(".agent-select__list") - ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - await element.updateComplete; - - expect(element.querySelector('[role="listbox"]')).toBeNull(); - expect(document.activeElement).toBe(trigger); - - trigger?.click(); - await element.updateComplete; - document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true })); - await element.updateComplete; - - expect(element.querySelector('[role="listbox"]')).toBeNull(); + expect(repeatedSelection.defaultPrevented).toBe(true); + expect((alpha as HTMLElement & { checked: boolean }).checked).toBe(true); + expect(document.activeElement).toBe(element.querySelector(".agent-select__trigger")); } finally { element.remove(); } diff --git a/ui/src/components/agent-select.ts b/ui/src/components/agent-select.ts index a38fa55f5550..5ec20eb789c3 100644 --- a/ui/src/components/agent-select.ts +++ b/ui/src/components/agent-select.ts @@ -1,5 +1,8 @@ -import { html, nothing, type PropertyValues } from "lit"; -import { property, state } from "lit/decorators.js"; +import "@awesome.me/webawesome/dist/components/dropdown/dropdown.js"; +import "@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js"; +import type { PropertyValues } from "lit"; +import { html, nothing } from "lit"; +import { property } from "lit/decorators.js"; import type { AgentIdentityResult, GatewayAgentRow } from "../api/types.ts"; import { t } from "../i18n/index.ts"; import { @@ -11,6 +14,8 @@ import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { icons } from "./icons.ts"; +type WebAwesomeSelectEvent = Event & { detail: { item: Element } }; + export class AgentSelect extends OpenClawLightDomElement { @property({ attribute: false }) agents: GatewayAgentRow[] = []; @property({ attribute: false }) selectedId: string | null = null; @@ -20,30 +25,17 @@ export class AgentSelect extends OpenClawLightDomElement { @property({ attribute: false }) disabled = false; @property({ attribute: false }) onSelect: (agentId: string) => void = () => {}; - @state() private open = false; - - override connectedCallback() { - super.connectedCallback(); - document.addEventListener("pointerdown", this.handleDocumentPointerDown, true); - } + private readonly avatarBlobUrlByRoute = new Map(); + private readonly avatarRoutesPending = new Set(); override disconnectedCallback() { - document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true); - clearTimeout(this.typeaheadResetTimer); this.releaseAvatarBlobUrls(); super.disconnectedCallback(); } - // Local /avatar/ routes require the bearer credential when gateway auth - // is active and cannot send headers, so fetch them and render blob - // URLs (same single-credential contract as chat-avatar.ts). "" marks a - // failed fetch so we do not retry every render. - private readonly avatarBlobUrlByRoute = new Map(); - private readonly avatarRoutesPending = new Set(); - protected override willUpdate(changed: PropertyValues) { // Cached blobs and failures belong to the credential that fetched them; - // a rotated token (e.g. device token after reconnect) must refetch. + // a rotated token must refetch with the current authorization. if (changed.has("authToken")) { this.releaseAvatarBlobUrls(); } @@ -69,8 +61,6 @@ export class AgentSelect extends OpenClawLightDomElement { .catch(() => "") .then((blobUrl) => { this.avatarRoutesPending.delete(url); - // Drop stale results: the element may be gone or the credential may - // have rotated while this request was in flight. if (!this.isConnected || this.authToken !== authToken) { if (blobUrl) { URL.revokeObjectURL(blobUrl); @@ -84,146 +74,6 @@ export class AgentSelect extends OpenClawLightDomElement { }); } - // Owns the open transition: focus must move into the listbox only after the - // options exist in the DOM, so wait for the post-toggle render. - private setOpen(next: boolean) { - if (this.open === next) { - return; - } - this.open = next; - if (next) { - void this.updateComplete.then(() => this.focusSelectedOption()); - return; - } - clearTimeout(this.typeaheadResetTimer); - this.typeaheadQuery = ""; - } - - private readonly handleDocumentPointerDown = (event: PointerEvent) => { - if (!this.open || event.composedPath().includes(this)) { - return; - } - this.setOpen(false); - }; - - private readonly handleTriggerKeydown = (event: KeyboardEvent) => { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { - return; - } - event.preventDefault(); - this.setOpen(true); - }; - - private readonly handleListboxKeydown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - this.setOpen(false); - this.trigger()?.focus(); - return; - } - if (event.key === "Tab") { - // Options are tabindex=-1, so hand focus back to the trigger before the - // default Tab moves on; otherwise the list unmounts under the focused - // option and focus falls to . - this.setOpen(false); - this.trigger()?.focus(); - return; - } - if (event.key === " ") { - // Space activates the focused option button natively. - return; - } - if ( - event.key.length === 1 && - !event.altKey && - !event.ctrlKey && - !event.metaKey && - !event.isComposing - ) { - event.preventDefault(); - this.focusTypeaheadOption(event.key); - return; - } - - const options = this.options(); - if (options.length === 0) { - return; - } - const currentIndex = options.indexOf(document.activeElement as HTMLButtonElement); - let nextIndex: number; - if (event.key === "ArrowDown") { - nextIndex = Math.min(currentIndex + 1, options.length - 1); - } else if (event.key === "ArrowUp") { - nextIndex = Math.max(currentIndex - 1, 0); - } else if (event.key === "Home") { - nextIndex = 0; - } else if (event.key === "End") { - nextIndex = options.length - 1; - } else { - return; - } - event.preventDefault(); - options[nextIndex]?.focus(); - }; - - // Buffered printable-key search, matching the native esc ${t("palette.footer.close")} - + `; } @@ -472,10 +363,6 @@ export class CommandPalette extends OpenClawLightDomContentsElement { this.activeIndex = 0; this.clearSessionSearch(); this.sessionSearchSource = undefined; - if (activeDialog) { - activeDialog.close(); - restoreFocus(); - } super.disconnectedCallback(); } @@ -494,20 +381,11 @@ export class CommandPalette extends OpenClawLightDomContentsElement { if (this.open) { this.open = false; this.clearSessionSearch(); - restoreFocus(); return; } this.openPalette(); }; - private readonly handleDialogRef = (element: Element | undefined) => { - if (!this.open) { - syncDialog(undefined); - return; - } - syncDialog(element); - }; - private readonly handleInputRef = (element: Element | undefined) => { if (this.open) { focusInput(element); @@ -670,7 +548,6 @@ export class CommandPalette extends OpenClawLightDomContentsElement { onNavigate: (routeId) => this.onNavigate?.(routeId), onSelectSession: this.onSelectSession, onSlashCommand: this.onSlashCommand, - onDialogRef: this.handleDialogRef, onInputRef: this.handleInputRef, }); } diff --git a/ui/src/components/config-form.browser.test.ts b/ui/src/components/config-form.browser.test.ts index 158fe785eeba..71df8df34707 100644 --- a/ui/src/components/config-form.browser.test.ts +++ b/ui/src/components/config-form.browser.test.ts @@ -43,6 +43,15 @@ function expectElement(element: T | null | undefined, label: return element; } +function selectSegmented(control: HTMLElement) { + const group = expectElement( + control.closest("wa-radio-group"), + "segmented radio group", + ); + group.value = control.getAttribute("value") ?? ""; + group.dispatchEvent(new Event("change", { bubbles: true })); +} + describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); @@ -73,17 +82,17 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123"); const tokenButton = expectElement( - Array.from(container.querySelectorAll(".settings-segmented__btn")).find( + Array.from(container.querySelectorAll(".settings-segmented__btn")).find( (btn) => btn.textContent?.trim() === "token", ), "token segmented button", ); - tokenButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + selectSegmented(tokenButton); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); const checkbox = expectElement( - container.querySelector("input[type='checkbox']"), - "enabled checkbox", + container.querySelector("wa-switch.settings-toggle"), + "enabled switch", ); checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); @@ -106,12 +115,12 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); const tailnetButton = expectElement( - Array.from(container.querySelectorAll(".settings-segmented__btn")).find( + Array.from(container.querySelectorAll(".settings-segmented__btn")).find( (btn) => btn.textContent?.trim() === "tailnet", ), "tailnet segmented button", ); - tailnetButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + selectSegmented(tailnetButton); expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); }); @@ -143,7 +152,7 @@ describe("config form renderer", () => { expect(rowTitles).toEqual(["Token"]); }); - it("renders boolean fields as label-wrapped toggle rows", () => { + it("renders boolean fields as named toggle rows", () => { const onPatch = vi.fn(); const container = document.createElement("div"); const analysis = analyzeConfigSchema({ @@ -169,15 +178,16 @@ describe("config form renderer", () => { ); const checkbox = expectElement( - container.querySelector("input[type='checkbox']"), - "beta checkbox", + container.querySelector("wa-switch.settings-toggle"), + "beta switch", ); const row = expectElement(checkbox.closest(".settings-row"), "beta toggle row"); - expect(row.tagName).toBe("LABEL"); + expect(row.tagName).toBe("DIV"); expect(row.querySelector(".settings-row__title")?.textContent?.trim()).toBe("Beta"); expect(row.querySelector(".settings-row__desc")?.textContent?.trim()).toBe( "Enable beta features", ); + expect(checkbox.textContent?.trim()).toBe("Beta"); checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["features", "beta"], true); diff --git a/ui/src/components/config-form.node.ts b/ui/src/components/config-form.node.ts index 8d495642e7a7..f81ad42afc04 100644 --- a/ui/src/components/config-form.node.ts +++ b/ui/src/components/config-form.node.ts @@ -23,6 +23,7 @@ import { } from "./config-form.shared.ts"; import { renderSettingsEmpty, + renderSettingsSegmented, renderSettingsToggle, renderSettingsToggleRow, } from "./settings-ui.ts"; @@ -196,32 +197,27 @@ function renderSegmentedControl(params: { options: unknown[]; resolvedValue: unknown; disabled: boolean; + ariaLabel: string; onSelect: (value: unknown) => void; }): TemplateResult { - return html` -
- ${params.options.map( - (option) => html` - - `, - )} -
- `; + const selectedIndex = params.options.findIndex((option) => + matchesComparablePrimitiveValue(option, params.resolvedValue), + ); + return renderSettingsSegmented({ + value: selectedIndex < 0 ? "" : String(selectedIndex), + options: params.options.map((option, index) => ({ + value: String(index), + label: formatUnknownText(option), + })), + disabled: params.disabled, + ariaLabel: params.ariaLabel, + onChange: (index) => { + const option = params.options[Number(index)]; + if (option !== undefined) { + params.onSelect(option); + } + }, + }); } export function renderNode(params: { @@ -300,6 +296,7 @@ export function renderNode(params: { options: literals, resolvedValue, disabled, + ariaLabel: label, onSelect: (literal) => onPatch(path, literal), }), }); @@ -365,6 +362,7 @@ export function renderNode(params: { options, resolvedValue, disabled, + ariaLabel: label, onSelect: (option) => onPatch(path, option), }), }); diff --git a/ui/src/components/file-preview-modal.test.ts b/ui/src/components/file-preview-modal.test.ts index de2c81219fa9..899439462536 100644 --- a/ui/src/components/file-preview-modal.test.ts +++ b/ui/src/components/file-preview-modal.test.ts @@ -141,6 +141,24 @@ describe("openclaw-file-preview-modal", () => { expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt"); }); + it("handles file navigation from the modal dialog event path", async () => { + const modal = await renderPreview(); + const onSelect = vi.fn(); + modal.addEventListener("file-preview-select", onSelect); + + const dialog = modal.shadowRoot?.querySelector("openclaw-modal-dialog"); + const arrowDown = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + composed: true, + }); + dialog?.dispatchEvent(arrowDown); + + expect(arrowDown.defaultPrevented).toBe(true); + expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt"); + }); + it("restores modal focus when the same element reconnects", async () => { const modal = await renderPreview(); const outside = document.createElement("button"); @@ -245,9 +263,10 @@ describe("openclaw-file-preview-modal", () => { await i18n.setLocale("pt-BR"); await modal.updateComplete; - expect(modal.shadowRoot?.querySelector(".modal")?.getAttribute("aria-label")).toBe( - "Arquivos de suporte", - ); + expect( + modal.shadowRoot?.querySelector("openclaw-modal-dialog") + ?.label, + ).toBe("Arquivos de suporte"); expect(shadowText(modal)).toContain("2 arquivos"); expect(shadowText(modal)).toContain("Fechar"); }); diff --git a/ui/src/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts index 569d72e0e183..e72fc129e4df 100644 --- a/ui/src/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -5,6 +5,7 @@ import { t } from "../i18n/index.ts"; import { OpenClawLitElement } from "../lit/openclaw-element.ts"; import { renderCopyButton } from "./copy-button.ts"; import { icons } from "./icons.ts"; +import "./modal-dialog.ts"; type FilePreviewModalFile = { path: string; @@ -38,46 +39,11 @@ export class OpenClawFilePreviewModal extends OpenClawLitElement { static override styles = css` :host { - position: fixed; - inset: 0; - z-index: 50; - display: block; - } - - .backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(6px); - animation: fade 140ms ease-out; - } - - @keyframes fade { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - @keyframes pop { - from { - transform: translate(-50%, -48%) scale(0.97); - opacity: 0; - } - to { - transform: translate(-50%, -50%) scale(1); - opacity: 1; - } + display: contents; } .modal { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(1100px, 92vw); + width: 100%; height: min(780px, 86vh); background: var(--bg); border: 1px solid var(--border-strong); @@ -86,7 +52,6 @@ export class OpenClawFilePreviewModal extends OpenClawLitElement { display: flex; flex-direction: column; overflow: hidden; - animation: pop 160ms ease-out; } .head { @@ -512,55 +477,54 @@ export class OpenClawFilePreviewModal extends OpenClawLitElement { const searchPlaceholder = this.searchPlaceholder || t("filePreview.searchPlaceholder"); return html` -
- `}
-
- ${this.tabs.map((tab) => { - const statusLabel = terminalTabStatusLabel(tab); - return html` - - `; - })} - -
+ ${renderTerminalPanelTabs({ + tabs: this.tabs, + activeId: this.activeId, + booting: this.booting, + onSelect: (id) => this.switchTo(id), + onClose: (id) => this.closeTab(id), + onNew: () => void this.openSession(), + })} ${this.fullscreen ? nothing : html`
@@ -919,11 +849,17 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { ${this.errorText ? html`` : nothing} -
+ ${this.booting && this.tabs.length === 0 ? html`
${t("terminal.starting")}
` : nothing} -
+ `; } @@ -1002,16 +938,23 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { min-height: 36px; } .tp-tabs { + --track-width: 0; + display: block; + overflow-x: auto; + scrollbar-width: none; + } + .tp-tabs::part(nav) { display: flex; align-items: stretch; gap: 1px; - overflow-x: auto; - scrollbar-width: none; + } + .tp-tabs::part(body) { + display: none; } .tp-tabs::-webkit-scrollbar { display: none; } - .tp-tab { + .tp-tab::part(base) { display: flex; align-items: center; gap: 7px; @@ -1026,15 +969,15 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { color 0.12s ease, background 0.12s ease; } - .tp-tab:hover { + .tp-tab:hover::part(base) { color: var(--text, #d7dae0); background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent); } - .tp-tab.is-active { + .tp-tab[active]::part(base) { color: var(--text, #d7dae0); border-bottom-color: var(--accent, #ff5c5c); } - .tp-tab.is-exited { + .tp-tab.is-exited::part(base) { opacity: 0.55; } .tp-tab__icon { @@ -1064,8 +1007,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { border-radius: 4px; padding: 0; } - .tp-tab:hover .tp-tab__close, - .tp-tab.is-active .tp-tab__close { + .tp-tab:hover + .tp-tab__close, + .tp-tab[active] + .tp-tab__close, + .tp-tab__close:hover, + .tp-tab__close:focus-visible { opacity: 0.7; } .tp-new, diff --git a/ui/src/components/tooltip.test.ts b/ui/src/components/tooltip.test.ts index 59e83dcd6804..303f024a9e19 100644 --- a/ui/src/components/tooltip.test.ts +++ b/ui/src/components/tooltip.test.ts @@ -31,13 +31,20 @@ function focusTrigger(trigger: HTMLElement) { } function hoverTrigger(trigger: HTMLElement) { - const event = new MouseEvent("pointermove", { bubbles: true, buttons: 0 }); + const event = new MouseEvent("pointerenter", { bubbles: true, buttons: 0 }); Object.defineProperty(event, "pointerType", { value: "mouse" }); trigger.dispatchEvent(event); } -function expectPortalCount(count: number) { - expect(document.body.querySelectorAll(".openclaw-tooltip")).toHaveLength(count); +function webAwesomeTooltip(tooltip: TooltipElement) { + return tooltip.shadowRoot?.querySelector("wa-tooltip"); +} + +function expectOpenCount(count: number) { + const open = [...document.querySelectorAll("openclaw-tooltip")].filter( + (tooltip) => webAwesomeTooltip(tooltip)?.open, + ); + expect(open).toHaveLength(count); } describe("openclaw-tooltip", () => { @@ -59,15 +66,15 @@ describe("openclaw-tooltip", () => { await tooltip.updateComplete; focusTrigger(trigger); - expectPortalCount(1); + expectOpenCount(1); provider.remove(); - expectPortalCount(0); + expectOpenCount(0); document.body.append(provider); await tooltip.updateComplete; focusTrigger(trigger); - expectPortalCount(1); + expectOpenCount(1); }); it("keeps show reentry idempotent", async () => { @@ -80,8 +87,8 @@ describe("openclaw-tooltip", () => { focusTrigger(trigger); focusTrigger(trigger); - expectPortalCount(1); - expect(document.body.querySelector(".openclaw-tooltip")?.textContent).toBe("Single portal"); + expectOpenCount(1); + expect(webAwesomeTooltip(tooltip)?.textContent).toBe("Single portal"); }); it("restores the normal hover delay after the provider reconnects", async () => { @@ -93,17 +100,17 @@ describe("openclaw-tooltip", () => { await tooltip.updateComplete; focusTrigger(trigger); - expectPortalCount(1); + expectOpenCount(1); provider.remove(); - expectPortalCount(0); + expectOpenCount(0); document.body.append(provider); await tooltip.updateComplete; hoverTrigger(trigger); vi.advanceTimersByTime(39); - expectPortalCount(0); + expectOpenCount(0); vi.advanceTimersByTime(1); - expectPortalCount(1); + expectOpenCount(1); }); it("suppresses a tooltip that repeats fully visible trigger text", async () => { @@ -114,10 +121,10 @@ describe("openclaw-tooltip", () => { await tooltip.updateComplete; focusTrigger(trigger); - expectPortalCount(0); + expectOpenCount(0); hoverTrigger(trigger); vi.runAllTimers(); - expectPortalCount(0); + expectOpenCount(0); }); it("keeps a repeated-label tooltip when the trigger clips its text", async () => { @@ -130,7 +137,52 @@ describe("openclaw-tooltip", () => { await tooltip.updateComplete; focusTrigger(trigger); - expectPortalCount(1); + expectOpenCount(1); + }); + + it("keeps a repeated-label tooltip when a nested label clips", async () => { + const provider = createProvider(); + const { tooltip, trigger } = createTooltip("Claude Opus 4.7", ""); + const label = document.createElement("span"); + label.textContent = "Claude Opus 4.7 Anthropic"; + Object.defineProperty(label, "scrollWidth", { value: 160, configurable: true }); + Object.defineProperty(label, "clientWidth", { value: 80, configurable: true }); + trigger.append(label); + provider.append(tooltip); + document.body.append(provider); + await tooltip.updateComplete; + + focusTrigger(trigger); + expectOpenCount(1); + }); + + it("does not reopen from pointer-origin focus", async () => { + const provider = createProvider(); + const { tooltip, trigger } = createTooltip("Pointer tooltip"); + provider.append(tooltip); + document.body.append(provider); + await tooltip.updateComplete; + + focusTrigger(trigger); + expectOpenCount(1); + const pointerDown = new MouseEvent("pointerdown", { bubbles: true }); + Object.defineProperty(pointerDown, "pointerType", { value: "mouse" }); + trigger.dispatchEvent(pointerDown); + focusTrigger(trigger); + + expectOpenCount(0); + }); + + it("keeps the accessible description in the trigger document tree", async () => { + const provider = createProvider(); + const { tooltip, trigger } = createTooltip("Accessible tooltip"); + provider.append(tooltip); + document.body.append(provider); + await tooltip.updateComplete; + + const descriptionId = trigger.getAttribute("aria-describedby"); + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? "")?.textContent).toBe("Accessible tooltip"); }); it("releases the active provider reference when an open tooltip is removed", async () => { @@ -143,9 +195,9 @@ describe("openclaw-tooltip", () => { await first.tooltip.updateComplete; focusTrigger(first.trigger); - expectPortalCount(1); + expectOpenCount(1); first.tooltip.remove(); - expectPortalCount(0); + expectOpenCount(0); vi.advanceTimersByTime(20); const second = createTooltip("Second tooltip"); @@ -153,8 +205,8 @@ describe("openclaw-tooltip", () => { await second.tooltip.updateComplete; hoverTrigger(second.trigger); vi.advanceTimersByTime(39); - expectPortalCount(0); + expectOpenCount(0); vi.advanceTimersByTime(1); - expectPortalCount(1); + expectOpenCount(1); }); }); diff --git a/ui/src/components/tooltip.ts b/ui/src/components/tooltip.ts index c9b1503c7f53..203e9a0cf5d7 100644 --- a/ui/src/components/tooltip.ts +++ b/ui/src/components/tooltip.ts @@ -1,14 +1,16 @@ -import { html } from "lit"; -import { property } from "lit/decorators.js"; +// Control UI adapter for Web Awesome tooltips. OpenClaw keeps its terse +// wrapper API; Web Awesome owns popup positioning, rendering, and dismissal. +import "@awesome.me/webawesome/dist/components/tooltip/tooltip.js"; +import type WaTooltip from "@awesome.me/webawesome/dist/components/tooltip/tooltip.js"; +import { css, html } from "lit"; +import { property, query } from "lit/decorators.js"; import { OpenClawLitElement } from "../lit/openclaw-element.ts"; const HOVER_DELAY = 150; const TOUCH_DELAY = 450; const TOUCH_VISIBLE = 900; -const MOVE_LIMIT = 10; const SKIP_DELAY = 300; -const VIEWPORT_PADDING = 8; -const TOOLTIP_GAP = 8; +const MOVE_LIMIT = 10; let nextTooltipId = 0; @@ -26,47 +28,24 @@ class TooltipProvider extends OpenClawLitElement { @property({ type: Number }) skipDelay = SKIP_DELAY; @property({ type: Number }) touchDelay = TOUCH_DELAY; + private activeTooltip: Tooltip | null = null; private delayed = true; private skipDelayTimer: number | null = null; - private activeTooltip: Tooltip | null = null; - private suppressFocus = false; override connectedCallback() { super.connectedCallback(); this.style.display = "contents"; - this.addEventListener("pointerdown", this.handlePointerDown, true); } override disconnectedCallback() { - this.removeEventListener("pointerdown", this.handlePointerDown, true); - // Sever ownership first so child teardown cannot start a skip-delay window - // on a provider that is no longer available. const activeTooltip = this.activeTooltip; this.activeTooltip = null; activeTooltip?.closeFromProvider(); this.clearSkipDelayTimer(); this.delayed = true; - this.suppressFocus = false; super.disconnectedCallback(); } - private readonly handlePointerDown = () => { - this.suppressFocus = true; - this.activeTooltip?.closeFromProvider(); - }; - - suppressNextFocus() { - this.suppressFocus = true; - } - - consumeFocusSuppression() { - if (!this.suppressFocus) { - return false; - } - this.suppressFocus = false; - return true; - } - openTooltip(tooltip: Tooltip) { if (this.activeTooltip && this.activeTooltip !== tooltip) { this.activeTooltip.closeFromProvider(); @@ -97,11 +76,10 @@ class TooltipProvider extends OpenClawLitElement { } private clearSkipDelayTimer() { - if (this.skipDelayTimer === null) { - return; + if (this.skipDelayTimer !== null) { + window.clearTimeout(this.skipDelayTimer); + this.skipDelayTimer = null; } - window.clearTimeout(this.skipDelayTimer); - this.skipDelayTimer = null; } override render() { @@ -112,97 +90,70 @@ class TooltipProvider extends OpenClawLitElement { class Tooltip extends OpenClawLitElement { @property() content = ""; - private trigger: HTMLElement | null = null; - private portal: HTMLDivElement | null = null; + @query("wa-tooltip") private webAwesomeTooltip?: WaTooltip; + + private triggerElement: HTMLElement | null = null; private openTimer: number | null = null; private touchTimer: number | null = null; private touchCloseTimer: number | null = null; private touchStart: { x: number; y: number } | null = null; - private touchOpened = false; - private open = false; - private pointerDown = false; + private suppressPointerFocus = false; private describedBy: string | null = null; - private activeProvider: TooltipProvider | null = null; + private descriptionCaptured = false; + private descriptionElement: HTMLSpanElement | null = null; + private tooltipProvider: TooltipProvider | null = null; private readonly tooltipId = createTooltipId(); + private readonly descriptionId = `${this.tooltipId}-description`; + + static override styles = css` + :host { + display: contents; + } + + wa-tooltip { + --max-width: min(260px, calc(100vw - 16px)); + font-family: var(--font-body); + } + + wa-tooltip::part(body) { + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--card) 94%, black 6%); + box-shadow: var(--shadow-md); + color: var(--text); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + text-align: center; + overflow-wrap: anywhere; + white-space: pre-line; + } + `; override connectedCallback() { super.connectedCallback(); + this.tooltipProvider = this.closest("openclaw-tooltip-provider"); this.style.display = "contents"; } protected override updated() { - if (this.isConnected) { - this.attachTrigger(); - } + this.attachTrigger(); + this.syncWebAwesomeTooltip(); } override disconnectedCallback() { this.close(); - document.removeEventListener("pointerup", this.handleDocumentPointerUp); - this.pointerDown = false; + this.tooltipProvider = null; this.detachTrigger(); super.disconnectedCallback(); } - private attachTrigger() { - if (!this.isConnected) { - return; - } - const slot = this.renderRoot.querySelector("slot"); - const trigger = slot - ?.assignedElements({ flatten: true }) - .find((element): element is HTMLElement => element instanceof HTMLElement); - if (trigger === this.trigger) { - return; - } - this.close(); - this.detachTrigger(); - if (!trigger) { - return; - } - this.trigger = trigger; - for (const type of [ - "pointermove", - "pointerdown", - "pointerup", - "pointerleave", - "pointercancel", - ]) { - trigger.addEventListener(type, this.handlePointer); - } - trigger.addEventListener("focusin", this.handleFocus); - trigger.addEventListener("focusout", this.handleFocus); - trigger.addEventListener("click", this.handleClick, true); - trigger.addEventListener("keydown", this.handleKeyDown); - } - - private detachTrigger() { - const trigger = this.trigger; - if (!trigger) { - return; - } - for (const type of [ - "pointermove", - "pointerdown", - "pointerup", - "pointerleave", - "pointercancel", - ]) { - trigger.removeEventListener(type, this.handlePointer); - } - trigger.removeEventListener("focusin", this.handleFocus); - trigger.removeEventListener("focusout", this.handleFocus); - trigger.removeEventListener("click", this.handleClick, true); - trigger.removeEventListener("keydown", this.handleKeyDown); - this.restoreDescription(); - this.trigger = null; - } - private get provider() { - return this.closest("openclaw-tooltip-provider"); + return this.tooltipProvider ?? this.closest("openclaw-tooltip-provider"); } - private get delay() { + private get hoverDelay() { return Math.max(0, this.provider?.delay ?? HOVER_DELAY); } @@ -210,87 +161,126 @@ class Tooltip extends OpenClawLitElement { return Math.max(0, this.provider?.touchDelay ?? TOUCH_DELAY); } - private readonly handlePointer = (event: Event) => { - const pointer = event as PointerEvent; - if (pointer.pointerType === "touch") { - if (event.type === "pointerdown") { - this.pointerDown = true; - document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); - this.clearTimers(); - this.touchStart = { x: pointer.clientX, y: pointer.clientY }; - this.touchOpened = false; - this.touchTimer = window.setTimeout(() => { - this.touchTimer = null; - this.touchOpened = true; - this.show(); - }, this.touchDelay); - } else if (event.type === "pointermove" && this.touchStart) { - if ( - Math.hypot(pointer.clientX - this.touchStart.x, pointer.clientY - this.touchStart.y) > - MOVE_LIMIT - ) { - this.close(); - } - } else if (event.type === "pointerup") { - this.clearTouchTimer(); - this.touchStart = null; - if (this.touchOpened) { - this.touchCloseTimer = window.setTimeout(() => this.close(), TOUCH_VISIBLE); - } - } else if (event.type === "pointercancel") { - this.pointerDown = false; - document.removeEventListener("pointerup", this.handleDocumentPointerUp); - this.close(); - } else if (event.type === "pointerleave") { - this.close(); - } + private attachTrigger() { + const slot = this.renderRoot.querySelector("slot"); + const trigger = slot + ?.assignedElements({ flatten: true }) + .find((element): element is HTMLElement => element instanceof HTMLElement); + if (trigger === this.triggerElement) { return; } - if (event.type === "pointermove") { - if (pointer.buttons === 0) { - this.scheduleOpen(); - } - } else if (event.type === "pointerleave" || event.type === "pointerdown") { - this.pointerDown = event.type === "pointerdown"; - this.close(); - if (this.pointerDown) { - document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); - } - } - }; - - private readonly handleFocus = (event: FocusEvent) => { - if (event.type === "focusin") { - if (this.provider?.consumeFocusSuppression()) { - return; - } - if (!this.pointerDown) { - this.show(); - } - return; - } - if (!(event.relatedTarget instanceof Node && this.trigger?.contains(event.relatedTarget))) { - this.close(); - } - }; - - private readonly handleClick = () => { - this.provider?.suppressNextFocus(); this.close(); + this.detachTrigger(); + if (!trigger) { + return; + } + this.triggerElement = trigger; + trigger.addEventListener("pointerenter", this.handlePointerEnter); + trigger.addEventListener("pointerleave", this.handlePointerLeave); + trigger.addEventListener("pointerdown", this.handlePointerDown); + trigger.addEventListener("pointermove", this.handlePointerMove); + trigger.addEventListener("pointerup", this.handlePointerUp); + trigger.addEventListener("pointercancel", this.handlePointerCancel); + trigger.addEventListener("focusin", this.handleFocusIn); + trigger.addEventListener("focusout", this.handleFocusOut); + trigger.addEventListener("click", this.handleClick, true); + trigger.addEventListener("keydown", this.handleKeyDown); + this.syncDescription(); + this.syncWebAwesomeTooltip(); + } + + private detachTrigger() { + const trigger = this.triggerElement; + if (!trigger) { + return; + } + trigger.removeEventListener("pointerenter", this.handlePointerEnter); + trigger.removeEventListener("pointerleave", this.handlePointerLeave); + trigger.removeEventListener("pointerdown", this.handlePointerDown); + trigger.removeEventListener("pointermove", this.handlePointerMove); + trigger.removeEventListener("pointerup", this.handlePointerUp); + trigger.removeEventListener("pointercancel", this.handlePointerCancel); + trigger.removeEventListener("focusin", this.handleFocusIn); + trigger.removeEventListener("focusout", this.handleFocusOut); + trigger.removeEventListener("click", this.handleClick, true); + trigger.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("pointerup", this.handleDocumentPointerUp); + this.suppressPointerFocus = false; + this.restoreDescription(); + this.triggerElement = null; + } + + private syncWebAwesomeTooltip() { + const tooltip = this.webAwesomeTooltip; + if (!tooltip) { + return; + } + tooltip.anchor = this.triggerElement; + tooltip.showDelay = 0; + tooltip.hideDelay = 0; + } + + private readonly handlePointerEnter = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.scheduleOpen(); + } }; - private readonly handleDocumentPointerUp = () => { - this.pointerDown = false; - if (!this.touchStart) { + private readonly handlePointerLeave = () => this.close(); + + private readonly handlePointerDown = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.suppressPointerFocus = true; + document.removeEventListener("pointerup", this.handleDocumentPointerUp); + document.addEventListener("pointerup", this.handleDocumentPointerUp, { once: true }); + this.close(); + return; + } + this.clearTimers(); + this.touchStart = { x: event.clientX, y: event.clientY }; + this.touchTimer = window.setTimeout(() => { + this.touchTimer = null; + this.show(); + }, this.touchDelay); + }; + + private readonly handlePointerMove = (event: PointerEvent) => { + if ( + event.pointerType === "touch" && + this.touchStart && + Math.hypot(event.clientX - this.touchStart.x, event.clientY - this.touchStart.y) > MOVE_LIMIT + ) { + this.close(); + } + }; + + private readonly handlePointerUp = (event: PointerEvent) => { + if (event.pointerType !== "touch") { + this.handleDocumentPointerUp(); return; } this.clearTouchTimer(); this.touchStart = null; - if (this.touchOpened) { + if (this.webAwesomeTooltip?.open) { this.touchCloseTimer = window.setTimeout(() => this.close(), TOUCH_VISIBLE); } }; + private readonly handlePointerCancel = () => { + this.handleDocumentPointerUp(); + this.close(); + }; + private readonly handleFocusIn = () => { + if (!this.suppressPointerFocus) { + this.show(); + } + }; + private readonly handleFocusOut = () => this.close(); + private readonly handleClick = () => this.close(); + private readonly handleDocumentPointerUp = () => { + document.removeEventListener("pointerup", this.handleDocumentPointerUp); + this.suppressPointerFocus = false; + }; private readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { this.close(); @@ -298,183 +288,92 @@ class Tooltip extends OpenClawLitElement { }; private scheduleOpen() { - if (this.open || !this.trigger || !this.content.trim()) { + if (this.webAwesomeTooltip?.open || this.openTimer !== null || this.isRedundant()) { return; } - this.clearOpenTimer(); - const delay = this.provider?.shouldDelayOpen() ? this.delay : 0; + const delay = this.provider?.shouldDelayOpen() === false ? 0 : this.hoverDelay; this.openTimer = window.setTimeout(() => { this.openTimer = null; this.show(); }, delay); } - // Tooltips that merely repeat text the trigger already shows in full are - // noise (e.g. a model row echoing its own visible label); suppress those - // opens, but keep the tooltip when any trigger element clips its content so - // ellipsized labels still reveal the full text on hover. - private isRedundantContent() { - const trigger = this.trigger; + private show() { + const tooltip = this.webAwesomeTooltip; + if (!tooltip || !this.triggerElement || !this.content || this.isRedundant()) { + return; + } + this.clearTimers(); + this.provider?.openTooltip(this); + this.syncDescription(); + tooltip.open = true; + } + + private close() { + this.clearTimers(); + this.touchStart = null; + if (this.webAwesomeTooltip?.open) { + this.webAwesomeTooltip.open = false; + } + this.provider?.closeTooltip(this); + } + + closeFromProvider() { + this.clearTimers(); + if (this.webAwesomeTooltip?.open) { + this.webAwesomeTooltip.open = false; + } + } + + private isRedundant() { + const trigger = this.triggerElement; if (!trigger) { return false; } const content = normalizeTooltipText(this.content); - if (!content || !normalizeTooltipText(trigger.textContent ?? "").includes(content)) { - return false; - } - return [trigger, ...trigger.querySelectorAll("*")].every( - (element) => !(element instanceof HTMLElement) || element.scrollWidth <= element.clientWidth, + const triggerText = normalizeTooltipText(trigger.textContent ?? ""); + const clipsContent = [trigger, ...trigger.querySelectorAll("*")].some( + (element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth, ); + return Boolean(content && triggerText && triggerText.includes(content) && !clipsContent); } - private show() { - const trigger = this.trigger; - if (!trigger || !this.content.trim()) { + private syncDescription() { + const trigger = this.triggerElement; + if (!trigger) { return; } - this.clearTimers(); - if (this.open) { - if (this.portal) { - this.portal.textContent = this.content; - this.positionTooltip(); - } - return; + const current = trigger.getAttribute("aria-describedby"); + if (!this.descriptionCaptured) { + this.describedBy = current; + this.descriptionCaptured = true; } - if (this.isRedundantContent()) { - return; + if (!this.descriptionElement) { + const description = document.createElement("span"); + description.id = this.descriptionId; + description.hidden = true; + this.append(description); + this.descriptionElement = description; } - const provider = this.provider; - provider?.openTooltip(this); - this.activeProvider = provider; - this.open = true; - this.describedBy ??= trigger.getAttribute("aria-describedby"); - this.portal = document.createElement("div"); - this.portal.className = "openclaw-tooltip"; - this.portal.id = this.tooltipId; - this.portal.setAttribute("role", "tooltip"); - this.portal.textContent = this.content; - this.portal.dataset.open = "true"; - document.body.append(this.portal); - trigger.setAttribute( - "aria-describedby", - this.describedBy ? `${this.describedBy} ${this.tooltipId}` : this.tooltipId, - ); - window.addEventListener("resize", this.handleViewportChange); - window.addEventListener("scroll", this.handleViewportChange, true); - const viewport = window.visualViewport; - if (typeof viewport?.addEventListener === "function") { - viewport.addEventListener("resize", this.handleViewportChange); - viewport.addEventListener("scroll", this.handleViewportChange); - } - this.positionTooltip(); - } - - private close() { - const wasOpen = this.open; - const provider = this.activeProvider; - this.activeProvider = null; - this.clearTimers(); - this.touchStart = null; - this.touchOpened = false; - this.open = false; - if (wasOpen) { - provider?.closeTooltip(this); - } - this.restoreDescription(); - this.portal?.remove(); - this.portal = null; - window.removeEventListener("resize", this.handleViewportChange); - window.removeEventListener("scroll", this.handleViewportChange, true); - const viewport = window.visualViewport; - if (typeof viewport?.removeEventListener === "function") { - viewport.removeEventListener("resize", this.handleViewportChange); - viewport.removeEventListener("scroll", this.handleViewportChange); - } - } - - closeFromProvider() { - this.close(); + this.descriptionElement.textContent = this.content; + const ids = new Set((current ?? "").split(/\s+/u).filter(Boolean)); + ids.add(this.descriptionId); + trigger.setAttribute("aria-describedby", [...ids].join(" ")); } private restoreDescription() { - if (!this.trigger) { + if (!this.triggerElement) { return; } - if (this.describedBy === null) { - this.trigger.removeAttribute("aria-describedby"); + if (this.describedBy) { + this.triggerElement.setAttribute("aria-describedby", this.describedBy); } else { - this.trigger.setAttribute("aria-describedby", this.describedBy); + this.triggerElement.removeAttribute("aria-describedby"); } + this.descriptionElement?.remove(); + this.descriptionElement = null; this.describedBy = null; - } - - private readonly handleViewportChange = () => { - if (this.open) { - this.positionTooltip(); - } - }; - - private positionTooltip() { - const trigger = this.trigger; - const portal = this.portal; - if (!trigger || !portal) { - return; - } - const triggerRect = trigger.getBoundingClientRect(); - const tooltipRect = portal.getBoundingClientRect(); - const available = { - top: triggerRect.top - TOOLTIP_GAP - VIEWPORT_PADDING, - bottom: window.innerHeight - triggerRect.bottom - TOOLTIP_GAP - VIEWPORT_PADDING, - left: triggerRect.left - TOOLTIP_GAP - VIEWPORT_PADDING, - right: window.innerWidth - triggerRect.right - TOOLTIP_GAP - VIEWPORT_PADDING, - }; - const preferredSide = - available.top >= tooltipRect.height - ? "top" - : available.bottom >= tooltipRect.height - ? "bottom" - : available.right >= tooltipRect.width - ? "right" - : available.left >= tooltipRect.width - ? "left" - : available.bottom >= available.top - ? "bottom" - : "top"; - const top = - preferredSide === "top" - ? triggerRect.top - tooltipRect.height - TOOLTIP_GAP - : preferredSide === "bottom" - ? triggerRect.bottom + TOOLTIP_GAP - : triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; - const left = - preferredSide === "left" - ? triggerRect.left - tooltipRect.width - TOOLTIP_GAP - : preferredSide === "right" - ? triggerRect.right + TOOLTIP_GAP - : triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; - const maxLeft = Math.max( - VIEWPORT_PADDING, - window.innerWidth - tooltipRect.width - VIEWPORT_PADDING, - ); - const maxTop = Math.max( - VIEWPORT_PADDING, - window.innerHeight - tooltipRect.height - VIEWPORT_PADDING, - ); - portal.dataset.side = preferredSide; - portal.style.left = `${Math.min(Math.max(VIEWPORT_PADDING, left), maxLeft)}px`; - portal.style.top = `${Math.min(Math.max(VIEWPORT_PADDING, top), maxTop)}px`; - } - - private clearTimers() { - this.clearOpenTimer(); - this.clearTouchTimer(); - } - - private clearOpenTimer() { - if (this.openTimer !== null) { - window.clearTimeout(this.openTimer); - this.openTimer = null; - } + this.descriptionCaptured = false; } private clearTouchTimer() { @@ -482,6 +381,14 @@ class Tooltip extends OpenClawLitElement { window.clearTimeout(this.touchTimer); this.touchTimer = null; } + } + + private clearTimers() { + if (this.openTimer !== null) { + window.clearTimeout(this.openTimer); + this.openTimer = null; + } + this.clearTouchTimer(); if (this.touchCloseTimer !== null) { window.clearTimeout(this.touchCloseTimer); this.touchCloseTimer = null; @@ -489,7 +396,10 @@ class Tooltip extends OpenClawLitElement { } override render() { - return html` this.attachTrigger()}>`; + return html` + this.attachTrigger()}> + ${this.content} + `; } } @@ -500,3 +410,10 @@ if (!customElements.get("openclaw-tooltip-provider")) { if (!customElements.get("openclaw-tooltip")) { customElements.define("openclaw-tooltip", Tooltip); } + +declare global { + interface HTMLElementTagNameMap { + "openclaw-tooltip-provider": TooltipProvider; + "openclaw-tooltip": Tooltip; + } +} diff --git a/ui/src/components/web-awesome-migration.node.test.ts b/ui/src/components/web-awesome-migration.node.test.ts new file mode 100644 index 000000000000..47b072646d89 --- /dev/null +++ b/ui/src/components/web-awesome-migration.node.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const sourceRoot = path.resolve(import.meta.dirname, ".."); + +async function productionTypeScriptFiles(dir = sourceRoot): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return productionTypeScriptFiles(filePath); + } + if (!entry.name.endsWith(".ts") || entry.name.includes(".test.")) { + return []; + } + return [filePath]; + }), + ); + return files.flat(); +} + +async function matchingFiles(pattern: RegExp): Promise { + const matches: string[] = []; + for (const filePath of await productionTypeScriptFiles()) { + if (pattern.test(await readFile(filePath, "utf8"))) { + matches.push(path.relative(sourceRoot, filePath)); + } + } + return matches.toSorted(); +} + +describe("Web Awesome control ownership", () => { + it("keeps dialogs, menus, and tabs on shared primitives", async () => { + expect(await matchingFiles(/]*\srole=["'](?:menu|menubar|menuitem|tab|tablist)["']/u), + ).toEqual([]); + expect( + await matchingFiles(/]*class=["'][^"']*(?:menu|select|popover|dropdown)/u), + ).toEqual(["pages/chat/components/chat-model-controls.ts"]); + }); + + it("limits custom comboboxes to dynamic suggestion surfaces", async () => { + // Web Awesome Core has no combobox; its combobox is a paid Pro component. + expect(await matchingFiles(/<[a-z][^>]*\srole=["'](?:combobox|listbox|option)["']/u)).toEqual([ + "components/command-palette.ts", + "pages/chat/components/chat-composer.ts", + "pages/chat/components/chat-model-controls.ts", + ]); + }); + + it("limits custom dividers to docked multi-pane layouts", async () => { + // Web Awesome split panel owns exactly two panes; these layouts coordinate + // sidebar, inspector, and responsive dock state across more than two panes. + expect(await matchingFiles(/ }; + +async function createDropdown(label?: string) { + const dropdown = document.createElement("wa-dropdown") as DropdownElement; + if (label) { + dropdown.setAttribute("aria-label", label); + } + const trigger = document.createElement("button"); + trigger.slot = "trigger"; + trigger.textContent = "Actions"; + const item = document.createElement("wa-dropdown-item"); + item.textContent = "Open"; + dropdown.append(trigger, item); + document.body.append(dropdown); + await dropdown.updateComplete; + dropdown.dispatchEvent(new CustomEvent("wa-show", { bubbles: true, composed: true })); + return { dropdown, trigger }; +} + +afterEach(() => document.body.replaceChildren()); + +describe("Web Awesome adapters", () => { + it("copies an explicit dropdown label to the menu", async () => { + const { dropdown } = await createDropdown("Message actions"); + + expect(dropdown.shadowRoot?.querySelector('[part="menu"]')?.getAttribute("aria-label")).toBe( + "Message actions", + ); + + dropdown.setAttribute("aria-label", "Updated actions"); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(dropdown.shadowRoot?.querySelector('[part="menu"]')?.getAttribute("aria-label")).toBe( + "Updated actions", + ); + }); + + it("labels a dropdown menu from its trigger", async () => { + const { dropdown } = await createDropdown(); + + expect(dropdown.shadowRoot?.querySelector('[part="menu"]')?.getAttribute("aria-label")).toBe( + "Actions", + ); + }); + + it("restores a durable trigger only after keyboard dismissal", async () => { + const { dropdown } = await createDropdown(); + dropdown.addEventListener("keydown", trackDropdownKeyboardDismissal); + + expect(consumeDropdownKeyboardDismissal(new CustomEvent("wa-after-hide"))).toBe(false); + + dropdown.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + let restoreFocus = false; + dropdown.addEventListener("wa-after-hide", (event) => { + restoreFocus = consumeDropdownKeyboardDismissal(event); + }); + dropdown.dispatchEvent(new CustomEvent("wa-after-hide")); + expect(restoreFocus).toBe(true); + }); + + it("restores the durable trigger before native Tab navigation", async () => { + const durableTrigger = document.createElement("button"); + document.body.append(durableTrigger); + const { dropdown } = await createDropdown(); + dropdown.addEventListener("keydown", (event) => + trackDropdownKeyboardDismissal(event, () => durableTrigger.focus()), + ); + + dropdown.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + + expect(document.activeElement).toBe(durableTrigger); + }); + + it("restores radio semantics after a dropdown item updates", async () => { + const item = document.createElement("wa-dropdown-item") as DropdownElement; + item.setAttribute("type", "normal"); + document.body.append(item); + + syncDropdownItemRadio(item, true); + await item.updateComplete; + await Promise.resolve(); + + expect(item.getAttribute("role")).toBe("menuitemradio"); + expect(item.getAttribute("aria-checked")).toBe("true"); + }); +}); diff --git a/ui/src/components/web-awesome.ts b/ui/src/components/web-awesome.ts new file mode 100644 index 000000000000..5d17d25948b6 --- /dev/null +++ b/ui/src/components/web-awesome.ts @@ -0,0 +1,108 @@ +// Shared dropdown registration and behavior. Other Web Awesome components use +// surface-specific registrars so route-only controls stay out of startup. +import "@awesome.me/webawesome/dist/components/dropdown/dropdown.js"; +import "@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js"; + +const keyboardDismissedDropdowns = new WeakSet(); + +/** Transient menus use hidden triggers. Keep Escape intent on the host so Lit + * re-renders cannot lose it before Web Awesome finishes hiding the popup. */ +export function trackDropdownKeyboardDismissal( + event: KeyboardEvent, + focusDurableTrigger?: () => void, +) { + // Web Awesome hides on Tab without restoring its trigger. Transient menus + // use a hidden trigger, so move focus first and let native Tab continue. + if (event.key === "Tab") { + focusDurableTrigger?.(); + return; + } + if (event.key === "Escape" && event.currentTarget) { + keyboardDismissedDropdowns.add(event.currentTarget); + } +} + +export function consumeDropdownKeyboardDismissal(event: Event): boolean { + const dropdown = event.currentTarget; + if (!dropdown || !keyboardDismissedDropdowns.has(dropdown)) { + return false; + } + keyboardDismissedDropdowns.delete(dropdown); + return true; +} + +/** Web Awesome exposes checkbox items only. Preserve its roving-focus item + * while restoring radio semantics for choices where exactly one value wins. */ +export function syncDropdownItemRadio(element: Element | undefined, checked: boolean) { + if (!(element instanceof HTMLElement) || element.localName !== "wa-dropdown-item") { + return; + } + const item = element as HTMLElement & { updateComplete?: Promise }; + void Promise.resolve(item.updateComplete).then(() => { + if (!item.isConnected) { + return; + } + item.setAttribute("role", "menuitemradio"); + item.setAttribute("aria-checked", String(checked)); + }); +} + +// Web Awesome labels its trigger but leaves the internal menu unnamed. Copy +// the host label, or reference the trigger, when the popup enters the a11y tree. +function labelDropdownMenu(dropdown: HTMLElement) { + const menu = dropdown.shadowRoot?.querySelector('[part="menu"]'); + if (!menu) { + return; + } + const label = dropdown.getAttribute("aria-label"); + if (label) { + menu.setAttribute("aria-label", label); + menu.removeAttribute("aria-labelledby"); + return; + } + const trigger = dropdown.querySelector('[slot="trigger"]'); + const triggerLabel = trigger?.getAttribute("aria-label") ?? trigger?.textContent?.trim(); + if (triggerLabel) { + menu.setAttribute("aria-label", triggerLabel); + menu.removeAttribute("aria-labelledby"); + } +} + +const dropdownLabelObservers = new WeakMap(); + +function startDropdownLabelSync(event: Event) { + const dropdown = event.target; + if (!(dropdown instanceof HTMLElement) || dropdown.localName !== "wa-dropdown") { + return; + } + labelDropdownMenu(dropdown); + dropdownLabelObservers.get(dropdown)?.disconnect(); + if (typeof MutationObserver === "undefined") { + return; + } + const observer = new MutationObserver(() => labelDropdownMenu(dropdown)); + // Open menus can survive an in-place locale render. Watch only their light + // DOM so translated labels stay current without observing the whole app. + observer.observe(dropdown, { + attributes: true, + attributeFilter: ["aria-label"], + childList: true, + characterData: true, + subtree: true, + }); + dropdownLabelObservers.set(dropdown, observer); +} + +function stopDropdownLabelSync(event: Event) { + const dropdown = event.target; + if (!(dropdown instanceof HTMLElement) || dropdown.localName !== "wa-dropdown") { + return; + } + dropdownLabelObservers.get(dropdown)?.disconnect(); + dropdownLabelObservers.delete(dropdown); +} + +if (typeof document !== "undefined") { + document.addEventListener("wa-show", startDropdownLabelSync); + document.addEventListener("wa-after-hide", stopDropdownLabelSync); +} diff --git a/ui/src/components/workspace-widget-cell.test.ts b/ui/src/components/workspace-widget-cell.test.ts index 6773fde888f9..08ff6832d11d 100644 --- a/ui/src/components/workspace-widget-cell.test.ts +++ b/ui/src/components/workspace-widget-cell.test.ts @@ -18,6 +18,7 @@ function callbacks(): WorkspaceWidgetCellCallbacks { return { onToggleCollapse: vi.fn(), onToggleMenu: vi.fn(), + onCloseMenu: vi.fn(), onHide: vi.fn(), onRemove: vi.fn(), onEditTitle: vi.fn(), diff --git a/ui/src/components/workspace-widget-cell.ts b/ui/src/components/workspace-widget-cell.ts index d5159cc78d57..7f156a27d67a 100644 --- a/ui/src/components/workspace-widget-cell.ts +++ b/ui/src/components/workspace-widget-cell.ts @@ -18,10 +18,12 @@ import type { import { getBuiltinRenderer, type BuiltinWidgetContext } from "../lib/workspace/widgets/index.ts"; import { icons } from "./icons.ts"; import { renderCustomWidgetHost, type CustomWidgetHostContext } from "./workspace-custom-widget.ts"; +import "./web-awesome.ts"; export type WorkspaceWidgetCellCallbacks = { onToggleCollapse: (widget: WorkspaceWidget) => void; onToggleMenu: (widget: WorkspaceWidget) => void; + onCloseMenu: (widget: WorkspaceWidget) => void; onHide: (widget: WorkspaceWidget) => void; onRemove: (widget: WorkspaceWidget) => void; onEditTitle: (widget: WorkspaceWidget) => void; @@ -88,45 +90,24 @@ function renderProvenanceChip(widget: WorkspaceWidget): TemplateResult | typeof >`; } -function renderMenu( - widget: WorkspaceWidget, - callbacks: WorkspaceWidgetCellCallbacks, -): TemplateResult { +function renderMenu(): TemplateResult { return html` - + + ${t("workspaces.widget.menu.editTitle")} + + + ${t("workspaces.widget.menu.moveToTab")} + + + ${t("workspaces.widget.menu.hide")} + + + ${t("workspaces.widget.menu.remove")} + `; } @@ -316,18 +297,50 @@ export function renderWidgetCell(props: WorkspaceWidgetCellProps): TemplateResul @keydown=${(event: KeyboardEvent) => handleNudgeKey(event, widget, "move", callbacks)} >${icons.arrowUpDown} - - ${props.menuOpen ? renderMenu(widget, callbacks) : nothing} + + ${renderMenu()} +
${widget.collapsed ? nothing diff --git a/ui/src/e2e/agent-page-scope.e2e.test.ts b/ui/src/e2e/agent-page-scope.e2e.test.ts index 670fdcf9896a..5c3a4a6f11c5 100644 --- a/ui/src/e2e/agent-page-scope.e2e.test.ts +++ b/ui/src/e2e/agent-page-scope.e2e.test.ts @@ -111,10 +111,14 @@ describeControlUiE2e("Control UI agent page scope", () => { }); try { - await page.goto(`${server.baseUrl}sessions`); + await page.goto(`${server.baseUrl}usage`); + await gateway.waitForRequest("agents.list"); const sidebar = page.locator("openclaw-app-sidebar"); - await sidebar.locator(".sidebar-agent-chip__main").click(); - await sidebar.getByRole("menuitemradio", { name: "Writer" }).click(); + await sidebar.getByRole("button", { name: /Agent menu/ }).click(); + await sidebar + .locator("wa-dropdown.sidebar-agent-menu") + .locator('wa-dropdown-item[value="agent:writer"]') + .click(); await waitForRequest(gateway, "sessions.list", (params) => params.agentId === "writer"); await expect .poll(() => sidebar.locator(".sidebar-agent-chip__name").textContent()) @@ -142,8 +146,11 @@ describeControlUiE2e("Control UI agent page scope", () => { .toBe("Writer"); await screenshot(page, "02-all-agents-usage.png"); - await sidebar.locator(".sidebar-agent-chip__main").click(); - await sidebar.getByRole("menuitem", { name: "Agent settings" }).click(); + await sidebar.getByRole("button", { name: /Agent menu/ }).click(); + await sidebar + .locator("wa-dropdown.sidebar-agent-menu") + .locator('wa-dropdown-item[value="command:agent-settings"]') + .click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents"); await expect.poll(() => new URL(page.url()).searchParams.get("agent")).toBe("writer"); await screenshot(page, "03-writer-settings.png"); diff --git a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts index 46ad3021ca23..bbaa2defd323 100644 --- a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts +++ b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts @@ -89,11 +89,9 @@ describeControlUiE2e("Control UI agents Set Default mocked Gateway E2E", () => { // Click auto-waits for the elements to be actionable (enabled), so // these implicitly assert the dropdown loaded and Set Default is clickable for a // non-default agent. - await page.locator(".agent-select__trigger").click(); - await page - .getByRole("listbox", { name: "Select an agent" }) - .getByRole("option", { name: "Kimi agent", exact: true }) - .click(); + const agentSelect = page.locator("wa-dropdown.agent-select"); + await agentSelect.locator(".agent-select__trigger").click(); + await agentSelect.getByRole("menuitemcheckbox", { name: "Kimi agent", exact: true }).click(); await page.getByRole("button", { name: "Set Default", exact: true }).click(); // The fix routes Set Default through the canonical save path; without it the click diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index caa2e9085d03..264f2820b41a 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -115,7 +115,7 @@ describeControlUiE2e("Control UI chat composer redesign", () => { const contextUsage = composer.locator(".context-ring"); const textarea = composer.locator("textarea"); const attach = composer.locator( - 'summary.agent-chat__input-btn--attach[aria-label="Add attachment"]', + 'button.agent-chat__input-btn--attach[aria-label="Add attachment"]', ); const camera = composerShell.locator(".agent-chat__camera-btn"); const takePhoto = composerShell.getByRole("menuitem", { name: "Take photo" }); @@ -151,11 +151,9 @@ describeControlUiE2e("Control UI chat composer redesign", () => { // the card edge (the old asymmetric footer padding pinned it to the top). await expect .poll(() => - page.evaluate(() => { - const footer = document - .querySelector(".agent-chat__composer-footer") - ?.getBoundingClientRect(); - const chip = document.querySelector(".chat-settings-chip")?.getBoundingClientRect(); + settings.evaluate((element) => { + const footer = element.closest(".agent-chat__composer-footer")?.getBoundingClientRect(); + const chip = element.getBoundingClientRect(); if (!footer || !chip) { return null; } @@ -314,13 +312,13 @@ describeControlUiE2e("Control UI chat composer redesign", () => { ); await expect .poll(() => - voice.evaluate( - (node) => - Number.parseFloat(getComputedStyle(node).borderRadius) === - Number.parseFloat( - getComputedStyle(node.closest(".agent-chat__input") as HTMLElement).borderRadius, - ), - ), + voice.evaluate((node) => { + const bounds = node.getBoundingClientRect(); + return ( + bounds.width === bounds.height && + Number.parseFloat(getComputedStyle(node).borderRadius) >= bounds.width / 2 + ); + }), ) .toBe(true); @@ -346,11 +344,12 @@ describeControlUiE2e("Control UI chat composer redesign", () => { await settings.click(); const viewMenu = page.getByRole("menu", { name: "View" }); + const viewDropdown = composer.locator("wa-dropdown.chat-view-menu"); await expect.poll(() => viewMenu.isVisible()).toBe(true); await expect - .poll(() => viewMenu.getByRole("menuitemcheckbox").allTextContents()) + .poll(() => viewDropdown.locator(".chat-view-menu__text").allTextContents()) .toEqual(["Reasoning", "Tool calls", "Keep commentary"]); - const reasoning = viewMenu.getByRole("menuitemcheckbox", { name: "Reasoning" }); + const reasoning = viewDropdown.getByRole("menuitemcheckbox", { name: "Reasoning" }); await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("true"); await reasoning.click(); await expect.poll(() => reasoning.getAttribute("aria-checked")).toBe("false"); diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index ac57d6ce0284..ca17084f4d0c 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -1072,7 +1072,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { try { await page.goto(`${server.baseUrl}chat`); await page.locator(".chat-workspace-toggle").click(); - await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 }); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); await page.getByRole("button", { name: "Copy path" }).click(); @@ -1152,8 +1154,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { timeout: 10_000, }); expect(await opener.count()).toBe(0); - await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 }); - await page.getByText("preview.png").waitFor({ timeout: 10_000 }); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); + await page + .locator(".chat-workspace-rail__file-name", { hasText: "preview.png" }) + .waitFor({ timeout: 10_000 }); await page.getByText("Project files").waitFor({ timeout: 10_000 }); await page.locator(".chat-workspace-rail__file-name", { hasText: "package.json" }).waitFor({ timeout: 10_000, @@ -1175,7 +1181,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await page.getByRole("button", { name: "Collapse session workspace" }).waitFor({ timeout: 10_000, }); - await page.getByText("AGENTS.md").waitFor({ timeout: 10_000 }); + await page.locator(".chat-workspace-rail__file-name", { hasText: "AGENTS.md" }).waitFor({ + timeout: 10_000, + }); expect(await gateway.getRequests("sessions.files.list")).toHaveLength(1); await page.setViewportSize({ height: 900, width: 760 }); @@ -2194,7 +2202,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { mimeType: attachmentMimeType, buffer: Buffer.from(attachmentText), }); - await page.getByText(attachmentName).waitFor({ timeout: 10_000 }); + await page.locator(".chat-attachment-file__name", { hasText: attachmentName }).waitFor({ + timeout: 10_000, + }); const send = page.getByRole("button", { name: "Send message" }); const sendEnabled = await send.isEnabled(); expect(sendEnabled).toBe(true); diff --git a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts index c12e6139ec73..544a54a8d059 100644 --- a/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts +++ b/ui/src/e2e/config-quick-thinking-persistence.e2e.test.ts @@ -74,11 +74,11 @@ describeControlUiE2e("Control UI Quick Config thinking persistence mocked Gatewa expect(response?.status()).toBe(200); const modelCard = page.locator("#settings-general-model"); - const lowButton = modelCard.getByRole("button", { name: "Low", exact: true }); + const lowButton = modelCard.getByRole("radio", { name: "Low", exact: true }); await lowButton.waitFor(); - expect(await lowButton.getAttribute("class")).toContain("settings-segmented__btn--active"); + expect(await lowButton.getAttribute("aria-checked")).toBe("true"); - await modelCard.getByRole("button", { name: "High", exact: true }).click(); + await modelCard.getByRole("radio", { name: "High", exact: true }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); const raw = requestRaw(await gateway.waitForRequest("config.set")); @@ -95,9 +95,9 @@ describeControlUiE2e("Control UI Quick Config thinking persistence mocked Gatewa await freshPage.goto(`${server.baseUrl}config`); const highButton = freshPage .locator("#settings-general-model") - .getByRole("button", { name: "High", exact: true }); + .getByRole("radio", { name: "High", exact: true }); await highButton.waitFor(); - expect(await highButton.getAttribute("class")).toContain("settings-segmented__btn--active"); + expect(await highButton.getAttribute("aria-checked")).toBe("true"); } finally { await context.close(); } diff --git a/ui/src/e2e/cron-filters.e2e.test.ts b/ui/src/e2e/cron-filters.e2e.test.ts index 3866604c20e2..a1be8cd5b587 100644 --- a/ui/src/e2e/cron-filters.e2e.test.ts +++ b/ui/src/e2e/cron-filters.e2e.test.ts @@ -204,7 +204,7 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { sortDir: "asc", }); - await page.locator("details.cron-filter-popover > summary").click(); + await page.locator(".cron-filter-popover__trigger").click(); await page.locator('[data-test-id="cron-jobs-schedule-filter"]').selectOption("cron"); await page.locator('[data-test-id="cron-jobs-last-status-filter"]').selectOption("unknown"); @@ -374,6 +374,7 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { await expect .poll(() => activityTab.evaluate((element) => element === document.activeElement)) .toBe(true); + await page.keyboard.press("Enter"); await expect.poll(() => activityTab.getAttribute("aria-selected")).toBe("true"); await expect .poll(() => page.getByRole("tabpanel", { name: "Run history" }).isVisible()) diff --git a/ui/src/e2e/logs-autofollow.e2e.test.ts b/ui/src/e2e/logs-autofollow.e2e.test.ts index 1adc649c60d4..f719d8d09ac9 100644 --- a/ui/src/e2e/logs-autofollow.e2e.test.ts +++ b/ui/src/e2e/logs-autofollow.e2e.test.ts @@ -64,18 +64,29 @@ describeControlUiE2e("Control UI logs auto-follow mocked Gateway E2E", () => { await expect.poll(() => page.locator(".log-row").count()).toBe(logLines.length); const stream = page.locator(".log-stream"); - const autoFollow = page.getByRole("checkbox", { name: "Auto-follow" }); + const autoFollow = page.locator("wa-switch.settings-toggle").filter({ + hasText: "Auto-follow", + }); await expect .poll(() => stream.evaluate((element) => element.scrollHeight - element.clientHeight)) .toBeGreaterThan(0); - await autoFollow.uncheck(); + await expect + .poll(() => autoFollow.evaluate((element) => Reflect.get(element, "checked"))) + .toBe(true); + await autoFollow.click(); + await expect + .poll(() => autoFollow.evaluate((element) => Reflect.get(element, "checked"))) + .toBe(false); await stream.evaluate((element) => { element.scrollTop = 0; element.dispatchEvent(new Event("scroll")); }); await expect.poll(() => stream.evaluate((element) => element.scrollTop)).toBe(0); - await autoFollow.check(); + await autoFollow.click(); + await expect + .poll(() => autoFollow.evaluate((element) => Reflect.get(element, "checked"))) + .toBe(true); await expect .poll(() => diff --git a/ui/src/e2e/mobile-pairing.e2e.test.ts b/ui/src/e2e/mobile-pairing.e2e.test.ts index e0cabe2d1adf..ff2c74c74be3 100644 --- a/ui/src/e2e/mobile-pairing.e2e.test.ts +++ b/ui/src/e2e/mobile-pairing.e2e.test.ts @@ -78,8 +78,11 @@ describeControlUiE2e("Control UI mobile pairing mocked Gateway E2E", () => { expect(response?.status()).toBe(200); // Pairing folded into the footer agent-chip menu. - await page.locator(".sidebar-agent-chip__main").click(); - const sidebarPairingButton = page.locator(".sidebar-pair-mobile"); + const sidebar = page.locator("openclaw-app-sidebar"); + await sidebar.getByRole("button", { name: /Agent menu/ }).click(); + const sidebarPairingButton = sidebar + .locator("wa-dropdown.sidebar-agent-menu") + .locator(".sidebar-pair-mobile"); await sidebarPairingButton.waitFor(); await expect.poll(async () => sidebarPairingButton.isEnabled()).toBe(true); await gateway.deferNext("device.pair.list"); diff --git a/ui/src/e2e/native-link-routing.e2e.test.ts b/ui/src/e2e/native-link-routing.e2e.test.ts index ca6fba6a0f73..b1fba6c3a9e5 100644 --- a/ui/src/e2e/native-link-routing.e2e.test.ts +++ b/ui/src/e2e/native-link-routing.e2e.test.ts @@ -161,17 +161,18 @@ describeControlUiE2e("native link routing", () => { await link.click({ button: "right" }); const menu = page.getByRole("menu", { name: "Link actions" }); + const menuHost = page.locator("openclaw-native-link-menu"); await expect.poll(() => menu.isVisible()).toBe(true); await expect.poll(() => replyMenu.count()).toBe(0); await expect - .poll(() => menu.locator(".session-menu__text").allTextContents()) + .poll(() => page.locator("openclaw-native-link-menu .session-menu__text").allTextContents()) .toEqual(["Open in Sidebar", "Open in Default Browser", "Copy Link"]); await page.screenshot({ path: path.join(artifactDir, "01-native-link-menu-page.jpg"), type: "jpeg", quality: 60, }); - await menu.getByRole("menuitem", { name: "Open in Default Browser" }).click(); + await menuHost.getByRole("menuitem", { name: "Open in Default Browser" }).click(); await expect .poll(() => page.evaluate( @@ -187,7 +188,7 @@ describeControlUiE2e("native link routing", () => { ]); await link.click({ button: "right" }); - await menu.getByRole("menuitem", { name: "Copy Link" }).click(); + await menuHost.getByRole("menuitem", { name: "Copy Link" }).click(); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe("https://example.com/report"); @@ -218,13 +219,17 @@ describeControlUiE2e("native link routing", () => { await expect .poll(() => page.locator("#native-link-routing-modal").evaluate((modal) => { - return modal.shadowRoot?.querySelector("dialog")?.open ?? false; + return ( + modal.shadowRoot?.querySelector("wa-dialog")?.shadowRoot?.querySelector("dialog") + ?.open ?? false + ); }), ) .toBe(true); await modalLink.click({ button: "right" }); - await expect.poll(() => menu.isVisible()).toBe(true); - await menu.getByRole("menuitem", { name: "Open in Sidebar" }).click(); + const modalSidebarItem = menuHost.getByRole("menuitem", { name: "Open in Sidebar" }); + await expect.poll(() => modalSidebarItem.isVisible()).toBe(true); + await modalSidebarItem.click(); await expect .poll(() => page.evaluate( diff --git a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts index 2bcc9470e0ca..58a002a50653 100644 --- a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts +++ b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts @@ -179,9 +179,7 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => { await page.evaluate(() => { window.dispatchEvent(new CustomEvent("openclaw:native-open-search")); }); - await expect - .poll(() => page.locator(".cmd-palette-overlay").getAttribute("open")) - .not.toBeNull(); + await expect.poll(() => page.locator(".cmd-palette-overlay").isVisible()).toBe(true); await page.evaluate(() => { window.dispatchEvent(new CustomEvent("openclaw:native-new-session")); @@ -206,9 +204,7 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => { .poll(() => page.locator(".shell").getAttribute("class")) .toContain("shell--nav-collapsed"); await toolbar.getByRole("button", { name: "Open command palette" }).click(); - await expect - .poll(() => page.locator(".cmd-palette-overlay").getAttribute("open")) - .not.toBeNull(); + await expect.poll(() => page.locator(".cmd-palette-overlay").isVisible()).toBe(true); await page.keyboard.press("Escape"); await page.evaluate(() => { diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index 3bc074937e42..273e09b6d1ed 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -93,18 +93,15 @@ async function deferTargetRepositorySelection( await page.goto(`${server.baseUrl}new`); await gateway.waitForRequest("worktrees.branches"); - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", - ); - await whereSelect.locator("summary").click(); - await page.getByRole("menuitemradio", { name: "Worktree" }).click(); + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + await page.locator("#new-session-where-trigger").click(); + await whereSelect.getByRole("button", { name: "Worktree" }).click(); const baseInput = page.getByLabel("Base branch"); await expect.poll(() => baseInput.inputValue()).toBe("alpha"); const requestsBeforeSwitch = (await gateway.getRequests("worktrees.branches")).length; await gateway.deferNext("worktrees.branches"); - const folderSelect = page.locator(".new-session-page__select--folder"); - await folderSelect.locator("summary").click(); + await page.locator("#new-session-folder-trigger").click(); await page .locator(".new-session-page__browser-list") .getByRole("button", { name: "Gateway" }) @@ -230,11 +227,15 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // The folder trigger labels the workspace and opens the browser menu. const folderSelect = page.locator(".new-session-page__select--folder"); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => + page + .locator("#new-session-folder-trigger .new-session-page__trigger-label") + .textContent(), + ) .toBe("openclaw"); // Browse from the workspace, descend one level, then adopt the folder. - await folderSelect.locator("summary").click(); + await page.locator("#new-session-folder-trigger").click(); await page .locator(".new-session-page__browser-list") .getByRole("button", { name: "Gateway" }) @@ -248,20 +249,52 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // The adopted folder closes the menu and updates the trigger label. await expect.poll(() => folderSelect.getAttribute("open")).toBeNull(); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("new-session-folder-trigger"); + await expect + .poll(() => + page + .locator("#new-session-folder-trigger .new-session-page__trigger-label") + .textContent(), + ) .toBe("packages"); // Custom host folders force a managed worktree (badge on the where // trigger; the menu item is checked and locked). + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); const whereTrigger = page.locator('.new-session-page__trigger[data-worktree="true"]'); await whereTrigger.waitFor(); await whereTrigger.click(); - const worktreeItem = page.getByRole("menuitemradio", { name: "Worktree" }); - await expect.poll(() => worktreeItem.getAttribute("aria-checked")).toBe("true"); + const worktreeItem = page.getByRole("button", { name: "Worktree" }); + await expect.poll(() => worktreeItem.getAttribute("aria-pressed")).toBe("true"); expect(await worktreeItem.isDisabled()).toBe(true); await page.keyboard.press("Escape"); + await expect + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("new-session-where-trigger"); - await page.locator(".new-session-page__message").fill("fix the flaky test"); + // Pointer light-dismiss keeps focus on the newly chosen control after + // the asynchronous hide animation completes. + await whereTrigger.click(); + const afterPointerHide = whereSelect.evaluate( + (element) => + new Promise((resolve) => { + element.addEventListener("wa-after-hide", () => resolve(), { once: true }); + }), + ); + await page.locator("#new-session-folder-trigger").click(); + await afterPointerHide; + expect(await folderSelect.evaluate((element) => element === document.activeElement)).toBe( + true, + ); + await page.keyboard.press("Escape"); + await expect.poll(() => folderSelect.getAttribute("open")).toBeNull(); + await expect + .poll(() => page.evaluate(() => document.activeElement?.id)) + .toBe("new-session-folder-trigger"); + + const message = page.locator(".new-session-page__message"); + await message.fill("fix the flaky test"); await page.getByRole("button", { name: "Start session" }).click(); const createRequest = await gateway.waitForRequest("sessions.create"); @@ -595,8 +628,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.goto(`${server.baseUrl}new`); await page.getByRole("heading", { name: "Main" }).waitFor(); await gateway.waitForRequest("worktrees.branches"); - await page.locator('.new-session-page__select > summary[title="Agent"]').click(); - await page.getByRole("menuitemradio", { name: "Research" }).click(); + const agentSelect = page.locator("wa-select.new-session-page__agent-select"); + await agentSelect.click(); + await agentSelect.getByRole("option", { name: "Research" }).click(); await page.getByRole("heading", { name: "Research" }).waitFor(); const message = page.locator(".new-session-page__message"); @@ -639,7 +673,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect .poll(() => page - .locator(".new-session-page__select--folder .new-session-page__trigger-label") + .locator("#new-session-folder-trigger .new-session-page__trigger-label") .textContent(), ) .toBe("research-next"); @@ -650,9 +684,10 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { repoRoot: REFRESHED_RESEARCH_WORKSPACE, }); - const whereTrigger = page.locator('summary[title="Where"]'); + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + const whereTrigger = page.locator("#new-session-where-trigger"); await whereTrigger.click(); - const worktreeItem = page.getByRole("menuitemradio", { name: "Worktree" }); + const worktreeItem = whereSelect.getByRole("button", { name: "Worktree" }); await worktreeItem.click(); const baseInput = page.getByLabel("Base branch"); await expect.poll(() => baseInput.inputValue()).toBe("main"); @@ -737,11 +772,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { try { await page.goto(`${server.baseUrl}new`); await gateway.waitForRequest("node.list"); - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", - ); - await whereSelect.locator("summary").click(); - await page.getByRole("menuitemradio", { name: "Old device" }).click(); + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + await page.locator("#new-session-where-trigger").click(); + await whereSelect.getByRole("button", { name: "Old device" }).click(); await page.locator(".new-session-page__message").fill("use a validated device"); const start = page.locator("button.chat-send-btn"); const nodeRequestsBefore = (await gateway.getRequests("node.list")).length; @@ -815,17 +848,17 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const message = page.locator(".new-session-page__message"); const folderSelect = page.locator(".new-session-page__select--folder"); - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", - ); + const folderTrigger = page.locator("#new-session-folder-trigger"); + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + const whereTrigger = page.locator("#new-session-where-trigger"); await message.fill("preserve this replacement draft"); - await whereSelect.locator("summary").click(); - await page.getByRole("menuitemradio", { name: "Old device" }).click(); + await whereTrigger.click(); + await whereSelect.getByRole("button", { name: "Old device" }).click(); // Keep an old-client browser request in flight. Replacement must close // its menu and prevent its eventual completion from reviving old state. await gateway.deferNext("fs.listDir"); - await folderSelect.locator("summary").click(); + await folderTrigger.click(); await page .locator(".new-session-page__browser-list") .getByRole("button", { name: "Old device" }) @@ -875,17 +908,21 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { .toBe(branchesBefore + 1); await page.getByRole("heading", { name: "Replacement agent" }).waitFor(); await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); - await expect.poll(() => folderSelect.getAttribute("open")).toBeNull(); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => + folderSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(false); + await expect + .poll(() => folderTrigger.locator(".new-session-page__trigger-label").textContent()) .toBe("target-repo"); const branchRequests = await gateway.getRequests("worktrees.branches"); expect(branchRequests.at(-1)?.params).toEqual({ repoRoot: TARGET_REPO }); - await whereSelect.locator("summary").click(); - await page.getByRole("menuitemradio", { name: "New device" }).waitFor(); - expect(await page.getByRole("menuitemradio", { name: "Old device" }).count()).toBe(0); - await page.getByRole("menuitemradio", { name: "Worktree" }).click(); + await whereTrigger.click(); + await whereSelect.getByRole("button", { name: "New device" }).waitFor(); + expect(await whereSelect.getByRole("button", { name: "Old device" }).count()).toBe(0); + await whereSelect.getByRole("button", { name: "Worktree" }).click(); await expect.poll(() => page.getByLabel("Base branch").inputValue()).toBe("beta"); await gateway.resolveDeferred("fs.listDir", { @@ -893,7 +930,11 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { home: "/stale-device-path", entries: [], }); - await expect.poll(() => folderSelect.getAttribute("open")).toBeNull(); + await expect + .poll(() => + folderSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(false); await expect.poll(() => message.inputValue()).toBe("preserve this replacement draft"); } finally { await context.close(); @@ -1043,7 +1084,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { try { await page.goto(`${server.baseUrl}new?agent=research`); const folderLabel = page.locator( - ".new-session-page__select--folder .new-session-page__trigger-label", + "#new-session-folder-trigger .new-session-page__trigger-label", ); await expect.poll(() => folderLabel.textContent()).toBe("research"); @@ -1118,11 +1159,11 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const draft = page.locator(".new-session-page__scroll"); const message = page.locator(".new-session-page__message"); - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + const whereSummary = page.locator("#new-session-where-trigger"); + const targetSummaries = page.locator( + "#new-session-folder-trigger, #new-session-where-trigger", ); - const whereSummary = whereSelect.locator("summary"); - const targetSummaries = page.locator(".new-session-page__select > summary"); await message.fill(submittedMessage); await whereSummary.click(); @@ -1137,9 +1178,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { expect(await whereSelect.getAttribute("open")).toBeNull(); expect( await targetSummaries.evaluateAll((summaries) => - summaries.map((summary) => summary.getAttribute("aria-disabled")), + summaries.map((summary) => (summary as HTMLButtonElement).disabled), ), - ).toEqual(["true", "true"]); + ).toEqual([true, true]); await expect( message.fill("silently discarded late edit", { timeout: 250 }), @@ -1160,9 +1201,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { expect(await message.inputValue()).toBe(submittedMessage); expect( await targetSummaries.evaluateAll((summaries) => - summaries.map((summary) => summary.getAttribute("aria-disabled")), + summaries.map((summary) => (summary as HTMLButtonElement).disabled), ), - ).toEqual(["false", "false"]); + ).toEqual([false, false]); await page.getByRole("button", { name: "Start session" }).click(); await expect.poll(async () => (await gateway.getRequests("sessions.create")).length).toBe(2); @@ -1216,9 +1257,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { try { const baseInput = await deferTargetRepositorySelection(page, gateway); - await page - .locator(".new-session-page__select:not(.new-session-page__select--folder) summary") - .click(); + await page.locator("#new-session-where-trigger").click(); await baseInput.fill("feature-choice"); await gateway.resolveDeferred("worktrees.branches"); await expect.poll(() => baseInput.getAttribute("placeholder")).not.toBe("Loading…"); @@ -1409,24 +1448,23 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.goto(`${server.baseUrl}new`); await page.locator(".new-session-page__message").waitFor(); const folderSelect = page.locator(".new-session-page__select--folder"); - const whereSelect = page.locator( - ".new-session-page__select:not(.new-session-page__select--folder)", - ); - const whereTrigger = whereSelect.locator("summary"); - const whereLabel = whereSelect.locator(".new-session-page__trigger-label"); + const folderTrigger = page.locator("#new-session-folder-trigger"); + const whereSelect = page.locator("wa-popover.new-session-page__where-popover"); + const whereTrigger = page.locator("#new-session-where-trigger"); + const whereLabel = whereTrigger.locator(".new-session-page__trigger-label"); // Pick the node from the where menu. await whereTrigger.click(); - await page.getByRole("menuitemradio", { name: "MacBook" }).click(); + await whereSelect.getByRole("button", { name: "MacBook" }).click(); await expect.poll(() => whereLabel.textContent()).toBe("MacBook"); // Node sessions cannot use managed worktrees, so the menu drops the item. await whereTrigger.click(); - expect(await page.getByRole("menuitemradio", { name: "Worktree" }).count()).toBe(0); + expect(await whereSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); await page.keyboard.press("Escape"); // Manual path entry in the browser head preserves UNC paths; these // cannot be rediscovered by starting at the node home directory. - await folderSelect.locator("summary").click(); + await folderTrigger.click(); const roots = page.locator(".new-session-page__browser-list"); await roots.getByRole("button", { name: "MacBook" }).click(); const pathInput = page.locator("input.new-session-page__browser-path"); @@ -1436,12 +1474,24 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC); // Close without applying; the draft keeps the node home default. await page.keyboard.press("Escape"); + await expect + .poll(() => + folderSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(false); // Back on the Gateway, the browser super-root lists every node. - await whereTrigger.click(); - await page.getByRole("menuitemradio", { name: "Gateway · local" }).click(); + await whereSelect.evaluate((element) => { + (element as HTMLElement & { open: boolean }).open = true; + }); + await expect + .poll(() => + whereSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(true); + await whereSelect.getByRole("button", { name: "Gateway · local" }).click(); await expect.poll(() => whereLabel.textContent()).toBe("Gateway · local"); - await folderSelect.locator("summary").click(); + await folderTrigger.click(); await expect .poll(() => roots @@ -1471,32 +1521,32 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // Using a node folder retargets the draft to that node. await expect.poll(() => whereLabel.textContent()).toBe("MacBook"); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => folderTrigger.locator(".new-session-page__trigger-label").textContent()) .toBe("Projects"); // Clearing the path applies the node's default directory (empty folder), // the state the replaced clearable folder textbox could express. - await folderSelect.locator("summary").click(); + await folderTrigger.click(); await roots.getByRole("button", { name: "MacBook" }).click(); await expect.poll(() => pathInput.inputValue()).toBe(NODE_PICKED); await pathInput.fill(""); await page.getByRole("button", { name: "Use this folder" }).click(); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => folderTrigger.locator(".new-session-page__trigger-label").textContent()) .toBe("Agent workspace"); await expect.poll(() => whereLabel.textContent()).toBe("MacBook"); // Browse back to the custom folder, then retarget to the exec-only node // with a manual absolute path for the final create assertion. - await folderSelect.locator("summary").click(); + await folderTrigger.click(); await roots.getByRole("button", { name: "MacBook" }).click(); await roots.getByRole("button", { name: "Projects" }).click(); await page.getByRole("button", { name: "Use this folder" }).click(); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => folderTrigger.locator(".new-session-page__trigger-label").textContent()) .toBe("Projects"); - await folderSelect.locator("summary").click(); + await folderTrigger.click(); await roots.getByRole("button", { name: "Old node" }).click(); await expect.poll(() => pathInput.inputValue()).toBe(""); await pathInput.fill(EXEC_ONLY_PICKED); @@ -1509,7 +1559,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.getByRole("button", { name: "Use this folder" }).click(); await expect.poll(() => whereLabel.textContent()).toBe("Old node"); await expect - .poll(() => folderSelect.locator(".new-session-page__trigger-label").textContent()) + .poll(() => folderTrigger.locator(".new-session-page__trigger-label").textContent()) .toBe("repo"); await page.locator(".new-session-page__message").fill("inspect the remote checkout"); diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts index a443781ae869..1b310cfdf691 100644 --- a/ui/src/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -107,6 +107,10 @@ async function waitForPatch( throw new Error(`No matching sessions.patch request found: ${JSON.stringify(requests)}`); } +async function activateMenuItem(item: Locator): Promise { + await item.evaluate((element) => (element as HTMLElement).click()); +} + function trimmedTextContents(locator: Locator): Promise { return locator.evaluateAll((elements) => elements.map((element) => element.textContent?.trim() ?? ""), @@ -377,7 +381,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await page.keyboard.press("Escape"); await sidebarResearch.hover(); await sidebarResearch.getByRole("button", { name: "Open session menu" }).click(); - await page.getByRole("menuitem", { name: "Archive session" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Archive session" })); const archivePatch = await waitForPatch( gateway, (params) => params.key === "agent:main:research" && params.archived === true, @@ -618,12 +622,14 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await row.waitFor({ state: "visible", timeout: 10_000 }); await row.click({ button: "right" }); - const menu = page.getByRole("menu", { name: "Actions for Research notes" }); - await menu.getByRole("menuitem", { name: "Archive session" }).waitFor({ state: "visible" }); + const menuHost = page.locator("openclaw-session-menu"); + await menuHost + .getByRole("menuitem", { name: "Archive session" }) + .waitFor({ state: "visible" }); await page.keyboard.press("Escape"); await row.getByRole("button", { name: "Open session menu" }).click(); - await menu.getByRole("menuitem", { name: "Archive session" }).click(); + await activateMenuItem(menuHost.getByRole("menuitem", { name: "Archive session" })); const patch = await waitForPatch( gateway, (params) => params.key === "agent:main:research" && params.archived === true, @@ -663,8 +669,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await row.waitFor({ state: "visible", timeout: 10_000 }); await row.getByRole("button", { name: "Open session menu" }).click(); - const menu = page.getByRole("menu", { name: "Actions for Research notes" }); - await menu.getByRole("menuitem", { name: "Delete…" }).click(); + await activateMenuItem( + page.locator("openclaw-session-menu").getByRole("menuitem", { name: "Delete…" }), + ); const request = await gateway.waitForRequest("sessions.delete"); expect(requireRecord(request.params)).toMatchObject({ key }); @@ -703,7 +710,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { message: "group name exceeds 512 characters", }); - const error = page.locator(".card .callout.danger"); + const error = page.locator(".sessions-error"); await error.waitFor({ state: "visible" }); await expect.poll(() => error.textContent()).toContain("group name exceeds 512 characters"); expect(pageErrors).toEqual([]); @@ -846,7 +853,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await page.getByRole("menuitem", { name: "Rename group…" }).waitFor({ state: "visible" }); await captureUiProof(page, "sidebar-group-menu.png"); page.once("dialog", (dialog) => void dialog.accept("Projects")); - await page.getByRole("menuitem", { name: "Rename group…" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); const renameRequest = await gateway.waitForRequest("sessions.groups.rename"); expect(requireRecord(renameRequest.params)).toMatchObject({ name: "Research", @@ -872,7 +879,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await projectsGroup.locator(".sidebar-recent-sessions__head").hover(); page.once("dialog", (dialog) => void dialog.accept()); await projectsMenuButton.click(); - await page.getByRole("menuitem", { name: "Delete group…" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Delete group…" })); const deleteRequest = await gateway.waitForRequest("sessions.groups.delete"); expect(requireRecord(deleteRequest.params)).toMatchObject({ name: "Projects" }); await expect @@ -891,7 +898,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { .toBe(3); // Group by "None" flattens the category sections into the plain list. - const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" }); + const sortSessionsButton = page.locator( + "button.sidebar-session-sort:not(.sidebar-session-new)", + ); await sortSessionsButton.click(); await page.getByRole("menuitemradio", { name: "None" }).waitFor({ state: "visible" }); await captureUiProof(page, "sidebar-groupby-sort-menu.png"); @@ -901,7 +910,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await captureUiProof(page, "sidebar-groupby-sort-menu-closed.png"); await sortSessionsButton.click(); - await page.getByRole("menuitemradio", { name: "None" }).click(); + await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); await expect.poll(() => groups.count()).toBe(1); await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(4); } finally { @@ -957,7 +966,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await researchGroup.locator(".sidebar-recent-sessions__head").hover(); await researchGroup.getByRole("button", { name: "Group options for Research" }).click(); page.once("dialog", (dialog) => void dialog.accept("Projects")); - await page.getByRole("menuitem", { name: "Rename group…" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "Rename group…" })); await gateway.waitForRequest("sessions.groups.rename"); await gateway.rejectDeferred("sessions.groups.rename", { code: "INVALID_REQUEST", @@ -1028,11 +1037,27 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { ); await sessionTen.hover(); await sessionTen.getByRole("button", { name: "Open session menu" }).click(); - // The submenu opens on pointerenter; clicking the host item would toggle - // the hover-opened submenu straight back closed. - await page.getByRole("menuitem", { name: "Move to group" }).hover(); + const moveToGroup = page.getByRole("menuitem", { name: "Move to group" }); + await expect.poll(() => moveToGroup.getAttribute("aria-haspopup")).toBe("menu"); + const moveToGroupIndex = await moveToGroup.evaluate((element) => + [...(element.parentElement?.children ?? [])] + .filter( + (item) => + item.localName === "wa-dropdown-item" && + item.getAttribute("slot") !== "submenu" && + !(item as HTMLElement & { disabled?: boolean }).disabled, + ) + .indexOf(element), + ); + expect(moveToGroupIndex).toBeGreaterThanOrEqual(0); + await page.keyboard.press("Home"); + for (let index = 0; index < moveToGroupIndex; index += 1) { + await page.keyboard.press("ArrowDown"); + } + await page.keyboard.press("ArrowRight"); + await expect.poll(() => moveToGroup.getAttribute("aria-expanded")).toBe("true"); page.once("dialog", (dialog) => void dialog.accept("Gamma")); - await page.getByRole("menuitem", { name: "New group…" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); const gamma = page.locator('[data-session-section="category:Gamma"]'); await gamma.waitFor({ state: "visible" }); const createdPatch = await waitForPatch( @@ -1120,7 +1145,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length; const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" }); await sortSessionsButton.click(); - await page.getByRole("menuitemradio", { name: "None" }).click(); + await activateMenuItem(page.getByRole("menuitemradio", { name: "None" })); const flatSection = page.locator('[data-session-section="ungrouped"]'); await flatSection .locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]') @@ -1160,7 +1185,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => { await firstGroup.locator(".sidebar-recent-sessions__head").hover(); await firstGroup.getByRole("button", { name: "Group options for First group" }).click(); page.once("dialog", (dialog) => void dialog.accept("Second group")); - await page.getByRole("menuitem", { name: "New group…" }).click(); + await activateMenuItem(page.getByRole("menuitem", { name: "New group…" })); await page.locator('[data-session-section="category:Second group"]').waitFor({ state: "visible", }); diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index fddad5aeaa6c..54a5cde00e16 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -217,7 +217,10 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = const agentChip = sidebar.getByRole("button", { name: /Agent menu/ }); const openSettingsFromChip = async () => { await agentChip.click(); - await sidebar.getByRole("menuitem", { name: "Settings" }).click(); + await sidebar + .locator("wa-dropdown.sidebar-agent-menu") + .getByRole("menuitem", { exact: true, name: "Settings" }) + .click(); }; await expect.poll(() => agentChip.isVisible()).toBe(true); await openSettingsFromChip(); @@ -298,6 +301,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = .poll(() => trimmedTextContents(settingsLinks)) .toEqual([ "Infrastructure", + "Devices", "Worktrees", "Debug", "Logs", @@ -358,8 +362,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = await expect.poll(() => new URL(page.url()).pathname).toBe("/chat"); await captureUiProof(page, "01-default-pinned.png"); - const moreButton = sidebar.getByRole("button", { exact: true, name: "More" }); - const moreMenu = sidebar.getByRole("menu", { exact: true, name: "More" }); + const moreButton = sidebar.locator("button.nav-item--action"); + const moreMenu = sidebar.locator("wa-dropdown.sidebar-more-menu"); await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("false"); await moreButton.click(); await expect.poll(() => moreButton.getAttribute("aria-expanded")).toBe("true"); @@ -373,7 +377,9 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = .not.toContain("Workboard"); await moreMenu.getByRole("menuitem", { name: "Edit pinned items" }).click(); - const menu = sidebar.getByRole("menu", { name: "Edit pinned items" }); + const menu = sidebar.locator( + "wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)", + ); // The pin editor replaces the More menu in place. await expect.poll(() => moreMenu.count()).toBe(0); await expect @@ -537,11 +543,11 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = try { await page.goto(`${server.baseUrl}chat`); const sidebar = page.locator("openclaw-app-sidebar"); - await sidebar.getByRole("button", { exact: true, name: "More" }).click(); + await sidebar.locator("button.nav-item--action").click(); await expect .poll(() => trimmedTextContents( - sidebar.getByRole("menu", { exact: true, name: "More" }).getByRole("menuitem"), + sidebar.locator("wa-dropdown.sidebar-more-menu").getByRole("menuitem"), ), ) .toContain("Workboard"); @@ -684,14 +690,16 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = try { const sidebar = page.locator("openclaw-app-sidebar"); - const moreButton = sidebar.getByRole("button", { exact: true, name: "More" }); + const moreButton = sidebar.locator("button.nav-item--action"); await moreButton.click(); await sidebar - .getByRole("menu", { exact: true, name: "More" }) + .locator("wa-dropdown.sidebar-more-menu") .getByRole("menuitem", { name: "Edit pinned items" }) .click(); const pinItems = sidebar - .getByRole("menu", { name: "Edit pinned items" }) + .locator( + "wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)", + ) .locator('[role="menuitem"], [role="menuitemcheckbox"]'); await page.keyboard.press("End"); await expect @@ -717,8 +725,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = try { const sidebar = page.locator("openclaw-app-sidebar"); - await sidebar.getByRole("button", { exact: true, name: "More" }).click(); - const moreMenu = sidebar.getByRole("menu", { exact: true, name: "More" }); + await sidebar.locator("button.nav-item--action").click(); + const moreMenu = sidebar.locator("wa-dropdown.sidebar-more-menu"); await expect .poll(() => moreMenu @@ -728,11 +736,15 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = ) .toBe(true); await moreMenu.getByRole("menuitem", { name: "Edit pinned items" }).click(); - const menu = sidebar.getByRole("menu", { name: "Edit pinned items" }); + const menu = sidebar.locator( + "wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)", + ); const menuItems = menu.locator('[role="menuitem"], [role="menuitemcheckbox"]'); await expect - .poll(() => menuItems.evaluateAll((items) => items.every((item) => item.tabIndex === -1))) - .toBe(true); + .poll(() => + menuItems.evaluateAll((items) => items.filter((item) => item.tabIndex === 0).length), + ) + .toBe(1); await expect .poll(() => menuItems.first().evaluate((element) => element === document.activeElement)) .toBe(true); @@ -751,6 +763,78 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = .toBe(true); await page.keyboard.press("Tab"); await expect.poll(() => menu.count()).toBe(0); + const sessionSortButton = sidebar.locator("button.sidebar-session-sort").first(); + await expect + .poll(() => sessionSortButton.evaluate((element) => element === document.activeElement)) + .toBe(true); + } finally { + await context.close(); + } + }); + + it("reaches per-agent new-session actions with menu keys", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1440 }, + }); + const page = await context.newPage(); + const agentsList = { + agents: [ + { id: "main", identity: { name: "Main" }, name: "Main" }, + { id: "research", identity: { name: "Research" }, name: "Research" }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }; + await installMockGateway(page, { + methodResponses: { + "agents.list": agentsList, + "chat.startup": { + agentsList, + messages: [], + metadata: { models: [] }, + sessionId: "control-ui-e2e-session", + thinkingLevel: null, + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + const sidebar = page.locator("openclaw-app-sidebar"); + await sidebar.getByRole("button", { name: /Agent menu/ }).click(); + const menu = sidebar.locator("wa-dropdown.sidebar-agent-menu"); + const mainSwitch = menu.getByRole("menuitemradio", { name: "Main" }); + const researchSwitch = menu.getByRole("menuitemradio", { name: "Research" }); + const researchNewSession = menu.getByRole("menuitem", { + name: "New session — Research", + }); + + // Web Awesome only includes direct light-DOM items in its roving focus model. + await expect + .poll(() => + researchNewSession.evaluate( + (element) => element.parentElement?.matches("wa-dropdown.sidebar-agent-menu") ?? false, + ), + ) + .toBe(true); + await expect + .poll(() => mainSwitch.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect + .poll(() => researchSwitch.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.press("ArrowDown"); + await expect + .poll(() => researchNewSession.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.press("Enter"); + await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); + expect(new URL(page.url()).searchParams.get("agent")).toBe("research"); } finally { await context.close(); } diff --git a/ui/src/lib/tab-list.test.ts b/ui/src/lib/tab-list.test.ts deleted file mode 100644 index 06214de3820d..000000000000 --- a/ui/src/lib/tab-list.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* @vitest-environment jsdom */ - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { handleTabListKeydown } from "./tab-list.ts"; - -function createTabs(options: { vertical?: boolean; disabledMiddle?: boolean } = {}) { - const tabList = document.createElement("div"); - tabList.setAttribute("role", "tablist"); - if (options.vertical) { - tabList.setAttribute("aria-orientation", "vertical"); - } - const tabs = ["one", "two", "three"].map((id, index) => { - const tab = document.createElement("button"); - tab.id = id; - tab.setAttribute("role", "tab"); - tab.tabIndex = index === 0 ? 0 : -1; - tab.addEventListener("keydown", handleTabListKeydown); - if (options.disabledMiddle && index === 1) { - tab.disabled = true; - } - tabList.append(tab); - return tab; - }); - document.body.append(tabList); - return tabs; -} - -function press(tab: HTMLElement, key: string) { - const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); - tab.dispatchEvent(event); - return event; -} - -describe("handleTabListKeydown", () => { - afterEach(() => { - document.body.replaceChildren(); - vi.restoreAllMocks(); - }); - - it("moves, activates, and wraps horizontal tabs", () => { - const [first, second, third] = createTabs(); - const activateSecond = vi.spyOn(second!, "click"); - first!.focus(); - - expect(press(first!, "ArrowRight").defaultPrevented).toBe(true); - expect(document.activeElement).toBe(second); - expect(activateSecond).toHaveBeenCalledOnce(); - - third!.focus(); - press(third!, "ArrowRight"); - expect(document.activeElement).toBe(first); - }); - - it("supports Home, End, vertical arrows, and skips disabled tabs", () => { - const [first, , third] = createTabs({ vertical: true, disabledMiddle: true }); - first!.focus(); - press(first!, "ArrowDown"); - expect(document.activeElement).toBe(third); - press(third!, "Home"); - expect(document.activeElement).toBe(first); - press(first!, "End"); - expect(document.activeElement).toBe(third); - }); -}); diff --git a/ui/src/lib/tab-list.ts b/ui/src/lib/tab-list.ts deleted file mode 100644 index 87075108eff2..000000000000 --- a/ui/src/lib/tab-list.ts +++ /dev/null @@ -1,48 +0,0 @@ -export function handleTabListKeydown(event: KeyboardEvent): void { - if (event.altKey || event.ctrlKey || event.metaKey) { - return; - } - - const current = event.currentTarget; - if (!(current instanceof HTMLElement) || current.getAttribute("role") !== "tab") { - return; - } - const tabList = current.closest("[role='tablist']"); - if (!tabList) { - return; - } - - const vertical = tabList.getAttribute("aria-orientation") === "vertical"; - const previousKey = vertical ? "ArrowUp" : "ArrowLeft"; - const nextKey = vertical ? "ArrowDown" : "ArrowRight"; - if (![previousKey, nextKey, "Home", "End"].includes(event.key)) { - return; - } - - const tabs = Array.from(tabList.querySelectorAll("[role='tab']")).filter( - (tab) => tab.closest("[role='tablist']") === tabList && !tab.hasAttribute("disabled"), - ); - const currentIndex = tabs.indexOf(current); - if (currentIndex < 0 || tabs.length === 0) { - return; - } - - const targetIndex = - event.key === "Home" - ? 0 - : event.key === "End" - ? tabs.length - 1 - : event.key === previousKey - ? (currentIndex - 1 + tabs.length) % tabs.length - : (currentIndex + 1) % tabs.length; - const target = tabs[targetIndex]; - if (!target) { - return; - } - - event.preventDefault(); - current.tabIndex = -1; - target.tabIndex = 0; - target.focus(); - target.click(); -} diff --git a/ui/src/pages/agents/agent-file-preview-state.ts b/ui/src/pages/agents/agent-file-preview-state.ts new file mode 100644 index 000000000000..f03f9160b475 --- /dev/null +++ b/ui/src/pages/agents/agent-file-preview-state.ts @@ -0,0 +1,21 @@ +import { t } from "../../i18n/index.ts"; + +export function setPreviewExpandButtonState( + button: Element | null | undefined, + isFullscreen: boolean, +) { + if (!(button instanceof HTMLElement)) { + return; + } + const label = isFullscreen ? t("agents.files.collapsePreview") : t("agents.files.expandPreview"); + button.classList.toggle("is-fullscreen", isFullscreen); + button.setAttribute("aria-pressed", String(isFullscreen)); + button.setAttribute("aria-label", label); + button.setAttribute("title", label); +} + +export function resetAgentFilePreview(modal: HTMLElement) { + modal.querySelector(".md-preview-dialog__panel")?.classList.remove("fullscreen"); + setPreviewExpandButtonState(modal.querySelector(".md-preview-expand-btn"), false); + modal.classList.remove("fullscreen"); +} diff --git a/ui/src/pages/agents/panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts index 74cc404aef01..2a27dc438546 100644 --- a/ui/src/pages/agents/panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -12,6 +12,8 @@ import type { CronStatus, } from "../../api/types.ts"; import { icons } from "../../components/icons.ts"; +import "../../components/modal-dialog.ts"; +import type { OpenClawModalDialog } from "../../components/modal-dialog.ts"; import "../../components/tooltip.ts"; import { renderSettingsEmpty, @@ -31,6 +33,7 @@ import { formatCronState, formatNextRun, } from "../../lib/presenter.ts"; +import { resetAgentFilePreview, setPreviewExpandButtonState } from "./agent-file-preview-state.ts"; function countWords(text: string) { const normalized = text.trim(); @@ -85,17 +88,6 @@ function toDomId(value: string) { return normalized.replace(/^-+|-+$/g, "") || "preview"; } -function setPreviewExpandButtonState(button: Element | null | undefined, isFullscreen: boolean) { - if (!(button instanceof HTMLElement)) { - return; - } - const label = isFullscreen ? t("agents.files.collapsePreview") : t("agents.files.expandPreview"); - button.classList.toggle("is-fullscreen", isFullscreen); - button.setAttribute("aria-pressed", String(isFullscreen)); - button.setAttribute("aria-label", label); - button.setAttribute("title", label); -} - function renderAgentContextSection( context: AgentContext, subtitle: string, @@ -492,12 +484,10 @@ export function renderAgentFiles(params: { class="btn btn--sm" @click=${(e: Event) => { const btn = e.currentTarget as HTMLElement; - const dialog = btn + btn .closest(".settings-group") - ?.querySelector("dialog"); - if (dialog) { - dialog.showModal(); - } + ?.querySelector("openclaw-modal-dialog") + ?.show(); }} > ${icons.eye} ${t("agents.files.preview")} @@ -533,24 +523,12 @@ export function renderAgentFiles(params: { )} > - { - const dialog = e.currentTarget as HTMLDialogElement; - if (e.target === dialog) { - dialog.close(); - } - }} - @close=${(e: Event) => { - const dialog = e.currentTarget as HTMLElement; - dialog - .querySelector(".md-preview-dialog__panel") - ?.classList.remove("fullscreen"); - setPreviewExpandButtonState( - dialog.querySelector(".md-preview-expand-btn"), - false, - ); + { + resetAgentFilePreview(e.currentTarget as HTMLElement); }} >
@@ -587,6 +565,9 @@ export function renderAgentFiles(params: { return; } const isFullscreen = panel.classList.toggle("fullscreen"); + btn + .closest("openclaw-modal-dialog") + ?.classList.toggle("fullscreen", isFullscreen); setPreviewExpandButtonState(btn, isFullscreen); }} > @@ -603,7 +584,13 @@ export function renderAgentFiles(params: { class="btn btn--sm md-preview-icon-btn" aria-label=${t("agents.files.editFile")} @click=${(e: Event) => { - (e.currentTarget as HTMLElement).closest("dialog")?.close(); + const modal = (e.currentTarget as HTMLElement).closest( + "openclaw-modal-dialog", + ) as OpenClawModalDialog | null; + modal?.hide(); + if (modal) { + resetAgentFilePreview(modal); + } const textarea = document.querySelector(".agent-file-textarea"); textarea?.focus(); @@ -618,7 +605,13 @@ export function renderAgentFiles(params: { class="btn btn--sm md-preview-icon-btn" aria-label=${t("agents.files.closePreview")} @click=${(e: Event) => { - (e.currentTarget as HTMLElement).closest("dialog")?.close(); + const modal = (e.currentTarget as HTMLElement).closest( + "openclaw-modal-dialog", + ) as OpenClawModalDialog | null; + modal?.hide(); + if (modal) { + resetAgentFilePreview(modal); + } }} > @@ -651,7 +644,7 @@ export function renderAgentFiles(params: {
-
+ `} `, diff --git a/ui/src/pages/agents/panels-tools-skills.browser.test.ts b/ui/src/pages/agents/panels-tools-skills.browser.test.ts index b060898be92c..64e4cba5ab9c 100644 --- a/ui/src/pages/agents/panels-tools-skills.browser.test.ts +++ b/ui/src/pages/agents/panels-tools-skills.browser.test.ts @@ -290,7 +290,7 @@ describe("agents tools panel (browser)", () => { const tool = container.querySelector(".agent-tool-card"); const summary = container.querySelector(".agent-tool-summary"); - const toggle = container.querySelector(".agent-tool-toggle input"); + const toggle = container.querySelector(".agent-tool-toggle wa-switch"); expect(tool?.open).toBe(false); expect(toggle?.closest(".agent-tool-summary")).toBe(summary); diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 12c310cdbc8f..446669c3c09e 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -647,11 +647,11 @@ describe("renderAgentFiles", () => { container, ); - const dialog = container.querySelector(".md-preview-dialog"); + const dialog = container.querySelector("openclaw-modal-dialog"); const panel = container.querySelector(".md-preview-dialog__panel"); const expandButton = container.querySelector(".md-preview-expand-btn"); - expect(dialog).toBeInstanceOf(HTMLDialogElement); + expect(dialog).not.toBeNull(); expect(panel).toBeInstanceOf(HTMLElement); expect(expandButton).toBeInstanceOf(HTMLButtonElement); const previewPanel = panel!; @@ -669,7 +669,7 @@ describe("renderAgentFiles", () => { expect(previewExpandButton.getAttribute("aria-pressed")).toBe("true"); expect(previewExpandButton.getAttribute("aria-label")).toBe("Collapse preview"); - dialog!.dispatchEvent(new Event("close")); + container.querySelector('[aria-label="Close preview"]')?.click(); expect([...previewPanel.classList]).toEqual(["md-preview-dialog__panel"]); expect([...previewExpandButton.classList]).toEqual([ diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts index b13186e3d9c8..cbbb7690711d 100644 --- a/ui/src/pages/channels/wizard-view.ts +++ b/ui/src/pages/channels/wizard-view.ts @@ -1,5 +1,7 @@ // Channel setup wizard modal: renders gateway wizard steps (note/select/text/ // confirm/multiselect) plus the WhatsApp QR linking phase after config write. +import "@awesome.me/webawesome/dist/components/radio/radio.js"; +import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import { html, nothing, type TemplateResult } from "lit"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; @@ -66,25 +68,38 @@ function renderNoteStep(step: ChannelWizardStep, props: ChannelWizardViewProps) function renderSelectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { const options = step.options ?? []; + const selectedIndex = options.findIndex((option) => option.value === step.initialValue); return html` -
${step.message ?? ""}
-
+ = 0 ? String(selectedIndex) : null} + @change=${(event: Event) => { + const rawIndex = (event.currentTarget as HTMLElement & { value?: string | number | null }) + .value; + const option = options[Number(rawIndex)]; + if (option) { + props.onAnswer(option.value); + } + }} + > ${options.map( - (option: ChannelWizardStepOption) => html` - + `, )} -
+ `; } diff --git a/ui/src/pages/chat/chat-controls.test.ts b/ui/src/pages/chat/chat-controls.test.ts index c8baf5f0ee01..eb06d09bd2e4 100644 --- a/ui/src/pages/chat/chat-controls.test.ts +++ b/ui/src/pages/chat/chat-controls.test.ts @@ -55,7 +55,11 @@ function createProps(overrides: Record = {}): ChatControlsProps } function menuItems(container: HTMLElement) { - return Array.from(container.querySelectorAll(".chat-view-menu__item")); + return Array.from( + container.querySelectorAll( + ".chat-view-menu__item", + ), + ); } describe("chat composer view menu", () => { @@ -69,11 +73,7 @@ describe("chat composer view menu", () => { t("chat.view.toolCalls"), t("chat.view.commentary"), ]); - expect(items.map((item) => item.getAttribute("aria-checked"))).toEqual([ - "true", - "true", - "false", - ]); + expect(items.map((item) => item.checked)).toEqual([true, true, false]); }); it("toggles settings from the menu rows", () => { @@ -82,15 +82,25 @@ describe("chat composer view menu", () => { render(renderChatControls(createProps({ onSettingsChange })), container); const [reasoning, toolCalls, commentary] = menuItems(container); - reasoning?.click(); + const dropdown = container.querySelector("wa-dropdown"); + const select = (item: HTMLElement) => + dropdown?.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + cancelable: true, + composed: true, + detail: { item }, + }), + ); + select(reasoning!); expect(onSettingsChange).toHaveBeenLastCalledWith( expect.objectContaining({ chatShowThinking: false }), ); - toolCalls?.click(); + select(toolCalls!); expect(onSettingsChange).toHaveBeenLastCalledWith( expect.objectContaining({ chatShowToolCalls: false }), ); - commentary?.click(); + select(commentary!); expect(onSettingsChange).toHaveBeenLastCalledWith( expect.objectContaining({ chatPersistCommentary: true }), ); @@ -104,12 +114,15 @@ describe("chat composer view menu", () => { const items = menuItems(container); expect(items.every((item) => item.disabled)).toBe(true); // Onboarding forces thinking hidden and tool calls visible. - expect(items.map((item) => item.getAttribute("aria-checked"))).toEqual([ - "false", - "true", - "false", - ]); - items[0]?.click(); + expect(items.map((item) => item.checked)).toEqual([false, true, false]); + container.querySelector("wa-dropdown")?.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + cancelable: true, + composed: true, + detail: { item: items[0] }, + }), + ); expect(onSettingsChange).not.toHaveBeenCalled(); }); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index eb1d00a84d26..7f4b3bd5b188 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -187,12 +187,12 @@ function composerControlsHtml() { return `
- - + + + Reasoning +
@@ -1770,10 +1770,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { await modelTrigger.click(); await page.locator(".chat-view-menu").evaluate((node) => { - node.classList.add("chat-view-menu--open"); + node.setAttribute("open", ""); }); - const settingsMenu = await getRect(page, ".chat-view-menu--open"); + const settingsMenu = await getRect(page, ".chat-view-menu[open]"); expect(settingsMenu.left).toBeGreaterThanOrEqual(0); expect(settingsMenu.right).toBeLessThanOrEqual(width); expect(settingsMenu.top).toBeGreaterThanOrEqual(0); @@ -1794,10 +1794,10 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { const page = await openFixture(width, height); try { await page.locator(".chat-view-menu").evaluate((node) => { - node.classList.add("chat-view-menu--open"); + node.setAttribute("open", ""); }); - const settingsMenu = await getRect(page, ".chat-view-menu--open"); + const settingsMenu = await getRect(page, ".chat-view-menu[open]"); expect(settingsMenu.left).toBeGreaterThanOrEqual(0); expect(settingsMenu.right).toBeLessThanOrEqual(width); expect(settingsMenu.top).toBeGreaterThanOrEqual(0); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 65168997a03b..4aebe44158fb 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3803,8 +3803,12 @@ describe("chat attachment picker", () => { ).find((button) => button.textContent?.trim() === t("chat.composer.attachFileOption")); const clickInput = vi.spyOn(input, "click").mockImplementation(() => undefined); - expect(attachButton).toBeInstanceOf(HTMLButtonElement); - attachButton!.click(); + expect(attachButton).toBeInstanceOf(HTMLElement); + attachButton! + .closest("wa-dropdown") + ?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: attachButton }, bubbles: true }), + ); expect(clickInput).toHaveBeenCalledTimes(1); }); @@ -3824,9 +3828,13 @@ describe("chat attachment picker", () => { expect(input.accept).toBe("image/*"); expect(input.getAttribute("capture")).toBe("environment"); - expect(cameraButton).toBeInstanceOf(HTMLButtonElement); + expect(cameraButton).toBeInstanceOf(HTMLElement); expect(container.querySelector(".agent-chat__camera-btn")).toBeNull(); - cameraButton!.click(); + cameraButton! + .closest("wa-dropdown") + ?.dispatchEvent( + new CustomEvent("wa-select", { detail: { item: cameraButton }, bubbles: true }), + ); expect(clickInput).toHaveBeenCalledTimes(1); const photo = new File(["photo"], "camera.jpg", { type: "image/jpeg" }); @@ -3850,7 +3858,7 @@ describe("chat attachment picker", () => { container.querySelectorAll(".agent-chat__attach-menu-option"), ).find((button) => button.textContent?.trim() === t("chat.composer.takePhoto")); - expect(cameraButton).toBeInstanceOf(HTMLButtonElement); + expect(cameraButton).toBeInstanceOf(HTMLElement); expect(container.querySelector(".agent-chat__camera-btn")).toBeNull(); expect(container.querySelector('button[aria-label="Send message"]')).not.toBeNull(); }); diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index e1f62f8701b7..b03a1c29c797 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -8,6 +8,7 @@ import { normalizeBasePath } from "../../../app-route-paths.ts"; import { normalizeChatSendShortcut, type ChatSendShortcut } from "../../../app/settings.ts"; import { icons, type IconName } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; +import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; import type { ChatAttachment, ChatQueueItem } from "../../../lib/chat/chat-types.ts"; import { @@ -67,6 +68,7 @@ const COMPOSER_CHROME_INTERACTIVE_SELECTOR = [ "select", "textarea", "summary", + "wa-dropdown", "[contenteditable='true']", "[role='button']", "[role='listbox']", @@ -1082,25 +1084,21 @@ function isSupportedChatAttachmentFile(file: Pick): boole return !/\.(?:avi|m4v|mov|mp4|mpeg|mpg|webm)$/i.test(file.name); } -function clickComposerInput(event: MouseEvent, selector: string) { - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) { - return; - } +function clickComposerInput(target: HTMLElement, selector: string) { target.closest("details")?.removeAttribute("open"); target.closest(".agent-chat__composer-shell")?.querySelector(selector)?.click(); } -function clickComposerFileInput(event: MouseEvent) { - clickComposerInput(event, ".agent-chat__file-input"); +function clickComposerFileInput(target: HTMLElement) { + clickComposerInput(target, ".agent-chat__file-input"); } -function clickComposerPhotoInput(event: MouseEvent) { - clickComposerInput(event, ".agent-chat__photo-input"); +function clickComposerPhotoInput(target: HTMLElement) { + clickComposerInput(target, ".agent-chat__photo-input"); } -function clickComposerCameraInput(event: MouseEvent) { - clickComposerInput(event, ".agent-chat__camera-input"); +function clickComposerCameraInput(target: HTMLElement) { + clickComposerInput(target, ".agent-chat__camera-input"); } function generateAttachmentId(): string { @@ -2610,59 +2608,55 @@ export function renderChatComposer(props: ChatComposerProps) { })}
-
- ) => { + const menu = event.currentTarget as HTMLElement; + switch (event.detail.item.value) { + case "camera": + clickComposerCameraInput(menu); + break; + case "photo": + clickComposerPhotoInput(menu); + break; + case "file": + clickComposerFileInput(menu); + break; + case undefined: + break; + } + }} + > + - -
+ + + + ${t("chat.composer.takePhoto")} + + + + ${t("chat.composer.attachPhoto")} + + + + ${t("chat.composer.attachFileOption")} + +
- +
+

${t("workboard.fieldNotes")}

+

${card.notes}

+
` : nothing} - - -
- ${writable && !archived ? renderEditCardAction(props, card) : nothing} - ${writable ? renderArchiveCardAction(props, card, busy, archived) : nothing} - ${writable ? renderCardMoveControl(props, card, busy, { wide: true }) : nothing} - ${writable && (linkedSessionKey ? live : activeTask) - ? renderStopCardAction(props, card, busy) + ${renderDependencyDetailList(dependencies)} + ${renderDetailList(t("workboard.fieldLabels"), card.labels)} + ${renderDetailList( + t("workboard.badgeAttempts", { count: String(attempts.length) }), + attempts.map((entry) => + [entry.status, entry.model, entry.sessionKey, entry.error] + .filter(Boolean) + .join(" - "), + ), + )} + ${renderDetailList( + t("workboard.badgeLinks", { count: String(links.length) }), + links.map((entry) => + [entry.type, entry.title, entry.targetCardId, entry.url].filter(Boolean).join(" - "), + ), + )} + ${renderDetailList( + t("workboard.detailProof"), + proof.map((entry) => + [entry.status, entry.label, entry.command, entry.url, entry.note] + .filter(Boolean) + .join(" - "), + ), + )} + ${renderDetailList( + t("workboard.badgeArtifacts", { count: String(artifacts.length) }), + artifacts.map((entry) => + [entry.label, entry.url, entry.path, entry.mimeType].filter(Boolean).join(" - "), + ), + )} + ${renderDetailList( + t("workboard.badgeAttachments", { count: String(attachments.length) }), + attachments.map((entry) => + [entry.fileName, entry.mimeType, entry.note].filter(Boolean).join(" - "), + ), + )} + ${renderDetailList( + t("workboard.detailDiagnostics"), + diagnostics.map((entry) => `${entry.severity}: ${entry.title}`), + )} + ${renderDetailList( + t("workboard.detailWorkerLogs"), + workerLogs.map((entry) => `${entry.level}: ${entry.message}`), + )} + ${workerProtocol + ? renderDetailList(t("workboard.detailWorkerProtocol"), [ + workerProtocol.state, + workerProtocol.detail ?? "", + workerProtocol.updatedAt + ? t("workboard.detailUpdatedValue", { + time: formatUpdatedTime(workerProtocol.updatedAt), + }) + : "", + ]) : nothing} - ${renderOpenSessionCardAction(props, linkedSessionKey)} - ${writable ? renderDeleteCardAction(props, card, busy) : nothing} - ${showStartControls ? renderStartExecutionControls(props, card) : nothing} + ${automation + ? renderDetailList(t("workboard.detailAutomation"), [ + automation.tenant + ? t("workboard.detailAutomationTenant", { tenant: automation.tenant }) + : "", + automation.boardId + ? t("workboard.detailAutomationBoard", { board: automation.boardId }) + : "", + automation.skills?.length + ? t("workboard.detailAutomationSkills", { skills: automation.skills.join(", ") }) + : "", + automation.workspace + ? t("workboard.detailAutomationWorkspace", { + workspace: [ + automation.workspace.kind, + automation.workspace.path, + automation.workspace.branch, + ] + .filter(Boolean) + .join(" "), + }) + : "", + automation.dispatchCount + ? t("workboard.badgeDispatches", { count: String(automation.dispatchCount) }) + : "", + automation.lastDispatchAt + ? t("workboard.detailUpdatedValue", { + time: formatUpdatedTime(automation.lastDispatchAt), + }) + : "", + automation.summary + ? t("workboard.detailAutomationSummary", { summary: automation.summary }) + : "", + ]) + : nothing} + ${renderDetailList( + t("workboard.eventsLabel"), + events.map((event) => `${formatEventLabel(event)} ${formatUpdatedTime(event.at)}`), + )} + +
+

${t("workboard.detailOperatorNotes")}

+ ${comments.length + ? html` +
    + ${comments.slice(-6).map((comment) => html`
  1. ${comment.body}
  2. `)} +
+ ` + : html`

${t("workboard.detailNoNotes")}

`} + ${writable + ? html` + + + ` + : nothing} +
+ +
+ ${writable && !archived ? renderEditCardAction(props, card) : nothing} + ${writable ? renderArchiveCardAction(props, card, busy, archived) : nothing} + ${writable ? renderCardMoveControl(props, card, busy, { wide: true }) : nothing} + ${writable && (linkedSessionKey ? live : activeTask) + ? renderStopCardAction(props, card, busy) + : nothing} + ${renderOpenSessionCardAction(props, linkedSessionKey)} + ${writable ? renderDeleteCardAction(props, card, busy) : nothing} + ${showStartControls ? renderStartExecutionControls(props, card) : nothing} +
-
- + + `; } @@ -2317,8 +1877,7 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { aria-haspopup="dialog" aria-expanded=${state.detailCardId === card.id ? "true" : "false"} aria-controls=${workboardCardDetailDrawerId} - @click=${(event: MouseEvent) => { - rememberWorkboardReturnFocus(event.currentTarget); + @click=${() => { openCardDetails(state, card); props.onRequestUpdate?.(); }} @@ -2352,7 +1911,6 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { draggable=${writable && !state.dispatching ? "true" : "false"} @click=${(event: MouseEvent) => { if (!isCardActionTarget(event)) { - rememberWorkboardReturnFocus(event.currentTarget); openCardDetails(state, card); props.onRequestUpdate?.(); } @@ -2361,7 +1919,6 @@ function renderCard(props: WorkboardProps, card: WorkboardCard) { if (isCardActionTarget(event) || (event.key !== "Enter" && event.key !== " ")) { return; } - rememberWorkboardReturnFocus(event.currentTarget); openCardDetails(state, card); props.onRequestUpdate?.(); event.preventDefault(); @@ -2593,7 +2150,7 @@ export function renderWorkboard(props: WorkboardProps) { const dialogOpen = state.draftOpen || Boolean(getVisibleDetailCard(state)); return html` -
+
@@ -2779,8 +2336,7 @@ export function renderWorkboard(props: WorkboardProps) { aria-expanded=${state.draftOpen ? "true" : "false"} aria-controls=${workboardCardModalId} ?disabled=${state.dispatching} - @click=${(event: MouseEvent) => { - rememberWorkboardReturnFocus(event.currentTarget); + @click=${() => { openCreateModal(state); props.onRequestUpdate?.(); }} diff --git a/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts b/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts index 658b67480e14..f3c1292f5321 100644 --- a/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts +++ b/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts @@ -128,14 +128,23 @@ async function chooseWorkboardSelectOption( ): Promise { const field = workboardField(scope, label); expect(await field.count()).toBe(1); - await field.locator(".workboard-select__trigger").click(); - await field.getByRole("option", { exact: true, name: optionLabel }).click(); + const optionValue = await field.locator("wa-option").evaluateAll((options, optionText) => { + const option = options.find( + (candidate) => (candidate as HTMLElement & { label?: string }).label === optionText, + ); + return option?.getAttribute("value") ?? null; + }, optionLabel); + expect(optionValue).not.toBeNull(); + await field.locator("wa-select").evaluate((select, value) => { + (select as HTMLElement & { value: string }).value = String(value); + select.dispatchEvent(new Event("change", { bubbles: true })); + }, optionValue); } async function workboardSelectValue(scope: Page | Locator, label: string): Promise { const field = workboardField(scope, label); expect(await field.count()).toBe(1); - return (await field.locator(".workboard-select__value").textContent()) ?? ""; + return field.getByRole("combobox").inputValue(); } function workboardColumn(page: Page, title: string) { @@ -317,6 +326,8 @@ describeControlUiE2e("Control UI Workboard status persistence E2E", () => { await workboardCard(page, "Todo", "Persist queue status") .locator('button[aria-label="Edit card"]') .click(); + const editDialog = page.getByRole("dialog", { name: "Edit card" }); + await editDialog.waitFor({ timeout: 10_000 }); await page.getByLabel("Title").fill("Persisted renamed card"); await page.getByLabel("Notes").fill("Edited notes survive reopening."); await chooseWorkboardSelectOption(page, "Priority", "High"); @@ -335,7 +346,7 @@ describeControlUiE2e("Control UI Workboard status persistence E2E", () => { }, }); try { - await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 }); + await editDialog.waitFor({ state: "detached", timeout: 10_000 }); } catch (err) { const requests = await gateway.getRequests("workboard.cards.update"); throw new Error( @@ -348,7 +359,7 @@ describeControlUiE2e("Control UI Workboard status persistence E2E", () => { await workboardCard(page, "Todo", "Persisted renamed card") .locator('button[aria-label="Edit card"]') .click(); - await page.locator('[role="dialog"]').waitFor({ timeout: 10_000 }); + await editDialog.waitFor({ timeout: 10_000 }); await expect.poll(() => page.getByLabel("Title").inputValue()).toBe("Persisted renamed card"); await expect .poll(() => page.getByLabel("Notes").inputValue()) @@ -359,11 +370,11 @@ describeControlUiE2e("Control UI Workboard status persistence E2E", () => { path: path.join(artifactDir, "workboard-edit-reopen.png"), }); await page - .locator('[role="dialog"] .workboard-modal__actions') + .locator('openclaw-modal-dialog[label="Edit card"] .workboard-modal__actions') .last() .getByRole("button", { name: "Cancel" }) .click(); - await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 }); + await editDialog.waitFor({ state: "detached", timeout: 10_000 }); await dispatchHtml5Drag( workboardCard(page, "Todo", "Persisted renamed card"), diff --git a/ui/src/pages/workboard/workboard.e2e.test.ts b/ui/src/pages/workboard/workboard.e2e.test.ts index 150f28ee3314..14ed9bfd84c4 100644 --- a/ui/src/pages/workboard/workboard.e2e.test.ts +++ b/ui/src/pages/workboard/workboard.e2e.test.ts @@ -79,8 +79,17 @@ async function chooseWorkboardSelectOption( ): Promise { const field = workboardField(scope, label); expect(await field.count()).toBe(1); - await field.locator(".workboard-select__trigger").click(); - await field.getByRole("option", { exact: true, name: optionLabel }).click(); + const optionValue = await field.locator("wa-option").evaluateAll((options, optionText) => { + const option = options.find( + (candidate) => (candidate as HTMLElement & { label?: string }).label === optionText, + ); + return option?.getAttribute("value") ?? null; + }, optionLabel); + expect(optionValue).not.toBeNull(); + await field.locator("wa-select").evaluate((select, value) => { + (select as HTMLElement & { value: string }).value = String(value); + select.dispatchEvent(new Event("change", { bubbles: true })); + }, optionValue); } async function waitForRequests( @@ -353,64 +362,46 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => { const prioritySelect = writable.page .locator(".workboard-toolbar__filters .workboard-select") .nth(1); - const priorityTrigger = prioritySelect.locator(".workboard-select__trigger"); - const priorityMenu = prioritySelect.locator(".workboard-select__menu"); - const immediateOpen = await prioritySelect.evaluate((select) => { - const details = select as HTMLDetailsElement; - const menu = details.querySelector(".workboard-select__menu"); - if (!menu) { - throw new Error("Workboard select menu is missing"); - } - details.open = true; - const style = getComputedStyle(menu); - return { left: style.left, top: style.top, visibility: style.visibility }; - }); - expect(immediateOpen).toEqual({ left: "0px", top: "0px", visibility: "hidden" }); - await expect - .poll(() => priorityMenu.evaluate((menu) => menu.style.visibility)) - .toBe("visible"); - const positionedMenu = await prioritySelect.evaluate((select) => { - const trigger = select.querySelector(".workboard-select__trigger"); - const menu = select.querySelector(".workboard-select__menu"); - if (!trigger || !menu) { - throw new Error("Workboard select is incomplete"); - } - const triggerRect = trigger.getBoundingClientRect(); - const menuRect = menu.getBoundingClientRect(); - return { - menuLeft: menuRect.left, - menuTop: menuRect.top, - triggerBottom: triggerRect.bottom, - triggerLeft: triggerRect.left, - visibility: getComputedStyle(menu).visibility, - }; - }); - expect(positionedMenu.visibility).toBe("visible"); - expect(Math.abs(positionedMenu.menuLeft - positionedMenu.triggerLeft)).toBeLessThanOrEqual(1); - expect(positionedMenu.menuTop).toBeGreaterThan(positionedMenu.triggerBottom); - await prioritySelect.evaluate((select) => { - (select as HTMLDetailsElement).open = false; - }); - await expect - .poll(() => priorityMenu.evaluate((menu) => menu.style.visibility)) - .toBe("hidden"); + const priorityCombobox = prioritySelect.getByRole("combobox"); + await priorityCombobox.focus(); + await writable.page.keyboard.press("ArrowDown"); + await expect.poll(() => priorityCombobox.getAttribute("aria-expanded")).toBe("true"); - await priorityTrigger.focus(); - await writable.page.keyboard.press("ArrowDown"); - expect(await writable.page.locator(":focus").textContent()).toContain("All priorities"); - await writable.page.keyboard.press("ArrowDown"); - expect(await writable.page.locator(":focus").textContent()).toContain("Low"); await writable.page.keyboard.press("End"); - expect(await writable.page.locator(":focus").textContent()).toContain("Urgent"); - await writable.page.keyboard.press("h"); - expect(await writable.page.locator(":focus").textContent()).toContain("High"); await writable.page.keyboard.press("Enter"); - expect(await prioritySelect.getAttribute("open")).toBeNull(); + await expect + .poll(() => + prioritySelect.evaluate( + (select) => (select as HTMLElement & { value?: string }).value ?? "", + ), + ) + .toBe("urgent"); + await expect.poll(() => priorityCombobox.getAttribute("aria-expanded")).toBe("false"); + + await priorityCombobox.focus(); + await writable.page.keyboard.press("ArrowDown"); + await writable.page.keyboard.press("ArrowUp"); + await writable.page.keyboard.press("Enter"); + await expect + .poll(() => + prioritySelect.evaluate( + (select) => (select as HTMLElement & { value?: string }).value ?? "", + ), + ) + .toBe("high"); + + await priorityCombobox.focus(); + await writable.page.keyboard.press("ArrowDown"); await writable.page.keyboard.press("Home"); await writable.page.keyboard.press("Enter"); - expect(await prioritySelect.locator(".workboard-select__value").textContent()).toBe( - "All priorities", - ); + await expect + .poll(() => + prioritySelect.evaluate( + (select) => (select as HTMLElement & { value?: string }).value ?? "", + ), + ) + .toBe("all"); + await expect.poll(() => priorityCombobox.getAttribute("aria-expanded")).toBe("false"); await writableGateway.deferNext("workboard.cards.create"); await writable.page @@ -418,13 +409,15 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => { .getByRole("button", { name: /New card/u }) .click(); const createDialog = writable.page.getByRole("dialog", { name: "New card" }); - await createDialog.getByLabel("Title").fill(createdCard.title); - await createDialog.getByLabel("Notes").fill(createdCard.notes ?? ""); - await chooseWorkboardSelectOption(createDialog, "Session", linkedSessionName); - await createDialog.getByLabel("Labels").fill("ui, proof"); + const createForm = writable.page.locator('openclaw-modal-dialog[label="New card"]'); + await expect.poll(() => createDialog.isVisible()).toBe(true); + await createForm.getByLabel("Title").fill(createdCard.title); + await createForm.getByLabel("Notes").fill(createdCard.notes ?? ""); + await chooseWorkboardSelectOption(createForm, "Session", linkedSessionName); + await createForm.getByLabel("Labels").fill("ui, proof"); await captureScreenshot(writable.page, artifacts, "02-create-dialog"); const createBefore = (await writableGateway.getRequests("workboard.cards.create")).length; - await createDialog.getByRole("button", { name: /^Create$/u }).click(); + await createForm.getByRole("button", { name: /^Create$/u }).click(); const createRequest = await waitForNextRequest( writableGateway, "workboard.cards.create", @@ -437,25 +430,27 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => { status: "todo", title: createdCard.title, }); - expect(await createDialog.getByLabel("Title").isDisabled()).toBe(true); - expect(await createDialog.getByLabel("Notes").isDisabled()).toBe(true); - expect(await createDialog.getByLabel("Labels").isDisabled()).toBe(true); + expect(await createForm.getByLabel("Title").isDisabled()).toBe(true); + expect(await createForm.getByLabel("Notes").isDisabled()).toBe(true); + expect(await createForm.getByLabel("Labels").isDisabled()).toBe(true); expect( - await createDialog.locator(".workboard-select__trigger[aria-disabled='true']").count(), + await createForm + .getByRole("combobox") + .evaluateAll( + (inputs) => inputs.filter((input) => (input as HTMLInputElement).disabled).length, + ), ).toBe(4); - const pendingCancelButtons = createDialog.getByRole("button", { + const pendingCancelButtons = createForm.getByRole("button", { name: "Cancel", exact: true, }); expect(await pendingCancelButtons.count()).toBe(2); expect(await pendingCancelButtons.first().isDisabled()).toBe(true); expect(await pendingCancelButtons.last().isDisabled()).toBe(true); - expect(await createDialog.locator(".workboard-template-strip button:disabled").count()).toBe( - 5, - ); + expect(await createForm.locator(".workboard-template-strip button:disabled").count()).toBe(5); await writable.page.keyboard.press("Escape"); await expect.poll(() => createDialog.isVisible()).toBe(true); - await writable.page.locator(".workboard-modal").click({ position: { x: 4, y: 4 } }); + await createDialog.click({ position: { x: 4, y: 4 } }); await expect.poll(() => createDialog.isVisible()).toBe(true); await writableGateway.resolveDeferred("workboard.cards.create", { card: createdCard }); await cardInColumn(writable.page, "Todo", createdCard.title).waitFor({ state: "visible" }); @@ -466,12 +461,14 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => { .locator('button[aria-label="Edit card"]') .click(); const editDialog = writable.page.getByRole("dialog", { name: "Edit card" }); - await editDialog.getByLabel("Title").fill(editedCard.title); - await editDialog.getByLabel("Notes").fill(editedCard.notes ?? ""); - await chooseWorkboardSelectOption(editDialog, "Priority", "High"); - await editDialog.getByLabel("Labels").fill("ui, proof, e2e"); + const editForm = writable.page.locator('openclaw-modal-dialog[label="Edit card"]'); + await expect.poll(() => editDialog.isVisible()).toBe(true); + await editForm.getByLabel("Title").fill(editedCard.title); + await editForm.getByLabel("Notes").fill(editedCard.notes ?? ""); + await chooseWorkboardSelectOption(editForm, "Priority", "High"); + await editForm.getByLabel("Labels").fill("ui, proof, e2e"); const updateBeforeEdit = (await writableGateway.getRequests("workboard.cards.update")).length; - await editDialog.getByRole("button", { name: /^Save$/u }).click(); + await editForm.getByRole("button", { name: /^Save$/u }).click(); const editRequest = await waitForNextRequest( writableGateway, "workboard.cards.update", diff --git a/ui/src/styles.css b/ui/src/styles.css index 8da732cac14d..1c4b32d29c86 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -1,3 +1,4 @@ +@import "@awesome.me/webawesome/dist/styles/themes/default.css"; @import "./styles/base.css"; @import "./styles/layout.css"; @import "./styles/layout.mobile.css"; diff --git a/ui/src/styles/channels.css b/ui/src/styles/channels.css index dac3e125a8bc..2843f7fbc0b0 100644 --- a/ui/src/styles/channels.css +++ b/ui/src/styles/channels.css @@ -180,6 +180,11 @@ gap: var(--space-2); } +wa-radio-group.channels-wizard__options::part(radios) { + display: grid; + gap: var(--space-2); +} + .channels-wizard__option { display: flex; flex-direction: column; @@ -202,7 +207,9 @@ background: var(--bg-elevated); } -.channels-wizard__option[aria-pressed="true"] { +.channels-wizard__option[aria-pressed="true"], +.channels-wizard__option[checked], +.channels-wizard__option:state(checked) { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: var(--accent-subtle); } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 6c8c0e9dad08..5d6600a4dea0 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1733,11 +1733,11 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { } .chat-view-menu { - position: absolute; - left: 0; - bottom: calc(100% + 10px); + display: inline-flex; z-index: 60; - display: none; +} + +.chat-view-menu::part(menu) { min-width: 200px; padding: 6px; border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); @@ -1746,34 +1746,12 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { box-shadow: var(--shadow-lg); } -.chat-view-menu--open { - display: grid; - gap: 2px; -} - .chat-view-menu__item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 7px 10px; - border: none; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); font-size: 13px; text-align: left; } -.chat-view-menu__item:hover:not(:disabled) { - background: color-mix(in srgb, var(--text) 7%, transparent); -} - -.chat-view-menu__item:disabled { - color: var(--muted); - opacity: 0.6; -} - .chat-view-menu__check { display: inline-flex; align-items: center; @@ -1898,31 +1876,17 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { flex: 0 0 auto; } -.agent-chat__attach-menu > summary { - list-style: none; -} - -.agent-chat__attach-menu > summary::-webkit-details-marker { - display: none; -} - -.agent-chat__attach-menu > summary[aria-disabled="true"] { +.agent-chat__attach-menu > button:disabled { opacity: 0.4; cursor: not-allowed; } -.agent-chat__attach-menu[open] > summary { +.agent-chat__attach-menu[open] > button { color: var(--text); background: var(--bg-hover); } -.agent-chat__attach-menu-popover { - position: absolute; - z-index: 30; - left: 0; - bottom: calc(100% + 8px); - display: grid; - gap: 2px; +.agent-chat__attach-menu::part(menu) { width: 152px; padding: 5px; border: 1px solid var(--border); @@ -1932,25 +1896,8 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { } .agent-chat__attach-menu-option { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: 34px; - padding: 6px 8px; - border: 0; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); - font: inherit; font-size: 12px; - text-align: left; -} - -.agent-chat__attach-menu-option:hover, -.agent-chat__attach-menu-option:focus-visible { - background: var(--bg-hover); - outline: none; } .agent-chat__attach-menu-option svg { @@ -2547,7 +2494,7 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { } .agent-chat__input:has(.chat-controls__inline-select[open]), - .agent-chat__input:has(.chat-view-menu--open), + .agent-chat__input:has(wa-dropdown.chat-view-menu[open]), .agent-chat__input:has(.agent-chat__attach-menu[open]) { overflow: visible; } @@ -2582,7 +2529,7 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { @media (max-width: 640px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { /* Backdrop-filter contains fixed descendants; lift it and the landscape scroll clip while open. */ .agent-chat__input:has(.chat-controls__inline-select[open]), - .agent-chat__input:has(.chat-view-menu--open), + .agent-chat__input:has(wa-dropdown.chat-view-menu[open]), .agent-chat__input:has(.context-usage details[open]), .agent-chat__input:has(.agent-chat__attach-menu[open]) { overflow: visible; @@ -3884,14 +3831,6 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread { justify-content: flex-start; } - .chat-view-menu { - position: fixed; - left: max(12px, var(--safe-area-left)); - right: max(12px, var(--safe-area-right)); - bottom: calc(96px + var(--safe-area-bottom)); - width: auto; - } - .chat-controls__model-browser { grid-template-columns: 120px minmax(0, 1fr); min-height: 180px; diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 5fc1e84123ff..6782ca1946b0 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -1039,10 +1039,10 @@ } .sidebar-file-view__editor-menu { - position: absolute; z-index: var(--z-dropdown); - top: calc(100% + 6px); - right: 0; +} + +.sidebar-file-view__editor-menu::part(menu) { min-width: 132px; padding: 5px; border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); @@ -1052,25 +1052,11 @@ } .sidebar-file-view__editor-item { - display: flex; - align-items: center; - width: 100%; - min-height: 30px; - padding: 0 8px; - border: 0; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); - font: inherit; font-size: 13px; text-align: left; } -.sidebar-file-view__editor-item:hover, -.sidebar-file-view__editor-item:focus-visible { - background: color-mix(in srgb, var(--bg-hover) 84%, transparent); -} - .file-view__search { position: relative; z-index: 1; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bb3395be69b1..84002332da99 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -819,52 +819,6 @@ stroke-linejoin: round; } -.openclaw-tooltip { - position: fixed; - z-index: 2000; - max-width: min(260px, calc(100vw - 16px)); - padding: 7px 9px; - border: 1px solid color-mix(in srgb, var(--border-strong) 84%, transparent); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--card) 94%, black 6%); - box-shadow: var(--shadow-md); - color: var(--text); - font-size: 12px; /* was 11px */ - font-weight: 500; - line-height: 1.35; - text-align: center; - opacity: 0; - pointer-events: none; - transform: translateY(-3px); - transition: - opacity var(--duration-fast) var(--ease-out), - transform var(--duration-fast) var(--ease-out); - overflow-wrap: anywhere; - white-space: pre-line; -} - -.openclaw-tooltip[data-side="bottom"] { - transform: translateY(3px); -} - -.openclaw-tooltip[data-side="left"] { - transform: translateX(3px); -} - -.openclaw-tooltip[data-side="right"] { - transform: translateX(-3px); -} - -.openclaw-tooltip[data-open="true"] { - opacity: 1; - transform: translateY(0); -} - -.openclaw-tooltip[data-open="true"][data-side="left"], -.openclaw-tooltip[data-open="true"][data-side="right"] { - transform: translateX(0); -} - .github-link-hovercard { position: fixed; z-index: 1990; @@ -3062,10 +3016,22 @@ td.data-table-key-col { } .agent-select { - position: relative; + display: block; width: 100%; } +.agent-select::part(menu) { + width: min(320px, calc(100vw - 24px)); + max-height: 320px; + overflow-y: auto; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); + color: var(--text); +} + .agent-select__trigger { display: flex; align-items: center; @@ -3114,8 +3080,7 @@ td.data-table-key-col { color: var(--muted); } -.agent-select__chevron svg, -.agent-select__check svg { +.agent-select__chevron svg { width: 14px; height: 14px; fill: none; @@ -3154,41 +3119,10 @@ td.data-table-key-col { text-transform: uppercase; } -.agent-select__list { - position: absolute; - z-index: 121; - top: calc(100% + 4px); - right: 0; - left: 0; - max-height: 320px; - overflow-y: auto; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); - border-radius: var(--radius-lg); - background: var(--bg-elevated); - box-shadow: var(--shadow-lg); -} - .agent-select__option { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-height: 32px; - padding: 4px 8px; - border: none; - border-radius: var(--radius-md); - background: transparent; color: var(--text); font: inherit; font-size: 13px; - text-align: left; -} - -.agent-select__option:hover, -.agent-select__option:focus-visible { - background: color-mix(in srgb, var(--bg-hover) 84%, transparent); - outline: none; } .agent-select__option-label { @@ -3204,12 +3138,6 @@ td.data-table-key-col { height: 24px; } -.agent-select__check { - display: inline-flex; - flex: 0 0 auto; - color: var(--accent); -} - .agents-toolbar-actions { display: flex; align-items: center; @@ -3556,30 +3484,12 @@ td.data-table-key-col { gap: 8px; } -.md-preview-dialog { - border: none; - background: transparent; - color: inherit; - padding: 0; - width: 100vw; - max-width: none; - height: 100vh; - max-height: none; - overflow: hidden; -} - -.md-preview-dialog::backdrop { - background: - radial-gradient(circle at top, rgba(255, 92, 92, 0.12), transparent 32%), rgba(5, 8, 15, 0.76); - backdrop-filter: blur(14px); -} - .md-preview-dialog__panel { position: relative; - width: min(1040px, calc(100vw - 32px)); + width: 100%; min-height: min(76vh, 820px); max-height: calc(100vh - 32px); - margin: 16px auto; + margin: 0; display: flex; flex-direction: column; border: 1px solid color-mix(in srgb, var(--border-strong) 84%, white 8%); @@ -3589,7 +3499,6 @@ td.data-table-key-col { 0 32px 96px rgba(0, 0, 0, 0.48), 0 1px 0 rgba(255, 255, 255, 0.04) inset; overflow: hidden; - animation: scale-in 0.22s var(--ease-out); transition: width var(--duration-normal) var(--ease-out), max-height var(--duration-normal) var(--ease-out), @@ -3936,9 +3845,9 @@ td.data-table-key-col { } .md-preview-dialog__panel.fullscreen { - width: calc(100vw - 20px); + width: 100%; max-height: calc(100vh - 20px); - margin: 10px auto; + margin: 0; } .md-preview-dialog__panel.fullscreen .md-preview-dialog__header { @@ -3978,16 +3887,11 @@ td.data-table-key-col { } @media (max-width: 720px) { - .md-preview-dialog { - width: 100vw; - height: 100vh; - } - .md-preview-dialog__panel { - width: calc(100vw - 10px); + width: 100%; min-height: calc(100vh - 12px); max-height: calc(100vh - 12px); - margin: 6px auto; + margin: 0; border-radius: var(--radius-xl); } @@ -4017,10 +3921,6 @@ td.data-table-key-col { } @media (prefers-reduced-motion: reduce) { - .md-preview-dialog::backdrop { - backdrop-filter: none; - } - .md-preview-dialog__panel { animation: none; transition: none; @@ -4546,35 +4446,8 @@ td.data-table-key-col { } } -.cmd-palette-overlay { - position: fixed; - inset: 0; - z-index: 1000; - width: 100vw; - max-width: none; - height: 100dvh; - max-height: none; - margin: 0; - border: 0; - box-sizing: border-box; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: min(20vh, 160px); - padding-right: 0; - padding-bottom: 0; - padding-left: 0; - background: rgba(0, 0, 0, 0.5); - color: var(--text); - animation: fade-in 0.12s ease-out; -} - -.cmd-palette-overlay::backdrop { - background: transparent; -} - .cmd-palette { - width: min(560px, 90vw); + width: 100%; overflow: hidden; background: var(--card); border: 1px solid var(--border); diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index f65843f0aa26..669c24aafce9 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -289,27 +289,19 @@ } .config-top-tabs__scroller { - display: flex; - align-items: center; - gap: 8px; + display: block; min-width: 0; flex: 1 1 auto; - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; + --track-width: 0; } -.config-top-tabs__scroller::-webkit-scrollbar { - display: none; +.config-top-tabs__scroller::part(tabs) { + gap: 8px; + border: 0; } .config-top-tabs__tab { flex: 0 0 auto; - border: 1px solid var(--border); - border-radius: var(--radius-full); - min-height: 36px; - padding: 8px 14px; - background: var(--bg-elevated); color: var(--muted); font-size: 11.5px; font-weight: 600; @@ -321,17 +313,26 @@ box-shadow var(--duration-fast) ease; } -:root[data-theme-mode="light"] .config-top-tabs__tab { +.config-top-tabs__tab::part(base) { + border: 1px solid var(--border); + border-radius: var(--radius-full); + min-height: 36px; + padding: 8px 14px; + background: var(--bg-elevated); + color: inherit; +} + +:root[data-theme-mode="light"] .config-top-tabs__tab::part(base) { background: white; } -.config-top-tabs__tab:hover { +.config-top-tabs__tab:hover::part(base) { color: var(--text); border-color: var(--border-strong); background: var(--bg-hover); } -.config-top-tabs__tab.active { +.config-top-tabs__tab[active]::part(base) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: var(--accent-subtle); diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index e361548f842e..c0c412e99e32 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -1062,3 +1062,35 @@ flex-basis: 100%; } } + +/* Web Awesome popup surfaces own positioning, dismissal, and focus. */ +.cron-filter-popover { + --max-width: min(280px, calc(100vw - 48px)); +} + +.cron-filter-popover::part(body) { + padding: 0; +} + +.cron-filter-popover .cron-filter-popover__panel { + position: static; + width: min(280px, calc(100vw - 48px)); +} + +.cron-job-menu::part(menu) { + min-width: 170px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + padding: var(--space-1); + box-shadow: var(--shadow-lg); +} + +.cron-filter-dropdown__details::part(menu) { + width: min(300px, calc(100vw - 48px)); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index c51130a74187..04f4a66b83aa 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1716,7 +1716,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse { text-align: left; } -.sidebar-customize-menu { +.sidebar-customize-menu:not(wa-dropdown) { position: fixed; z-index: 120; min-width: 224px; @@ -1730,6 +1730,23 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse { box-shadow: var(--shadow-lg); } +wa-dropdown.sidebar-customize-menu { + position: fixed; + z-index: 120; +} + +wa-dropdown.sidebar-customize-menu::part(menu) { + min-width: 224px; + max-width: 264px; + max-height: 420px; + overflow-y: auto; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + .sidebar-customize-menu__title { padding: 6px 10px 8px; font-size: 11px; @@ -1762,6 +1779,15 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse { background: color-mix(in srgb, var(--bg-hover) 84%, transparent); } +.sidebar-customize-menu__item > a { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + color: inherit; + text-decoration: none; +} + .sidebar-customize-menu__check { display: flex; align-items: center; @@ -1804,7 +1830,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse { color: var(--accent); } -.session-menu { +.session-menu:not(wa-dropdown) { position: fixed; z-index: 121; min-width: 224px; @@ -1816,6 +1842,21 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse { box-shadow: var(--shadow-lg); } +wa-dropdown.session-menu { + position: fixed; + z-index: 121; +} + +wa-dropdown.session-menu::part(menu) { + min-width: 224px; + max-width: 264px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + /* Popover hosts live in the top layer so menus escape in-page stacking contexts (e.g. the sidebar resizer above the nav); the host itself must stay a zero-size shell around its fixed-position menu. */ @@ -1831,7 +1872,7 @@ openclaw-session-menu[popover] { background: transparent; } -.sidebar-session-sort-menu { +.sidebar-session-sort-menu:not(wa-dropdown) { position: fixed; z-index: 121; min-width: 176px; @@ -1843,6 +1884,21 @@ openclaw-session-menu[popover] { box-shadow: var(--shadow-lg); } +wa-dropdown.sidebar-session-sort-menu { + position: fixed; + z-index: 121; +} + +wa-dropdown.sidebar-session-sort-menu::part(menu) { + min-width: 176px; + max-width: 220px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + .sidebar-session-sort-menu__title { padding: 4px 8px 2px; color: var(--muted); @@ -2182,19 +2238,12 @@ openclaw-session-menu[popover] { height: 6px; } -.sidebar-agent-menu__agent-row { - display: flex; - align-items: center; - gap: 2px; -} - .sidebar-agent-menu__agent-switch { min-width: 0; - flex: 1 1 auto; } .sidebar-agent-menu__new { - margin-inline-end: 4px; + padding-inline-start: 26px; } .sidebar-agent-menu__new:disabled { @@ -2217,81 +2266,6 @@ openclaw-session-menu[popover] { overflow: visible; max-height: calc(100vh - 16px); } - -/* Large rosters: the switcher list scrolls; the utility rows below stay put. */ -.sidebar-agent-menu__list { - flex: 0 1 auto; - min-height: 30px; - max-height: 264px; - overflow-y: auto; - overscroll-behavior: contain; -} - -.sidebar-customize-menu__submenu-host { - position: relative; -} - -/* Bottom-aligned like the parent menu: both grow upward from the footer. - Fixed link list, no scrolling: overflow stays visible so the ::before - hover bridge below is not clipped away. */ -.sidebar-agent-menu .sidebar-customize-menu__submenu { - position: absolute; - bottom: -6px; - left: calc(100% + 4px); - overflow: visible; -} - -.sidebar-agent-menu .sidebar-customize-menu__submenu--left { - right: calc(100% + 4px); - left: auto; -} - -/* Viewports too narrow for side-by-side menus: cover the parent instead. */ -.sidebar-agent-menu .sidebar-customize-menu__submenu--overlay { - right: -6px; - left: -6px; -} - -/* Invisible bridge over the 4px visual gap so a slow pointer crossing it - never leaves the submenu host (which would close the flyout mid-travel). */ -.sidebar-agent-menu .sidebar-customize-menu__submenu::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: -8px; - width: 8px; -} - -.sidebar-agent-menu .sidebar-customize-menu__submenu--left::before { - right: -8px; - left: auto; -} - -.sidebar-agent-menu .sidebar-customize-menu__submenu--overlay::before { - content: none; -} - -.sidebar-customize-menu__chevron { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 14px; - height: 14px; - color: var(--muted); -} - -.sidebar-customize-menu__chevron svg { - width: 14px; - height: 14px; - stroke: currentColor; - fill: none; - stroke-width: 1.7px; - stroke-linecap: round; - stroke-linejoin: round; -} - .sidebar-agent-menu__filter { padding: 2px 8px 6px; } diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index 5b67604225b3..22457ab17e84 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -50,8 +50,7 @@ openclaw-new-session-page { text-align: left; } -/* Cursor-style quiet trigger row: each control is a
whose summary - reads as muted text with a chevron; the menu floats below the trigger. */ +/* Cursor-style quiet trigger row. Web Awesome owns popup/listbox behavior. */ .new-session-page__triggers { display: flex; flex-wrap: wrap; @@ -74,6 +73,9 @@ openclaw-new-session-page { list-style: none; cursor: pointer; user-select: none; + border: 0; + background: transparent; + font-family: inherit; } .new-session-page__trigger::-webkit-details-marker { @@ -81,7 +83,7 @@ openclaw-new-session-page { } .new-session-page__trigger:hover, -.new-session-page__select[open] > .new-session-page__trigger { +.new-session-page__trigger[aria-expanded="true"] { color: var(--text); } @@ -94,6 +96,11 @@ openclaw-new-session-page { color: var(--muted); } +/* Keep the anchor focusable while Web Awesome finishes its hide animation. */ +.new-session-page__trigger--hiding { + pointer-events: none; +} + .new-session-page__runtime { color: var(--text); cursor: default; @@ -140,11 +147,11 @@ openclaw-new-session-page { /* Menu panel anchored under its trigger; item rows reuse the shared .session-menu__* styles so every menu in the app reads the same. */ -.new-session-page__menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 80; +wa-popover.new-session-page__select { + --max-width: min(320px, calc(100vw - 24px)); +} + +wa-popover.new-session-page__select::part(body) { min-width: 224px; max-width: min(320px, calc(100vw - 24px)); padding: 6px; @@ -194,13 +201,43 @@ openclaw-new-session-page { } /* The folder menu hosts the directory browser; wider than the list menus. */ -.new-session-page__menu--browser { +wa-popover.new-session-page__select--folder::part(body) { width: min(420px, calc(100vw - 24px)); max-width: none; padding: 0; overflow: hidden; } +.new-session-page__browser-menu { + width: 100%; +} + +.new-session-page__agent-select::part(form-control-label) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.new-session-page__agent-select::part(combobox) { + min-height: 0; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--muted); + box-shadow: none; +} + +.new-session-page__agent-select::part(display-input) { + font-size: 12px; + color: inherit; +} + .new-session-page__target-icon { display: inline-flex; color: var(--muted); @@ -355,9 +392,7 @@ openclaw-new-session-page { position: static; } - .new-session-page__menu { - right: 0; - left: 0; + wa-popover.new-session-page__select::part(body) { width: auto; max-width: none; } diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index 70f24504e5b7..d429535fdb22 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -231,7 +231,7 @@ cursor: pointer; } -.settings-row--toggle:has(input:disabled) { +.settings-row--toggle:has(wa-switch[disabled]) { cursor: not-allowed; } @@ -322,56 +322,20 @@ select.settings-select:not([multiple]) { cursor: pointer; } -.settings-toggle input { +.settings-control__sr-label { position: absolute; - opacity: 0; - pointer-events: none; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -.settings-toggle__track { - position: relative; - width: 38px; - height: 22px; - background: color-mix(in srgb, var(--bg) 70%, var(--border) 30%); - border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - border-radius: var(--radius-full); - transition: - background var(--duration-normal) var(--ease-out), - border-color var(--duration-normal) var(--ease-out); - flex-shrink: 0; -} - -.settings-toggle__track::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - background: var(--muted); - border-radius: 50%; - transition: - transform var(--duration-normal) var(--ease-spring), - background var(--duration-normal) var(--ease-out); -} - -.settings-toggle input:checked + .settings-toggle__track { - background: var(--accent); - border-color: var(--accent); -} - -.settings-toggle input:checked + .settings-toggle__track::after { - transform: translateX(16px); - background: #fff; -} - -.settings-toggle input:focus-visible + .settings-toggle__track { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); -} - -.settings-toggle input:disabled + .settings-toggle__track { - opacity: 0.45; - cursor: not-allowed; +wa-switch.settings-toggle { + flex: none; } .settings-segmented { @@ -385,6 +349,20 @@ select.settings-select:not([multiple]) { padding: 2px; } +wa-radio-group.settings-segmented::part(form-control-label) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +wa-radio-group.settings-segmented::part(radios) { + display: flex; + flex-wrap: wrap; + gap: 2px; +} + .settings-segmented__btn { font-size: var(--control-ui-text-sm); font-weight: 550; diff --git a/ui/src/styles/skill-workshop.css b/ui/src/styles/skill-workshop.css index 27f214a263c4..1964da3df968 100644 --- a/ui/src/styles/skill-workshop.css +++ b/ui/src/styles/skill-workshop.css @@ -935,19 +935,23 @@ } .sw-mode-switch { - position: relative; - display: inline-grid; - grid-template-columns: 1fr 1fr; + --track-width: 0; + --indicator-color: var(--accent); + display: inline-block; +} + +.sw-mode-switch::part(nav) { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 999px; padding: 3px; - gap: 0; } -.sw-mode-switch__opt { - position: relative; - z-index: 1; +.sw-mode-switch::part(body) { + display: none; +} + +.sw-mode-switch__opt::part(base) { display: inline-flex; align-items: center; gap: 6px; @@ -963,16 +967,16 @@ white-space: nowrap; } -.sw-mode-switch__opt:hover { +.sw-mode-switch__opt:hover::part(base) { color: var(--text); } -.sw-mode-switch__opt.is-active { +.sw-mode-switch__opt[active]::part(base) { color: var(--text-strong); font-weight: 600; } -.sw-mode-switch__opt:focus-visible { +.sw-mode-switch__opt:focus-visible::part(base) { outline: none; box-shadow: none; } @@ -988,25 +992,6 @@ flex-shrink: 0; } -.sw-mode-switch__indicator { - position: absolute; - top: 3px; - bottom: 3px; - left: 3px; - width: calc(50% - 3px); - background: var(--card); - border: 1px solid var(--border); - border-radius: 999px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); - transition: transform 260ms cubic-bezier(0.32, 0.72, 0.32, 1); - pointer-events: none; - z-index: 0; -} - -.sw-mode-switch[data-mode="today"] .sw-mode-switch__indicator { - transform: translateX(100%); -} - /* ── View pane fade-on-switch ───────────────────────────────────────── */ .sw-view { flex: 1 1 auto; @@ -1038,9 +1023,6 @@ .sw-view__pane { animation: none; } - .sw-mode-switch__indicator { - transition: none; - } } /* Progressive history review */ diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index 9e35f8baa3cf..cd59ed0265f3 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -531,7 +531,7 @@ gap: 6px; } -details.usage-filter-select, +.usage-filter-select, .usage-export-menu { position: relative; } @@ -546,7 +546,7 @@ details.usage-filter-select summary::-webkit-details-marker, display: none; } -.usage-filter-select summary { +.usage-filter-trigger { display: inline-flex; align-items: center; gap: 6px; @@ -564,16 +564,22 @@ details.usage-filter-select summary::-webkit-details-marker, background 0.18s var(--ease-out); } -.usage-filter-select summary:hover { +.usage-filter-trigger:hover { border-color: var(--border-strong); background: var(--bg-hover); } -.usage-filter-select[open] summary { +.usage-filter-select[open] .usage-filter-trigger { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent); } +.usage-filter-select::part(menu), +.usage-export-menu::part(menu) { + min-width: 220px; + max-width: min(320px, calc(100vw - 48px)); +} + .usage-filter-popover, .usage-export-popover { position: absolute; diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 5d538be2e4dc..f02218516aaa 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -267,24 +267,11 @@ accent-color: var(--accent); } -.workboard-modal { - position: fixed; - inset: 0; - z-index: 1000; - display: grid; - place-items: center; - padding: 28px; - overflow: hidden; - overscroll-behavior: contain; - background: color-mix(in srgb, #000 60%, transparent); - backdrop-filter: blur(8px); -} - .workboard-draft { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 14px; - width: min(1120px, calc(100vw - 56px)); + width: 100%; height: min(860px, calc(100vh - 56px)); min-height: 0; overflow: hidden; @@ -370,7 +357,7 @@ min-width: 0; } -.workboard-field span { +.workboard-field > span { color: var(--muted); font-size: 0.72rem; font-weight: 650; @@ -384,9 +371,9 @@ } .workboard-select { - position: relative; width: 100%; min-width: 0; + --wa-form-control-activated-color: var(--accent); } .workboard-select--toolbar { @@ -399,72 +386,29 @@ max-width: 236px; } -.workboard-select > summary { - list-style: none; +.workboard-select::part(form-control-label) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -.workboard-select > summary::-webkit-details-marker { - display: none; -} - -.workboard-select__trigger.input { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; +.workboard-select::part(combobox) { min-height: var(--workboard-control-height); width: 100%; - padding: 0 9px 0 10px; - user-select: none; + border-color: var(--border); + border-radius: 7px; + background: var(--bg-elevated); + color: var(--text); } -.workboard-select__value { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workboard-select__chevron, -.workboard-select__check { - display: inline-flex; - flex: 0 0 auto; -} - -.workboard-select__chevron, -.workboard-select__chevron svg { - width: 16px; - height: 16px; -} - -.workboard-select__check, -.workboard-select__check svg { - width: 18px; - height: 18px; -} - -.workboard-select[open] .workboard-select__trigger { - border-color: color-mix(in srgb, var(--accent) 70%, var(--border-strong)); - box-shadow: - inset 0 1px 0 color-mix(in srgb, white 5%, transparent), - 0 0 0 2px color-mix(in srgb, var(--accent) 16%, transparent); -} - -.workboard-select[open] .workboard-select__chevron { - transform: rotate(180deg); -} - -.workboard-select__menu { - position: fixed; - z-index: 1100; - top: var(--workboard-select-menu-top, 0); - left: var(--workboard-select-menu-left, 0); - display: none; - visibility: hidden; - gap: 2px; - width: var(--workboard-select-menu-width, 100%); - max-height: var(--workboard-select-menu-max-height, min(320px, calc(100vh - 220px))); - overflow: auto; +.workboard-select::part(listbox) { + max-height: min(320px, calc(100vh - 48px)); padding: 6px; border: 1px solid color-mix(in srgb, var(--border-strong) 86%, transparent); border-radius: 8px; @@ -474,44 +418,8 @@ inset 0 1px 0 color-mix(in srgb, white 5%, transparent); } -.workboard-select[open] .workboard-select__menu { - display: grid; -} - .workboard-select__option { - display: grid; - grid-template-columns: 20px minmax(0, 1fr); - gap: 8px; - align-items: start; - width: 100%; - min-height: 34px; - padding: 7px 8px; - border: 0; - border-radius: 6px; - background: transparent; color: var(--text); - font: inherit; - text-align: left; -} - -.workboard-select__option:hover, -.workboard-select__option:focus-visible { - background: color-mix(in srgb, var(--accent) 12%, transparent); - outline: none; -} - -.workboard-select__option.is-selected { - background: color-mix(in srgb, var(--accent) 17%, transparent); - color: var(--text); -} - -.workboard-select__option:disabled { - cursor: not-allowed; - opacity: 0.46; -} - -.workboard-select__option:disabled:hover { - background: transparent; } .workboard-select__label { @@ -538,11 +446,6 @@ white-space: normal; } -.workboard-select--disabled .workboard-select__trigger { - cursor: not-allowed; - opacity: 0.5; -} - .workboard-field--wide { grid-column: 1 / -1; } @@ -1336,13 +1239,10 @@ } .workboard-detail-drawer { - position: fixed; - inset: 0 0 0 auto; - z-index: 900; display: flex; justify-content: flex-end; - width: min(460px, 100vw); - pointer-events: none; + width: 100%; + height: 100%; } .workboard-detail { @@ -1353,7 +1253,6 @@ min-height: 0; padding: 16px; overflow: auto; - pointer-events: auto; border-left: 1px solid color-mix(in srgb, var(--border) 86%, transparent); background: color-mix(in srgb, var(--panel) 98%, var(--bg) 2%); box-shadow: var(--shadow-xl); @@ -1519,13 +1418,8 @@ } @media (max-width: 860px) { - .workboard-modal { - padding: 0; - place-items: stretch; - } - .workboard-draft { - width: 100vw; + width: 100%; height: 100dvh; max-height: none; border: 0; @@ -1562,8 +1456,8 @@ max-width: none; } - .workboard-select__menu { - max-height: min(260px, var(--workboard-select-menu-max-height, 260px)); + .workboard-select::part(listbox) { + max-height: min(260px, calc(100vh - 48px)); } .workboard-draft__meta { @@ -1608,8 +1502,7 @@ } .workboard-detail-drawer { - inset: 0; - width: 100vw; + width: 100%; height: 100dvh; max-height: none; } diff --git a/ui/src/styles/workspace.css b/ui/src/styles/workspace.css index 61bae8f8d437..59224ef3d2b7 100644 --- a/ui/src/styles/workspace.css +++ b/ui/src/styles/workspace.css @@ -28,15 +28,26 @@ /* --- Tab strip ----------------------------------------------------------- */ .workspace-tabs { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; + --track-width: 0; + --indicator-color: var(--accent); + display: block; border-bottom: 1px solid var(--border); padding-bottom: 8px; } -.workspace-tab { +.workspace-tabs::part(tabs) { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.workspace-tabs::part(body) { + display: none; +} + +.workspace-tab::part(base), +button.workspace-tab { display: inline-flex; align-items: center; gap: 6px; @@ -53,12 +64,13 @@ color 120ms var(--ease-out); } -.workspace-tab:hover { +.workspace-tab:hover::part(base), +button.workspace-tab:hover { background: var(--bg-hover); color: var(--text); } -.workspace-tab--active { +.workspace-tab[active]::part(base) { background: color-mix(in srgb, var(--accent) 16%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--text); @@ -78,14 +90,6 @@ margin-inline-start: auto; } -.workspace-tabs__hidden summary { - list-style: none; -} - -.workspace-tabs__hidden summary::-webkit-details-marker { - display: none; -} - .workspace-tab--overflow { color: var(--text-muted, var(--text)); /* #6 nit: keep the "Hidden (N)" affordance a compact single-line chip — the @@ -99,36 +103,18 @@ height: 15px; } -.workspace-tabs__hidden-menu { - position: absolute; - /* #18: keep a small gap from the viewport edge so the chip menu never kisses - the right border of the scroll container. */ - inset-inline-end: 4px; - margin-top: 4px; +.workspace-tabs__hidden::part(menu) { min-width: 160px; - display: flex; - flex-direction: column; padding: 4px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-md); - z-index: 20; } .workspace-tabs__hidden-item { - text-align: start; - padding: 6px 8px; - border: none; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); font: inherit; - cursor: pointer; -} - -.workspace-tabs__hidden-item:hover { - background: var(--bg-hover); } /* --- Grid ---------------------------------------------------------------- */ @@ -282,33 +268,21 @@ } .workspace-widget__menu { - position: absolute; - inset-inline-end: 6px; - top: 34px; + z-index: 30; +} + +.workspace-widget__menu::part(menu) { min-width: 160px; - display: flex; - flex-direction: column; padding: 4px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-md); - z-index: 30; } .workspace-widget__menu-item { text-align: start; - padding: 6px 8px; - border: none; - border-radius: var(--radius-sm); - background: transparent; color: var(--text); - font: inherit; - cursor: pointer; -} - -.workspace-widget__menu-item:hover { - background: var(--bg-hover); } .workspace-widget__menu-item--danger { diff --git a/ui/src/test-helpers/lit-warnings.setup.ts b/ui/src/test-helpers/lit-warnings.setup.ts index 6680350ef04c..e0babec91397 100644 --- a/ui/src/test-helpers/lit-warnings.setup.ts +++ b/ui/src/test-helpers/lit-warnings.setup.ts @@ -4,3 +4,76 @@ const issuedWarnings = ((globalThis as { litIssuedWarnings?: Set }).litI new Set()); issuedWarnings.add("dev-mode"); + +// Web Awesome resolves `for` targets while Lit content is still in a detached +// render root. The app renders into a connected root; JSDOM unit helpers do not. +const findElementById = (root: ParentNode, id: string) => + [...root.querySelectorAll("[id]")].find((element) => element.id === id) ?? null; + +if (typeof DocumentFragment !== "undefined" && !("getElementById" in DocumentFragment.prototype)) { + Object.defineProperty(DocumentFragment.prototype, "getElementById", { + configurable: true, + value(this: DocumentFragment, id: string) { + return findElementById(this, id); + }, + }); +} + +if (typeof Element !== "undefined" && !("getElementById" in Element.prototype)) { + Object.defineProperty(Element.prototype, "getElementById", { + configurable: true, + value(this: Element, id: string) { + return findElementById(this, id); + }, + }); +} + +// JSDOM has no Web Animations API. Web Awesome uses this probe to skip +// animations when none are active. +if (typeof Element !== "undefined" && !("getAnimations" in Element.prototype)) { + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); +} + +// JSDOM exposes partial ElementInternals. Web Awesome form controls require +// the form-associated methods even when tests do not mount them in a form. +if (typeof HTMLElement !== "undefined") { + Object.defineProperty(HTMLElement.prototype, "attachInternals", { + configurable: true, + value() { + const validity = { valid: true } as ValidityState; + return { + checkValidity: () => true, + form: null, + labels: null, + reportValidity: () => true, + setFormValue: () => {}, + setValidity: () => {}, + states: new Set(), + validationMessage: "", + validity, + willValidate: true, + }; + }, + }); +} + +if (typeof HTMLDialogElement !== "undefined" && !("showModal" in HTMLDialogElement.prototype)) { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + }); +} + +if (typeof HTMLDialogElement !== "undefined" && !("close" in HTMLDialogElement.prototype)) { + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value(this: HTMLDialogElement) { + this.removeAttribute("open"); + }, + }); +} diff --git a/ui/src/test-helpers/modal-dialog.ts b/ui/src/test-helpers/modal-dialog.ts index c30dd1189fc0..e5a36f2f412b 100644 --- a/ui/src/test-helpers/modal-dialog.ts +++ b/ui/src/test-helpers/modal-dialog.ts @@ -1,3 +1,4 @@ +import type WaDialog from "@awesome.me/webawesome/dist/components/dialog/dialog.js"; // Control UI test helper supports modal dialog setup. import { expect } from "vitest"; @@ -51,10 +52,18 @@ export async function getRenderedModalDialog(container: HTMLElement) { } await modal.updateComplete; await nextFrame(); - const dialog = modal.shadowRoot?.querySelector("dialog"); + const webAwesomeDialog = modal.shadowRoot?.querySelector("wa-dialog"); + expect(webAwesomeDialog).toBeInstanceOf(HTMLElement); + if (!webAwesomeDialog) { + throw new Error("Expected rendered Web Awesome dialog"); + } + await webAwesomeDialog.updateComplete; + await nextFrame(); + const dialog = webAwesomeDialog.shadowRoot?.querySelector("dialog"); expect(dialog).toBeInstanceOf(HTMLDialogElement); if (!(dialog instanceof HTMLDialogElement)) { throw new Error("Expected rendered dialog"); } - return { modal, dialog }; + await nextFrame(); + return { modal, webAwesomeDialog, dialog }; }