refactor(ui): use Web Awesome controls throughout Control UI (#106865)

* refactor(ui): add Web Awesome foundation

* refactor(ui): migrate shared menus and chat controls

* refactor(ui): migrate page controls to Web Awesome

* refactor(ui): migrate workboard controls to Web Awesome

* fix(ui): harden Web Awesome interactions

* test(ui): type Web Awesome dropdown state

* fix(ui): preserve menu focus and selection

* fix(ui): restore agent picker focus

* fix(ui): track keyboard menu dismissal

* fix(ui): ignore stale session menu closes

* fix(ui): complete Web Awesome keyboard migration

* fix(ui): repair Web Awesome lockfile graph

* docs(ui): keep release note in PR body

* fix(ui): satisfy Web Awesome migration gates

* perf(ui): keep Web Awesome theme in startup stylesheet

* fix(ui): replace reopened Web Awesome session menus

* fix(ui): keep replacement session menus open
This commit is contained in:
Peter Steinberger
2026-07-13 17:46:28 -07:00
committed by GitHub
parent cae29a0792
commit 8590655190
121 changed files with 5505 additions and 5787 deletions
+270 -12
View File
@@ -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
+3 -3
View File
@@ -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,
});
+1
View File
@@ -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",
+2
View File
@@ -74,6 +74,8 @@ function applyStartupPresentation(settings: ReturnType<typeof loadSettings>): 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);
+26
View File
@@ -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<HTMLElement & { updateComplete: Promise<boolean> }>(
"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();
+5 -2
View File
@@ -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);
+37 -93
View File
@@ -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<HTMLButtonElement>(".agent-select__trigger")?.click();
await element.updateComplete;
const listbox = element.querySelector('[role="listbox"]');
const dropdown = element.querySelector<HTMLElement & { open: boolean }>("wa-dropdown");
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>('.agent-select__option[role="option"]'),
element.querySelectorAll<HTMLElement & { checked: boolean; value: string }>(
"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<HTMLButtonElement>(".agent-select__trigger");
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await element.updateComplete;
const dropdown = element.querySelector<
HTMLElement & { open: boolean; updateComplete: Promise<boolean> }
>("wa-dropdown");
trigger?.click();
await dropdown?.updateComplete;
expect(dropdown?.open).toBe(true);
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>(".agent-select__trigger")?.click();
await element.updateComplete;
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>(".agent-select__trigger");
trigger?.click();
await element.updateComplete;
element.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>(".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();
}
+50 -206
View File
@@ -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<string, string>();
private readonly avatarRoutesPending = new Set<string>();
override disconnectedCallback() {
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
clearTimeout(this.typeaheadResetTimer);
this.releaseAvatarBlobUrls();
super.disconnectedCallback();
}
// Local /avatar/<id> routes require the bearer credential when gateway auth
// is active and <img> 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<string, string>();
private readonly avatarRoutesPending = new Set<string>();
protected override willUpdate(changed: PropertyValues<this>) {
// 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 <body>.
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 <select> type-ahead:
// repeating one letter cycles its matches, mixed letters accumulate a prefix.
private typeaheadQuery = "";
private typeaheadResetTimer: ReturnType<typeof setTimeout> | undefined;
private focusTypeaheadOption(key: string) {
clearTimeout(this.typeaheadResetTimer);
const normalizedKey = key.toLocaleLowerCase();
const accumulated = `${this.typeaheadQuery}${normalizedKey}`;
this.typeaheadQuery =
accumulated === normalizedKey.repeat(accumulated.length) ? normalizedKey : accumulated;
this.typeaheadResetTimer = setTimeout(() => {
this.typeaheadQuery = "";
}, 500);
const options = this.options();
const activeIndex = options.indexOf(document.activeElement as HTMLButtonElement);
const ordered = [...options.slice(activeIndex + 1), ...options.slice(0, activeIndex + 1)];
const match = ordered.find((option) =>
option
.querySelector(".agent-select__option-label")
?.textContent?.trim()
.toLocaleLowerCase()
.startsWith(this.typeaheadQuery),
);
match?.focus();
}
private options() {
return Array.from(this.querySelectorAll<HTMLButtonElement>(".agent-select__option"));
}
private trigger() {
return this.querySelector<HTMLButtonElement>(".agent-select__trigger");
}
private focusSelectedOption() {
const selected = this.querySelector<HTMLButtonElement>(
'.agent-select__option[aria-selected="true"]',
);
(selected ?? this.querySelector<HTMLButtonElement>(".agent-select__option"))?.focus();
}
private readonly toggle = () => {
if (this.disabled || this.agents.length === 0) {
return;
}
this.setOpen(!this.open);
};
private choose(agentId: string) {
this.setOpen(false);
this.trigger()?.focus();
if (agentId !== this.selectedId) {
this.onSelect(agentId);
}
}
private renderAvatar(agent: GatewayAgentRow) {
const identity = this.identityById[agent.id] ?? null;
const url = resolveAgentAvatarUrl(agent, identity);
@@ -252,24 +102,34 @@ export class AgentSelect extends OpenClawLightDomElement {
return null;
}
private readonly handleSelect = (event: WebAwesomeSelectEvent) => {
const item = event.detail.item as HTMLElement & { checked?: boolean; value?: string };
const agentId = item.value ?? item.getAttribute("value");
if (!agentId) {
return;
}
if (agentId === this.selectedId) {
event.preventDefault();
item.checked = true;
const dropdown = event.currentTarget as HTMLElement & { open: boolean };
dropdown.querySelector<HTMLElement>('[slot="trigger"]')?.focus({ preventScroll: true });
dropdown.open = false;
return;
}
this.onSelect(agentId);
};
override render() {
const selectedAgent =
this.agents.find((agent) => agent.id === this.selectedId) ??
this.agents.find((agent) => agent.id === this.defaultId) ??
this.agents[0];
const selectedBadge = selectedAgent ? agentBadgeText(selectedAgent.id, this.defaultId) : null;
const unavailable = this.disabled || this.agents.length === 0;
return html`
<div class="agent-select">
<button
type="button"
class="agent-select__trigger"
aria-haspopup="listbox"
aria-expanded=${String(this.open)}
?disabled=${this.disabled || this.agents.length === 0}
@click=${this.toggle}
@keydown=${this.handleTriggerKeydown}
>
<wa-dropdown class="agent-select" placement="bottom-start" @wa-select=${this.handleSelect}>
<button slot="trigger" type="button" class="agent-select__trigger" ?disabled=${unavailable}>
${selectedAgent
? html`
${this.renderAvatar(selectedAgent)}
@@ -281,42 +141,26 @@ export class AgentSelect extends OpenClawLightDomElement {
: html`<span class="agent-select__label">${t("agents.noAgents")}</span>`}
<span class="agent-select__chevron" aria-hidden="true">${icons.chevronDown}</span>
</button>
${this.open
? html`
<div
class="agent-select__list"
role="listbox"
aria-label=${t("agents.selectTitle")}
@keydown=${this.handleListboxKeydown}
>
${this.agents.map((agent) => {
const badge = agentBadgeText(agent.id, this.defaultId);
const selected = agent.id === this.selectedId;
return html`
<button
type="button"
class="agent-select__option"
role="option"
aria-selected=${String(selected)}
data-agent-id=${agent.id}
tabindex="-1"
@click=${() => this.choose(agent.id)}
>
${this.renderAvatar(agent)}
<span class="agent-select__option-label">${normalizeAgentLabel(agent)}</span>
${badge ? html`<span class="agent-select__badge">${badge}</span>` : nothing}
${selected
? html`<span class="agent-select__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
</button>
`;
})}
</div>
`
: nothing}
</div>
${this.agents.map((agent) => {
const badge = agentBadgeText(agent.id, this.defaultId);
const selected = agent.id === this.selectedId;
return html`
<wa-dropdown-item
class="agent-select__option"
data-agent-id=${agent.id}
.value=${agent.id}
type="checkbox"
.checked=${selected}
>
<span slot="icon">${this.renderAvatar(agent)}</span>
<span class="agent-select__option-label">${normalizeAgentLabel(agent)}</span>
${badge
? html`<span slot="details" class="agent-select__badge">${badge}</span>`
: nothing}
</wa-dropdown-item>
`;
})}
</wa-dropdown>
`;
}
}
+184 -165
View File
@@ -1,14 +1,21 @@
// Sidebar agent-chip menu (switcher, filter, utility rows), split out of
// app-sidebar.ts to keep that hot component inside the TS LOC ratchet.
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts";
import { openExternalUrlSafe } from "../lib/open-external-url.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import { icons, type IconName } from "./icons.ts";
import {
consumeDropdownKeyboardDismissal,
syncDropdownItemRadio,
trackDropdownKeyboardDismissal,
} from "./web-awesome.ts";
// External rows of the footer agent menu. Docs-first: public docs pages over
// raw GitHub, matching the ClawSweeper docs-link policy for user-facing copy.
@@ -29,6 +36,10 @@ const AGENT_MENU_LINKS: ReadonlyArray<{ href: string; icon: IconName; label: ()
/** Above this roster size the chip menu switches to pinned agents + filter. */
const QUICK_SWITCH_AGENT_LIMIT = 10;
const AGENT_VALUE_PREFIX = "agent:";
const NEW_SESSION_VALUE_PREFIX = "new-session:";
const COMMAND_VALUE_PREFIX = "command:";
const LINK_VALUE_PREFIX = "link:";
type AgentMenuAgent = { id: string; name?: string; identity?: { name?: string; emoji?: string } };
@@ -45,13 +56,12 @@ type SidebarAgentMenuParams = {
gatewayVersion: string | null;
themeMode: ThemeMode;
agentUnreadCount: (agentId: string) => number;
helpOpen: boolean;
onHelpOpenChange: (next: boolean) => void;
onFilterChange: (next: string) => void;
onSwitchAgent: (agentId: string) => void;
onAskCapabilities: (agentId: string) => void;
onOpenNewSession: (agentId: string) => void;
onClose: () => void;
onTabAway: () => void;
onClose: (restoreFocus?: boolean) => void;
onNavigate: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
onPairMobile: () => void;
};
@@ -117,89 +127,69 @@ function renderAgentRow(agent: AgentMenuAgent, params: SidebarAgentMenuParams) {
const unread = active ? 0 : params.agentUnreadCount(agentId);
const initial = resolveAgentTextAvatar(agent) ?? (label || agent.id).slice(0, 1).toUpperCase();
return html`
<div class="sidebar-agent-menu__agent-row">
<button
type="button"
class="sidebar-customize-menu__item sidebar-agent-menu__agent-switch"
role="menuitemradio"
tabindex="-1"
aria-checked=${String(active)}
@click=${() => params.onSwitchAgent(agentId)}
>
<span class="sidebar-agent-section__avatar" aria-hidden="true">${initial}</span>
<span class="sidebar-customize-menu__text">${label}</span>
${unread > 0
? html`<span
class="session-unread-dot"
role="img"
aria-label=${t("sessionsView.unread")}
></span>`
: nothing}
<span class="sidebar-customize-menu__check" aria-hidden="true">
${active ? icons.check : nothing}
</span>
</button>
<button
type="button"
class="sidebar-session-sort sidebar-agent-menu__new"
role="menuitem"
tabindex="-1"
title=${`${t("chat.runControls.newSession")}${label}`}
aria-label=${`${t("chat.runControls.newSession")}${label}`}
?disabled=${!params.connected}
@click=${() => {
params.onClose();
params.onOpenNewSession(agentId);
}}
>
${icons.plus}
</button>
</div>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-agent-menu__agent-switch"
value=${`${AGENT_VALUE_PREFIX}${encodeURIComponent(agentId)}`}
type="checkbox"
role="menuitemradio"
aria-checked=${String(active)}
${ref((element) => syncDropdownItemRadio(element, active))}
>
<span slot="icon" class="sidebar-agent-section__avatar" aria-hidden="true">${initial}</span>
<span class="sidebar-customize-menu__text">${label}</span>
${active
? html`<span slot="details" class="session-menu__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
${unread > 0
? html`<span
slot="details"
class="session-unread-dot"
role="img"
aria-label=${t("sessionsView.unread")}
></span>`
: nothing}
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-agent-menu__new"
value=${`${NEW_SESSION_VALUE_PREFIX}${encodeURIComponent(agentId)}`}
?disabled=${!params.connected}
>
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.plus}</span>
<span class="sidebar-customize-menu__text">
${t("chat.runControls.newSession")}${label}
</span>
</wa-dropdown-item>
`;
}
// Prefer a right flyout, flip left when the right edge is tight, and on
// viewports too narrow for two side-by-side menus overlay the parent
// instead — a flipped submenu would land offscreen there. Menus are
// shrink-to-fit (224-264px, localized labels vary), so measure the open
// parent and budget the flyout at its max width; underestimating picks a
// side placement whose flyout clips past the viewport.
function agentMenuHelpPlacement(position: { x: number }): "" | "--left" | "--overlay" {
const flyoutMaxWidth = 264;
const parentWidth =
document.querySelector(".sidebar-agent-menu")?.getBoundingClientRect().width ?? flyoutMaxWidth;
const fitsRight = position.x + parentWidth + 4 + flyoutMaxWidth <= window.innerWidth - 8;
const fitsLeft = position.x >= flyoutMaxWidth + 4 + 8;
return fitsRight ? "" : fitsLeft ? "--left" : "--overlay";
}
function renderAgentMenuHelpSubmenu(position: { x: number }, params: SidebarAgentMenuParams) {
const placement = agentMenuHelpPlacement(position);
function renderAgentMenuHelpSubmenu() {
return html`
<div
class="sidebar-customize-menu sidebar-customize-menu__submenu ${placement
? `sidebar-customize-menu__submenu${placement}`
: ""}"
role="menu"
aria-label=${t("agentChip.help")}
>
${AGENT_MENU_LINKS.map(
(link) => html`
${AGENT_MENU_LINKS.map(
(link) => html`
<wa-dropdown-item
slot="submenu"
class="sidebar-customize-menu__item"
value=${`${LINK_VALUE_PREFIX}${encodeURIComponent(link.href)}`}
@click=${(event: MouseEvent) => {
if (event.target instanceof Element && event.target.closest("a")) {
(event.currentTarget as HTMLElement).dataset.nativeNavigation = "true";
}
}}
>
<a
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
href=${link.href}
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
@click=${() => params.onClose()}
tabindex="-1"
>
<span class="nav-item__icon" aria-hidden="true">${icons[link.icon]}</span>
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons[link.icon]}</span>
<span class="sidebar-customize-menu__text">${link.label()}</span>
</a>
`,
)}
</div>
</wa-dropdown-item>
`,
)}
`;
}
@@ -212,12 +202,73 @@ export function renderSidebarAgentMenu(params: SidebarAgentMenuParams) {
const { rows, showFilter } = sidebarAgentMenuRows(params);
return html`
<openclaw-menu-surface>
<div
<wa-dropdown
class="sidebar-customize-menu sidebar-agent-menu"
role="menu"
.open=${true}
placement="top-start"
.distance=${0}
aria-label=${t("agentChip.menuLabel")}
style="left: ${position.x}px; bottom: ${position.bottom}px;"
@wa-select=${(event: CustomEvent<{ item: HTMLElement & { value?: string } }>) => {
event.preventDefault();
const item = event.detail.item;
if (item.dataset.nativeNavigation) {
delete item.dataset.nativeNavigation;
params.onClose(false);
return;
}
const value = item.value;
if (!value) {
return;
}
params.onClose(false);
if (value.startsWith(AGENT_VALUE_PREFIX)) {
params.onSwitchAgent(decodeURIComponent(value.slice(AGENT_VALUE_PREFIX.length)));
return;
}
if (value.startsWith(NEW_SESSION_VALUE_PREFIX)) {
params.onOpenNewSession(
decodeURIComponent(value.slice(NEW_SESSION_VALUE_PREFIX.length)),
);
return;
}
if (value.startsWith(LINK_VALUE_PREFIX)) {
openExternalUrlSafe(decodeURIComponent(value.slice(LINK_VALUE_PREFIX.length)));
return;
}
switch (value) {
case `${COMMAND_VALUE_PREFIX}capabilities`:
params.onAskCapabilities(activeId);
break;
case `${COMMAND_VALUE_PREFIX}agent-settings`:
params.onNavigate("agents", { search: `?agent=${encodeURIComponent(activeId)}` });
break;
case `${COMMAND_VALUE_PREFIX}settings`:
params.onNavigate("config");
break;
case `${COMMAND_VALUE_PREFIX}pair-mobile`:
params.onPairMobile();
break;
}
}}
@wa-after-show=${(event: Event) => {
if (showFilter) {
(event.currentTarget as HTMLElement)
.querySelector<HTMLInputElement>(".sidebar-agent-menu__filter input")
?.focus();
}
}}
@keydown=${(event: KeyboardEvent) =>
trackDropdownKeyboardDismissal(event, params.onTabAway)}
@wa-after-hide=${(event: Event) => params.onClose(consumeDropdownKeyboardDismissal(event))}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("agentChip.menuLabel")}
style="position: fixed; left: ${position.x}px; bottom: ${position.bottom}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${agents.length > 1
? html`
<div class="sidebar-customize-menu__title">${t("agentChip.agents")}</div>
@@ -231,115 +282,83 @@ export function renderSidebarAgentMenu(params: SidebarAgentMenuParams) {
aria-label=${t("agentChip.filterAgents")}
@input=${(event: Event) =>
params.onFilterChange((event.target as HTMLInputElement).value)}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const dropdown = (event.currentTarget as HTMLElement).closest(
"wa-dropdown",
);
const items = Array.from(dropdown?.children ?? []).filter(
(child): child is HTMLElement & { active: boolean } =>
child instanceof HTMLElement &&
child.localName === "wa-dropdown-item" &&
!child.hasAttribute("disabled"),
);
const target = event.key === "ArrowDown" ? items.at(0) : items.at(-1);
if (target) {
items.forEach((item) => (item.active = item === target));
target.focus({ preventScroll: true });
}
return;
}
// Keep editing keys out of Web Awesome's document-level
// menu handler; Escape still dismisses the whole menu.
if (event.key !== "Escape" && event.key !== "Tab") {
event.stopPropagation();
}
}}
/>
</div>
`
: nothing}
<div class="sidebar-agent-menu__list">
${rows.map((entry) => renderAgentRow(entry, params))}
${rows.length === 0
? html`<div class="sidebar-agent-menu__empty">
${t("agentChip.noAgentMatches")}
</div>`
: nothing}
</div>
${rows.map((entry) => renderAgentRow(entry, params))}
${rows.length === 0
? html`<div class="sidebar-agent-menu__empty">
${t("agentChip.noAgentMatches")}
</div>`
: nothing}
<div class="sidebar-customize-menu__separator" role="separator"></div>
`
: nothing}
<button
type="button"
<wa-dropdown-item
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
value="command:capabilities"
?disabled=${!params.connected}
@click=${() => params.onAskCapabilities(activeId)}
>
<span class="nav-item__icon" aria-hidden="true">${icons.bot}</span>
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.bot}</span>
<span class="sidebar-customize-menu__text">
${t("agentChip.whatCanAgentDo", { name: activeName })}
</span>
</button>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => {
params.onClose();
params.onNavigate("agents", { search: `?agent=${encodeURIComponent(activeId)}` });
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons.users}</span>
</wa-dropdown-item>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:agent-settings">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.users}</span>
<span class="sidebar-customize-menu__text">${t("agentChip.agentSettings")}</span>
</button>
</wa-dropdown-item>
<div class="sidebar-customize-menu__separator" role="separator"></div>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => {
params.onClose();
params.onNavigate("config");
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons.settings}</span>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:settings">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.settings}</span>
<span class="sidebar-customize-menu__text">${titleForRoute("config")}</span>
</button>
<button
type="button"
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-pair-mobile"
role="menuitem"
tabindex="-1"
value="command:pair-mobile"
?disabled=${!params.canPairDevice}
title=${params.canPairDevice ? nothing : t("nodes.pairing.adminRequired")}
@click=${() => {
params.onClose();
params.onPairMobile();
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons.smartphone}</span>
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.smartphone}</span>
<span class="sidebar-customize-menu__text">${t("nodes.pairing.button")}</span>
</button>
<div
class="sidebar-customize-menu__submenu-host"
@pointerenter=${(event: PointerEvent) => {
// Hover open/close is mouse-only: on touch/pen taps the
// enter/leave pair would race the click below. Overlay placement
// covers this trigger row, so hover-opening there would land the
// pending click on a flyout link instead.
if (event.pointerType === "mouse" && agentMenuHelpPlacement(position) !== "--overlay") {
params.onHelpOpenChange(true);
}
}}
@pointerleave=${(event: PointerEvent) => {
if (event.pointerType === "mouse") {
params.onHelpOpenChange(false);
}
}}
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-agent-menu__help"
value="command:help"
>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
aria-haspopup="menu"
aria-expanded=${String(params.helpOpen)}
@click=${() => {
// Open-only: with hover-open, a toggle would close the flyout
// on the very click that follows the opening pointerenter.
// Closing happens by leaving the host or closing the menu.
params.onHelpOpenChange(true);
}}
<span slot="icon" class="nav-item__icon" aria-hidden="true"
>${icons.circleQuestionMark}</span
>
<span class="nav-item__icon" aria-hidden="true">${icons.circleQuestionMark}</span>
<span class="sidebar-customize-menu__text">${t("agentChip.help")}</span>
<span class="sidebar-customize-menu__chevron" aria-hidden="true">
${icons.chevronRight}
</span>
</button>
${params.helpOpen ? renderAgentMenuHelpSubmenu(position, params) : nothing}
</div>
<span class="sidebar-customize-menu__text">${t("agentChip.help")}</span>
${renderAgentMenuHelpSubmenu()}
</wa-dropdown-item>
<div class="sidebar-customize-menu__separator" role="separator"></div>
<div class="sidebar-agent-menu__footer">
<openclaw-sidebar-build-chip
@@ -354,7 +373,7 @@ export function renderSidebarAgentMenu(params: SidebarAgentMenuParams) {
<openclaw-theme-mode-toggle .mode=${params.themeMode}></openclaw-theme-mode-toggle>
</span>
</div>
</div>
</wa-dropdown>
</openclaw-menu-surface>
`;
}
+102 -55
View File
@@ -16,6 +16,7 @@ import { pathForRoute } from "../app-route-paths.ts";
import { t } from "../i18n/index.ts";
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
import { icons, type IconName } from "./icons.ts";
import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from "./web-awesome.ts";
type SidebarMenuPosition = { x: number; y: number };
@@ -129,32 +130,34 @@ type SidebarMoreMenuParams = SidebarMenuNavigationHandlers & {
pluginTabs: readonly GatewayControlUiPluginTab[];
isRouteEnabled: (routeId: NavigationRouteId) => boolean;
onEditPinnedItems: () => void;
onTabAway: () => void;
onClose: (restoreFocus: boolean) => void;
};
function renderMoreMenuRoute(params: SidebarMoreMenuParams, routeId: SidebarNavRoute) {
const active = isSidebarRouteActive(params.activeRouteId, routeId);
return html`
<a
href=${pathForRoute(routeId, params.basePath)}
<wa-dropdown-item
value=${routeId}
class="sidebar-customize-menu__item ${active ? "sidebar-customize-menu__item--active" : ""}"
role="menuitem"
tabindex="-1"
aria-current=${active ? "page" : nothing}
@pointerenter=${(event: Event) => params.onPreloadRoute(routeId, event)}
@pointerleave=${params.onCancelPreload}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
(event.currentTarget as HTMLElement).dataset.nativeNavigation = "true";
return;
}
event.preventDefault();
params.onNavigateRoute(routeId);
}}
>
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="sidebar-customize-menu__text">${titleForRoute(routeId)}</span>
</a>
<a href=${pathForRoute(routeId, params.basePath)} tabindex="-1">
<span class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="sidebar-customize-menu__text">${titleForRoute(routeId)}</span>
</a>
</wa-dropdown-item>
`;
}
@@ -165,23 +168,23 @@ function renderMoreMenuPluginTab(params: SidebarMoreMenuParams, tab: GatewayCont
params.activeRouteId === "plugin" && params.activePluginTabId === pluginTabKey(ref);
const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle";
return html`
<a
href=${`${pathForRoute("plugin", params.basePath)}${search}`}
<wa-dropdown-item
value=${`plugin:${pluginTabKey(ref)}`}
class="sidebar-customize-menu__item ${active ? "sidebar-customize-menu__item--active" : ""}"
role="menuitem"
tabindex="-1"
aria-current=${active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
(event.currentTarget as HTMLElement).dataset.nativeNavigation = "true";
return;
}
event.preventDefault();
params.onNavigatePluginTab(search);
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
<span class="sidebar-customize-menu__text">${tab.label}</span>
</a>
<a href=${`${pathForRoute("plugin", params.basePath)}${search}`} tabindex="-1">
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
<span class="sidebar-customize-menu__text">${tab.label}</span>
</a>
</wa-dropdown-item>
`;
}
@@ -195,26 +198,58 @@ export function renderSidebarMoreMenu(params: SidebarMoreMenuParams) {
);
return html`
<openclaw-menu-surface>
<div
<wa-dropdown
class="sidebar-customize-menu sidebar-more-menu"
role="menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${t("nav.more")}
style="left: ${position.x}px; top: ${position.y}px;"
@wa-select=${(event: CustomEvent<{ item: HTMLElement & { value?: string } }>) => {
event.preventDefault();
const item = event.detail.item;
if (item.dataset.nativeNavigation) {
delete item.dataset.nativeNavigation;
return;
}
const value = item.value;
if (value === "customize") {
params.onEditPinnedItems();
return;
}
if (value?.startsWith("plugin:")) {
const tab = params.pluginTabs.find((candidate) => {
const ref = { pluginId: candidate.pluginId, id: candidate.id };
return `plugin:${pluginTabKey(ref)}` === value;
});
if (tab) {
params.onNavigatePluginTab(pluginTabSearch({ pluginId: tab.pluginId, id: tab.id }));
}
return;
}
if (value && moreRoutes.includes(value as SidebarNavRoute)) {
params.onNavigateRoute(value as SidebarNavRoute);
}
}}
@keydown=${(event: KeyboardEvent) =>
trackDropdownKeyboardDismissal(event, params.onTabAway)}
@wa-after-hide=${(event: Event) => params.onClose(consumeDropdownKeyboardDismissal(event))}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("nav.more")}
style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${moreRoutes.map((routeId) => renderMoreMenuRoute(params, routeId))}
${params.pluginTabs.map((tab) => renderMoreMenuPluginTab(params, tab))}
<div class="sidebar-customize-menu__separator" role="separator"></div>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => params.onEditPinnedItems()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.penLine}</span>
<wa-dropdown-item class="sidebar-customize-menu__item" value="customize">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.penLine}</span>
<span class="sidebar-customize-menu__text">${t("nav.customize")}</span>
</button>
</div>
</wa-dropdown-item>
</wa-dropdown>
</openclaw-menu-surface>
`;
}
@@ -225,6 +260,8 @@ type SidebarCustomizeMenuParams = {
isRouteEnabled: (routeId: NavigationRouteId) => boolean;
onToggleRoute: (routeId: SidebarNavRoute) => void;
onReset: () => void;
onTabAway: () => void;
onClose: (restoreFocus: boolean) => void;
};
export function renderSidebarCustomizeMenu(params: SidebarCustomizeMenuParams) {
@@ -234,46 +271,56 @@ export function renderSidebarCustomizeMenu(params: SidebarCustomizeMenuParams) {
}
return html`
<openclaw-menu-surface>
<div
<wa-dropdown
class="sidebar-customize-menu"
role="menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${t("nav.customize")}
style="left: ${position.x}px; top: ${position.y}px;"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
if (value === "reset") {
params.onReset();
} else if (value && SIDEBAR_NAV_ROUTES.includes(value as SidebarNavRoute)) {
params.onToggleRoute(value as SidebarNavRoute);
}
}}
@keydown=${(event: KeyboardEvent) =>
trackDropdownKeyboardDismissal(event, params.onTabAway)}
@wa-after-hide=${(event: Event) => params.onClose(consumeDropdownKeyboardDismissal(event))}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("nav.customize")}
style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<div class="sidebar-customize-menu__title">${t("nav.customize")}</div>
${SIDEBAR_NAV_ROUTES.filter((routeId) => params.isRouteEnabled(routeId)).map((routeId) => {
const pinned = params.pinnedRoutes.includes(routeId);
return html`
<button
type="button"
<wa-dropdown-item
class="sidebar-customize-menu__item"
role="menuitemcheckbox"
tabindex="-1"
aria-checked=${String(pinned)}
@click=${() => params.onToggleRoute(routeId)}
type="checkbox"
value=${routeId}
.checked=${pinned}
>
<span class="nav-item__icon" aria-hidden="true"
<span slot="icon" class="nav-item__icon" aria-hidden="true"
>${icons[navigationIconForRoute(routeId)]}</span
>
<span class="sidebar-customize-menu__text">${titleForRoute(routeId)}</span>
<span class="sidebar-customize-menu__check" aria-hidden="true">
${pinned ? icons.check : nothing}
</span>
</button>
</wa-dropdown-item>
`;
})}
<div class="sidebar-customize-menu__separator" role="separator"></div>
<button
type="button"
class="sidebar-customize-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => params.onReset()}
>
<span class="nav-item__icon" aria-hidden="true">${icons.refresh}</span>
<wa-dropdown-item class="sidebar-customize-menu__item" value="reset">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.refresh}</span>
<span class="sidebar-customize-menu__text">${t("nav.customizeReset")}</span>
</button>
</div>
</wa-dropdown-item>
</wa-dropdown>
</openclaw-menu-surface>
`;
}
+243 -39
View File
@@ -344,9 +344,9 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const rows = [
...(sidebar.querySelectorAll<HTMLButtonElement>(
'.sidebar-agent-menu [role="menuitemradio"]',
) ?? []),
...sidebar.querySelectorAll<HTMLElement>(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
];
rows.find((row) => row.textContent?.includes("Molty"))?.click();
// createSessionState stamps ascending updatedAt, so the last key is newest.
@@ -354,6 +354,46 @@ describe("AppSidebar agent chip", () => {
expect(onNavigate).toHaveBeenCalledWith("chat", { search: "?session=agent%3Amain%3Atask" });
});
it("keeps agent ids distinct from utility command values", async () => {
const gatewayHarness = createGatewayHarness({} as GatewayBrowserClient);
const setSessionKey = vi.fn();
(gatewayHarness.gateway as { setSessionKey: (key: string) => void }).setSessionKey =
setSessionKey;
const agents = {
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [{ id: "main" }, { id: "settings" }],
} as AgentsListResult;
const { sidebar } = await mountSidebar(
gatewayHarness.gateway,
createSessions("main", ["agent:main:main"]),
"panel",
agents,
);
const onNavigate = vi.fn();
sidebar.connected = true;
sidebar.onNavigate = onNavigate;
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const menu = sidebar.querySelector<HTMLElement>(".sidebar-agent-menu");
const settingsAgent = [
...(menu?.querySelectorAll<HTMLElement>('wa-dropdown-item[type="checkbox"]') ?? []),
].find((row) => row.textContent?.includes("settings"));
menu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: settingsAgent }, bubbles: true }),
);
await sidebar.updateComplete;
expect(setSessionKey).toHaveBeenCalledWith("agent:settings:main");
expect(onNavigate).toHaveBeenCalledWith("chat", {
search: "?session=agent%3Asettings%3Amain",
});
expect(onNavigate).not.toHaveBeenCalledWith("config");
});
it("shows offline in the chip subtitle when disconnected", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
@@ -467,17 +507,18 @@ describe("AppSidebar agent chip", () => {
expect(menu?.querySelector(".sidebar-pair-mobile")).not.toBeNull();
expect(menu?.querySelector("openclaw-sidebar-build-chip")).not.toBeNull();
expect(menu?.querySelector("openclaw-theme-mode-toggle")).not.toBeNull();
// External help links fold into the Help flyout; they only render open.
expect(menu?.querySelector('a[role="menuitem"]')).toBeNull();
const helpRow = [
...(menu?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
].find((row) => row.textContent?.includes("Help"));
// External help links stay folded into Web Awesome's keyboard-navigable submenu.
const helpRow = [...(menu?.querySelectorAll<HTMLElement>("wa-dropdown-item") ?? [])].find(
(row) => row.textContent?.includes("Help"),
);
await (helpRow as (HTMLElement & { updateComplete?: Promise<unknown> }) | undefined)
?.updateComplete;
expect(helpRow?.getAttribute("aria-haspopup")).toBe("menu");
helpRow?.click();
await sidebar.updateComplete;
const linkHrefs = [
...(menu?.querySelectorAll('.sidebar-customize-menu__submenu a[role="menuitem"]') ?? []),
...(menu?.querySelectorAll('wa-dropdown-item[slot="submenu"] a[href]') ?? []),
].map((link) => link.getAttribute("href"));
expect(linkHrefs).toEqual([
"https://docs.openclaw.ai",
@@ -485,38 +526,45 @@ describe("AppSidebar agent chip", () => {
"https://discord.gg/clawd",
"https://docs.openclaw.ai/releases",
]);
// Real mouse flow fires pointerenter before the click; the click must not
// invert the hover-opened state back to closed.
const helpHost = menu?.querySelector(".sidebar-customize-menu__submenu-host");
helpHost?.dispatchEvent(Object.assign(new Event("pointerenter"), { pointerType: "mouse" }));
await sidebar.updateComplete;
helpRow?.click();
await sidebar.updateComplete;
expect(menu?.querySelector(".sidebar-customize-menu__submenu")).not.toBeNull();
const agentRows = [...(menu?.querySelectorAll('[role="menuitemradio"]') ?? [])];
expect(agentRows).toHaveLength(2);
const researchAgentRow = [
...(menu?.querySelectorAll(".sidebar-agent-menu__agent-row") ?? []),
].find((row) => row.textContent?.includes("research"));
expect(researchAgentRow).toBeDefined();
const newSessionButton = researchAgentRow?.querySelector<HTMLButtonElement>(
".sidebar-agent-menu__new",
const openExternal = vi.spyOn(window, "open").mockReturnValue(null);
menu?.querySelector<HTMLElement>('wa-dropdown-item[slot="submenu"]')?.click();
expect(openExternal).toHaveBeenCalledWith(
"https://docs.openclaw.ai/",
"_blank",
"noopener,noreferrer",
);
openExternal.mockRestore();
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const reopenedMenu = sidebar.querySelector(".sidebar-agent-menu");
const agentRows = [
...(reopenedMenu?.querySelectorAll('wa-dropdown-item[type="checkbox"]') ?? []),
];
expect(agentRows).toHaveLength(2);
const newSessionItem = [
...(reopenedMenu?.querySelectorAll<HTMLElement>(".sidebar-agent-menu__new") ?? []),
].find((item) => item.textContent?.includes("research"));
expect(newSessionItem).toBeDefined();
reopenedMenu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: newSessionItem }, bubbles: true }),
);
expect(newSessionButton).toBeInstanceOf(HTMLButtonElement);
newSessionButton?.click();
await sidebar.updateComplete;
expect(onOpenNewSession).toHaveBeenCalledWith("research");
expect(sidebar.querySelector(".sidebar-agent-menu")).toBeNull();
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const researchRow = [...sidebar.querySelectorAll('[role="menuitemradio"]')].find((row) =>
row.textContent?.includes("research"),
);
const switchMenu = sidebar.querySelector<HTMLElement>(".sidebar-agent-menu");
const researchRow = [
...(switchMenu?.querySelectorAll<HTMLElement>('wa-dropdown-item[type="checkbox"]') ?? []),
].find((row) => row.textContent?.includes("research"));
expect(researchRow).toBeDefined();
(researchRow as HTMLButtonElement).click();
switchMenu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: researchRow }, bubbles: true }),
);
await sidebar.updateComplete;
// No cached sessions for the other agent: resume falls back to its main key.
@@ -543,7 +591,7 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const settingsRow = [
...sidebar.querySelectorAll<HTMLButtonElement>('.sidebar-agent-menu [role="menuitem"]'),
...sidebar.querySelectorAll<HTMLElement>(".sidebar-agent-menu wa-dropdown-item"),
].find((row) => row.textContent?.includes("Agent settings"));
expect(settingsRow).toBeDefined();
settingsRow?.click();
@@ -574,7 +622,11 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-agent-menu__filter")).toBeNull();
expect(sidebar.querySelectorAll('.sidebar-agent-menu [role="menuitemradio"]')).toHaveLength(10);
expect(
sidebar.querySelectorAll(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
).toHaveLength(10);
});
it("shows pinned agents plus filter for large rosters and filters on input", async () => {
@@ -599,7 +651,7 @@ describe("AppSidebar agent chip", () => {
const labels = () =>
[
...sidebar.querySelectorAll(
'.sidebar-agent-menu [role="menuitemradio"] .sidebar-customize-menu__text',
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch .sidebar-customize-menu__text",
),
].map((el) => el.textContent?.trim());
expect(labels()).toEqual(["agent-7", "agent-12", "agent-1"]);
@@ -607,6 +659,24 @@ describe("AppSidebar agent chip", () => {
if (!input) {
throw new Error("Expected agent menu filter input");
}
const dropdown = sidebar.querySelector("wa-dropdown");
const onDropdownKeydown = vi.fn();
dropdown?.addEventListener("keydown", onDropdownKeydown);
input.focus();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(onDropdownKeydown).not.toHaveBeenCalled();
expect(document.activeElement).toBe(
sidebar.querySelector(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
);
onDropdownKeydown.mockClear();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
expect(onDropdownKeydown).toHaveBeenCalledOnce();
onDropdownKeydown.mockClear();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
expect(onDropdownKeydown).not.toHaveBeenCalled();
input.value = "agent-11";
input.dispatchEvent(new Event("input", { bubbles: true }));
await sidebar.updateComplete;
@@ -627,7 +697,11 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-agent-menu__filter")).not.toBeNull();
expect(sidebar.querySelectorAll('.sidebar-agent-menu [role="menuitemradio"]')).toHaveLength(10);
expect(
sidebar.querySelectorAll(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
).toHaveLength(10);
});
it("ignores stale pins when choosing the large-roster fallback", async () => {
@@ -644,7 +718,11 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
expect(sidebar.querySelectorAll('.sidebar-agent-menu [role="menuitemradio"]')).toHaveLength(10);
expect(
sidebar.querySelectorAll(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
).toHaveLength(10);
});
it("keeps an active agent outside the first ten reachable when nothing is pinned", async () => {
@@ -663,7 +741,11 @@ describe("AppSidebar agent chip", () => {
sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main")?.click();
await sidebar.updateComplete;
const rows = [...sidebar.querySelectorAll('.sidebar-agent-menu [role="menuitemradio"]')];
const rows = [
...sidebar.querySelectorAll(
".sidebar-agent-menu wa-dropdown-item.sidebar-agent-menu__agent-switch",
),
];
expect(rows).toHaveLength(10);
expect(rows.some((row) => row.textContent?.includes("agent-12"))).toBe(true);
});
@@ -2336,6 +2418,128 @@ describe("AppSidebar transient menus", () => {
expect(menu).not.toBeNull();
expect(menu?.closest("openclaw-menu-surface")).not.toBeNull();
});
it("ignores a stale sort-menu hide after opening its replacement", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const trigger = sidebar.querySelector<HTMLButtonElement>(".sidebar-session-sort");
if (!trigger) {
throw new Error("expected sort menu trigger");
}
trigger.click();
await sidebar.updateComplete;
const firstMenu = sidebar.querySelector<HTMLElement>(".sidebar-session-sort-menu");
expect(firstMenu).not.toBeNull();
firstMenu?.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: "sort:created" } },
}),
);
await sidebar.updateComplete;
trigger.click();
await sidebar.updateComplete;
const replacement = sidebar.querySelector<HTMLElement>(".sidebar-session-sort-menu");
expect(replacement).not.toBe(firstMenu);
firstMenu?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-session-sort-menu")).toBe(replacement);
});
it("ignores a stale agent-menu hide after opening its replacement", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const trigger = sidebar.querySelector<HTMLButtonElement>(".sidebar-agent-chip__main");
if (!trigger) {
throw new Error("expected agent menu trigger");
}
trigger.click();
await sidebar.updateComplete;
const firstMenu = sidebar.querySelector<HTMLElement>(".sidebar-agent-menu");
const settingsItem = firstMenu?.querySelector<HTMLElement>(
'wa-dropdown-item[value="command:settings"]',
);
expect(firstMenu).not.toBeNull();
expect(settingsItem).not.toBeNull();
firstMenu?.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: settingsItem },
}),
);
await sidebar.updateComplete;
trigger.click();
await sidebar.updateComplete;
const replacement = sidebar.querySelector<HTMLElement>(".sidebar-agent-menu");
expect(replacement).not.toBe(firstMenu);
firstMenu?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-agent-menu")).toBe(replacement);
});
it("ignores a stale More-menu hide after opening its replacement", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const trigger = sidebar.querySelector<HTMLButtonElement>("button.nav-item--action");
if (!trigger) {
throw new Error("expected More menu trigger");
}
trigger.click();
await sidebar.updateComplete;
const firstMenu = sidebar.querySelector<HTMLElement>(".sidebar-more-menu");
expect(firstMenu).not.toBeNull();
trigger.click();
await sidebar.updateComplete;
trigger.click();
await sidebar.updateComplete;
const replacement = sidebar.querySelector<HTMLElement>(".sidebar-more-menu");
expect(replacement).not.toBe(firstMenu);
firstMenu?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-more-menu")).toBe(replacement);
});
it("ignores a stale Customize-menu hide after opening its replacement", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) {
throw new Error("expected sidebar navigation");
}
nav.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }),
);
await sidebar.updateComplete;
const firstMenu = sidebar.querySelector<HTMLElement>(".sidebar-customize-menu");
expect(firstMenu).not.toBeNull();
firstMenu?.dispatchEvent(
new CustomEvent("wa-select", {
bubbles: true,
detail: { item: { value: "reset" } },
}),
);
await sidebar.updateComplete;
nav.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 24, clientY: 24 }),
);
await sidebar.updateComplete;
const replacement = sidebar.querySelector<HTMLElement>(".sidebar-customize-menu");
expect(replacement).not.toBe(firstMenu);
firstMenu?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-customize-menu")).toBe(replacement);
});
});
describe("AppSidebar custom group reordering", () => {
+282 -332
View File
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { ref } from "lit/directives/ref.js";
import type {
SessionCatalog,
SessionsCatalogListResult,
@@ -33,8 +34,8 @@ import "./sidebar-attention.ts";
import "./sidebar-build-chip.ts";
import "./sidebar-update-card.ts";
import "./theme-mode-toggle.ts";
import "./tooltip.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import "./tooltip.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { editorOpenUrl } from "../lib/editor-links.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
@@ -120,6 +121,8 @@ import {
} from "./lobster-pet.ts";
import { fetchSessionMenuWork } from "./session-menu-work.ts";
import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts";
import { syncDropdownItemRadio } from "./web-awesome.ts";
import { consumeDropdownKeyboardDismissal, trackDropdownKeyboardDismissal } from "./web-awesome.ts";
type SidebarRecentSession = {
key: string;
@@ -295,7 +298,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
// Anchored by its bottom edge so the footer menu grows upward regardless of height.
@state() private agentMenuPosition: { x: number; bottom: number } | null = null;
@state() private agentMenuFilter = "";
@state() private agentMenuHelpOpen = false;
@state() private visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
@state() private sessionsResult: SessionsListResult | null = null;
@state() private sessionsAgentId: string | null = null;
@@ -1377,19 +1379,12 @@ class AppSidebar extends OpenClawLightDomContentsElement {
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
};
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".sidebar-customize-menu__item")?.focus();
});
}
private closeCustomizeMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.customizeMenuTrigger;
this.customizeMenuTrigger = null;
this.customizeMenuPosition = null;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
if (options.restoreFocus) {
trigger?.focus();
}
@@ -1414,19 +1409,12 @@ class AppSidebar extends OpenClawLightDomContentsElement {
x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuMaxHeight - 8)),
};
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".sidebar-more-menu .sidebar-customize-menu__item")?.focus();
});
}
private closeMoreMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.moreMenuTrigger;
this.moreMenuTrigger = null;
this.moreMenuPosition = null;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
if (options.restoreFocus) {
trigger?.focus();
}
@@ -1512,19 +1500,12 @@ class AppSidebar extends OpenClawLightDomContentsElement {
x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)),
};
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".sidebar-session-group-menu .session-menu__item")?.focus();
});
}
private closeSessionGroupMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.sessionGroupMenuTrigger;
this.sessionGroupMenuTrigger = null;
this.sessionGroupMenu = null;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
if (options.restoreFocus) {
trigger?.focus();
}
@@ -1548,19 +1529,12 @@ class AppSidebar extends OpenClawLightDomContentsElement {
x: Math.max(8, Math.min(rect.right, window.innerWidth - menuWidth - 8)),
y: Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuMaxHeight - 8)),
};
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
void this.updateComplete.then(() => {
this.querySelector<HTMLElement>(".sidebar-session-sort-menu__item")?.focus();
});
}
private closeSessionSortMenu(options: { restoreFocus?: boolean } = {}) {
const trigger = this.sessionSortMenuTrigger;
this.sessionSortMenuTrigger = null;
this.sessionSortMenuPosition = null;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
if (options.restoreFocus) {
trigger?.focus();
}
@@ -1585,14 +1559,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
x: Math.max(8, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8)),
bottom: Math.max(8, window.innerHeight - rect.top + 4),
};
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
void this.updateComplete.then(() => {
// Large rosters open on the filter so typing narrows immediately.
this.querySelector<HTMLElement>(
".sidebar-agent-menu__filter input, .sidebar-agent-menu .sidebar-customize-menu__item",
)?.focus();
});
}
private closeAgentMenu(options: { restoreFocus?: boolean } = {}) {
@@ -1600,9 +1566,6 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.agentMenuTrigger = null;
this.agentMenuPosition = null;
this.agentMenuFilter = "";
this.agentMenuHelpOpen = false;
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
if (options.restoreFocus) {
trigger?.focus();
}
@@ -2053,101 +2016,20 @@ class AppSidebar extends OpenClawLightDomContentsElement {
}
}
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
if (this.sessionSortMenuTrigger && path.includes(this.sessionSortMenuTrigger)) {
return;
}
if (this.agentMenuTrigger && path.includes(this.agentMenuTrigger)) {
return;
}
if (this.moreMenuTrigger && path.includes(this.moreMenuTrigger)) {
return;
}
const menu = this.querySelector(
".sidebar-customize-menu, .sidebar-session-group-menu, .sidebar-session-sort-menu, .sidebar-agent-menu",
);
if (menu && path.includes(menu)) {
return;
}
this.closeCustomizeMenu();
this.closeMoreMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.closeAgentMenu();
};
// Registered only while one of the transient menus is open. Keeping this
// listener lifecycle-bound prevents menu shortcuts from consuming page keys.
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.closeCustomizeMenu({ restoreFocus: true });
this.closeMoreMenu({ restoreFocus: true });
this.closeSessionGroupMenu({ restoreFocus: true });
this.closeSessionSortMenu({ restoreFocus: true });
this.closeAgentMenu({ restoreFocus: true });
return;
}
if (event.key === "Tab") {
// Menu items stay outside the page tab order. Restore the durable trigger
// before the browser performs its normal forward/backward Tab movement.
this.closeCustomizeMenu({ restoreFocus: true });
this.closeMoreMenu({ restoreFocus: true });
this.closeSessionGroupMenu({ restoreFocus: true });
this.closeSessionSortMenu({ restoreFocus: true });
this.closeAgentMenu({ restoreFocus: true });
return;
}
this.moveTransientMenuFocus(event);
};
private moveTransientMenuFocus(event: KeyboardEvent) {
const menu = this.querySelector<HTMLElement>(
".sidebar-customize-menu, .sidebar-session-group-menu, .sidebar-session-sort-menu, .sidebar-agent-menu",
);
if (!menu) {
return;
}
const items = Array.from(
menu.querySelectorAll<HTMLElement>(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
),
).filter((item) => !item.matches(":disabled"));
if (items.length === 0) {
return;
}
// Typing in a menu filter keeps caret keys; arrows still enter the list.
if (
(event.target as HTMLElement | null)?.tagName === "INPUT" &&
(event.key === "Home" || event.key === "End")
) {
return;
}
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
let nextIndex: number;
if (event.key === "ArrowDown") {
nextIndex = (activeIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else {
return;
}
event.preventDefault();
event.stopPropagation();
items[nextIndex]?.focus();
}
private renderCustomizeMenu() {
const position = this.customizeMenuPosition;
const trigger = this.customizeMenuTrigger;
return renderSidebarCustomizeMenu({
position: this.customizeMenuPosition,
position,
pinnedRoutes: this.sidebarPinnedRoutes,
isRouteEnabled: (routeId) => this.isRouteEnabled(routeId),
onTabAway: () => trigger?.focus(),
onClose: (restoreFocus) => {
if (this.customizeMenuPosition !== position) {
return;
}
this.closeCustomizeMenu({ restoreFocus });
},
onToggleRoute: (routeId) => {
const pinned = this.sidebarPinnedRoutes;
const next = pinned.includes(routeId)
@@ -2163,9 +2045,11 @@ class AppSidebar extends OpenClawLightDomContentsElement {
}
private renderAgentMenu() {
const position = this.agentMenuPosition;
const trigger = this.agentMenuTrigger;
const { activeId, agent, agents } = this.activeChipAgent();
return renderSidebarAgentMenu({
position: this.agentMenuPosition,
position,
activeId,
activeName: agent ? normalizeAgentLabel(agent) : activeId,
agents,
@@ -2177,17 +2061,21 @@ class AppSidebar extends OpenClawLightDomContentsElement {
gatewayVersion: this.gatewayVersion,
themeMode: this.themeMode,
agentUnreadCount: (agentId) => this.agentUnreadCount(agentId),
helpOpen: this.agentMenuHelpOpen,
onHelpOpenChange: (next) => {
this.agentMenuHelpOpen = next;
},
onFilterChange: (next) => {
this.agentMenuFilter = next;
},
onSwitchAgent: (agentId) => this.switchChipAgent(agentId),
onAskCapabilities: (agentId) => this.askAgentCapabilities(agentId),
onOpenNewSession: (agentId) => this.onOpenNewSession?.(agentId),
onClose: () => this.closeAgentMenu(),
onTabAway: () => trigger?.focus(),
onClose: (restoreFocus) => {
// An old Web Awesome menu can finish hiding after its replacement opens.
// Only the render that owns the current position may close it.
if (this.agentMenuPosition !== position) {
return;
}
this.closeAgentMenu({ restoreFocus });
},
onNavigate: (routeId, options) => this.onNavigate?.(routeId, options),
onPairMobile: () => this.onPairMobile?.(),
});
@@ -2217,140 +2105,169 @@ class AppSidebar extends OpenClawLightDomContentsElement {
)
? (rows[0]?.category ?? null)
: null;
return html`
<openclaw-session-menu
.session=${{
key: session.key,
label: session.label,
pinned: session.pinned,
unread: batchRows ? allUnread : session.unread,
archived: false,
category: batchRows ? sharedCategory : (session.category ?? null),
}}
.selectionCount=${rows.length}
.x=${menu.x}
.y=${menu.y}
.trigger=${this.sessionMenuTrigger}
.disabled=${!this.connected}
.forkDisabled=${this.sessionsLoading || session.modelSelectionLocked}
.archiveAllowed=${archiveAllowed}
.groups=${this.knownSessionGroups()}
.canOpenChat=${true}
.work=${batchRows ? null : this.sessionMenuWork}
.workboard=${null}
.onClose=${() => this.closeSessionMenu()}
.onAction=${(action: SessionMenuAction) => {
if (batchRows) {
this.runBatchSessionAction(action, batchRows, allUnread);
return;
}
switch (action.kind) {
case "open-chat":
this.selectSession(session.key);
break;
case "open-pr":
window.open(action.url, "_blank", "noopener");
break;
case "open-in":
// A custom-scheme window hands off to the OS without navigating this page.
window.open(editorOpenUrl(action.editor, action.path));
break;
case "toggle-pin":
void this.patchSession(session, { pinned: !session.pinned });
break;
case "toggle-unread":
void this.patchSession(session, { unread: !session.unread });
break;
case "rename":
this.renameSession(session);
break;
case "fork":
void this.forkSession(session);
break;
case "workboard":
break;
case "move-to-group":
if (action.category === null || session.category !== action.category) {
this.assignSessionCategory(session, action.category);
}
break;
case "new-group":
this.createSessionGroup([session]);
break;
case "toggle-archived":
void this.patchSession(session, { archived: true });
break;
case "delete":
void this.deleteSession(session);
break;
}
}}
></openclaw-session-menu>
`;
return keyed(
menu,
html`
<openclaw-session-menu
.session=${{
label: session.label,
pinned: session.pinned,
unread: batchRows ? allUnread : session.unread,
archived: false,
category: batchRows ? sharedCategory : (session.category ?? null),
}}
.selectionCount=${rows.length}
.anchor=${menu}
.trigger=${this.sessionMenuTrigger}
.disabled=${!this.connected}
.forkDisabled=${this.sessionsLoading || session.modelSelectionLocked}
.archiveAllowed=${archiveAllowed}
.groups=${this.knownSessionGroups()}
.canOpenChat=${true}
.work=${batchRows ? null : this.sessionMenuWork}
.workboard=${null}
.onClose=${() => {
// A removed Web Awesome menu can finish hiding after its replacement
// opens. Ignore that stale close or the new menu disappears too.
if (this.sessionMenu === menu) {
this.closeSessionMenu();
}
}}
.onAction=${(action: SessionMenuAction) => {
if (batchRows) {
this.runBatchSessionAction(action, batchRows, allUnread);
return;
}
switch (action.kind) {
case "open-chat":
this.selectSession(session.key);
break;
case "open-pr":
window.open(action.url, "_blank", "noopener");
break;
case "open-in":
// A custom-scheme window hands off to the OS without navigating this page.
window.open(editorOpenUrl(action.editor, action.path));
break;
case "toggle-pin":
void this.patchSession(session, { pinned: !session.pinned });
break;
case "toggle-unread":
void this.patchSession(session, { unread: !session.unread });
break;
case "rename":
this.renameSession(session);
break;
case "fork":
void this.forkSession(session);
break;
case "workboard":
break;
case "move-to-group":
if (action.category === null || session.category !== action.category) {
this.assignSessionCategory(session, action.category);
}
break;
case "new-group":
this.createSessionGroup([session]);
break;
case "toggle-archived":
void this.patchSession(session, { archived: true });
break;
case "delete":
void this.deleteSession(session);
break;
}
}}
></openclaw-session-menu>
`,
);
}
private renderSessionGroupMenu() {
const menu = this.sessionGroupMenu;
const trigger = this.sessionGroupMenuTrigger;
if (!menu) {
return nothing;
}
return html`
<openclaw-menu-surface>
<div
class="session-menu sidebar-session-group-menu"
role="menu"
aria-label=${t("sessionsView.groupMenu", { group: menu.group })}
style="left: ${menu.x}px; top: ${menu.y}px;"
>
<button
type="button"
class="session-menu__item"
role="menuitem"
tabindex="-1"
?disabled=${!this.connected}
@click=${() => {
this.closeSessionGroupMenu();
this.renameSessionGroupFromMenu(menu.group);
return keyed(
menu,
html`
<openclaw-menu-surface>
<wa-dropdown
class="session-menu sidebar-session-group-menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${t("sessionsView.groupMenu", { group: menu.group })}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
this.closeSessionGroupMenu({ restoreFocus: true });
switch (value) {
case "rename-group":
this.renameSessionGroupFromMenu(menu.group);
break;
case "new-group":
this.createSessionGroup();
break;
case "delete-group":
this.deleteSessionGroupFromMenu(menu.group);
break;
case undefined:
break;
}
}}
@keydown=${(event: KeyboardEvent) =>
trackDropdownKeyboardDismissal(event, () => trigger?.focus())}
@wa-after-hide=${(event: Event) => {
if (this.sessionGroupMenu && this.sessionGroupMenu !== menu) {
return;
}
this.closeSessionGroupMenu({
restoreFocus: consumeDropdownKeyboardDismissal(event),
});
}}
>
<span class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span class="session-menu__text">${t("sessionsView.renameGroupMenu")}</span>
</button>
<button
type="button"
class="session-menu__item"
role="menuitem"
tabindex="-1"
@click=${() => {
this.closeSessionGroupMenu();
this.createSessionGroup();
}}
>
<span class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text">${t("sessionsView.newGroup")}</span>
</button>
<div class="session-menu__separator" role="separator"></div>
<button
type="button"
class="session-menu__item session-menu__item--destructive"
role="menuitem"
tabindex="-1"
?disabled=${!this.connected}
@click=${() => {
this.closeSessionGroupMenu();
this.deleteSessionGroupFromMenu(menu.group);
}}
>
<span class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span class="session-menu__text">${t("sessionsView.deleteGroupMenu")}</span>
</button>
</div>
</openclaw-menu-surface>
`;
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("sessionsView.groupMenu", { group: menu.group })}
style="position: fixed; left: ${menu.x}px; top: ${menu.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<wa-dropdown-item
class="session-menu__item"
value="rename-group"
?disabled=${!this.connected}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span class="session-menu__text">${t("sessionsView.renameGroupMenu")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="session-menu__item" value="new-group">
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text">${t("sessionsView.newGroup")}</span>
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item
class="session-menu__item session-menu__item--destructive"
value="delete-group"
variant="danger"
?disabled=${!this.connected}
>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span class="session-menu__text">${t("sessionsView.deleteGroupMenu")}</span>
</wa-dropdown-item>
</wa-dropdown>
</openclaw-menu-surface>
`,
);
}
private renderSessionSortMenu() {
const position = this.sessionSortMenuPosition;
const trigger = this.sessionSortMenuTrigger;
if (!position) {
return nothing;
}
@@ -2358,77 +2275,101 @@ class AppSidebar extends OpenClawLightDomContentsElement {
{ grouping: "category", label: t("sessionsView.groupByCategory") },
{ grouping: "none", label: t("sessionsView.groupByNone") },
] as const satisfies ReadonlyArray<{ grouping: SidebarSessionsGrouping; label: string }>;
return html`
<openclaw-menu-surface>
<div
class="sidebar-session-sort-menu"
role="menu"
aria-label=${t("chat.sidebar.sortSessions")}
style="left: ${position.x}px; top: ${position.y}px;"
>
<div class="sidebar-session-sort-menu__title">${t("sessionsView.groupBy")}</div>
${groupingOptions.map(
(option) => html`
<button
type="button"
class="sidebar-session-sort-menu__item"
role="menuitemradio"
tabindex="-1"
aria-checked=${String(this.sessionsGrouping === option.grouping)}
@click=${() => {
this.setSessionsGrouping(option.grouping);
this.closeSessionSortMenu({ restoreFocus: true });
}}
>
<span class="session-menu__check" aria-hidden="true">
${this.sessionsGrouping === option.grouping ? icons.check : nothing}
</span>
<span class="session-menu__text">${option.label}</span>
</button>
`,
)}
<div class="session-menu__separator" role="separator"></div>
<div class="sidebar-session-sort-menu__title">${t("chat.sidebar.sortBy")}</div>
${SIDEBAR_SESSION_SORT_OPTIONS.map(
(option) => html`
<button
type="button"
class="sidebar-session-sort-menu__item"
role="menuitemradio"
tabindex="-1"
aria-checked=${String(this.sessionSortMode === option.mode)}
@click=${() => {
this.sessionSortMode = option.mode;
this.closeSessionSortMenu({ restoreFocus: true });
}}
>
<span class="session-menu__check" aria-hidden="true">
${this.sessionSortMode === option.mode ? icons.check : nothing}
</span>
<span class="session-menu__text">${t(option.labelKey)}</span>
</button>
`,
)}
<div class="session-menu__separator" role="separator"></div>
<button
type="button"
class="sidebar-session-sort-menu__item"
role="menuitemcheckbox"
tabindex="-1"
aria-checked=${String(this.sessionsShowCron)}
@click=${() => {
this.setSessionsShowCron(!this.sessionsShowCron);
return keyed(
position,
html`
<openclaw-menu-surface>
<wa-dropdown
class="sidebar-session-sort-menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${t("chat.sidebar.sortSessions")}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
if (value?.startsWith("grouping:")) {
this.setSessionsGrouping(
value.slice("grouping:".length) as SidebarSessionsGrouping,
);
} else if (value?.startsWith("sort:")) {
this.sessionSortMode = value.slice("sort:".length) as SidebarSessionSortMode;
} else if (value === "show-cron") {
this.setSessionsShowCron(!this.sessionsShowCron);
}
this.closeSessionSortMenu({ restoreFocus: true });
}}
@keydown=${(event: KeyboardEvent) =>
trackDropdownKeyboardDismissal(event, () => trigger?.focus())}
@wa-after-hide=${(event: Event) => {
if (this.sessionSortMenuPosition !== position) {
return;
}
this.closeSessionSortMenu({
restoreFocus: consumeDropdownKeyboardDismissal(event),
});
}}
>
<span class="session-menu__check" aria-hidden="true">
${this.sessionsShowCron ? icons.check : nothing}
</span>
<span class="session-menu__text">${t("sessionsView.showCronSessions")}</span>
</button>
</div>
</openclaw-menu-surface>
`;
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("chat.sidebar.sortSessions")}
style="position: fixed; left: ${position.x}px; top: ${position.y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<div class="sidebar-session-sort-menu__title">${t("sessionsView.groupBy")}</div>
${groupingOptions.map(
(option) => html`
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value=${`grouping:${option.grouping}`}
role="menuitemradio"
aria-checked=${String(this.sessionsGrouping === option.grouping)}
${ref((element) =>
syncDropdownItemRadio(element, this.sessionsGrouping === option.grouping),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${this.sessionsGrouping === option.grouping ? icons.check : nothing}</span
>
<span class="session-menu__text">${option.label}</span>
</wa-dropdown-item>
`,
)}
<div class="session-menu__separator" role="separator"></div>
<div class="sidebar-session-sort-menu__title">${t("chat.sidebar.sortBy")}</div>
${SIDEBAR_SESSION_SORT_OPTIONS.map(
(option) => html`
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
value=${`sort:${option.mode}`}
role="menuitemradio"
aria-checked=${String(this.sessionSortMode === option.mode)}
${ref((element) =>
syncDropdownItemRadio(element, this.sessionSortMode === option.mode),
)}
>
<span slot="details" class="session-menu__check" aria-hidden="true"
>${this.sessionSortMode === option.mode ? icons.check : nothing}</span
>
<span class="session-menu__text">${t(option.labelKey)}</span>
</wa-dropdown-item>
`,
)}
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item
class="sidebar-session-sort-menu__item"
type="checkbox"
value="show-cron"
.checked=${this.sessionsShowCron}
>
<span class="session-menu__text">${t("sessionsView.showCronSessions")}</span>
</wa-dropdown-item>
</wa-dropdown>
</openclaw-menu-surface>
`,
);
}
private renderRoute(routeId: NavigationRouteId) {
@@ -2966,14 +2907,23 @@ class AppSidebar extends OpenClawLightDomContentsElement {
}
private renderMoreMenu() {
const position = this.moreMenuPosition;
const trigger = this.moreMenuTrigger;
return renderSidebarMoreMenu({
position: this.moreMenuPosition,
position,
basePath: this.basePath,
activeRouteId: this.activeRouteId,
activePluginTabId: this.activePluginTabId,
pinnedRoutes: this.sidebarPinnedRoutes,
pluginTabs: sidebarPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs),
isRouteEnabled: (routeId) => this.isRouteEnabled(routeId),
onTabAway: () => trigger?.focus(),
onClose: (restoreFocus) => {
if (this.moreMenuPosition !== position) {
return;
}
this.closeMoreMenu({ restoreFocus });
},
onNavigateRoute: (routeId) => {
this.closeMoreMenu({ restoreFocus: true });
this.onNavigate?.(routeId);
@@ -2987,10 +2937,10 @@ class AppSidebar extends OpenClawLightDomContentsElement {
onEditPinnedItems: () => {
// Hand the durable More row over as the pin editor's trigger so
// Escape restores focus there once this menu is gone.
const position = this.moreMenuPosition;
const trigger = this.moreMenuTrigger;
if (position) {
this.openCustomizeMenu(position.x, position.y, trigger);
const customizePosition = this.moreMenuPosition;
const customizeTrigger = this.moreMenuTrigger;
if (customizePosition) {
this.openCustomizeMenu(customizePosition.x, customizePosition.y, customizeTrigger);
}
},
});
@@ -0,0 +1,76 @@
import { html, svg } from "lit";
import { t } from "../../i18n/index.ts";
import "../web-awesome-tabs.ts";
import type { BrowserPanelTab } from "./browser-client.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
function tabLabel(tab: BrowserPanelTab): string {
if (tab.title.trim()) {
return tab.title.trim();
}
try {
return new URL(tab.url).host || t("browser.untitledTab");
} catch {
return tab.url || t("browser.untitledTab");
}
}
export function renderBrowserPanelTabs(params: {
tabs: BrowserPanelTab[];
activeTargetId: string | null;
onSelect: (targetId: string) => void;
onClose: (targetId: string) => void;
onNew: () => void;
}) {
return html`
<wa-tab-group
class="bp-tabs"
.active=${params.activeTargetId ?? ""}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => params.onSelect(event.detail.name)}
>
${params.tabs.map(
(tab) => html`
<wa-tab
id=${`browser-tab-${tab.id}`}
class="bp-tab"
panel=${tab.id}
aria-controls="browser-tab-panel"
title=${tab.url}
@auxclick=${(event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
params.onClose(tab.id);
}
}}
>
<span class="bp-tab__label">${tabLabel(tab)}</span>
</wa-tab>
<button
slot="nav"
class="bp-tab__close"
type="button"
title=${t("browser.closeTab")}
aria-label=${`${t("browser.closeTab")}: ${tabLabel(tab)}`}
@click=${() => params.onClose(tab.id)}
>
${CLOSE_GLYPH}
</button>
`,
)}
<button
slot="nav"
class="bp-new"
type="button"
title=${t("browser.newTab")}
aria-label=${t("browser.newTab")}
@click=${params.onNew}
>
${PLUS_GLYPH}
</button>
</wa-tab-group>
`;
}
@@ -65,17 +65,24 @@ export const browserPanelStyles = css`
min-height: 36px;
}
.bp-tabs {
display: flex;
align-items: stretch;
gap: 1px;
--track-width: 0;
display: block;
overflow-x: auto;
scrollbar-width: none;
min-width: 0;
}
.bp-tabs::part(nav) {
display: flex;
align-items: stretch;
gap: 1px;
}
.bp-tabs::part(body) {
display: none;
}
.bp-tabs::-webkit-scrollbar {
display: none;
}
.bp-tab {
.bp-tab::part(base) {
display: flex;
align-items: center;
gap: 7px;
@@ -91,11 +98,11 @@ export const browserPanelStyles = css`
color 0.12s ease,
background 0.12s ease;
}
.bp-tab:hover {
.bp-tab:hover::part(base) {
color: var(--text, #d7dae0);
background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent);
}
.bp-tab.is-active {
.bp-tab[active]::part(base) {
color: var(--text, #d7dae0);
border-bottom-color: var(--accent, #ff5c5c);
}
@@ -116,8 +123,10 @@ export const browserPanelStyles = css`
border-radius: 4px;
padding: 0;
}
.bp-tab:hover .bp-tab__close,
.bp-tab.is-active .bp-tab__close {
.bp-tab:hover + .bp-tab__close,
.bp-tab[active] + .bp-tab__close,
.bp-tab__close:hover,
.bp-tab__close:focus-visible {
opacity: 0.7;
}
.bp-new,
+20 -64
View File
@@ -45,12 +45,12 @@ import {
type BrowserPageMetrics,
type BrowserPanelTab,
} from "./browser-client.ts";
import { renderBrowserPanelTabs } from "./browser-panel-tabs.ts";
import { browserPanelStyles } from "./browser-panel.styles.ts";
import { normalizeBrowserUrlDraft } from "./browser-url.ts";
// Inline icon set (self-contained; the Control UI blocks external asset loads).
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
const BACK_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3L5 8l5 5" /></svg>`;
@@ -97,17 +97,6 @@ const FORWARDED_KEYS = new Set([
"PageDown",
]);
function tabLabel(tab: BrowserPanelTab): string {
if (tab.title.trim()) {
return tab.title.trim();
}
try {
return new URL(tab.url).host || t("browser.untitledTab");
} catch {
return tab.url || t("browser.untitledTab");
}
}
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
@@ -990,56 +979,19 @@ class OpenClawBrowserPanel extends OpenClawLitElement {
// --- render ---------------------------------------------------------------
private renderTabStrip() {
return html`
<div class="bp-tabs" role="tablist">
${this.tabs.map(
(tab) => html`
<div
class="bp-tab ${tab.id === this.activeTargetId ? "is-active" : ""}"
role="tab"
title=${tab.url}
aria-selected=${tab.id === this.activeTargetId ? "true" : "false"}
@click=${() => void this.selectTab(tab.id)}
@auxclick=${(event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
void this.closeTab(tab.id);
}
}}
>
<span class="bp-tab__label">${tabLabel(tab)}</span>
<button
class="bp-tab__close"
type="button"
title=${t("browser.closeTab")}
aria-label=${t("browser.closeTab")}
@click=${(event: Event) => {
event.stopPropagation();
void this.closeTab(tab.id);
}}
>
${CLOSE_GLYPH}
</button>
</div>
`,
)}
<button
class="bp-new"
type="button"
title=${t("browser.newTab")}
aria-label=${t("browser.newTab")}
@click=${() => {
this.pendingNewTab = true;
this.urlDraft = "";
void this.updateComplete.then(() =>
this.renderRoot.querySelector<HTMLInputElement>(".bp-url")?.focus(),
);
}}
>
${PLUS_GLYPH}
</button>
</div>
`;
return renderBrowserPanelTabs({
tabs: this.tabs,
activeTargetId: this.activeTargetId,
onSelect: (targetId) => void this.selectTab(targetId),
onClose: (targetId) => void this.closeTab(targetId),
onNew: () => {
this.pendingNewTab = true;
this.urlDraft = "";
void this.updateComplete.then(() =>
this.renderRoot.querySelector<HTMLInputElement>(".bp-url")?.focus(),
);
},
});
}
private renderHeaderActions() {
@@ -1320,8 +1272,12 @@ class OpenClawBrowserPanel extends OpenClawLitElement {
: this.noticeText
? html`<div class="bp-note" role="status">${this.noticeText}</div>`
: nothing}
<div
<wa-tab-panel
id="browser-tab-panel"
class="bp-viewport"
name=${this.activeTargetId ?? "browser"}
active
aria-labelledby=${this.activeTargetId ? `browser-tab-${this.activeTargetId}` : nothing}
tabindex="0"
@wheel=${(event: WheelEvent) => this.handleWheel(event)}
@keydown=${(event: KeyboardEvent) => this.handleViewportKeydown(event)}
@@ -1330,7 +1286,7 @@ class OpenClawBrowserPanel extends OpenClawLitElement {
? html`<span class="bp-loading">${t("browser.loading")}</span>`
: nothing}
${this.renderViewport()}
</div>
</wa-tab-panel>
</section>
`;
}
+5 -1
View File
@@ -161,7 +161,11 @@ describe("CommandPalette lifecycle", () => {
palette.remove();
provider.append(palette);
expect(palette.querySelector("dialog")?.open).toBe(false);
const modal = palette.querySelector("openclaw-modal-dialog");
const dialog = modal?.shadowRoot
?.querySelector("wa-dialog")
?.shadowRoot?.querySelector("dialog");
expect(dialog?.open).toBe(false);
await palette.updateComplete;
expect(palette.querySelector("dialog")).toBeNull();
+8 -131
View File
@@ -14,6 +14,7 @@ import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { isCommandPaletteShortcut } from "./command-palette-contract.ts";
import { icons, type IconName } from "./icons.ts";
import "./modal-dialog.ts";
type PaletteItem = {
id: string;
@@ -107,7 +108,6 @@ type CommandPaletteProps = {
onNavigate: (routeId: RouteId) => void;
onSelectSession?: (sessionKey: string) => void;
onSlashCommand?: (command: string) => void;
onDialogRef: (element: Element | undefined) => void;
onInputRef: (element: Element | undefined) => void;
};
@@ -143,43 +143,10 @@ function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
return [...map.entries()];
}
let previouslyFocused: Element | null = null;
let activeDialog: HTMLDialogElement | null = null;
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[tabindex]:not([tabindex='-1'])",
].join(",");
const paletteDialogLabelId = "cmd-palette-label";
const paletteInputId = "cmd-palette-input";
const paletteListboxId = "cmd-palette-listbox";
function saveFocus() {
if (previouslyFocused) {
return;
}
previouslyFocused = document.activeElement;
}
function restoreFocus() {
const target = previouslyFocused;
previouslyFocused = null;
activeDialog = null;
if (target instanceof HTMLElement && target.isConnected) {
requestAnimationFrame(() => {
if (target.isConnected) {
target.focus();
}
});
}
}
function selectItem(item: PaletteItem, props: CommandPaletteProps) {
if (item.action.startsWith("nav:")) {
props.onNavigate(item.action.slice(4) as RouteId);
@@ -189,15 +156,10 @@ function selectItem(item: PaletteItem, props: CommandPaletteProps) {
props.onSlashCommand?.(item.action);
}
props.onToggle();
restoreFocus();
}
function closePalette(props: CommandPaletteProps) {
if (!activeDialog) {
return;
}
props.onToggle();
restoreFocus();
}
function scrollActiveIntoView() {
@@ -207,44 +169,7 @@ function scrollActiveIntoView() {
});
}
function trapFocus(event: KeyboardEvent, root: HTMLElement) {
const focusable = [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => element.isConnected && element.tabIndex >= 0 && !element.closest("[hidden]"),
);
if (focusable.length === 0) {
event.preventDefault();
root.focus();
return;
}
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) {
return;
}
const focusInside = active ? focusable.includes(active) : false;
if (event.shiftKey && (!focusInside || active === first)) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && (!focusInside || active === last)) {
event.preventDefault();
first.focus();
}
}
function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
if (e.key === "Tab") {
const dialog = (e.currentTarget as HTMLElement | null)?.closest("dialog");
if (dialog instanceof HTMLElement) {
trapFocus(e, dialog);
}
return;
}
const items = filteredItems(props.query, Boolean(props.onSlashCommand), props.sessionItems);
if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) {
return;
@@ -296,33 +221,6 @@ function getOptionId(item: PaletteItem): string {
return `cmd-palette-option-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
function syncDialog(el: Element | undefined) {
if (!(el instanceof HTMLDialogElement)) {
if (activeDialog) {
restoreFocus();
}
return;
}
if (activeDialog !== el) {
saveFocus();
activeDialog = el;
}
if (el.open) {
return;
}
if (typeof el.showModal === "function") {
try {
el.removeAttribute("aria-modal");
el.showModal();
return;
} catch {
// Fall through to the open attribute fallback below.
}
}
el.setAttribute("aria-modal", "true");
el.setAttribute("open", "");
}
function focusInput(el: Element | undefined) {
if (el instanceof HTMLInputElement) {
requestAnimationFrame(() => {
@@ -344,19 +242,11 @@ function renderCommandPalette(props: CommandPaletteProps) {
const paletteLabel = t("palette.placeholder");
return html`
<dialog
${ref(props.onDialogRef)}
class="cmd-palette-overlay"
aria-labelledby=${paletteDialogLabelId}
@cancel=${(e: Event) => {
e.preventDefault();
closePalette(props);
}}
@click=${(e: Event) => {
if (e.target === e.currentTarget) {
closePalette(props);
}
}}
<openclaw-modal-dialog
class="cmd-palette-overlay palette"
label=${paletteLabel}
style="--openclaw-modal-width: min(640px, calc(100vw - 32px));"
@modal-cancel=${() => closePalette(props)}
>
<div
class="cmd-palette"
@@ -368,6 +258,7 @@ function renderCommandPalette(props: CommandPaletteProps) {
>
<input
${ref(props.onInputRef)}
autofocus
id=${paletteInputId}
class="cmd-palette__input"
role="combobox"
@@ -427,7 +318,7 @@ function renderCommandPalette(props: CommandPaletteProps) {
<span><kbd>esc</kbd> ${t("palette.footer.close")}</span>
</div>
</div>
</dialog>
</openclaw-modal-dialog>
`;
}
@@ -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,
});
}
+20 -10
View File
@@ -43,6 +43,15 @@ function expectElement<T extends Element>(element: T | null | undefined, label:
return element;
}
function selectSegmented(control: HTMLElement) {
const group = expectElement(
control.closest<HTMLElement & { value: string }>("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<HTMLButtonElement>(".settings-segmented__btn")).find(
Array.from(container.querySelectorAll<HTMLElement>(".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<HTMLInputElement>("input[type='checkbox']"),
"enabled checkbox",
container.querySelector<HTMLElement & { checked: boolean }>("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<HTMLButtonElement>(".settings-segmented__btn")).find(
Array.from(container.querySelectorAll<HTMLElement>(".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<HTMLInputElement>("input[type='checkbox']"),
"beta checkbox",
container.querySelector<HTMLElement & { checked: boolean }>("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);
+22 -24
View File
@@ -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`
<div class="settings-segmented" role="group">
${params.options.map(
(option) => html`
<button
type="button"
class="settings-segmented__btn ${matchesComparablePrimitiveValue(
option,
params.resolvedValue,
)
? "settings-segmented__btn--active"
: ""}"
aria-pressed=${matchesComparablePrimitiveValue(option, params.resolvedValue)
? "true"
: "false"}
?disabled=${params.disabled}
@click=${() => params.onSelect(option)}
>
${formatUnknownText(option)}
</button>
`,
)}
</div>
`;
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),
}),
});
+22 -3
View File
@@ -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<HTMLElement>("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<HTMLElement & { label: string }>("openclaw-modal-dialog")
?.label,
).toBe("Arquivos de suporte");
expect(shadowText(modal)).toContain("2 arquivos");
expect(shadowText(modal)).toContain("Fechar");
});
+48 -84
View File
@@ -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`
<div class="backdrop" @click=${this.emitClose}></div>
<div
class="modal"
role="dialog"
aria-label=${label}
aria-modal="true"
tabindex="-1"
<openclaw-modal-dialog
label=${label}
style="--openclaw-modal-width: min(1100px, 92vw); --openclaw-modal-max-height: 86vh;"
@modal-cancel=${this.emitClose}
@keydown=${this.handleKeydown}
>
<header class="head">
<span class="search-icon">⌕</span>
<input
class="search"
placeholder=${searchPlaceholder}
.value=${this.query}
@input=${this.handleQueryInput}
/>
<span class="state">${fileCount}</span>
</header>
<div class="body">
<aside class="list">
<div class="list-section">${listLabel} · ${filteredFiles.length}</div>
${filteredFiles.length === 0
? html`<div class="empty-list">${t("filePreview.noMatches")}</div>`
: filteredFiles.map(
(file) => html`
<button
class="item ${file.path === activeFile?.path ? "is-active" : ""}"
@pointerdown=${this.preventItemPointerFocus}
@mousedown=${this.preventItemPointerFocus}
@click=${() => this.emitSelect(file.path)}
>
<span class="item-icon">${iconForFile(file.path)}</span>
<span class="item-name">${file.path}</span>
<span class="item-meta">${file.size}</span>
</button>
`,
)}
</aside>
${activeFile ? this.renderFile(activeFile) : this.renderEmpty()}
<div class="modal">
<header class="head">
<span class="search-icon">⌕</span>
<input
class="search"
placeholder=${searchPlaceholder}
.value=${this.query}
@input=${this.handleQueryInput}
/>
<span class="state">${fileCount}</span>
</header>
<div class="body">
<aside class="list">
<div class="list-section">${listLabel} · ${filteredFiles.length}</div>
${filteredFiles.length === 0
? html`<div class="empty-list">${t("filePreview.noMatches")}</div>`
: filteredFiles.map(
(file) => html`
<button
class="item ${file.path === activeFile?.path ? "is-active" : ""}"
@pointerdown=${this.preventItemPointerFocus}
@mousedown=${this.preventItemPointerFocus}
@click=${() => this.emitSelect(file.path)}
>
<span class="item-icon">${iconForFile(file.path)}</span>
<span class="item-name">${file.path}</span>
<span class="item-meta">${file.size}</span>
</button>
`,
)}
</aside>
${activeFile ? this.renderFile(activeFile) : this.renderEmpty()}
</div>
<footer class="foot">
<span class="foot-group"><span class="kbd">↑↓</span> ${t("filePreview.navigate")}</span>
<span class="spacer"></span>
<button class="button" @click=${this.emitClose}>
${t("common.close")} <span class="kbd">esc</span>
</button>
</footer>
</div>
<footer class="foot">
<span class="foot-group"><span class="kbd">↑↓</span> ${t("filePreview.navigate")}</span>
<span class="spacer"></span>
<button class="button" @click=${this.emitClose}>
${t("common.close")} <span class="kbd">esc</span>
</button>
</footer>
</div>
</openclaw-modal-dialog>
`;
}
+11 -3
View File
@@ -4,7 +4,9 @@ import { html } from "lit";
// rendered hint; menu hosts route non-Escape keydowns here so a bare letter
// clicks the matching enabled item and disabled items swallow nothing.
export function menuShortcutHint(key: string) {
return html`<span class="session-menu__shortcut" aria-hidden="true">${key.toUpperCase()}</span>`;
return html`<span slot="details" class="session-menu__shortcut" aria-hidden="true"
>${key.toUpperCase()}</span
>`;
}
export function activateMenuShortcut(root: ParentNode, event: KeyboardEvent): boolean {
@@ -18,8 +20,14 @@ export function activateMenuShortcut(root: ParentNode, event: KeyboardEvent): bo
if (!/^[a-z0-9]$/.test(key)) {
return false;
}
const item = root.querySelector<HTMLButtonElement>(`button[data-shortcut="${key}"]`);
if (!item || item.disabled) {
const item = root.querySelector<HTMLElement & { disabled?: boolean }>(`[data-shortcut="${key}"]`);
if (!item || item.disabled || item.getAttribute("aria-disabled") === "true") {
return false;
}
const parentItem = item.closest<HTMLElement & { submenuOpen?: boolean }>(
'wa-dropdown-item:not([slot="submenu"])',
);
if (item.getAttribute("slot") === "submenu" && parentItem?.submenuOpen !== true) {
return false;
}
event.preventDefault();
+19 -70
View File
@@ -32,17 +32,6 @@ async function renderModal() {
return await getRenderedModalDialog(container);
}
function expectShadowElement(
modal: Awaited<ReturnType<typeof getRenderedModalDialog>>["modal"],
id: string,
): HTMLElement {
const element = modal.shadowRoot?.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Expected shadow element #${id}`);
}
return element;
}
describe("openclaw-modal-dialog", () => {
beforeEach(() => {
restoreDialogPolyfill = installDialogPolyfill();
@@ -58,30 +47,22 @@ describe("openclaw-modal-dialog", () => {
});
it("opens a labelled modal dialog with an optional description", async () => {
const { modal, dialog } = await renderModal();
const { webAwesomeDialog, dialog } = await renderModal();
expect(dialog.open).toBe(true);
expect(dialog.getAttribute("role")).toBe("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
const labelId = dialog.getAttribute("aria-labelledby");
const descriptionId = dialog.getAttribute("aria-describedby");
expect(labelId).toBe("openclaw-modal-dialog-label");
expect(descriptionId).toBe("openclaw-modal-dialog-description");
expect(dialog.getRootNode()).toBe(modal.shadowRoot);
expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull();
expect(expectShadowElement(modal, "openclaw-modal-dialog-label").textContent).toBe(
"Confirm action",
);
expect(expectShadowElement(modal, "openclaw-modal-dialog-description").textContent).toBe(
"Review the operation before continuing.",
);
expect(dialog.localName).toBe("dialog");
expect(dialog.getAttribute("aria-label")).toBe("Confirm action");
expect(dialog.getAttribute("aria-description")).toBe("Review the operation before continuing.");
expect(dialog.getRootNode()).toBe(webAwesomeDialog.shadowRoot);
});
it("focuses the dialog container first", async () => {
const { modal, dialog } = await renderModal();
const focus = vi.spyOn(HTMLDialogElement.prototype, "focus");
const { dialog } = await renderModal();
expect(modal.shadowRoot?.activeElement).toBe(dialog);
expect(focus).toHaveBeenCalledWith();
expect(document.activeElement).not.toBe(container.querySelector("#first-action"));
expect(dialog.open).toBe(true);
});
it("focuses slotted autofocus content", async () => {
@@ -96,38 +77,12 @@ describe("openclaw-modal-dialog", () => {
expect(document.activeElement).toBe(container.querySelector("#autofocus-target"));
});
it("cycles Tab and Shift+Tab inside focusable dialog content", async () => {
const { dialog } = await renderModal();
const first = container.querySelector<HTMLButtonElement>("#first-action");
const last = container.querySelector<HTMLButtonElement>("#last-action");
expect(first?.id).toBe("first-action");
expect(last?.id).toBe("last-action");
if (!first || !last) {
throw new Error("expected modal focus trap actions");
}
it("delegates native modality and light dismissal to Web Awesome", async () => {
const { webAwesomeDialog, dialog } = await renderModal();
last.focus();
const tab = new KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
composed: true,
});
last.dispatchEvent(tab);
expect(tab.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(first);
first.focus();
const shiftTab = new KeyboardEvent("keydown", {
key: "Tab",
shiftKey: true,
bubbles: true,
cancelable: true,
composed: true,
});
first.dispatchEvent(shiftTab);
expect(shiftTab.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(last);
expect(webAwesomeDialog.open).toBe(true);
expect(webAwesomeDialog.lightDismiss).toBe(true);
expect(webAwesomeDialog.withoutHeader).toBe(true);
expect(dialog.open).toBe(true);
});
@@ -136,14 +91,7 @@ describe("openclaw-modal-dialog", () => {
const onCancel = vi.fn();
modal.addEventListener("modal-cancel", onCancel);
dialog.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
composed: true,
}),
);
dialog.dispatchEvent(new Event("cancel", { bubbles: true, cancelable: true }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
@@ -153,7 +101,7 @@ describe("openclaw-modal-dialog", () => {
const onCancel = vi.fn();
modal.addEventListener("modal-cancel", onCancel);
dialog.dispatchEvent(new MouseEvent("click", { bubbles: true }));
dialog.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
@@ -165,7 +113,6 @@ describe("openclaw-modal-dialog", () => {
returnTarget.focus();
await renderModal();
expect(document.activeElement).not.toBe(returnTarget);
render(nothing, container);
await nextFrame();
@@ -175,7 +122,9 @@ describe("openclaw-modal-dialog", () => {
});
it("reopens the same dialog element after reconnect", async () => {
const focus = vi.spyOn(HTMLDialogElement.prototype, "focus");
const { modal, dialog } = await renderModal();
const initialFocusCalls = focus.mock.calls.length;
modal.remove();
expect(dialog.open).toBe(false);
@@ -185,6 +134,6 @@ describe("openclaw-modal-dialog", () => {
await nextFrame();
expect(dialog.open).toBe(true);
expect(modal.shadowRoot?.activeElement).toBe(dialog);
expect(focus.mock.calls.length).toBeGreaterThan(initialFocusCalls);
});
});
+137 -218
View File
@@ -1,294 +1,213 @@
// Control UI component implements the modal dialog element.
import { css, html, nothing } from "lit";
// Control UI adapter for Web Awesome's accessible modal dialog.
import "@awesome.me/webawesome/dist/components/dialog/dialog.js";
import type WaDialog from "@awesome.me/webawesome/dist/components/dialog/dialog.js";
import { css, html } from "lit";
import { property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[tabindex]:not([tabindex='-1'])",
].join(",");
class OpenClawModalDialog extends OpenClawLitElement {
export class OpenClawModalDialog extends OpenClawLitElement {
@property({ type: Boolean }) open = true;
@property({ type: Boolean, reflect: true }) manual = false;
@property() label = "";
@property() description = "";
@query("dialog") private dialogElement?: HTMLDialogElement;
@query("slot") private slotElement?: HTMLSlotElement;
@query("wa-dialog") private webAwesomeDialog?: WaDialog;
private previouslyFocused: Element | null = null;
private opened = false;
private returnFocus: HTMLElement | null = null;
private syncGeneration = 0;
private suppressNextCancel = false;
static override styles = css`
:host {
position: fixed;
inset: 0;
z-index: 200;
display: block;
padding: 24px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: contents;
}
dialog {
position: fixed;
top: 50%;
left: 50%;
width: min(var(--openclaw-modal-width, 540px), calc(100vw - 48px));
wa-dialog {
--width: min(var(--openclaw-modal-width, 540px), calc(100vw - 48px));
--spacing: 0;
--backdrop-filter: blur(4px);
}
wa-dialog::part(dialog) {
max-height: var(--openclaw-modal-max-height, calc(100dvh - 48px));
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
transform: translate(-50%, -50%);
overflow: visible;
outline: none;
}
dialog::backdrop {
background: transparent;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
wa-dialog::part(body) {
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
overflow: visible;
}
:host(.fullscreen) wa-dialog {
--width: calc(100vw - 20px);
}
:host(.fullscreen) wa-dialog::part(dialog) {
max-height: calc(100dvh - 20px);
}
:host(.palette) wa-dialog::part(dialog) {
margin-block-start: min(20dvh, 160px);
margin-block-end: auto;
}
:host(.drawer) wa-dialog::part(dialog) {
height: 100dvh;
max-height: 100dvh;
margin: 0 0 0 auto;
border-radius: 0;
}
@media (max-width: 640px) {
:host {
padding: 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
wa-dialog {
--width: calc(100vw - 24px);
}
dialog {
width: calc(100vw - 24px);
wa-dialog::part(dialog) {
max-height: 90dvh;
}
}
`;
override connectedCallback() {
super.connectedCallback();
this.previouslyFocused = this.ownerDocument.activeElement;
// firstUpdated only runs once; retained dialogs must reopen on later connection epochs.
if (this.hasUpdated) {
this.openDialog();
if (this.manual) {
this.open = false;
}
}
override firstUpdated() {
this.openDialog();
super.connectedCallback();
void this.updateComplete.then(() => this.syncDialogOpen());
}
override disconnectedCallback() {
this.closeDialog();
this.restoreFocus();
this.syncGeneration += 1;
const webAwesomeDialog = this.webAwesomeDialog;
const dialog = webAwesomeDialog?.shadowRoot?.querySelector("dialog");
if (dialog?.open) {
dialog.close();
}
if (webAwesomeDialog) {
webAwesomeDialog.open = false;
}
const returnFocus = this.returnFocus;
this.returnFocus = null;
if (returnFocus?.isConnected) {
returnFocus.focus({ preventScroll: true });
}
super.disconnectedCallback();
}
override render() {
const labelId = this.label ? "openclaw-modal-dialog-label" : "";
const descriptionId = this.description ? "openclaw-modal-dialog-description" : "";
return html`
<dialog
role="dialog"
aria-modal="true"
aria-labelledby=${ifDefined(labelId || undefined)}
aria-describedby=${ifDefined(descriptionId || undefined)}
tabindex="-1"
@cancel=${this.handleCancel}
@click=${this.handleDialogClick}
@keydown=${this.handleKeydown}
<wa-dialog
without-header
light-dismiss
.label=${this.label}
@wa-show=${this.handleShow}
@wa-after-show=${this.handleAfterShow}
@wa-after-hide=${this.handleAfterHide}
@wa-hide=${this.handleHide}
>
${this.label
? html`<span id=${labelId} class="visually-hidden">${this.label}</span>`
: nothing}
${this.description
? html`<span id=${descriptionId} class="visually-hidden">${this.description}</span>`
: nothing}
<slot></slot>
</dialog>
</wa-dialog>
`;
}
private openDialog() {
if (this.opened) {
protected override updated() {
void this.syncAccessibility();
void this.syncDialogOpen();
}
private async syncDialogOpen() {
const generation = ++this.syncGeneration;
const webAwesomeDialog = this.webAwesomeDialog;
if (!webAwesomeDialog) {
return;
}
const dialog = this.dialogElement;
if (!dialog) {
await webAwesomeDialog.updateComplete;
if (generation !== this.syncGeneration || !this.isConnected) {
return;
}
this.opened = true;
if (typeof dialog.showModal === "function") {
try {
if (!dialog.open) {
dialog.showModal();
}
} catch {
if (!dialog.open) {
dialog.setAttribute("open", "");
}
}
} else if (!dialog.open) {
dialog.setAttribute("open", "");
}
requestAnimationFrame(() => {
if (!this.isConnected || !this.dialogElement?.open) {
const dialog = webAwesomeDialog.shadowRoot?.querySelector("dialog");
if (this.open) {
if (dialog?.open) {
return;
}
this.focusDialog();
});
}
private closeDialog() {
this.opened = false;
const dialog = this.dialogElement;
if (!dialog?.open) {
this.returnFocus =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
webAwesomeDialog.open = true;
return;
}
if (typeof dialog.close === "function") {
dialog.close();
return;
if (webAwesomeDialog.open || dialog?.open) {
this.suppressNextCancel = true;
webAwesomeDialog.open = false;
}
dialog.removeAttribute("open");
}
private restoreFocus() {
const target = this.previouslyFocused;
this.previouslyFocused = null;
if (!(target instanceof HTMLElement) || !target.isConnected) {
private async syncAccessibility() {
const webAwesomeDialog = this.webAwesomeDialog;
if (!webAwesomeDialog) {
return;
}
requestAnimationFrame(() => {
if (target.isConnected) {
target.focus();
}
});
}
private focusDialog() {
const dialog = this.dialogElement;
await webAwesomeDialog.updateComplete;
const dialog = webAwesomeDialog.shadowRoot?.querySelector("dialog");
if (!dialog) {
return;
}
const autofocusTarget = this.getFocusableElements().find((element) =>
element.hasAttribute("autofocus"),
);
if (autofocusTarget) {
autofocusTarget.focus({ preventScroll: true });
return;
if (this.label) {
dialog.setAttribute("aria-label", this.label);
} else {
dialog.removeAttribute("aria-label");
}
try {
dialog.focus({ preventScroll: true });
} catch {
dialog.focus();
if (this.description) {
dialog.setAttribute("aria-description", this.description);
} else {
dialog.removeAttribute("aria-description");
}
}
private handleCancel = (event: Event) => {
event.preventDefault();
this.dispatchCancel();
private handleAfterShow = () => {
if (!this.isConnected) {
return;
}
const autofocusTarget = this.querySelector<HTMLElement>("[autofocus]");
autofocusTarget?.focus({ preventScroll: true });
};
private handleDialogClick = (event: MouseEvent) => {
if (event.target === event.currentTarget) {
this.dispatchCancel();
private handleShow = () => {
// Web Awesome cannot see autofocus targets through this adapter's slot.
queueMicrotask(() => requestAnimationFrame(() => this.handleAfterShow()));
};
private handleAfterHide = () => {
this.open = false;
this.returnFocus = null;
};
private handleHide = (event: Event) => {
if (this.suppressNextCancel) {
this.suppressNextCancel = false;
return;
}
const cancelEvent = new CustomEvent("modal-cancel", {
bubbles: true,
composed: true,
cancelable: true,
});
this.dispatchEvent(cancelEvent);
if (cancelEvent.defaultPrevented) {
event.preventDefault();
}
};
private handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dispatchCancel();
return;
}
if (event.key === "Tab") {
this.trapFocus(event);
}
};
private trapFocus(event: KeyboardEvent) {
const focusable = this.getFocusableElements();
if (focusable.length === 0) {
event.preventDefault();
this.focusDialog();
return;
}
const active = this.getActiveElement();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) {
return;
}
const focusInside = active ? focusable.includes(active) : false;
if (event.shiftKey && (!focusInside || active === first || active === this.dialogElement)) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && (!focusInside || active === last || active === this.dialogElement)) {
event.preventDefault();
first.focus();
}
show() {
this.open = true;
}
private getActiveElement(): HTMLElement | null {
const active = this.ownerDocument.activeElement;
if (active === this && this.shadowRoot?.activeElement instanceof HTMLElement) {
return this.shadowRoot.activeElement;
}
return active instanceof HTMLElement ? active : null;
}
private getFocusableElements(): HTMLElement[] {
const assigned = this.slotElement?.assignedElements({ flatten: true }) ?? [];
const focusable: HTMLElement[] = [];
for (const element of assigned) {
this.collectFocusable(element, focusable);
}
return focusable.filter((element) => this.isFocusable(element));
}
private collectFocusable(element: Element, output: HTMLElement[]) {
if (element instanceof HTMLElement && element.matches(FOCUSABLE_SELECTOR)) {
output.push(element);
}
for (const child of element.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)) {
output.push(child);
}
}
private isFocusable(element: HTMLElement): boolean {
if (element.closest("[hidden], [inert]")) {
return false;
}
if (element.tabIndex < 0) {
return false;
}
return element.isConnected;
}
private dispatchCancel() {
this.dispatchEvent(new CustomEvent("modal-cancel", { bubbles: true, composed: true }));
hide() {
this.open = false;
}
}
+37 -11
View File
@@ -6,6 +6,7 @@ import { NativeLinkMenu, type NativeLinkMenuAction } from "./native-link-menu.ts
const NATIVE_LINK_MENU_ELEMENT_NAME = `test-openclaw-native-link-menu-${crypto.randomUUID()}`;
const containers: HTMLElement[] = [];
type DropdownElement = HTMLElement & { readonly updateComplete: Promise<unknown> };
// The non-isolated UI runner resets modules but not customElements. Register
// the current class graph so instanceof and locale updates share one module.
@@ -47,7 +48,7 @@ async function mountMenu(options: {
}
function menuItems(menu: ParentNode): HTMLButtonElement[] {
return [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
return [...menu.querySelectorAll<HTMLButtonElement>("wa-dropdown-item")];
}
describe("native link menu", () => {
@@ -59,6 +60,9 @@ describe("native link menu", () => {
});
const items = menuItems(menu);
await Promise.resolve();
expect(document.activeElement).toBe(items[0]);
expect(
items.map((item) => item.querySelector(".session-menu__text")?.textContent?.trim()),
).toEqual(["Open in Sidebar", "Open in Default Browser", "Copy Link"]);
@@ -69,11 +73,19 @@ describe("native link menu", () => {
it("rerenders open actions when the locale changes", async () => {
const menu = await mountMenu({});
const dropdown = menu.querySelector<DropdownElement>("wa-dropdown");
expect(dropdown).not.toBeNull();
await dropdown?.updateComplete;
await i18n.setLocale("de");
await menu.updateComplete;
expect(menu.querySelector('[role="menu"]')?.getAttribute("aria-label")).toBe("Link-Aktionen");
expect(menu.querySelector("wa-dropdown")?.getAttribute("aria-label")).toBe("Link-Aktionen");
await vi.waitFor(() =>
expect(dropdown?.shadowRoot?.querySelector('[part="menu"]')?.getAttribute("aria-label")).toBe(
"Link-Aktionen",
),
);
expect(
menuItems(menu).map((item) => item.querySelector(".session-menu__text")?.textContent?.trim()),
).toEqual(["In der Seitenleiste öffnen", "Im Standardbrowser öffnen", "Link kopieren"]);
@@ -97,28 +109,42 @@ describe("native link menu", () => {
expect(calls).toEqual(["close", "copy"]);
});
it("closes on Escape and outside pointerdown while preserving trigger clicks", async () => {
it("closes on Escape without leaking the key to an underlying dialog", async () => {
const trigger = document.createElement("a");
trigger.href = "https://example.com";
document.body.append(trigger);
containers.push(trigger);
const onClose = vi.fn();
await mountMenu({ trigger, onClose });
const menu = await mountMenu({ trigger, onClose });
const escaped = vi.fn();
menu.addEventListener("keydown", escaped);
const escape = new KeyboardEvent("keydown", {
const keydown = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
});
document.dispatchEvent(escape);
menu.dispatchEvent(keydown);
expect(keydown.defaultPrevented).toBe(true);
expect(escaped).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
expect(escape.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(trigger);
});
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(2);
it("closes after Web Awesome hides without stealing focus", async () => {
const trigger = document.createElement("a");
trigger.href = "https://example.com";
document.body.append(trigger);
containers.push(trigger);
const onClose = vi.fn();
const menu = await mountMenu({ trigger, onClose });
trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(2);
menu
.querySelector("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(document.activeElement).not.toBe(trigger);
});
});
+45 -32
View File
@@ -4,6 +4,7 @@ import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import { activateMenuShortcut, menuShortcutHint } from "./menu-shortcuts.ts";
import "./web-awesome.ts";
export type NativeLinkMenuAction = "inline" | "external" | "copy";
@@ -16,29 +17,23 @@ export class NativeLinkMenu extends OpenClawLightDomElement {
override connectedCallback() {
super.connectedCallback();
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
}
override disconnectedCallback() {
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
super.disconnectedCallback();
}
protected override firstUpdated() {
this.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
const dropdown = this.querySelector<HTMLElement & { updateComplete?: Promise<unknown> }>(
"wa-dropdown",
);
void Promise.resolve(dropdown?.updateComplete).then(() => {
this.querySelector<HTMLElement>("wa-dropdown-item:not([disabled])")?.focus();
});
}
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
const menu = this.querySelector(".native-link-menu");
if ((menu && path.includes(menu)) || (this.trigger && path.includes(this.trigger))) {
return;
}
this.onClose();
};
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -61,50 +56,68 @@ export class NativeLinkMenu extends OpenClawLightDomElement {
const clampedX = Math.max(8, Math.min(this.x, window.innerWidth - menuWidth - 8));
const clampedY = Math.max(8, Math.min(this.y, window.innerHeight - menuMaxHeight - 8));
return html`
<div
<wa-dropdown
class="session-menu native-link-menu"
role="menu"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${t("nativeLinkMenu.label")}
style="left: ${clampedX}px; top: ${clampedY}px;"
@wa-select=${(event: CustomEvent<{ item: { value?: NativeLinkMenuAction } }>) => {
event.preventDefault();
const action = event.detail.item.value;
if (action) {
this.trigger?.focus();
this.runAction(action);
}
}}
@wa-after-hide=${() => {
this.onClose();
}}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${t("nativeLinkMenu.label")}
style="position: fixed; left: ${clampedX}px; top: ${clampedY}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="inline"
data-shortcut="s"
aria-keyshortcuts="S"
@click=${() => this.runAction("inline")}
>
<span class="session-menu__icon" aria-hidden="true">${icons.panelRightOpen}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.panelRightOpen}</span
>
<span class="session-menu__text">${t("nativeLinkMenu.openInline")}</span>
${menuShortcutHint("s")}
</button>
<button
type="button"
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="external"
data-shortcut="b"
aria-keyshortcuts="B"
@click=${() => this.runAction("external")}
>
<span class="session-menu__icon" aria-hidden="true">${icons.externalLink}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.externalLink}</span
>
<span class="session-menu__text">${t("nativeLinkMenu.openExternal")}</span>
${menuShortcutHint("b")}
</button>
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="copy"
data-shortcut="c"
aria-keyshortcuts="C"
@click=${() => this.runAction("copy")}
>
<span class="session-menu__icon" aria-hidden="true">${icons.copy}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.copy}</span>
<span class="session-menu__text">${t("nativeLinkMenu.copy")}</span>
${menuShortcutHint("c")}
</button>
</div>
</wa-dropdown-item>
</wa-dropdown>
`;
}
}
+110 -43
View File
@@ -7,10 +7,14 @@ import { renderPluginsHubTabs } from "./plugins-hub-tabs.ts";
type PluginsHubTabsProps = Parameters<typeof renderPluginsHubTabs>[0];
function mount(props: PluginsHubTabsProps): HTMLDivElement {
async function mount(props: PluginsHubTabsProps): Promise<HTMLDivElement> {
const container = document.createElement("div");
document.body.append(container);
render(renderPluginsHubTabs(props), container);
const group = container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-tab-group",
);
await group?.updateComplete;
return container;
}
@@ -23,9 +27,13 @@ describe("renderPluginsHubTabs", () => {
document.body.innerHTML = "";
});
it("renders all hub tabs with the active tab selected", () => {
const container = mount({ active: "skills", installedCount: 4, onSelect: () => undefined });
const tabs = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
it("renders all hub tabs with the active tab selected", async () => {
const container = await mount({
active: "skills",
installedCount: 4,
onSelect: () => undefined,
});
const tabs = [...container.querySelectorAll<HTMLElement>("wa-tab")];
expect(tabs.map((tab) => tab.id)).toEqual([
"plugins-tab-installed",
"plugins-tab-discover",
@@ -41,84 +49,143 @@ describe("renderPluginsHubTabs", () => {
expect(container.querySelector("#plugins-tab-installed")?.textContent).toContain("4");
});
it("omits the installed count badge when no catalog data is provided", () => {
const container = mount({ active: "workshop", onSelect: () => undefined });
it("omits the installed count badge when no catalog data is provided", async () => {
const container = await mount({ active: "workshop", onSelect: () => undefined });
expect(container.querySelector("#plugins-tab-installed span")).toBeNull();
});
it("selects tabs on click", () => {
it("selects tabs on click", async () => {
const onSelect = vi.fn();
const container = mount({ active: "installed", onSelect });
const container = await mount({ active: "installed", onSelect });
container
.querySelector<HTMLButtonElement>("#plugins-tab-workshop")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onSelect).toHaveBeenLastCalledWith("workshop");
});
it("moves focus with arrow keys without activating cross-route tabs", () => {
it("uses manual Web Awesome activation for cross-route tabs", async () => {
const onSelect = vi.fn();
const container = mount({ active: "installed", onSelect });
const installed = container.querySelector<HTMLButtonElement>("#plugins-tab-installed")!;
const discover = container.querySelector<HTMLButtonElement>("#plugins-tab-discover")!;
const workshop = container.querySelector<HTMLButtonElement>("#plugins-tab-workshop")!;
const container = await mount({ active: "installed", onSelect });
const group = container.querySelector("wa-tab-group");
const installed = container.querySelector<HTMLElement>("#plugins-tab-installed");
expect([installed.tabIndex, discover.tabIndex]).toEqual([0, -1]);
installed.focus();
installed.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(document.activeElement).toBe(discover);
discover.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(document.activeElement).toBe(workshop);
workshop.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(document.activeElement).toBe(installed);
// Manual activation: arrowing never selects; activation stays on
// click/Enter so cross-route tabs cannot unmount the strip under focus.
expect(group?.getAttribute("activation")).toBe("manual");
installed?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, composed: true }),
);
expect(onSelect).not.toHaveBeenCalled();
});
it("hands focus to the destination strip after keyboard activation", async () => {
const onSelect = vi.fn();
const source = mount({ active: "installed", onSelect });
// element.click() dispatches with detail 0, matching keyboard activation.
source.querySelector<HTMLButtonElement>("#plugins-tab-workshop")?.click();
const source = await mount({ active: "installed", onSelect });
const target = source.querySelector<HTMLElement>("#plugins-tab-workshop");
target?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }),
);
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "workshop" },
}),
);
expect(onSelect).toHaveBeenLastCalledWith("workshop");
source.remove();
const destination = mount({ active: "workshop", onSelect: () => undefined });
// Focus lands on a microtask once the rendered strip is connected.
await Promise.resolve();
expect(document.activeElement).toBe(
destination.querySelector<HTMLButtonElement>("#plugins-tab-workshop"),
const destination = await mount({ active: "workshop", onSelect: () => undefined });
await vi.waitFor(() => {
expect(document.activeElement).toBe(
destination.querySelector<HTMLElement>("#plugins-tab-workshop"),
);
});
});
it("hands focus to the destination strip after synthesized activation", async () => {
const source = await mount({ active: "installed", onSelect: () => undefined });
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "discover" },
}),
);
source.remove();
const destination = await mount({ active: "discover", onSelect: () => undefined });
await vi.waitFor(() => {
expect(document.activeElement).toBe(
destination.querySelector<HTMLElement>("#plugins-tab-discover"),
);
});
});
it("does not queue focus recovery for same-tab keyboard activation", async () => {
const container = mount({ active: "installed", onSelect: () => undefined });
container.querySelector<HTMLButtonElement>("#plugins-tab-installed")?.click();
const container = await mount({ active: "installed", onSelect: () => undefined });
const installed = container.querySelector<HTMLElement>("#plugins-tab-installed");
installed?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }),
);
container.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "installed" },
}),
);
// A later re-render of the strip must not reclaim focus from whatever
// control the user moved on to.
container.remove();
const rerendered = mount({ active: "installed", onSelect: () => undefined });
const rerendered = await mount({ active: "installed", onSelect: () => undefined });
await Promise.resolve();
expect(document.activeElement).not.toBe(
rerendered.querySelector<HTMLButtonElement>("#plugins-tab-installed"),
rerendered.querySelector<HTMLElement>("#plugins-tab-installed"),
);
});
it("does not steal focus after mouse activation", async () => {
const source = mount({ active: "installed", onSelect: () => undefined });
const source = await mount({ active: "installed", onSelect: () => undefined });
source
.querySelector<HTMLButtonElement>("#plugins-tab-skills")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
.querySelector<HTMLElement>("#plugins-tab-skills")
?.dispatchEvent(new MouseEvent("click", { detail: 1 }));
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "skills" },
}),
);
source.remove();
const destination = mount({ active: "skills", onSelect: () => undefined });
const destination = await mount({ active: "skills", onSelect: () => undefined });
await Promise.resolve();
expect(document.activeElement).not.toBe(
destination.querySelector<HTMLButtonElement>("#plugins-tab-skills"),
destination.querySelector<HTMLElement>("#plugins-tab-skills"),
);
});
it("clears a same-tab pointer source when keyboard navigation follows", async () => {
const source = await mount({ active: "installed", onSelect: () => undefined });
const active = source.querySelector<HTMLElement>("#plugins-tab-installed");
active?.dispatchEvent(new MouseEvent("click", { detail: 1 }));
active?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, composed: true }),
);
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "discover" },
}),
);
source.remove();
const destination = await mount({ active: "discover", onSelect: () => undefined });
await vi.waitFor(() => {
expect(document.activeElement).toBe(
destination.querySelector<HTMLElement>("#plugins-tab-discover"),
);
});
});
});
+34 -56
View File
@@ -4,6 +4,7 @@
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import { t } from "../i18n/index.ts";
import "./web-awesome-tabs.ts";
export type PluginsHubTab = "installed" | "discover" | "skills" | "workshop";
@@ -14,6 +15,7 @@ const HUB_TABS: readonly PluginsHubTab[] = ["installed", "discover", "skills", "
// Time-bounded so an aborted navigation cannot steal focus much later.
const PENDING_FOCUS_WINDOW_MS = 2000;
let pendingFocus: { tab: PluginsHubTab; at: number } | null = null;
let pointerActivation = false;
type PluginsHubTabsProps = {
active: PluginsHubTab;
@@ -37,50 +39,15 @@ function hubTabLabel(tab: PluginsHubTab): string {
}
}
/**
* Manual-activation tablist: arrows and Home/End only move focus. Activating
* a tab can navigate to another route, so activation stays on click/Enter and
* arrowing must never unmount the strip under the user's focus.
*/
function handleHubTabKeydown(event: KeyboardEvent, tab: PluginsHubTab) {
const currentIndex = HUB_TABS.indexOf(tab);
let nextIndex: number;
switch (event.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % HUB_TABS.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + HUB_TABS.length) % HUB_TABS.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = HUB_TABS.length - 1;
break;
default:
return;
}
event.preventDefault();
const nextTab = HUB_TABS[nextIndex];
const tablist = (event.currentTarget as HTMLElement).closest('[role="tablist"]');
const next = tablist?.querySelector<HTMLElement>(`#plugins-tab-${nextTab}`);
if (!next) {
return;
}
(event.currentTarget as HTMLElement).tabIndex = -1;
next.tabIndex = 0;
next.focus();
}
function selectHubTab(event: MouseEvent, tab: PluginsHubTab, props: PluginsHubTabsProps) {
// detail === 0 means the click came from the keyboard (Enter/Space); only
// then should the destination strip pull focus after the route swap. Skip
function selectHubTab(tab: PluginsHubTab, props: PluginsHubTabsProps) {
// Keyboard activation unmounts the focused strip; only then should the
// destination strip pull focus after the route swap. Skip
// same-tab activation: it does not navigate, and a lingering entry would
// let a later re-render steal focus from whatever the user moved on to.
if (event.detail === 0 && tab !== props.active) {
if (!pointerActivation && tab !== props.active) {
pendingFocus = { tab, at: Date.now() };
}
pointerActivation = false;
props.onSelect(tab);
}
@@ -93,13 +60,13 @@ function reclaimFocus(tab: PluginsHubTab, element: Element | undefined) {
if (Date.now() - pending.at > PENDING_FOCUS_WINDOW_MS) {
return;
}
// The ref fires while the strip is still inside lit's template fragment;
// focus only works once the rendered tree is connected to the document.
queueMicrotask(() => {
// The ref fires while the strip is still inside lit's template fragment.
// A task lets both Lit and Web Awesome finish connecting before focus moves.
window.setTimeout(() => {
if (element.isConnected) {
(element as HTMLElement).focus();
}
});
}, 0);
}
/**
@@ -110,32 +77,43 @@ function reclaimFocus(tab: PluginsHubTab, element: Element | undefined) {
*/
export function renderPluginsHubTabs(props: PluginsHubTabsProps) {
return html`
<div
class="settings-segmented plugins-hub-tabs"
role="tablist"
<wa-tab-group
class="settings-segmented plugins-hub-tabs plugins-tabs"
aria-label=${t("pluginsPage.hubTablistLabel")}
.active=${props.active}
activation="manual"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: PluginsHubTab }>) =>
selectHubTab(event.detail.name, props)}
>
${HUB_TABS.map((tab) => {
const selected = props.active === tab;
const count = tab === "installed" ? (props.installedCount ?? null) : null;
return html`
<button
<wa-tab
id=${`plugins-tab-${tab}`}
type="button"
role="tab"
aria-selected=${selected ? "true" : "false"}
panel=${tab}
aria-controls="plugins-hub-panel"
.tabIndex=${selected ? 0 : -1}
class="settings-segmented__btn ${selected ? "settings-segmented__btn--active" : ""}"
?active=${selected}
@click=${(event: MouseEvent) => {
// Trusted pointer clicks carry a click count. Keyboard and AT
// synthesized clicks use detail=0 and need focus recovery.
pointerActivation = event.detail > 0;
}}
@keydown=${() => {
// Any keyboard interaction supersedes a prior pointer click.
// This also clears clicks on the already-active tab, which do
// not emit wa-tab-show and would otherwise leave stale state.
pointerActivation = false;
}}
${selected ? ref((element) => reclaimFocus(tab, element)) : nothing}
@click=${(event: MouseEvent) => selectHubTab(event, tab, props)}
@keydown=${(event: KeyboardEvent) => handleHubTabKeydown(event, tab)}
>
${hubTabLabel(tab)}
${count === null ? nothing : html`<span class="settings-count">${count}</span>`}
</button>
</wa-tab>
`;
})}
</div>
</wa-tab-group>
`;
}
+83 -58
View File
@@ -5,15 +5,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import "./session-menu.ts";
import type { SessionMenuAction, SessionMenuWork } from "./session-menu.ts";
type SessionMenuElement = HTMLElement & { updateComplete: Promise<boolean> };
type SessionMenuData = {
key: string;
label: string;
pinned: boolean;
unread: boolean;
archived: boolean;
category: string | null;
};
type SessionMenuElement = HTMLElement & {
anchor: { x: number; y: number };
session: SessionMenuData;
updateComplete: Promise<boolean>;
};
type SessionMenuItem = HTMLElement & { disabled: boolean; updateComplete: Promise<unknown> };
const containers: HTMLElement[] = [];
@@ -41,7 +45,6 @@ async function mountMenu(
containers.push(container);
document.body.append(container);
const session: SessionMenuData = {
key: "agent:main:test",
label: "Test session",
pinned: false,
unread: false,
@@ -53,8 +56,7 @@ async function mountMenu(
html`<openclaw-session-menu
.session=${session}
.selectionCount=${options.selectionCount ?? 1}
.x=${100}
.y=${100}
.anchor=${{ x: 100, y: 100 }}
.trigger=${options.trigger ?? null}
.disabled=${false}
.forkDisabled=${false}
@@ -83,11 +85,15 @@ function itemLabel(item: HTMLElement): string {
}
function menuItemLabels(menu: ParentNode): string[] {
return Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(itemLabel);
const selector =
menu instanceof Element && menu.matches("wa-dropdown-item")
? ":scope > wa-dropdown-item[slot='submenu']"
: ":scope > wa-dropdown > wa-dropdown-item";
return Array.from(menu.querySelectorAll<HTMLElement>(selector)).map(itemLabel);
}
function menuItem(menu: ParentNode, label: string): HTMLButtonElement {
const item = Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).find(
function menuItem(menu: ParentNode, label: string): SessionMenuItem {
const item = Array.from(menu.querySelectorAll<SessionMenuItem>("wa-dropdown-item")).find(
(candidate) => itemLabel(candidate) === label,
);
if (!item) {
@@ -178,12 +184,20 @@ describe("session menu", () => {
onAction,
});
menuItem(menu, "Move to group").click();
await menu.updateComplete;
const submenu = menuItem(menu, "Move to group");
(submenu as SessionMenuItem & { submenuOpen: boolean }).submenuOpen = true;
expect(menuItemLabels(menu)).toContain("Research");
expect(menuItemLabels(menu)).toContain("Projects");
expect(menuItem(menu, "Research").querySelector(".session-menu__check svg")).not.toBeNull();
expect(menuItemLabels(submenu)).toContain("Research");
expect(menuItemLabels(submenu)).toContain("Projects");
const research = menuItem(submenu, "Research");
const remove = menuItem(submenu, "Remove from group");
const create = menuItem(submenu, "New group…");
await Promise.all([research.updateComplete, remove.updateComplete, create.updateComplete]);
await Promise.resolve();
expect(research.getAttribute("role")).toBe("menuitemradio");
expect(research.getAttribute("aria-checked")).toBe("true");
expect(remove.getAttribute("role")).toBe("menuitem");
expect(create.getAttribute("role")).toBe("menuitem");
menuItem(menu, "Projects").click();
expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: "Projects" });
@@ -198,37 +212,24 @@ describe("session menu", () => {
it("omits Remove from group when the session has no category", async () => {
const menu = await mountMenu({ groups: ["Research"] });
menuItem(menu, "Move to group").click();
await menu.updateComplete;
const submenu = menuItem(menu, "Move to group");
expect(menuItemLabels(menu)).not.toContain("Remove from group");
expect(menuItemLabels(submenu)).not.toContain("Remove from group");
});
it("omits the submenu separator when New group is the only entry", async () => {
it("uses Web Awesome submenu slots when New group is the only entry", async () => {
const menu = await mountMenu({ groups: [] });
menuItem(menu, "Move to group").click();
await menu.updateComplete;
const submenu = menu.querySelector<HTMLElement>(".session-menu__submenu");
if (!submenu) {
throw new Error("Expected group submenu");
}
const submenu = menuItem(menu, "Move to group");
expect(menuItemLabels(submenu)).toEqual(["New group…"]);
expect(submenu.querySelector('[role="separator"]')).toBeNull();
expect(submenu.querySelector("wa-dropdown-item")?.getAttribute("slot")).toBe("submenu");
});
it("keeps the submenu separator when groups exist", async () => {
it("renders existing groups in the Web Awesome submenu", async () => {
const menu = await mountMenu({ groups: ["Research"] });
menuItem(menu, "Move to group").click();
await menu.updateComplete;
const submenu = menu.querySelector<HTMLElement>(".session-menu__submenu");
if (!submenu) {
throw new Error("Expected group submenu");
}
expect(submenu.querySelector('[role="separator"]')).not.toBeNull();
const submenu = menuItem(menu, "Move to group");
expect(menuItemLabels(submenu)).toEqual(["Research", "New group…"]);
});
it("numbers group submenu entries and dispatches them from digit keys", async () => {
@@ -243,22 +244,17 @@ describe("session menu", () => {
document.dispatchEvent(closedDigit);
expect(onAction).not.toHaveBeenCalled();
menuItem(menu, "Move to group").click();
await menu.updateComplete;
const submenu = menu.querySelector<HTMLElement>(".session-menu__submenu");
if (!submenu) {
throw new Error("Expected group submenu");
}
const submenu = menuItem(menu, "Move to group");
(submenu as SessionMenuItem & { submenuOpen: boolean }).submenuOpen = true;
expect(menuItemLabels(submenu)).toEqual([
"Research",
"Projects",
"Remove from group",
"New group…",
]);
const shortcuts = Array.from(submenu.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(
(item) => item.dataset.shortcut,
);
const shortcuts = Array.from(
submenu.querySelectorAll<HTMLElement>("wa-dropdown-item[slot='submenu']"),
).map((item) => item.dataset.shortcut);
expect(shortcuts).toEqual(["1", "2", "3", "4"]);
expect(
menuItem(submenu, "Projects").querySelector(".session-menu__shortcut")?.textContent,
@@ -313,13 +309,11 @@ describe("session menu", () => {
});
expect(menuItem(menu, "Open PR").disabled).toBe(true);
menuItem(menu, "Open in").click();
await menu.updateComplete;
const openIn = menuItem(menu, "Open in");
(openIn as SessionMenuItem & { submenuOpen: boolean }).submenuOpen = true;
expect(menuItemLabels(menu)).toEqual(
expect.arrayContaining(["Cursor", "VS Code", "Windsurf", "Zed"]),
);
menuItem(menu, "VS Code").click();
expect(menuItemLabels(openIn)).toEqual(["Cursor", "VS Code", "Windsurf", "Zed"]);
menuItem(openIn, "VS Code").click();
expect(onAction).toHaveBeenCalledWith({
kind: "open-in",
editor: "vscode",
@@ -337,7 +331,7 @@ describe("session menu", () => {
const pin = menuItem(menu, "Pin session");
expect(pin.querySelector(".session-menu__shortcut")?.textContent).toBe("P");
expect(pin.getAttribute("aria-keyshortcuts")).toBe("P");
expect(menuItem(menu, "Move to group").querySelector(".session-menu__shortcut")).toBeNull();
expect(menuItem(menu, "Move to group").dataset.shortcut).toBeUndefined();
const keydown = new KeyboardEvent("keydown", { key: "p", bubbles: true, cancelable: true });
document.dispatchEvent(keydown);
@@ -362,20 +356,51 @@ describe("session menu", () => {
expect(onAction).not.toHaveBeenCalled();
});
it("closes on Escape and outside pointerdown but ignores its trigger", async () => {
it("closes on Escape without leaking the key past the menu", async () => {
const trigger = document.createElement("button");
document.body.append(trigger);
containers.push(trigger);
const onClose = vi.fn();
await mountMenu({ trigger, onClose });
const menu = await mountMenu({ trigger, onClose });
const escaped = vi.fn();
menu.addEventListener("keydown", escaped);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
menu.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
expect(escaped).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(trigger);
});
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(2);
it("closes after Web Awesome hides without stealing focus", async () => {
const trigger = document.createElement("button");
document.body.append(trigger);
containers.push(trigger);
const onClose = vi.fn();
const menu = await mountMenu({ trigger, onClose });
trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(2);
menu
.querySelector("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-after-hide", { bubbles: true, composed: true }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(document.activeElement).not.toBe(trigger);
});
it("ignores a stale hide after reopening the same session", async () => {
const onClose = vi.fn();
const menu = await mountMenu({ onClose });
const staleDropdown = menu.querySelector("wa-dropdown");
menu.anchor = { x: 120, y: 120 };
await menu.updateComplete;
staleDropdown?.dispatchEvent(
new CustomEvent("wa-after-hide", { bubbles: true, composed: true }),
);
expect(onClose).not.toHaveBeenCalled();
expect(menu.querySelector("wa-dropdown")).not.toBe(staleDropdown);
});
});
+170 -224
View File
@@ -1,14 +1,16 @@
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { ref } from "lit/directives/ref.js";
import { t } from "../i18n/index.ts";
import { EDITOR_IDS, EDITOR_LABELS, type EditorId } from "../lib/editor-links.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import { activateMenuShortcut, menuShortcutHint } from "./menu-shortcuts.ts";
import { promoteToPopoverTopLayer } from "./menu-surface.ts";
import { syncDropdownItemRadio } from "./web-awesome.ts";
type SessionMenuData = {
key: string;
label: string;
pinned: boolean;
unread: boolean;
@@ -42,7 +44,6 @@ export type SessionMenuAction =
| { kind: "delete" };
const EMPTY_SESSION: SessionMenuData = {
key: "",
label: "",
pinned: false,
unread: false,
@@ -56,8 +57,7 @@ class SessionMenu extends OpenClawLightDomElement {
// session (unread/group/archive/delete); `session` then carries aggregated
// flags (unread = all unread, category = shared category or null).
@property({ attribute: false }) selectionCount = 1;
@property({ attribute: false }) x = 0;
@property({ attribute: false }) y = 0;
@property({ attribute: false }) anchor: { x: number; y: number } = { x: 0, y: 0 };
@property({ attribute: false }) trigger: HTMLElement | null = null;
@property({ attribute: false }) disabled = false;
@property({ attribute: false }) forkDisabled = false;
@@ -71,11 +71,8 @@ class SessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) onAction: (action: SessionMenuAction) => void = () => {};
@property({ attribute: false }) onClose: () => void = () => {};
@state() private openSubmenu: "editor" | "group" | null = null;
override connectedCallback() {
super.connectedCallback();
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
// Sidebar-hosted menus live inside the nav stacking context (z-index 10),
// which paints below the sidebar resizer divider (z-index 20); promoting
@@ -85,40 +82,13 @@ class SessionMenu extends OpenClawLightDomElement {
}
override disconnectedCallback() {
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
super.disconnectedCallback();
}
protected override willUpdate(changed: PropertyValues<this>) {
if (changed.has("session")) {
const previous = changed.get("session") as SessionMenuData | undefined;
if (previous?.key !== this.session.key) {
this.openSubmenu = null;
}
}
}
protected override updated(changed: PropertyValues<this>) {
if (changed.has("session")) {
const previous = changed.get("session") as SessionMenuData | undefined;
if (previous?.key !== this.session.key) {
this.querySelector<HTMLElement>(".session-menu__item")?.focus();
}
}
}
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
const menu = this.querySelector(".session-menu");
if ((menu && path.includes(menu)) || (this.trigger && path.includes(this.trigger))) {
return;
}
this.onClose();
};
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.trigger?.focus();
this.onClose();
@@ -132,7 +102,56 @@ class SessionMenu extends OpenClawLightDomElement {
this.onAction(action);
}
private renderWorkItems(submenuLeft: boolean) {
private readonly handleSelect = (event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
if (!value) {
return;
}
const simpleActions: Partial<Record<string, SessionMenuAction>> = {
"open-chat": { kind: "open-chat" },
"toggle-pin": { kind: "toggle-pin" },
"toggle-unread": { kind: "toggle-unread" },
rename: { kind: "rename" },
fork: { kind: "fork" },
workboard: { kind: "workboard" },
"new-group": { kind: "new-group" },
"toggle-archived": { kind: "toggle-archived" },
delete: { kind: "delete" },
};
const simpleAction = simpleActions[value];
if (simpleAction) {
this.runAction(simpleAction);
return;
}
if (value === "open-pr" && this.work?.pullRequestUrl) {
this.runAction({ kind: "open-pr", url: this.work.pullRequestUrl });
return;
}
if (value.startsWith("open-in:") && this.work?.worktreePath) {
const editor = value.slice("open-in:".length) as EditorId;
if (EDITOR_IDS.includes(editor)) {
this.runAction({ kind: "open-in", editor, path: this.work.worktreePath });
}
return;
}
if (value.startsWith("move-to-group:")) {
const encodedCategory = value.slice("move-to-group:".length);
this.runAction({
kind: "move-to-group",
category: encodedCategory ? decodeURIComponent(encodedCategory) : null,
});
}
};
private readonly handleAfterHide = (event: Event) => {
// A keyed replacement can finish hiding after its successor opens.
if (event.currentTarget instanceof Node && event.currentTarget.isConnected) {
this.onClose();
}
};
private renderWorkItems() {
const work = this.work;
if (!work) {
return nothing;
@@ -140,187 +159,146 @@ class SessionMenu extends OpenClawLightDomElement {
const pullRequestUrl = work.pullRequestUrl;
const worktreePath = work.worktreePath;
return html`
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="open-pr"
data-shortcut="g"
aria-keyshortcuts="G"
?disabled=${this.disabled || !pullRequestUrl}
@click=${() => {
if (pullRequestUrl) {
this.runAction({ kind: "open-pr", url: pullRequestUrl });
}
}}
>
<span class="session-menu__icon" aria-hidden="true">${icons.gitPullRequest}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.gitPullRequest}</span
>
<span class="session-menu__text">${t("sessionsView.openPullRequest")}</span>
${menuShortcutHint("g")}
</button>
<div
class="session-menu__submenu-host"
@pointerenter=${() => {
if (worktreePath) {
this.openSubmenu = "editor";
}
}}
@pointerleave=${() => {
this.openSubmenu = null;
}}
>
<button
type="button"
class="session-menu__item"
role="menuitem"
aria-haspopup="menu"
aria-expanded=${String(this.openSubmenu === "editor")}
?disabled=${this.disabled || !worktreePath}
@click=${() => {
if (worktreePath) {
this.openSubmenu = this.openSubmenu === "editor" ? null : "editor";
}
}}
>
<span class="session-menu__icon" aria-hidden="true">${icons.externalLink}</span>
<span class="session-menu__text">${t("sessionsView.openInEditorMenu")}</span>
<span class="session-menu__chevron" aria-hidden="true">${icons.chevronRight}</span>
</button>
${this.openSubmenu === "editor" && worktreePath
? this.renderEditorSubmenu(worktreePath, submenuLeft)
: nothing}
</div>
</wa-dropdown-item>
<wa-dropdown-item class="session-menu__item" ?disabled=${this.disabled || !worktreePath}>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.externalLink}</span>
<span class="session-menu__text">${t("sessionsView.openInEditorMenu")}</span>
${worktreePath ? this.renderEditorSubmenu() : nothing}
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
`;
}
private renderEditorSubmenu(path: string, submenuLeft: boolean) {
private renderEditorSubmenu() {
return html`
<div
class="session-menu session-menu__submenu ${submenuLeft
? "session-menu__submenu--left"
: ""}"
role="menu"
aria-label=${t("sessionsView.openInEditorMenu")}
>
${EDITOR_IDS.map(
(editor) => html`
<button
type="button"
class="session-menu__item"
role="menuitem"
?disabled=${this.disabled}
@click=${() => this.runAction({ kind: "open-in", editor, path })}
>
<span class="session-menu__check" aria-hidden="true"></span>
<span class="session-menu__text">${EDITOR_LABELS[editor]}</span>
</button>
`,
)}
</div>
${EDITOR_IDS.map(
(editor) => html`
<wa-dropdown-item
slot="submenu"
class="session-menu__item"
value=${`open-in:${editor}`}
?disabled=${this.disabled}
>
<span class="session-menu__text">${EDITOR_LABELS[editor]}</span>
</wa-dropdown-item>
`,
)}
`;
}
private renderGroupSubmenu(submenuLeft: boolean) {
private renderGroupSubmenu() {
const session = this.session;
// Entries are numbered like the digits users see: existing groups first,
// then the ungroup entry, then New group…; entries past 9 stay unnumbered
// rather than reusing digits.
let nextDigit = 1;
const takeDigit = () => (nextDigit <= 9 ? String(nextDigit++) : null);
const entry = (label: string, checked: boolean, action: SessionMenuAction) => {
const entry = (label: string, checked: boolean, value: string, radio = true) => {
const digit = takeDigit();
return html`
<button
type="button"
<wa-dropdown-item
slot="submenu"
class="session-menu__item"
role="menuitem"
value=${value}
role=${radio ? "menuitemradio" : "menuitem"}
aria-checked=${radio ? String(checked) : nothing}
${radio ? ref((element) => syncDropdownItemRadio(element, checked)) : nothing}
data-shortcut=${digit ?? nothing}
aria-keyshortcuts=${digit ?? nothing}
?disabled=${this.disabled}
@click=${() => this.runAction(action)}
>
<span class="session-menu__check" aria-hidden="true"
>${checked ? icons.check : nothing}</span
>
<span class="session-menu__text">${label}</span>
${radio && checked
? html`<span slot="details" class="session-menu__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
${digit ? menuShortcutHint(digit) : nothing}
</button>
</wa-dropdown-item>
`;
};
return html`
<div
class="session-menu session-menu__submenu ${submenuLeft
? "session-menu__submenu--left"
: ""}"
role="menu"
aria-label=${t("sessionsView.moveToGroupMenu")}
>
${this.groups.map((group) =>
entry(group, session.category === group, { kind: "move-to-group", category: group }),
)}
${session.category
? entry(t("sessionsView.removeFromGroup"), false, {
kind: "move-to-group",
category: null,
})
: nothing}
${this.groups.length > 0 || session.category
? html`<div class="session-menu__separator" role="separator"></div>`
: nothing}
${entry(t("sessionsView.newGroup"), false, { kind: "new-group" })}
</div>
${this.groups.map((group) =>
entry(group, session.category === group, `move-to-group:${encodeURIComponent(group)}`),
)}
${session.category
? entry(t("sessionsView.removeFromGroup"), false, "move-to-group:", false)
: nothing}
${entry(t("sessionsView.newGroup"), false, "new-group", false)}
`;
}
override render() {
const menuWidth = 240;
const menuMaxHeight = 460;
const clampedX = Math.max(8, Math.min(this.x, window.innerWidth - menuWidth - 8));
const clampedY = Math.max(8, Math.min(this.y, window.innerHeight - menuMaxHeight - 8));
const submenuLeft = clampedX + menuWidth * 2 + 4 > window.innerWidth - 8;
const clampedX = Math.max(8, Math.min(this.anchor.x, window.innerWidth - menuWidth - 8));
const clampedY = Math.max(8, Math.min(this.anchor.y, window.innerHeight - menuMaxHeight - 8));
const session = this.session;
const batch = this.selectionCount > 1;
const count = String(this.selectionCount);
return html`
<div
const menuLabel = batch
? t("chat.sidebar.sessionMenuMany", { count })
: t("chat.sidebar.sessionMenu", { session: session.label });
return keyed(
this.anchor,
html`<wa-dropdown
class="session-menu"
role="menu"
aria-label=${batch
? t("chat.sidebar.sessionMenuMany", { count })
: t("chat.sidebar.sessionMenu", { session: session.label })}
style="left: ${clampedX}px; top: ${clampedY}px;"
.open=${true}
placement="bottom-start"
.distance=${0}
aria-label=${menuLabel}
@wa-select=${this.handleSelect}
@wa-after-hide=${this.handleAfterHide}
>
<button
slot="trigger"
type="button"
tabindex="-1"
aria-hidden="true"
aria-label=${menuLabel}
style="position: fixed; left: ${clampedX}px; top: ${clampedY}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${!batch && this.canOpenChat
? html`
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="open-chat"
data-shortcut="o"
aria-keyshortcuts="O"
?disabled=${this.disabled}
@click=${() => this.runAction({ kind: "open-chat" })}
>
<span class="session-menu__icon" aria-hidden="true">${icons.messageSquare}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.messageSquare}</span
>
<span class="session-menu__text">${t("sessionsView.openChat")}</span>
${menuShortcutHint("o")}
</button>
</wa-dropdown-item>
`
: nothing}
${batch ? nothing : this.renderWorkItems(submenuLeft)}
${batch ? nothing : this.renderWorkItems()}
${batch
? nothing
: html`
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="toggle-pin"
data-shortcut="p"
aria-keyshortcuts="P"
?disabled=${this.disabled || session.archived}
@click=${() => this.runAction({ kind: "toggle-pin" })}
>
<span class="session-menu__icon" aria-hidden="true"
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.pinned ? icons.pinOff : icons.pin}</span
>
<span class="session-menu__text"
@@ -329,18 +307,16 @@ class SessionMenu extends OpenClawLightDomElement {
: t("sessionsView.pinSession")}</span
>
${menuShortcutHint("p")}
</button>
</wa-dropdown-item>
`}
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="toggle-unread"
data-shortcut="u"
aria-keyshortcuts="U"
?disabled=${this.disabled}
@click=${() => this.runAction({ kind: "toggle-unread" })}
>
<span class="session-menu__icon" aria-hidden="true"
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.unread ? icons.eye : icons.circle}</span
>
<span class="session-menu__text"
@@ -353,49 +329,43 @@ class SessionMenu extends OpenClawLightDomElement {
: t("sessionsView.markUnread")}</span
>
${menuShortcutHint("u")}
</button>
</wa-dropdown-item>
${batch
? nothing
: html`
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="rename"
data-shortcut="r"
aria-keyshortcuts="R"
?disabled=${this.disabled}
@click=${() => this.runAction({ kind: "rename" })}
>
<span class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.edit}</span>
<span class="session-menu__text">${t("sessionsView.renameSessionMenu")}</span>
${menuShortcutHint("r")}
</button>
<button
type="button"
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="fork"
data-shortcut="f"
aria-keyshortcuts="F"
?disabled=${this.disabled || this.forkDisabled}
@click=${() => this.runAction({ kind: "fork" })}
>
<span class="session-menu__icon" aria-hidden="true">${icons.copy}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.copy}</span>
<span class="session-menu__text">${t("sessionsView.forkSession")}</span>
${menuShortcutHint("f")}
</button>
</wa-dropdown-item>
`}
${!batch && this.workboard
? html`
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="workboard"
data-shortcut="w"
aria-keyshortcuts="W"
?disabled=${this.disabled || this.workboard.busy}
@click=${() => this.runAction({ kind: "workboard" })}
>
<span class="session-menu__icon" aria-hidden="true"
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${this.workboard.captured ? icons.check : icons.plus}</span
>
<span class="session-menu__text"
@@ -404,50 +374,27 @@ class SessionMenu extends OpenClawLightDomElement {
: t("sessionsView.addToWorkboard")}</span
>
${menuShortcutHint("w")}
</button>
</wa-dropdown-item>
`
: nothing}
<div
class="session-menu__submenu-host"
@pointerenter=${() => {
this.openSubmenu = "group";
}}
@pointerleave=${() => {
this.openSubmenu = null;
}}
>
<button
type="button"
class="session-menu__item"
role="menuitem"
aria-haspopup="menu"
aria-expanded=${String(this.openSubmenu === "group")}
?disabled=${this.disabled}
@click=${() => {
this.openSubmenu = this.openSubmenu === "group" ? null : "group";
}}
<wa-dropdown-item class="session-menu__item" ?disabled=${this.disabled}>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text"
>${batch
? t("sessionsView.moveToGroupMenuCount", { count })
: t("sessionsView.moveToGroupMenu")}</span
>
<span class="session-menu__icon" aria-hidden="true">${icons.folder}</span>
<span class="session-menu__text"
>${batch
? t("sessionsView.moveToGroupMenuCount", { count })
: t("sessionsView.moveToGroupMenu")}</span
>
<span class="session-menu__chevron" aria-hidden="true">${icons.chevronRight}</span>
</button>
${this.openSubmenu === "group" ? this.renderGroupSubmenu(submenuLeft) : nothing}
</div>
${this.renderGroupSubmenu()}
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
<button
type="button"
<wa-dropdown-item
class="session-menu__item"
role="menuitem"
value="toggle-archived"
data-shortcut="a"
aria-keyshortcuts="A"
?disabled=${this.disabled || (!session.archived && !this.archiveAllowed)}
@click=${() => this.runAction({ kind: "toggle-archived" })}
>
<span class="session-menu__icon" aria-hidden="true"
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${session.archived ? icons.archiveRestore : icons.archive}</span
>
<span class="session-menu__text"
@@ -458,26 +405,25 @@ class SessionMenu extends OpenClawLightDomElement {
: t("sessionsView.archiveSession")}</span
>
${menuShortcutHint("a")}
</button>
<button
type="button"
</wa-dropdown-item>
<wa-dropdown-item
class="session-menu__item session-menu__item--destructive"
role="menuitem"
value="delete"
variant="danger"
data-shortcut="d"
aria-keyshortcuts="D"
?disabled=${this.disabled || !(session.archived || this.archiveAllowed)}
@click=${() => this.runAction({ kind: "delete" })}
>
<span class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.trash}</span>
<span class="session-menu__text"
>${batch
? t("sessionsView.deleteSessionCount", { count })
: t("sessionsView.deleteSessionMenu")}</span
>
${menuShortcutHint("d")}
</button>
</div>
`;
</wa-dropdown-item>
</wa-dropdown>`,
);
}
}
+64 -35
View File
@@ -2,6 +2,9 @@
// layout through these helpers so pages cannot drift back into bespoke
// card/pill markup. Styles live in ui/src/styles/settings.css; rules in
// ui/docs/settings-design.md.
import "@awesome.me/webawesome/dist/components/radio/radio.js";
import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js";
import "@awesome.me/webawesome/dist/components/switch/switch.js";
import { html, nothing, type TemplateResult } from "lit";
import { icons } from "./icons.ts";
@@ -126,18 +129,17 @@ export function renderSettingsToggle(props: {
ariaLabel: string;
}): TemplateResult {
return html`
<label class="settings-toggle">
<input
type="checkbox"
.checked=${props.checked}
?disabled=${props.disabled ?? false}
aria-label=${props.ariaLabel}
@change=${(event: Event) => {
props.onChange((event.target as HTMLInputElement).checked);
}}
/>
<span class="settings-toggle__track"></span>
</label>
<wa-switch
class="settings-toggle"
size="s"
.checked=${props.checked}
?disabled=${props.disabled ?? false}
@change=${(event: Event) => {
props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked);
}}
>
<span class="settings-control__sr-label">${props.ariaLabel}</span>
</wa-switch>
`;
}
@@ -151,7 +153,16 @@ export function renderSettingsToggleRow(props: {
disabled?: boolean;
}): TemplateResult {
return html`
<label class="settings-row settings-row--toggle">
<div
class="settings-row settings-row--toggle"
@click=${(event: MouseEvent) => {
const target = event.target;
if (props.disabled || (target instanceof Element && target.closest("wa-switch") !== null)) {
return;
}
props.onChange(!props.checked);
}}
>
<div class="settings-row__text">
<span class="settings-row__title">${props.title}</span>
${props.description
@@ -159,49 +170,67 @@ export function renderSettingsToggleRow(props: {
: nothing}
</div>
<div class="settings-row__control">
<span class="settings-toggle">
<input
type="checkbox"
.checked=${props.checked}
?disabled=${props.disabled ?? false}
@change=${(event: Event) => {
props.onChange((event.target as HTMLInputElement).checked);
}}
/>
<span class="settings-toggle__track"></span>
</span>
<wa-switch
class="settings-toggle"
size="s"
.checked=${props.checked}
?disabled=${props.disabled ?? false}
@change=${(event: Event) => {
props.onChange((event.currentTarget as HTMLElement & { checked: boolean }).checked);
}}
>
<span class="settings-control__sr-label">${props.title}</span>
</wa-switch>
</div>
</label>
</div>
`;
}
export function renderSettingsSegmented<T extends string>(props: {
value: T;
options: ReadonlyArray<{ value: T; label: unknown; title?: string }>;
/** The clicked button is passed so callers can anchor transitions on it. */
onChange: (value: T, event: MouseEvent) => void;
/** The selected radio is passed so callers can anchor visual transitions. */
onChange: (value: T, element: HTMLElement) => void;
disabled?: boolean;
ariaLabel?: string;
}): TemplateResult {
return html`
<div class="settings-segmented" role="group" aria-label=${props.ariaLabel ?? nothing}>
<wa-radio-group
class="settings-segmented"
size="s"
orientation="horizontal"
.value=${props.value}
?disabled=${props.disabled ?? false}
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: string }).value;
if (value !== undefined) {
const group = event.currentTarget as HTMLElement;
const selected = [...group.querySelectorAll<HTMLElement>("wa-radio")].find(
(radio) => radio.getAttribute("value") === value,
);
props.onChange(value as T, selected ?? group);
}
}}
>
${props.ariaLabel
? html`<span slot="label" class="settings-control__sr-label">${props.ariaLabel}</span>`
: nothing}
${props.options.map(
(option) => html`
<button
type="button"
<wa-radio
class="settings-segmented__btn ${option.value === props.value
? "settings-segmented__btn--active"
: ""}"
aria-pressed=${option.value === props.value ? "true" : "false"}
appearance="button"
value=${option.value}
.checked=${option.value === props.value}
title=${option.title ?? nothing}
?disabled=${props.disabled ?? false}
@click=${(event: MouseEvent) => props.onChange(option.value, event)}
>
${option.label}
</button>
</wa-radio>
`,
)}
</div>
</wa-radio-group>
`;
}
@@ -0,0 +1,97 @@
import { html, nothing, svg } from "lit";
import { t } from "../../i18n/index.ts";
import "../web-awesome-tabs.ts";
export type TerminalPanelTab = {
id: string;
sequence: number;
shellName: string | null;
agentId: string | null;
cwd: string | null;
status: "live" | "exited";
exitReason?: string;
exitCode?: number | null;
};
const TERMINAL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4l3 3-3 3M8 11h5" /></svg>`;
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
function terminalTabLabel(tab: TerminalPanelTab): string {
return tab.shellName ?? t("terminal.tabLabel", { n: String(tab.sequence) });
}
function terminalTabHint(tab: TerminalPanelTab): string | null {
return tab.agentId === null || tab.cwd === null
? null
: t("terminal.tabHint", { agent: tab.agentId, cwd: tab.cwd });
}
function terminalTabStatusLabel(tab: TerminalPanelTab): string | null {
if (tab.status !== "exited") {
return null;
}
if (tab.exitReason === "detached") {
return t("terminal.detached");
}
return tab.exitReason === "process_exit" && typeof tab.exitCode === "number"
? t("terminal.exitedCode", { code: String(tab.exitCode) })
: t("terminal.exited");
}
export function renderTerminalPanelTabs(params: {
tabs: TerminalPanelTab[];
activeId: string | null;
booting: boolean;
onSelect: (id: string) => void;
onClose: (id: string) => void;
onNew: () => void;
}) {
return html`
<wa-tab-group
class="tp-tabs"
.active=${params.activeId ?? ""}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => params.onSelect(event.detail.name)}
>
${params.tabs.map((tab) => {
const statusLabel = terminalTabStatusLabel(tab);
return html`
<wa-tab
id=${`terminal-tab-${tab.id}`}
class="tp-tab ${tab.status === "exited" ? "is-exited" : ""}"
panel=${tab.id}
aria-controls="terminal-tab-panel"
title=${terminalTabHint(tab) || nothing}
>
<span class="tp-tab__icon" aria-hidden="true">${TERMINAL_GLYPH}</span>
<span class="tp-tab__label">${terminalTabLabel(tab)}</span>
${statusLabel ? html`<span class="tp-tab__status">${statusLabel}</span>` : nothing}
</wa-tab>
<button
slot="nav"
class="tp-tab__close"
type="button"
title=${t("terminal.closeSession")}
aria-label=${`${t("terminal.closeSession")}: ${terminalTabLabel(tab)}`}
@click=${() => params.onClose(tab.id)}
>
${CLOSE_GLYPH}
</button>
`;
})}
<button
slot="nav"
class="tp-new"
type="button"
?disabled=${params.booting}
title=${t("terminal.newSession")}
aria-label=${t("terminal.newSession")}
@click=${params.onNew}
>
${PLUS_GLYPH}
</button>
</wa-tab-group>
`;
}
+35 -90
View File
@@ -16,30 +16,20 @@ import {
type TerminalPanelToggleDetail,
} from "../panel-toggle-contract.ts";
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { renderTerminalPanelTabs, type TerminalPanelTab } from "./terminal-panel-tabs.ts";
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
import { terminalTheme } from "./terminal-theme.ts";
// Inline icon set (self-contained; the Control UI blocks external asset loads).
const TERMINAL_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4l3 3-3 3M8 11h5" /></svg>`;
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const PLUS_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 3v10M3 8h10" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
type TerminalDock = DockPanelSide;
type TerminalTabState = {
id: string;
sequence: number;
type TerminalTabState = TerminalPanelTab & {
gatewaySessionId: string;
/** Shell basename shown on the tab, e.g. "zsh". */
shellName: string | null;
agentId: string | null;
cwd: string | null;
controller: GhosttyTerminalController;
host: HTMLDivElement;
status: "live" | "exited";
exitReason?: string;
exitCode?: number | null;
/** Why an in-flight open/attach must not adopt this disposed terminal. */
cancelled?: "close" | "lifecycle";
};
@@ -56,29 +46,6 @@ function shellBasename(shell: string): string {
return base && base.length > 0 ? base : "shell";
}
function terminalTabLabel(tab: TerminalTabState): string {
return tab.shellName ?? t("terminal.tabLabel", { n: String(tab.sequence) });
}
function terminalTabHint(tab: TerminalTabState): string | null {
if (tab.agentId === null || tab.cwd === null) {
return null;
}
return t("terminal.tabHint", { agent: tab.agentId, cwd: tab.cwd });
}
function terminalTabStatusLabel(tab: TerminalTabState): string | null {
if (tab.status !== "exited") {
return null;
}
if (tab.exitReason === "detached") {
return t("terminal.detached");
}
return tab.exitReason === "process_exit" && typeof tab.exitCode === "number"
? t("terminal.exitedCode", { code: String(tab.exitCode) })
: t("terminal.exited");
}
const panelLayout = createDockPanelLayout({
storageKey: "openclaw.terminal.panel.v1",
minHeight: 140,
@@ -839,51 +806,14 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
aria-label=${t("terminal.resize")}
></div>`}
<header class="tp-header">
<div class="tp-tabs" role="tablist">
${this.tabs.map((tab) => {
const statusLabel = terminalTabStatusLabel(tab);
return html`
<div
class="tp-tab ${tab.id === this.activeId ? "is-active" : ""} ${tab.status ===
"exited"
? "is-exited"
: ""}"
role="tab"
title=${terminalTabHint(tab) || nothing}
aria-selected=${tab.id === this.activeId ? "true" : "false"}
@click=${() => this.switchTo(tab.id)}
>
<span class="tp-tab__icon" aria-hidden="true">${TERMINAL_GLYPH}</span>
<span class="tp-tab__label">${terminalTabLabel(tab)}</span>
${statusLabel
? html`<span class="tp-tab__status">${statusLabel}</span>`
: nothing}
<button
class="tp-tab__close"
type="button"
title=${t("terminal.closeSession")}
aria-label=${t("terminal.closeSession")}
@click=${(e: Event) => {
e.stopPropagation();
this.closeTab(tab.id);
}}
>
${CLOSE_GLYPH}
</button>
</div>
`;
})}
<button
class="tp-new"
type="button"
?disabled=${this.booting}
title=${t("terminal.newSession")}
aria-label=${t("terminal.newSession")}
@click=${() => void this.openSession()}
>
${PLUS_GLYPH}
</button>
</div>
${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`<div class="tp-actions">
@@ -919,11 +849,17 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
${this.errorText
? html`<div class="tp-error" role="alert">${this.errorText}</div>`
: nothing}
<div class="tp-viewport">
<wa-tab-panel
id="terminal-tab-panel"
class="tp-viewport"
name=${this.activeId ?? "terminal"}
active
aria-labelledby=${this.activeId ? `terminal-tab-${this.activeId}` : nothing}
>
${this.booting && this.tabs.length === 0
? html`<div class="tp-empty">${t("terminal.starting")}</div>`
: nothing}
</div>
</wa-tab-panel>
</section>
`;
}
@@ -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,
+71 -19
View File
@@ -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<HTMLElement & { open: boolean }>("wa-tooltip");
}
function expectOpenCount(count: number) {
const open = [...document.querySelectorAll<TooltipElement>("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);
});
});
+236 -319
View File
@@ -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<TooltipProvider>("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<TooltipProvider>("openclaw-tooltip-provider");
return this.tooltipProvider ?? this.closest<TooltipProvider>("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`<slot @slotchange=${() => this.attachTrigger()}></slot>`;
return html`
<slot @slotchange=${() => this.attachTrigger()}></slot>
<wa-tooltip id=${this.tooltipId} trigger="manual">${this.content}</wa-tooltip>
`;
}
}
@@ -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;
}
}
@@ -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<string[]> {
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<string[]> {
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(/<dialog\b/u)).toEqual([]);
expect(
await matchingFiles(/<[a-z][^>]*\srole=["'](?:menu|menubar|menuitem|tab|tablist)["']/u),
).toEqual([]);
expect(
await matchingFiles(/<details\b[^>]*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(/<resizable-divider\b/u)).toEqual([
"app/app-host.ts",
"pages/chat/chat-page.ts",
"pages/chat/chat-view.ts",
]);
});
});
+2
View File
@@ -0,0 +1,2 @@
// Route-local registration keeps the popover implementation out of startup.
import "@awesome.me/webawesome/dist/components/popover/popover.js";
+3
View File
@@ -0,0 +1,3 @@
// Route-local registration keeps form controls out of startup.
import "@awesome.me/webawesome/dist/components/option/option.js";
import "@awesome.me/webawesome/dist/components/select/select.js";
+4
View File
@@ -0,0 +1,4 @@
// Route-local registration keeps tab internals out of startup.
import "@awesome.me/webawesome/dist/components/tab-group/tab-group.js";
import "@awesome.me/webawesome/dist/components/tab-panel/tab-panel.js";
import "@awesome.me/webawesome/dist/components/tab/tab.js";
+96
View File
@@ -0,0 +1,96 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it } from "vitest";
import {
consumeDropdownKeyboardDismissal,
syncDropdownItemRadio,
trackDropdownKeyboardDismissal,
} from "./web-awesome.ts";
type DropdownElement = HTMLElement & { readonly updateComplete: Promise<unknown> };
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");
});
});
+108
View File
@@ -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<EventTarget>();
/** 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<unknown> };
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement, MutationObserver>();
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);
}
@@ -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(),
+61 -48
View File
@@ -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`
<div class="workspace-widget__menu" role="menu">
<button
class="workspace-widget__menu-item"
type="button"
role="menuitem"
@click=${() => callbacks.onEditTitle(widget)}
>
${t("workspaces.widget.menu.editTitle")}
</button>
<button
class="workspace-widget__menu-item"
type="button"
role="menuitem"
@click=${() => callbacks.onMoveToTab(widget)}
>
${t("workspaces.widget.menu.moveToTab")}
</button>
<button
class="workspace-widget__menu-item"
type="button"
role="menuitem"
@click=${() => callbacks.onHide(widget)}
>
${t("workspaces.widget.menu.hide")}
</button>
<button
class="workspace-widget__menu-item workspace-widget__menu-item--danger"
type="button"
role="menuitem"
@click=${() => callbacks.onRemove(widget)}
>
${t("workspaces.widget.menu.remove")}
</button>
</div>
<wa-dropdown-item class="workspace-widget__menu-item" value="edit-title">
${t("workspaces.widget.menu.editTitle")}
</wa-dropdown-item>
<wa-dropdown-item class="workspace-widget__menu-item" value="move-to-tab">
${t("workspaces.widget.menu.moveToTab")}
</wa-dropdown-item>
<wa-dropdown-item class="workspace-widget__menu-item" value="hide">
${t("workspaces.widget.menu.hide")}
</wa-dropdown-item>
<wa-dropdown-item
class="workspace-widget__menu-item workspace-widget__menu-item--danger"
value="remove"
variant="danger"
>
${t("workspaces.widget.menu.remove")}
</wa-dropdown-item>
`;
}
@@ -316,18 +297,50 @@ export function renderWidgetCell(props: WorkspaceWidgetCellProps): TemplateResul
@keydown=${(event: KeyboardEvent) => handleNudgeKey(event, widget, "move", callbacks)}
>${icons.arrowUpDown}</span
>
<button
class="workspace-widget__menu-toggle"
type="button"
aria-haspopup="menu"
aria-expanded=${props.menuOpen ? "true" : "false"}
aria-label=${t("workspaces.widget.menuLabel")}
<wa-dropdown
class="workspace-widget__menu"
placement="bottom-end"
.open=${props.menuOpen}
@pointerdown=${(event: PointerEvent) => event.stopPropagation()}
@click=${() => callbacks.onToggleMenu(widget)}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
switch (event.detail.item.value) {
case "edit-title":
callbacks.onEditTitle(widget);
break;
case "move-to-tab":
callbacks.onMoveToTab(widget);
break;
case "hide":
callbacks.onHide(widget);
break;
case "remove":
callbacks.onRemove(widget);
break;
case undefined:
break;
}
}}
@wa-show=${() => {
if (!props.menuOpen) {
callbacks.onToggleMenu(widget);
}
}}
@wa-hide=${() => {
if (props.menuOpen) {
callbacks.onCloseMenu(widget);
}
}}
>
${icons.moreHorizontal}
</button>
${props.menuOpen ? renderMenu(widget, callbacks) : nothing}
<button
slot="trigger"
class="workspace-widget__menu-toggle"
type="button"
aria-label=${t("workspaces.widget.menuLabel")}
>
${icons.moreHorizontal}
</button>
${renderMenu()}
</wa-dropdown>
</header>
${widget.collapsed
? nothing
+12 -5
View File
@@ -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");
@@ -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
+14 -15
View File
@@ -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");
+15 -5
View File
@@ -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);
@@ -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();
}
+2 -1
View File
@@ -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())
+14 -3
View File
@@ -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(() =>
+5 -2
View File
@@ -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");
+11 -6
View File
@@ -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(
@@ -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(() => {
+116 -66
View File
@@ -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<void>((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");
+43 -18
View File
@@ -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<void> {
await item.evaluate((element) => (element as HTMLElement).click());
}
function trimmedTextContents(locator: Locator): Promise<string[]> {
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",
});
+98 -14
View File
@@ -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();
}
-64
View File
@@ -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);
});
});
-48
View File
@@ -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<HTMLElement>("[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<HTMLElement>("[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();
}
@@ -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");
}
+30 -37
View File
@@ -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<OpenClawModalDialog>("openclaw-modal-dialog")
?.show();
}}
>
${icons.eye} ${t("agents.files.preview")}
@@ -533,24 +523,12 @@ export function renderAgentFiles(params: {
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
aria-labelledby=${previewTitleId}
@click=${(e: Event) => {
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,
);
<openclaw-modal-dialog
manual
label=${activeEntry.name}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${(e: Event) => {
resetAgentFilePreview(e.currentTarget as HTMLElement);
}}
>
<div class="md-preview-dialog__panel">
@@ -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<HTMLElement>(".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);
}
}}
>
<span aria-hidden="true">${icons.x}</span>
@@ -651,7 +644,7 @@ export function renderAgentFiles(params: {
</article>
</div>
</div>
</dialog>
</openclaw-modal-dialog>
`}
</div>
`,
@@ -290,7 +290,7 @@ describe("agents tools panel (browser)", () => {
const tool = container.querySelector<HTMLDetailsElement>(".agent-tool-card");
const summary = container.querySelector<HTMLElement>(".agent-tool-summary");
const toggle = container.querySelector<HTMLInputElement>(".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);
+3 -3
View File
@@ -647,11 +647,11 @@ describe("renderAgentFiles", () => {
container,
);
const dialog = container.querySelector<HTMLDialogElement>(".md-preview-dialog");
const dialog = container.querySelector("openclaw-modal-dialog");
const panel = container.querySelector<HTMLElement>(".md-preview-dialog__panel");
const expandButton = container.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>('[aria-label="Close preview"]')?.click();
expect([...previewPanel.classList]).toEqual(["md-preview-dialog__panel"]);
expect([...previewExpandButton.classList]).toEqual([
+24 -9
View File
@@ -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`
<div class="channels-wizard__message">${step.message ?? ""}</div>
<div class="channels-wizard__options" role="listbox">
<wa-radio-group
class="channels-wizard__options"
label=${step.message ?? ""}
orientation="vertical"
.value=${selectedIndex >= 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`
<button
type="button"
(option: ChannelWizardStepOption, index) => html`
<wa-radio
class="channels-wizard__option"
aria-pressed=${option.value === step.initialValue ? "true" : "false"}
@click=${() => props.onAnswer(option.value)}
appearance="button"
value=${String(index)}
.checked=${index === selectedIndex}
>
<span class="channels-wizard__option-label">${option.label}</span>
${option.hint
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
: nothing}
</button>
</wa-radio>
`,
)}
</div>
</wa-radio-group>
`;
}
+28 -15
View File
@@ -55,7 +55,11 @@ function createProps(overrides: Record<string, unknown> = {}): ChatControlsProps
}
function menuItems(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLButtonElement>(".chat-view-menu__item"));
return Array.from(
container.querySelectorAll<HTMLElement & { checked: boolean; disabled: boolean }>(
".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();
});
@@ -187,12 +187,12 @@ function composerControlsHtml() {
return `
<div class="agent-chat__composer-controls">
<div class="chat-view-menu-wrapper">
<button class="chat-view-menu-trigger" type="button" aria-label="View">
${iconSvg()}
</button>
<div class="chat-view-menu" role="menu" aria-label="View">
<button class="chat-view-menu__item" role="menuitemcheckbox">Reasoning</button>
</div>
<wa-dropdown class="chat-view-menu" aria-label="View">
<button slot="trigger" class="chat-view-menu-trigger" type="button" aria-label="View">
${iconSvg()}
</button>
<wa-dropdown-item class="chat-view-menu__item" type="checkbox">Reasoning</wa-dropdown-item>
</wa-dropdown>
</div>
<div class="chat-composer-model-control">
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
@@ -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);
+13 -5
View File
@@ -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<HTMLButtonElement>(".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();
});
+48 -54
View File
@@ -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<File, "name" | "type">): 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<HTMLInputElement>(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) {
})}
<div class="agent-chat__composer-input-row">
<details class="agent-chat__attach-menu">
<summary
<wa-dropdown
class="agent-chat__attach-menu"
placement="top-start"
aria-label=${t("chat.composer.addAttachment")}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
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;
}
}}
>
<button
slot="trigger"
type="button"
class="agent-chat__input-btn agent-chat__input-btn--attach"
aria-label=${t("chat.composer.addAttachment")}
aria-disabled=${canCompose ? "false" : "true"}
?disabled=${!canCompose}
title=${t("chat.composer.addAttachment")}
@pointerdown=${(event: PointerEvent) => {
if (document.activeElement === composerTextarea) {
event.preventDefault();
}
}}
@click=${(event: MouseEvent) => {
if (!canCompose) {
event.preventDefault();
}
}}
>
${icons.plus}
</summary>
<div
class="agent-chat__attach-menu-popover"
role="menu"
aria-label=${t("chat.composer.addAttachment")}
>
<button
type="button"
class="agent-chat__attach-menu-option"
role="menuitem"
@click=${clickComposerCameraInput}
>
${icons.camera}
<span>${t("chat.composer.takePhoto")}</span>
</button>
<button
type="button"
class="agent-chat__attach-menu-option"
role="menuitem"
@click=${clickComposerPhotoInput}
>
${icons.image}
<span>${t("chat.composer.attachPhoto")}</span>
</button>
<button
type="button"
class="agent-chat__attach-menu-option"
role="menuitem"
@click=${clickComposerFileInput}
>
${icons.folder}
<span>${t("chat.composer.attachFileOption")}</span>
</button>
</div>
</details>
</button>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="camera">
<span slot="icon" aria-hidden="true">${icons.camera}</span>
<span>${t("chat.composer.takePhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="photo">
<span slot="icon" aria-hidden="true">${icons.image}</span>
<span>${t("chat.composer.attachPhoto")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="agent-chat__attach-menu-option" value="file">
<span slot="icon" aria-hidden="true">${icons.folder}</span>
<span>${t("chat.composer.attachFileOption")}</span>
</wa-dropdown-item>
</wa-dropdown>
<div class="agent-chat__composer-combobox">
<textarea
${ref((element) => {
+46 -39
View File
@@ -3,6 +3,7 @@ import { html } from "lit";
import type { UiSettings } from "../../../app/settings.ts";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
import { renderChatModelControls, type ChatModelControlsProps } from "./chat-model-controls.ts";
@@ -57,52 +58,58 @@ export function renderChatControls(props: ChatControlsProps) {
const open = props.viewMenuOpen;
const menuTitle = props.onboarding ? t("chat.onboardingDisabled") : t("chat.view.menu");
const menuId = `chat-view-menu-${encodeURIComponent(props.paneId)}`;
const rows = chatViewMenuRows(props);
return html`
<div class="chat-view-menu-wrapper">
<openclaw-tooltip .content=${menuTitle}>
<button
class="chat-view-menu-trigger ${open ? "chat-view-menu-trigger--open" : ""}"
type="button"
<wa-dropdown
id=${menuId}
class="chat-view-menu"
placement="top-start"
aria-label=${menuTitle}
aria-haspopup="menu"
aria-expanded=${open}
aria-controls=${menuId}
@click=${(event: Event) => {
event.stopPropagation();
props.onViewMenuOpenChange(!open, { trigger: event.currentTarget as HTMLElement });
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
const index = value?.startsWith("view-") ? Number(value.slice(5)) : -1;
if (!props.onboarding && Number.isInteger(index)) {
rows[index]?.onToggle();
}
}}
.open=${open}
@wa-show=${() => {
if (!open) {
props.onViewMenuOpenChange(true);
}
}}
@wa-hide=${() => {
if (open) {
props.onViewMenuOpenChange(false, { restoreFocus: true });
}
}}
>
${icons.eye}
</button>
<button
slot="trigger"
class="chat-view-menu-trigger ${open ? "chat-view-menu-trigger--open" : ""}"
type="button"
aria-label=${menuTitle}
>
${icons.eye}
</button>
${rows.map(
(row, index) => html`
<wa-dropdown-item
class="chat-view-menu__item"
type="checkbox"
value=${`view-${index}`}
.checked=${row.checked}
?disabled=${props.onboarding}
>
<span class="chat-view-menu__text">${row.label}</span>
</wa-dropdown-item>
`,
)}
</wa-dropdown>
</openclaw-tooltip>
<div
id=${menuId}
class="chat-view-menu ${open ? "chat-view-menu--open" : ""}"
role="menu"
aria-label=${t("chat.view.menu")}
>
${chatViewMenuRows(props).map(
(row) => html`
<button
type="button"
class="chat-view-menu__item"
role="menuitemcheckbox"
aria-checked=${row.checked}
?disabled=${props.onboarding}
@click=${() => {
if (!props.onboarding) {
row.onToggle();
}
}}
>
<span class="chat-view-menu__check" aria-hidden="true">
${row.checked ? icons.check : ""}
</span>
<span class="chat-view-menu__text">${row.label}</span>
</button>
`,
)}
</div>
</div>
<div
class="chat-composer-model-control"
@@ -0,0 +1,52 @@
import { html, nothing } from "lit";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import "../../../components/web-awesome.ts";
import { EDITOR_IDS, EDITOR_LABELS, type EditorId } from "../../../lib/editor-links.ts";
export function renderChatSidebarEditorMenu(params: {
absolutePath: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenEditor: (editor: EditorId) => void;
}) {
const label = params.absolutePath ? "Open in editor" : "Workspace root unknown";
return html`
<div class="sidebar-file-view__editor">
<openclaw-tooltip .content=${label}>
<wa-dropdown
class="sidebar-file-view__editor-menu"
placement="bottom-end"
.open=${params.open}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
const editor = event.detail.item.value;
if (editor && EDITOR_IDS.includes(editor as EditorId)) {
params.onOpenEditor(editor as EditorId);
}
}}
@wa-show=${() => params.onOpenChange(true)}
@wa-hide=${() => params.onOpenChange(false)}
>
<button
slot="trigger"
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label=${label}
?disabled=${!params.absolutePath}
>
${icons.externalLink}
</button>
${params.absolutePath
? EDITOR_IDS.map(
(editor) => html`
<wa-dropdown-item class="sidebar-file-view__editor-item" value=${editor}>
${EDITOR_LABELS[editor]}
</wa-dropdown-item>
`,
)
: nothing}
</wa-dropdown>
</openclaw-tooltip>
</div>
`;
}
+12 -44
View File
@@ -3,6 +3,7 @@ import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/web-awesome.ts";
import {
handleMarkdownCodeBlockCopy,
markdownFileLinkFromEvent,
@@ -17,14 +18,10 @@ import {
type EmbedSandboxMode,
} from "../../../lib/chat/tool-display.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import {
EDITOR_IDS,
EDITOR_LABELS,
type EditorId,
editorOpenUrl,
} from "../../../lib/editor-links.ts";
import { type EditorId, editorOpenUrl } from "../../../lib/editor-links.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
import "./session-diff-panel.ts";
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
import type { FileEditorViewHandle } from "./file-editor-view.ts";
import type { SessionDiffLoader } from "./session-diff-panel.ts";
@@ -268,7 +265,7 @@ type FileViewControls = {
onSave: () => void;
onSearchInput: (query: string) => void;
onSearchKeydown: (event: KeyboardEvent) => void;
onToggleEditorMenu: () => void;
onEditorMenuOpenChange: (open: boolean) => void;
onToggleSearch: () => void;
};
@@ -358,41 +355,12 @@ function renderFileSidebarContent(
</openclaw-tooltip>
`
: nothing}
<div class="sidebar-file-view__editor">
<openclaw-tooltip
.content=${absolutePath ? "Open in editor" : "Workspace root unknown"}
>
<button
class="btn btn--sm sidebar-file-view__action"
type="button"
aria-label=${absolutePath ? "Open in editor" : "Workspace root unknown"}
aria-haspopup="menu"
aria-expanded=${String(controls.editorMenuOpen)}
?disabled=${!absolutePath}
@click=${controls.onToggleEditorMenu}
>
${icons.externalLink}
</button>
</openclaw-tooltip>
${controls.editorMenuOpen && absolutePath
? html`
<div class="sidebar-file-view__editor-menu" role="menu">
${EDITOR_IDS.map(
(editor) => html`
<button
class="sidebar-file-view__editor-item"
type="button"
role="menuitem"
@click=${() => controls.onOpenEditor(editor)}
>
${EDITOR_LABELS[editor]}
</button>
`,
)}
</div>
`
: nothing}
</div>
${renderChatSidebarEditorMenu({
absolutePath,
open: controls.editorMenuOpen,
onOpenChange: controls.onEditorMenuOpenChange,
onOpenEditor: controls.onOpenEditor,
})}
<openclaw-tooltip content="Copy file contents">
<button
class="btn btn--sm sidebar-file-view__action ${controls.copied
@@ -1297,8 +1265,8 @@ class ChatDetailPanel extends OpenClawLightDomElement {
onSave: this.saveFile,
onSearchInput: this.updateFileSearch,
onSearchKeydown: this.handleFileSearchKeydown,
onToggleEditorMenu: () => {
this.fileEditorMenuOpen = !this.fileEditorMenuOpen;
onEditorMenuOpenChange: (open) => {
this.fileEditorMenuOpen = open;
},
onToggleSearch: this.toggleFileSearch,
},
+11 -11
View File
@@ -56,8 +56,8 @@ describe("supportsSystemInfo", () => {
});
});
describe("ConfigPage settings mode tabs", () => {
it("moves and activates tabs with arrow keys", () => {
describe("ConfigPage settings mode control", () => {
it("uses a Web Awesome radio group to switch modes", () => {
const page = new ConfigPage();
const state = page as unknown as {
pageId: string;
@@ -69,19 +69,19 @@ describe("ConfigPage settings mode tabs", () => {
const container = document.createElement("div");
document.body.append(container);
render(state.renderSettingsModeToggle(), container);
const group = container.querySelector<HTMLElement & { value: string }>("wa-radio-group");
const [quick, advanced] = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
container.querySelectorAll<HTMLElement & { checked: boolean }>("wa-radio"),
);
expect(quick?.tabIndex).toBe(0);
expect(advanced?.tabIndex).toBe(-1);
expect(advanced?.getAttribute("aria-controls")).toBe("config-settings-panel");
quick?.focus();
quick?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
expect(group?.getAttribute("label")).toBe("Settings view");
expect(quick?.checked).toBe(true);
expect(advanced?.checked).toBe(false);
if (group) {
group.value = "advanced";
group.dispatchEvent(new Event("change", { bubbles: true }));
}
expect(document.activeElement).toBe(advanced);
expect(state.settingsMode).toBe("advanced");
});
});
+18 -19
View File
@@ -26,7 +26,6 @@ import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { i18n, isSupportedLocale, t, type Locale } from "../../i18n/index.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
import { handleTabListKeydown } from "../../lib/tab-list.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
@@ -959,30 +958,31 @@ export class ConfigPage extends OpenClawLightDomElement {
["advanced", t("configPage.advanced")],
] as const;
return html`
<div
class="config-view-toggle settings-segmented"
role="tablist"
aria-label=${t("configPage.settingsView")}
<wa-radio-group
class="config-view-toggle qs-segmented"
label=${t("configPage.settingsView")}
.value=${this.settingsMode}
orientation="horizontal"
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: string }).value;
if (value === "quick" || value === "advanced") {
this.settingsMode = value;
}
}}
>
${modes.map(
([mode, label]) => html`
<button
type="button"
class="settings-segmented__btn ${this.settingsMode === mode
? "settings-segmented__btn--active"
: ""}"
role="tab"
aria-selected=${this.settingsMode === mode}
aria-controls="config-settings-panel"
.tabIndex=${this.settingsMode === mode ? 0 : -1}
@keydown=${handleTabListKeydown}
@click=${() => (this.settingsMode = mode)}
<wa-radio
class="qs-segmented__btn"
appearance="button"
value=${mode}
.checked=${this.settingsMode === mode}
>
${label}
</button>
</wa-radio>
`,
)}
</div>
</wa-radio-group>
`;
}
@@ -1010,7 +1010,6 @@ export class ConfigPage extends OpenClawLightDomElement {
this.pageId === "config"
? {
id: "config-settings-panel",
role: "tabpanel",
ariaLabel: t("configPage.content"),
}
: {},
+25
View File
@@ -0,0 +1,25 @@
import { html } from "lit";
import "../../components/web-awesome-select.ts";
import { SUPPORTED_LOCALES, t, type Locale } from "../../i18n/index.ts";
export function renderLanguageSelect(locale: Locale, onChange: (locale: Locale) => void) {
return html`
<wa-select
class="settings-select"
value=${locale}
@change=${(event: Event) =>
onChange((event.currentTarget as HTMLElement & { value: string }).value as Locale)}
>
<span slot="label" class="settings-control__sr-label">${t("quickSettings.language")}</span>
${SUPPORTED_LOCALES.map((option) => {
const key = option.replace(/-([a-zA-Z])/g, (_, character) => character.toUpperCase());
const label = t(`languages.${key}`);
return html`
<wa-option value=${option} .label=${label} .selected=${option === locale}>
${label}
</wa-option>
`;
})}
</wa-select>
`;
}
+53 -34
View File
@@ -6,16 +6,31 @@ import { renderQuickSettings } from "./quick.ts";
type QuickSettingsProps = Parameters<typeof renderQuickSettings>[0];
function expectButtonByText(container: Element, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find(
type QuickControl = HTMLElement & { checked?: boolean; disabled: boolean };
function expectButtonByText(container: Element, text: string): QuickControl {
const button = Array.from(container.querySelectorAll<QuickControl>("button, wa-radio")).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!(button instanceof HTMLButtonElement)) {
if (!(button instanceof HTMLElement)) {
throw new Error(`Expected button labelled ${text}`);
}
return button;
}
function selectRadio(control: QuickControl) {
if (control.checked) {
return;
}
const group = control.closest<HTMLElement & { value: string }>("wa-radio-group");
expect(group).not.toBeNull();
if (!group) {
return;
}
group.value = control.getAttribute("value") ?? "";
group.dispatchEvent(new Event("change", { bubbles: true }));
}
function expectRowByTitle(container: Element, text: string): HTMLElement {
const row = Array.from(container.querySelectorAll<HTMLElement>(".settings-row")).find(
(candidate) => candidate.querySelector(".settings-row__title")?.textContent?.trim() === text,
@@ -152,12 +167,12 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps({ locale: "pt-BR", onLocaleChange })), container);
const row = expectRowByTitle(container, "Language");
const select = row.querySelector("select");
expect(select?.value).toBe("pt-BR");
if (!(select instanceof HTMLSelectElement)) {
const select = row.querySelector<HTMLElement & { value: string }>("wa-select");
if (!(select instanceof HTMLElement)) {
throw new Error("Expected language selector");
}
select.value = "en";
expect(select.getAttribute("value")).toBe("pt-BR");
Object.defineProperty(select, "value", { configurable: true, value: "en" });
select.dispatchEvent(new Event("change"));
expect(onLocaleChange).toHaveBeenCalledWith("en");
});
@@ -406,19 +421,25 @@ describe("renderQuickSettings", () => {
);
const thinkingButton = expectButtonByText(expectRowByTitle(container, "Thinking"), "High");
expect(thinkingButton.disabled).toBe(true);
expect(
(thinkingButton.closest("wa-radio-group") as HTMLElement & { disabled?: boolean }).disabled,
).toBe(true);
thinkingButton.click();
expect(onThinkingChange).not.toHaveBeenCalled();
const fastButton = expectButtonByText(expectRowByTitle(container, "Fast mode"), "Fast");
expect(fastButton.disabled).toBe(true);
expect(
(fastButton.closest("wa-radio-group") as HTMLElement & { disabled?: boolean }).disabled,
).toBe(true);
fastButton.click();
expect(onFastModeChange).not.toHaveBeenCalled();
const profileButton = expectButtonByText(expectRowByTitle(container, "Tool profile"), "full");
expect(profileButton.disabled).toBe(true);
expect(
(profileButton.closest("wa-radio-group") as HTMLElement & { disabled?: boolean }).disabled,
).toBe(true);
profileButton.click();
expect(onToolProfileChange).not.toHaveBeenCalled();
const browserRow = expectRowByTitle(container, "Browser enabled");
expect(browserRow.querySelector("input")?.hasAttribute("disabled")).toBe(true);
expect(browserRow.querySelector("wa-switch")?.hasAttribute("disabled")).toBe(true);
const pendingRow = expectRowByTitle(container, "Unsaved changes");
expect(
[...pendingRow.querySelectorAll<HTMLButtonElement>("button")].every(
@@ -445,7 +466,7 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps({ fastMode: "auto", onFastModeChange })), container);
const row = expectRowByTitle(container, "Fast mode");
const buttons = Array.from(row.querySelectorAll<HTMLButtonElement>("button"));
const buttons = Array.from(row.querySelectorAll<QuickControl>("wa-radio"));
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
"Auto",
"Fast",
@@ -453,10 +474,10 @@ describe("renderQuickSettings", () => {
]);
expect(row.querySelector(".settings-segmented__btn--active")?.textContent?.trim()).toBe("Auto");
expectButtonByText(row, "Auto").click();
selectRadio(expectButtonByText(row, "Auto"));
expect(onFastModeChange).not.toHaveBeenCalled();
expectButtonByText(row, "Standard").click();
selectRadio(expectButtonByText(row, "Standard"));
expect(onFastModeChange).toHaveBeenCalledWith(false);
});
@@ -484,17 +505,18 @@ describe("renderQuickSettings", () => {
);
const browserRow = expectRowByTitle(container, "Browser enabled");
// Toggle rows are one label so the whole row is clickable.
expect(browserRow.tagName).toBe("LABEL");
const browserInput = browserRow.querySelector("input");
expect(browserInput).toBeInstanceOf(HTMLInputElement);
expect((browserInput as HTMLInputElement).checked).toBe(false);
const browserInput = browserRow.querySelector<HTMLElement & { checked: boolean }>("wa-switch");
expect(browserInput).toBeInstanceOf(HTMLElement);
expect(browserInput?.checked).toBe(false);
(browserInput as HTMLInputElement).checked = true;
if (!browserInput) {
throw new Error("Expected browser switch");
}
browserInput.checked = true;
browserInput?.dispatchEvent(new Event("change"));
expect(onBrowserEnabledToggle).toHaveBeenCalledWith(true);
expectButtonByText(container, "full").click();
selectRadio(expectButtonByText(container, "full"));
expect(onToolProfileChange).toHaveBeenCalledWith("full");
const activeProfile = expectButtonByText(container, "messaging");
expect(activeProfile.classList.contains("settings-segmented__btn--active")).toBe(true);
@@ -546,7 +568,7 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps({ textScale: 125, setTextScale })), container);
const textSizeRow = expectRowByTitle(container, "Text size");
const active = Array.from(textSizeRow.querySelectorAll("button")).find((button) =>
const active = Array.from(textSizeRow.querySelectorAll("wa-radio")).find((button) =>
button.classList.contains("settings-segmented__btn--active"),
);
expect(active?.textContent?.trim()).toBe("XL");
@@ -554,7 +576,7 @@ describe("renderQuickSettings", () => {
const xxlButton = expectButtonByText(textSizeRow, "XXL");
expect(xxlButton.getAttribute("title")).toBe("140%");
xxlButton.click();
selectRadio(xxlButton);
expect(setTextScale).toHaveBeenCalledWith(140);
});
@@ -566,7 +588,7 @@ describe("renderQuickSettings", () => {
const modeRow = expectRowByTitle(container, "Mode");
const darkButton = expectButtonByText(modeRow, "Dark");
darkButton.click();
selectRadio(darkButton);
expect(setThemeMode).toHaveBeenCalledWith("dark", { element: darkButton });
});
@@ -577,10 +599,9 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps({ setLobsterPetVisits })), container);
const visitsRow = expectRowByTitle(container, "Lobster visits");
expect(visitsRow.tagName).toBe("LABEL");
const input = visitsRow.querySelector("input");
if (!(input instanceof HTMLInputElement)) {
throw new Error("Expected lobster visits toggle input");
const input = visitsRow.querySelector<HTMLElement & { checked: boolean }>("wa-switch");
if (!(input instanceof HTMLElement)) {
throw new Error("Expected lobster visits switch");
}
input.checked = false;
input.dispatchEvent(new Event("change"));
@@ -940,11 +961,9 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps()), container);
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Import",
),
).toBe(true);
const importButton = expectButtonByText(container, "Import");
expect(importButton.localName).toBe("button");
expect(importButton.closest("wa-radio-group")).toBeNull();
});
it("routes custom clicks into the tweakcn importer until a custom theme exists", () => {
@@ -988,7 +1007,7 @@ describe("renderQuickSettings", () => {
);
const customButton = expectButtonByText(container, "Light Green");
customButton.click();
selectRadio(customButton);
expect(setTheme).toHaveBeenCalledWith("custom", { element: customButton });
expect(onOpenCustomThemeImport).not.toHaveBeenCalled();
+26 -35
View File
@@ -38,11 +38,12 @@ import {
renderSettingsValue,
type SettingsSectionProps,
} from "../../components/settings-ui.ts";
import { SUPPORTED_LOCALES, t, type Locale } from "../../i18n/index.ts";
import { t, type Locale } from "../../i18n/index.ts";
import { formatBytes } from "../../lib/agents/display.ts";
import { resolveAssistantTextAvatar, resolveChatAvatarRenderUrl } from "../../lib/avatar.ts";
import { formatDurationHuman } from "../../lib/format.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import { renderLanguageSelect } from "./language-select.ts";
import { GENERAL_SETTINGS_TARGET_IDS } from "./settings-targets.ts";
// ── Types ──
@@ -375,23 +376,7 @@ function renderGeneralSection(props: QuickSettingsProps) {
return renderSettingsSection({ title: t("nav.settingsGeneral") }, [
renderSettingsRow({
title: t("quickSettings.language"),
control: html`
<select
class="settings-select"
aria-label=${t("quickSettings.language")}
.value=${props.locale}
@change=${(event: Event) => {
props.onLocaleChange((event.target as HTMLSelectElement).value as Locale);
}}
>
${SUPPORTED_LOCALES.map((locale) => {
const key = locale.replace(/-([a-zA-Z])/g, (_, character) => character.toUpperCase());
return html`<option value=${locale} ?selected=${props.locale === locale}>
${t(`languages.${key}`)}
</option>`;
})}
</select>
`,
control: renderLanguageSelect(props.locale, props.onLocaleChange),
}),
]);
}
@@ -769,7 +754,7 @@ function renderAppearanceSection(props: QuickSettingsProps) {
: t("quickSettings.appearance.import");
const themeOptions: Array<{ value: ThemeName; label: string }> = [
...BUILTIN_THEME_OPTIONS.map((option) => ({ value: option.id, label: t(option.labelKey) })),
{ value: "custom", label: importedThemeName },
...(props.hasCustomTheme ? [{ value: "custom" as const, label: importedThemeName }] : []),
];
return renderTargetSection(
GENERAL_SETTINGS_TARGET_IDS.appearance,
@@ -778,20 +763,26 @@ function renderAppearanceSection(props: QuickSettingsProps) {
renderSettingsRow({
title: t("quickSettings.appearance.theme"),
stacked: true,
control: renderSettingsSegmented<ThemeName>({
value: props.theme,
options: themeOptions,
onChange: (theme, event) => {
if (theme === "custom" && !props.hasCustomTheme) {
props.onOpenCustomThemeImport?.();
return;
}
if (theme !== props.theme) {
// Anchor the theme transition on the clicked segmented button.
props.setTheme(theme, { element: (event.currentTarget as HTMLElement) ?? undefined });
}
},
}),
control: html`
${renderSettingsSegmented<ThemeName>({
value: props.theme,
options: themeOptions,
onChange: (theme, element) => {
if (theme !== props.theme) {
props.setTheme(theme, { element });
}
},
})}
${props.hasCustomTheme
? nothing
: html`<button
type="button"
class="btn btn--sm"
@click=${() => props.onOpenCustomThemeImport?.()}
>
${importedThemeName}
</button>`}
`,
}),
renderSettingsRow({
title: t("common.mode"),
@@ -801,10 +792,10 @@ function renderAppearanceSection(props: QuickSettingsProps) {
value: mode,
label: t(`common.${mode}`),
})),
onChange: (mode, event) => {
onChange: (mode, element) => {
if (mode !== props.themeMode) {
props.setThemeMode(mode, {
element: (event.currentTarget as HTMLElement) ?? undefined,
element,
});
}
},
+30 -17
View File
@@ -158,6 +158,27 @@ describe("config view", () => {
return button;
}
function findInteractiveByText(container: HTMLElement, text: string): HTMLElement {
const element = Array.from(container.querySelectorAll<HTMLElement>("button, wa-tab")).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!element) {
throw new Error(`Expected interactive control with text "${text}"`);
}
return element;
}
function selectConfigTab(container: HTMLElement, name: string) {
const group = queryRequired(container, "wa-tab-group", HTMLElement);
group.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name },
}),
);
}
function queryRequired<T extends Element>(
container: HTMLElement,
selector: string,
@@ -375,20 +396,18 @@ describe("config view", () => {
tab.textContent?.trim(),
);
expect(tabs).toEqual(["Settings", "Agents", "Gateway", "Theme"]);
expect(container.querySelector("wa-tab-group")?.getAttribute("activation")).toBe("manual");
const btn = findButtonByText(container, "Gateway");
btn.click();
const btn = findInteractiveByText(container, "Gateway");
selectConfigTab(container, "gateway");
expect(onSectionChange).toHaveBeenCalledWith("gateway");
onSectionChange.mockClear();
const settings = findButtonByText(container, "Settings");
expect(settings.tabIndex).toBe(0);
expect(btn.tabIndex).toBe(-1);
const settings = findInteractiveByText(container, "Settings");
expect(settings.hasAttribute("active")).toBe(true);
expect(btn.hasAttribute("active")).toBe(false);
expect(btn.getAttribute("aria-controls")).toBe("config-section-panel");
settings.focus();
settings.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
selectConfigTab(container, "agents");
expect(onSectionChange).toHaveBeenCalledWith("agents");
});
@@ -421,11 +440,7 @@ describe("config view", () => {
);
expect(tabs).toContain("Notifications");
const btn = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Notifications",
);
expect(btn).toBeTruthy();
btn?.click();
selectConfigTab(container, "__notifications__");
expect(onSectionChange).toHaveBeenCalledWith("__notifications__");
});
@@ -505,9 +520,7 @@ describe("config view", () => {
content.scrollLeft = left ?? content.scrollLeft;
}) as typeof content.scrollTo;
const messagesButton = findButtonByText(container, "Messages");
messagesButton.click();
selectConfigTab(container, "messages");
await Promise.resolve();
expect(content["scrollTo"]).toHaveBeenCalledOnce();
+20 -21
View File
@@ -27,13 +27,13 @@ import {
type ConfigSchemaAnalysis,
} from "../../components/config-form.ts";
import { icons } from "../../components/icons.ts";
import "../../components/web-awesome-tabs.ts";
import {
renderSettingsRow,
renderSettingsStatus,
renderSettingsValue,
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { handleTabListKeydown } from "../../lib/tab-list.ts";
import type { RealtimeTalkInputDevice } from "../chat/realtime-talk-input.ts";
import {
APPEARANCE_SETTINGS_TARGET_IDS,
@@ -1750,35 +1750,33 @@ export function renderConfig(props: ConfigProps) {
`
: nothing}
<div
<wa-tab-group
class="config-top-tabs__scroller"
role="tablist"
activation="manual"
.active=${props.activeSection ?? "root"}
aria-label="${t("common.settingsSections")}"
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => {
const key = event.detail.name === "root" ? null : event.detail.name;
props.onSectionChange(key);
resetContentScroll(event.currentTarget);
}}
>
${topTabs.map(
(tab) => html`
<button
type="button"
<wa-tab
slot="nav"
id=${configSectionTabId(tab.key)}
class="config-top-tabs__tab ${props.activeSection === tab.key
? "active"
: ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
class="config-top-tabs__tab"
panel=${tab.key ?? "root"}
?active=${(props.activeSection ?? "root") === (tab.key ?? "root")}
aria-controls="config-section-panel"
.tabIndex=${props.activeSection === tab.key ? 0 : -1}
@keydown=${handleTabListKeydown}
@click=${(e: Event) => {
props.onSectionChange(tab.key);
resetContentScroll(e.currentTarget);
}}
title=${tab.label}
>
${tab.label}
</button>
</wa-tab>
`,
)}
</div>
</wa-tab-group>
</div>
`}
${validity === "invalid" && !viewState.validityDismissed
@@ -1926,10 +1924,11 @@ export function renderConfig(props: ConfigProps) {
`
: nothing}
<!-- Form content -->
<div
<wa-tab-panel
id="config-section-panel"
class="config-content"
role="tabpanel"
name=${props.activeSection}
active
aria-labelledby=${configSectionTabId(props.activeSection)}
>
${props.activeSection === "__appearance__"
@@ -2067,7 +2066,7 @@ export function renderConfig(props: ConfigProps) {
</div>
`;
})()}
</div>
</wa-tab-panel>
${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
+3 -3
View File
@@ -280,11 +280,11 @@ describe("CronPage editor state sync", () => {
expect(page.cron.cronForm.enabled).toBe(true);
await vi.waitFor(() =>
expect(page.querySelector('[data-test-id="cron-toggle-enabled"] input')).not.toBeNull(),
expect(page.querySelector('[data-test-id="cron-toggle-enabled"] wa-switch')).not.toBeNull(),
);
const enabledToggle = page.querySelector(
'[data-test-id="cron-toggle-enabled"] input',
) as HTMLInputElement;
'[data-test-id="cron-toggle-enabled"] wa-switch',
) as HTMLElement & { checked: boolean };
enabledToggle.checked = false;
enabledToggle.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(page.cron.cronForm.enabled).toBe(false));
+70
View File
@@ -0,0 +1,70 @@
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import "../../components/web-awesome-tabs.ts";
export function renderSegmented<T extends string>(params: {
value: T;
options: ReadonlyArray<{ value: T; label: string; testId?: string }>;
ariaLabel?: string;
onChange: (value: T) => void;
/** Render as a tablist controlling `panelId`; option ids are `idPrefix` + value. */
tabs?: { idPrefix: string; panelId: string };
}) {
const tabs = params.tabs;
if (tabs) {
return html`
<wa-tab-group
class="settings-segmented cron-tabs"
activation="manual"
.active=${params.value}
aria-label=${ifDefined(params.ariaLabel)}
@wa-tab-show=${(event: CustomEvent<{ name: T }>) => params.onChange(event.detail.name)}
>
${params.options.map(
(option) => html`
<wa-tab
slot="nav"
id=${`${tabs.idPrefix}${option.value}`}
class="settings-segmented__btn cron-tab"
panel=${option.value}
.active=${option.value === params.value}
aria-controls=${tabs.panelId}
data-test-id=${ifDefined(option.testId)}
>
${option.label}
</wa-tab>
`,
)}
</wa-tab-group>
`;
}
return html`
<wa-radio-group
class="settings-segmented"
size="s"
orientation="horizontal"
label=${ifDefined(params.ariaLabel)}
.value=${params.value}
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: string }).value;
if (value !== undefined) {
params.onChange(value as T);
}
}}
>
${params.options.map(
(option) => html`
<wa-radio
class="settings-segmented__btn"
appearance="button"
value=${option.value}
.checked=${option.value === params.value}
data-test-id=${ifDefined(option.testId)}
>
${option.label}
</wa-radio>
`,
)}
</wa-radio-group>
`;
}
+41 -29
View File
@@ -7,6 +7,7 @@ import type { CronRunLogEntry } from "../../api/types.ts";
import type { CronDeliveryStatus, CronRunsStatusValue, CronSortDir } from "../../api/types.ts";
import { pathForRoute } from "../../app-route-paths.ts";
import { icon } from "../../components/icons.ts";
import "../../components/web-awesome.ts";
import { toSanitizedMarkdownHtml } from "../../components/markdown.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp, formatMs } from "../../lib/format.ts";
@@ -71,6 +72,9 @@ function summarizeSelection(selectedLabels: string[], allLabel: string) {
return `${selectedLabels[0]} +${selectedLabels.length - 1}`;
}
const FILTER_OPTION_PREFIX = "option:";
const FILTER_COMMAND_PREFIX = "command:";
function renderFilterDropdown(params: {
id: string;
title: string;
@@ -82,8 +86,25 @@ function renderFilterDropdown(params: {
}) {
return html`
<div class="cron-filter-dropdown" data-filter=${params.id}>
<details class="cron-filter-dropdown__details">
<summary
<wa-dropdown
class="cron-filter-dropdown__details"
placement="bottom-start"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
const value = event.detail.item.value;
if (value === `${FILTER_COMMAND_PREFIX}clear`) {
params.onClear();
return;
}
if (value?.startsWith(FILTER_OPTION_PREFIX)) {
event.preventDefault();
const optionValue = value.slice(FILTER_OPTION_PREFIX.length);
params.onToggle(optionValue, !params.selected.includes(optionValue));
}
}}
>
<button
slot="trigger"
type="button"
class="btn btn--sm cron-filter-dropdown__trigger ${params.selected.length > 0
? "active"
: ""}"
@@ -92,33 +113,24 @@ function renderFilterDropdown(params: {
>
<span>${params.summary}</span>
${icon("chevronDown")}
</summary>
<div class="cron-filter-dropdown__panel">
<div class="cron-filter-dropdown__list">
${params.options.map(
(option) => html`
<label class="cron-filter-dropdown__option">
<input
type="checkbox"
value=${option.value}
.checked=${params.selected.includes(option.value)}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
params.onToggle(option.value, target.checked);
}}
/>
<span>${option.label}</span>
</label>
`,
)}
</div>
<div class="row">
<button class="btn" type="button" @click=${params.onClear}>
${t("cron.runs.clear")}
</button>
</div>
</div>
</details>
</button>
${params.options.map(
(option) => html`
<wa-dropdown-item
class="cron-filter-dropdown__option"
type="checkbox"
value=${`${FILTER_OPTION_PREFIX}${option.value}`}
.checked=${params.selected.includes(option.value)}
>
${option.label}
</wa-dropdown-item>
`,
)}
<div class="session-menu__separator" role="separator"></div>
<wa-dropdown-item value=${`${FILTER_COMMAND_PREFIX}clear`}>
${t("cron.runs.clear")}
</wa-dropdown-item>
</wa-dropdown>
</div>
`;
}
+95 -42
View File
@@ -118,6 +118,16 @@ function getElement<T extends Element>(
return element;
}
function selectSegmented(control: HTMLElement) {
const group = control.closest<HTMLElement & { value: string }>("wa-radio-group");
expect(group).not.toBeNull();
if (!group) {
return;
}
group.value = control.getAttribute("value") ?? "";
group.dispatchEvent(new Event("change", { bubbles: true }));
}
describe("cron view list pane", () => {
it("uses agent-scoped summary values", () => {
const container = renderView({
@@ -164,13 +174,15 @@ describe("cron view list pane", () => {
const onJobsFiltersChange = vi.fn();
const container = renderView({ jobsEnabledFilter: "enabled", onJobsFiltersChange });
const active = getElement(container, '[data-test-id="cron-tab-enabled"]', HTMLButtonElement);
expect(active.classList.contains("settings-segmented__btn--active")).toBe(true);
expect(active.getAttribute("aria-pressed")).toBe("true");
expect(active.hasAttribute("role")).toBe(false);
expect(active.closest('[role="group"]')?.getAttribute("aria-label")).toBe("Automation status");
const active = getElement(
container,
'[data-test-id="cron-tab-enabled"]',
HTMLElement,
) as HTMLElement & { checked: boolean };
expect(active.checked).toBe(true);
expect(active.closest("wa-radio-group")?.getAttribute("label")).toBe("Automation status");
getElement(container, '[data-test-id="cron-tab-disabled"]', HTMLButtonElement).click();
selectSegmented(getElement(container, '[data-test-id="cron-tab-disabled"]', HTMLElement));
expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsEnabledFilter: "disabled" });
});
@@ -263,7 +275,9 @@ describe("cron view list pane", () => {
expect(onRun).toHaveBeenCalledWith(job, "force");
const toggle = getElement(container, '[data-test-id="cron-row-toggle-job-1"]', HTMLSpanElement);
const toggleInput = getElement(toggle, "input", HTMLInputElement);
const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & {
checked: boolean;
};
expect(toggleInput.checked).toBe(true);
toggleInput.checked = false;
toggleInput.dispatchEvent(new Event("change", { bubbles: true }));
@@ -272,7 +286,9 @@ describe("cron view list pane", () => {
const runIfDue = Array.from(
container.querySelectorAll(".cron-table__row .cron-job-menu__item"),
).find((item) => item.textContent?.trim() === "Run if due") as HTMLButtonElement;
runIfDue.click();
runIfDue
.closest("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-select", { detail: { item: runIfDue }, bubbles: true }));
expect(onRun).toHaveBeenCalledWith(job, "due");
expect(onSelectJob).not.toHaveBeenCalled();
});
@@ -354,7 +370,11 @@ describe("cron view list pane", () => {
const tasks = renderView({ onListTabChange });
expect(tasks.querySelector(".cron-table")).not.toBeNull();
expect(tasks.querySelector(".cron-activity")).toBeNull();
getElement(tasks, '[data-test-id="cron-list-tab-activity"]', HTMLButtonElement).click();
tasks
.querySelector("wa-tab-group")
?.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "activity" }, bubbles: true }),
);
expect(onListTabChange).toHaveBeenCalledWith("activity");
const activity = renderView({ listTab: "activity" });
@@ -362,25 +382,21 @@ describe("cron view list pane", () => {
expect(activity.querySelector(".cron-activity")).not.toBeNull();
});
it("moves and activates list tabs with arrow keys", () => {
it("configures manual Web Awesome list tabs", () => {
const onListTabChange = vi.fn();
const container = renderView({ onListTabChange });
document.body.append(container);
const tasks = getElement(container, '[data-test-id="cron-list-tab-tasks"]', HTMLButtonElement);
const activity = getElement(
container,
'[data-test-id="cron-list-tab-activity"]',
HTMLButtonElement,
const group = getElement(container, ".cron-toolbar > wa-tab-group", HTMLElement);
const tasks = getElement(container, '[data-test-id="cron-list-tab-tasks"]', HTMLElement);
const activity = getElement(container, '[data-test-id="cron-list-tab-activity"]', HTMLElement);
expect(group.getAttribute("activation")).toBe("manual");
expect((tasks as HTMLElement & { active: boolean }).active).toBe(true);
expect((activity as HTMLElement & { active: boolean }).active).toBe(false);
group.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "activity" }, bubbles: true }),
);
expect(tasks.tabIndex).toBe(0);
expect(activity.tabIndex).toBe(-1);
tasks.focus();
tasks.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(activity);
expect(onListTabChange).toHaveBeenCalledWith("activity");
expect(activity.getAttribute("aria-controls")).toBe("cron-list-panel");
container.remove();
@@ -411,12 +427,26 @@ describe("cron view run history", () => {
search.dispatchEvent(new Event("input", { bubbles: true }));
expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsQuery: "fail" });
const statusOption = container.querySelector(
'[data-filter="status"] input[value="error"]',
) as HTMLInputElement;
statusOption.checked = true;
statusOption.dispatchEvent(new Event("change", { bubbles: true }));
const statusOption = container.querySelector<HTMLElement & { checked: boolean }>(
'[data-filter="status"] wa-dropdown-item[value="option:error"]',
);
expect(statusOption).not.toBeNull();
statusOption
?.closest("wa-dropdown")
?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: statusOption }, bubbles: true }),
);
expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["error"] });
const clearCommand = container.querySelector<HTMLElement>(
'[data-filter="status"] wa-dropdown-item[value="command:clear"]',
);
clearCommand
?.closest("wa-dropdown")
?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: clearCommand }, bubbles: true }),
);
expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: [] });
});
it("renders run summaries as sanitized markdown", () => {
@@ -471,7 +501,7 @@ describe("cron view editor", () => {
expect(container.querySelector("#cron-payload-text")).toBeInstanceOf(HTMLTextAreaElement);
expect(container.querySelector("#cron-name")).toBeInstanceOf(HTMLInputElement);
expect(container.querySelector('[data-test-id="cron-schedule-kind-every"]')).toBeInstanceOf(
HTMLButtonElement,
HTMLElement,
);
// Create mode has no run-history tab and no enabled switch.
expect(container.querySelector('[data-test-id="cron-detail-tab-history"]')).toBeNull();
@@ -523,14 +553,12 @@ describe("cron view editor", () => {
const activeEvery = getElement(
everyContainer,
'[data-test-id="cron-schedule-kind-every"]',
HTMLButtonElement,
HTMLElement,
) as HTMLElement & { checked: boolean };
expect(activeEvery.checked).toBe(true);
selectSegmented(
getElement(everyContainer, '[data-test-id="cron-schedule-kind-cron"]', HTMLElement),
);
expect(activeEvery.getAttribute("aria-pressed")).toBe("true");
getElement(
everyContainer,
'[data-test-id="cron-schedule-kind-cron"]',
HTMLButtonElement,
).click();
expect(onFormChange).toHaveBeenCalledWith({ scheduleKind: "cron" });
const atContainer = renderView({
@@ -686,21 +714,44 @@ describe("cron view editor", () => {
expect(onRun).toHaveBeenCalledWith(job, "force");
const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement);
const toggleInput = getElement(toggle, "input", HTMLInputElement);
const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & {
checked: boolean;
};
expect(toggleInput.checked).toBe(true);
expect(toggle.textContent).toContain("Active");
toggleInput.checked = false;
toggleInput.dispatchEvent(new Event("change", { bubbles: true }));
expect(onToggle).toHaveBeenCalledWith(job, false);
getButtonByText(container, "Run if due").click();
const jobMenu = container.querySelector("wa-dropdown.cron-job-menu");
const runIfDue = jobMenu?.querySelector<HTMLElement>('wa-dropdown-item[value="run-if-due"]');
if (runIfDue) {
jobMenu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: runIfDue }, bubbles: true }),
);
}
expect(onRun).toHaveBeenCalledWith(job, "due");
getButtonByText(container, "Clone").click();
const clone = jobMenu?.querySelector<HTMLElement>('wa-dropdown-item[value="clone"]');
if (clone) {
jobMenu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: clone }, bubbles: true }),
);
}
expect(onClone).toHaveBeenCalledWith(job);
getButtonByText(container, "Remove").click();
const remove = jobMenu?.querySelector<HTMLElement>('wa-dropdown-item[value="remove"]');
if (remove) {
jobMenu?.dispatchEvent(
new CustomEvent("wa-select", { detail: { item: remove }, bubbles: true }),
);
}
expect(onRemove).toHaveBeenCalledWith(job);
getElement(container, '[data-test-id="cron-detail-tab-history"]', HTMLButtonElement).click();
container
.querySelector('[data-test-id="cron-detail-tab-history"]')
?.closest("wa-tab-group")
?.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "history" }, bubbles: true }),
);
expect(onDetailTabChange).toHaveBeenCalledWith("history");
});
@@ -738,7 +789,9 @@ describe("cron view editor", () => {
const job = createJob("job-1", { enabled: false });
const container = renderView({ jobs: [job], editingJobId: "job-1", onToggle });
const toggle = getElement(container, '[data-test-id="cron-toggle-enabled"]', HTMLSpanElement);
const toggleInput = getElement(toggle, "input", HTMLInputElement);
const toggleInput = getElement(toggle, "wa-switch", HTMLElement) as HTMLElement & {
checked: boolean;
};
expect(toggleInput.checked).toBe(false);
expect(toggle.textContent).toContain("Paused");
toggleInput.checked = true;
+65 -74
View File
@@ -22,6 +22,8 @@ import {
renderSettingsToggleRow,
} from "../../components/settings-ui.ts";
import "../../components/tooltip.ts";
import "../../components/web-awesome.ts";
import "../../components/web-awesome-popover.ts";
import { t } from "../../i18n/index.ts";
import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "../../lib/cron-status.ts";
import type {
@@ -34,7 +36,7 @@ import type { CronFormState } from "../../lib/cron/index.ts";
import { formatRelativeTimestamp, formatMs } from "../../lib/format.ts";
import { formatCronSchedule } from "../../lib/presenter.ts";
import { normalizeStringEntries, uniqueStrings } from "../../lib/string-coerce.ts";
import { handleTabListKeydown } from "../../lib/tab-list.ts";
import { renderSegmented } from "./segmented-control.ts";
import { renderCronStats } from "./stats.ts";
import { CRON_SUGGESTIONS, suggestionFormPatch } from "./suggestions.ts";
import { renderRunsSection, runStatusLabel } from "./view-runs.ts";
@@ -303,48 +305,6 @@ function renderRequiredTitle(label: string) {
`;
}
// Mirrors renderSettingsSegmented markup exactly; local only so options can carry the stable
// data-test-id hooks used by colocated and e2e tests, and so view switchers can opt into real
// tablist semantics (roving tabindex + arrow keys) wired to the panel they control.
function renderSegmented<T extends string>(params: {
value: T;
options: ReadonlyArray<{ value: T; label: string; testId?: string }>;
ariaLabel?: string;
onChange: (value: T) => void;
/** Render as a tablist controlling `panelId`; option ids are `idPrefix` + value. */
tabs?: { idPrefix: string; panelId: string };
}) {
const tabs = params.tabs;
return html`
<div
class="settings-segmented"
role=${tabs ? "tablist" : "group"}
aria-label=${ifDefined(params.ariaLabel)}
>
${params.options.map((option) => {
const active = option.value === params.value;
return html`
<button
type="button"
role=${ifDefined(tabs ? "tab" : undefined)}
id=${ifDefined(tabs ? `${tabs.idPrefix}${option.value}` : undefined)}
class="settings-segmented__btn ${active ? "settings-segmented__btn--active" : ""}"
aria-pressed=${tabs ? nothing : active ? "true" : "false"}
aria-selected=${tabs ? (active ? "true" : "false") : nothing}
aria-controls=${ifDefined(tabs?.panelId)}
.tabIndex=${tabs && !active ? -1 : 0}
data-test-id=${ifDefined(option.testId)}
@keydown=${tabs ? handleTabListKeydown : nothing}
@click=${() => params.onChange(option.value)}
>
${option.label}
</button>
`;
})}
</div>
`;
}
// Settings row whose control keeps its own validation message underneath. Mirrors
// renderSettingsRow markup; local only so the title can be a real <label for> that gives the
// wrapped control its accessible name (including the visually-hidden required marker).
@@ -539,14 +499,35 @@ function renderToolbar(props: CronProps, hasAdvancedJobsFilters: boolean) {
function renderJobsFilterPopover(props: CronProps, active: boolean) {
return html`
<details class="cron-filter-popover">
<summary
class="btn btn--sm cron-filter-popover__trigger ${active ? "active" : ""}"
title=${t("cron.list.filters")}
aria-label=${t("cron.list.filters")}
>
${icon("listFilter")}
</summary>
<button
id="cron-jobs-filter-trigger"
type="button"
class="btn btn--sm cron-filter-popover__trigger ${active ? "active" : ""}"
title=${t("cron.list.filters")}
aria-label=${t("cron.list.filters")}
aria-haspopup="dialog"
aria-expanded="false"
>
${icon("listFilter")}
</button>
<wa-popover
class="cron-filter-popover"
for="cron-jobs-filter-trigger"
placement="bottom-end"
without-arrow
@wa-show=${(event: Event) => {
(event.currentTarget as Element).previousElementSibling?.setAttribute(
"aria-expanded",
"true",
);
}}
@wa-hide=${(event: Event) => {
(event.currentTarget as Element).previousElementSibling?.setAttribute(
"aria-expanded",
"false",
);
}}
>
<div class="cron-filter-popover__panel">
<label class="field">
<span>${t("cron.jobs.schedule")}</span>
@@ -623,7 +604,7 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) {
${t("cron.jobs.reset")}
</button>
</div>
</details>
</wa-popover>
`;
}
@@ -767,24 +748,38 @@ function renderLastRunCell(job: CronJob) {
// the menu only carries the low-traffic actions.
function renderJobMenu(props: CronProps, job: CronJob) {
return html`
<details class="cron-job-menu">
<summary
<wa-dropdown
class="cron-job-menu"
placement="bottom-end"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
switch (event.detail.item.value) {
case "run-if-due":
props.onRun(job, "due");
break;
case "clone":
props.onClone(job);
break;
case "remove":
props.onRemove(job);
break;
case undefined:
break;
}
}}
>
<button
slot="trigger"
type="button"
class="btn btn--sm btn--ghost cron-job-menu__trigger"
role="button"
aria-haspopup="menu"
aria-label=${t("cron.actions.more")}
title=${t("cron.actions.more")}
>
${icon("moreHorizontal")}
</summary>
<div class="cron-job-menu__panel" role="menu">
${renderMenuItem(props, t("cron.actions.runIfDue"), () => props.onRun(job, "due"))}
${renderMenuItem(props, t("cron.actions.clone"), () => props.onClone(job))}
${renderMenuItem(props, t("cron.actions.remove"), () => props.onRemove(job), {
danger: true,
})}
</div>
</details>
</button>
${renderMenuItem(props, "run-if-due", t("cron.actions.runIfDue"))}
${renderMenuItem(props, "clone", t("cron.actions.clone"))}
${renderMenuItem(props, "remove", t("cron.actions.remove"), { danger: true })}
</wa-dropdown>
`;
}
@@ -1033,23 +1028,19 @@ function renderEditor(props: CronProps, mode: CronPanelMode) {
function renderMenuItem(
props: CronProps,
value: string,
label: string,
action: () => void,
options?: { danger?: boolean },
) {
return html`
<button
<wa-dropdown-item
class=${options?.danger ? "cron-job-menu__item danger" : "cron-job-menu__item"}
role="menuitem"
value=${value}
variant=${options?.danger ? "danger" : "default"}
?disabled=${props.busy}
@click=${(event: Event) => {
// Close the details-based menu before acting so it does not linger open.
(event.currentTarget as HTMLElement).closest("details")?.removeAttribute("open");
action();
}}
>
${label}
</button>
</wa-dropdown-item>
`;
}
-29
View File
@@ -1,29 +0,0 @@
/** Focusable rows for the menu keyboard contract (menu items + browser rows). */
export const MENU_ITEM_SELECTOR =
".session-menu__item:not(:disabled), .new-session-page__browser-entry:not(:disabled)";
/** Arrow keys wrap through rows; Home/End jump. Text fields retain native behavior. */
export function handleMenuNavigation(event: KeyboardEvent) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
return;
}
const origin = event.target as HTMLElement;
if (origin instanceof HTMLInputElement || origin instanceof HTMLTextAreaElement) {
return;
}
const items = [
...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
];
if (items.length === 0) {
return;
}
event.preventDefault();
const index = items.indexOf(document.activeElement as HTMLElement);
const target =
event.key === "Home"
? items[0]
: event.key === "End"
? items.at(-1)
: items[(index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length];
target?.focus();
}
+246 -263
View File
@@ -8,6 +8,9 @@ import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { loadSettings } from "../../app/settings.ts";
import { icons } from "../../components/icons.ts";
import "../../components/tooltip.ts";
import "../../components/web-awesome-popover.ts";
import "../../components/web-awesome-select.ts";
import { t } from "../../i18n/index.ts";
import { searchForSession } from "../../lib/sessions/index.ts";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
@@ -29,7 +32,6 @@ import {
readDraftNodes,
} from "./discovery.ts";
import type { NewSessionRouteData } from "./location.ts";
import { handleMenuNavigation, MENU_ITEM_SELECTOR } from "./menu-keyboard.ts";
import { folderDisplayName, isAbsolutePath } from "./path.ts";
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -60,6 +62,9 @@ class NewSessionPage extends OpenClawLightDomElement {
@state() private browserError: string | null = null;
@state() private browserListing: FsListDirResult | null = null;
@state() private browserTarget: BrowserTarget | null = null;
@state() private wherePopoverOpen = false;
@state() private wherePopoverHiding = false;
@state() private folderPopoverHiding = false;
// Live head input; absolute paths stay applicable even without fs.listDir.
@state() private browserPathDraft = "";
@@ -193,15 +198,7 @@ class NewSessionPage extends OpenClawLightDomElement {
}, delayMs);
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.addEventListener("keydown", this.handleDocumentKeydown, true);
}
override disconnectedCallback() {
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
document.removeEventListener("keydown", this.handleDocumentKeydown, true);
this.subscriptions.clear();
this.invalidateGatewayDiscovery(true);
this.gatewaySource = null;
@@ -215,68 +212,6 @@ class NewSessionPage extends OpenClawLightDomElement {
super.disconnectedCallback();
}
private openMenus(): HTMLDetailsElement[] {
return [...this.querySelectorAll<HTMLDetailsElement>(".new-session-page__select[open]")];
}
// Same central dismissal contract as the chat composer's <details> menus:
// pointerdown outside an open menu closes it, Escape closes and restores
// trigger focus.
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
const path = event.composedPath();
for (const details of this.openMenus()) {
if (!path.includes(details)) {
details.open = false;
}
}
};
private readonly handleDocumentKeydown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
const open = this.openMenus().at(-1);
if (open) {
event.stopPropagation();
open.open = false;
open.querySelector<HTMLElement>("summary")?.focus();
}
};
// Mutual exclusion must hook the details toggle, not just pointerdown:
// keyboard activation (Enter/Space on a summary) opens without any pointer
// event, and two open panels would overlap.
private readonly handleMenuToggle = (event: Event) => {
const details = event.currentTarget as HTMLDetailsElement;
if (this.submitting) {
// Native details can reopen from keyboard or scripted activation even
// after the draft becomes inert. Submission owns one frozen snapshot.
details.open = false;
return;
}
if (!details.open) {
return;
}
for (const other of this.openMenus()) {
if (other !== details) {
other.open = false;
}
}
// Keyboard contract of the replaced native selects: opening moves focus
// into the menu (browser content renders on the next Lit update). The
// summary sits outside the menu div, so this only skips when the user
// already focused menu content (e.g. a field).
void this.updateComplete.then(() => {
if (!details.open) {
return;
}
const menu = details.querySelector(".new-session-page__menu");
if (menu && !menu.contains(document.activeElement)) {
menu.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)?.focus();
}
});
};
override updated() {
this.retryPendingCatalogTarget();
const agentState = this.context?.agents.state;
@@ -389,6 +324,9 @@ class NewSessionPage extends OpenClawLightDomElement {
this.execNode = "";
this.message = "";
this.error = null;
this.wherePopoverHiding = false;
this.folderPopoverHiding = false;
this.closeWherePopover();
this.closeBrowser();
this.adoptAgentDefaults();
void this.updateComplete.then(() => {
@@ -550,9 +488,12 @@ class NewSessionPage extends OpenClawLightDomElement {
this.error = null;
// Collapse menus and retire browser requests before awaiting the Gateway;
// otherwise a now-hidden picker can keep mutating the submitted draft.
this.closeWherePopover();
this.closeBrowser();
for (const details of this.openMenus()) {
details.open = false;
for (const dropdown of this.querySelectorAll<HTMLElement & { open: boolean }>(
"wa-dropdown[open]",
)) {
dropdown.open = false;
}
try {
const result = await context.sessions.createResult(
@@ -679,7 +620,7 @@ class NewSessionPage extends OpenClawLightDomElement {
private closeBrowser() {
this.browserRequestToken += 1;
// Reset state before collapsing the <details> so its toggle handler sees
// Reset state before collapsing the dropdown so its hide handler sees
// browserOpen === false and does not re-enter this method.
this.browserOpen = false;
this.browserLoading = false;
@@ -687,14 +628,43 @@ class NewSessionPage extends OpenClawLightDomElement {
this.browserListing = null;
this.browserTarget = null;
this.browserPathDraft = "";
const details = this.querySelector<HTMLDetailsElement>(
".new-session-page__select--folder[open]",
const popover = this.querySelector<HTMLElement & { open: boolean }>(
".new-session-page__select--folder",
);
if (details) {
details.open = false;
if (popover) {
popover.open = false;
}
}
private closeWherePopover() {
this.wherePopoverOpen = false;
const popover = this.querySelector<HTMLElement & { open: boolean }>(
".new-session-page__where-popover",
);
if (popover) {
popover.open = false;
}
}
private guardPopoverTransition(event: Event, hiding: boolean) {
if (!hiding) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
}
private restorePopoverTrigger(id: string, popoverSelector: string) {
const active = this.ownerDocument.activeElement;
const popover = this.querySelector(popoverSelector);
// Light-dismissal may already have moved focus to another control. Only
// recover when focus stayed in the closing popover or fell back to body.
if (active && active !== this.ownerDocument.body && !popover?.contains(active)) {
return;
}
this.querySelector<HTMLButtonElement>(`#${id}`)?.focus();
}
private showBrowserRoot() {
this.browserRequestToken += 1;
this.browserLoading = false;
@@ -848,7 +818,11 @@ class NewSessionPage extends OpenClawLightDomElement {
${this.browserError
? html`<div class="new-session-page__error">${this.browserError}</div>`
: nothing}
<div class="new-session-page__browser-list" role="listbox">
<div
class="new-session-page__browser-list"
role="group"
aria-label=${t("newSession.folder")}
>
${!target
? html`
<button
@@ -929,28 +903,22 @@ class NewSessionPage extends OpenClawLightDomElement {
`;
}
/** Closes the menu containing the clicked item and hands focus back. */
private closeMenuFrom(event: Event) {
const details = (event.currentTarget as HTMLElement).closest("details");
if (details?.open) {
details.open = false;
details.querySelector<HTMLElement>("summary")?.focus();
}
}
private renderMenuItem(params: {
value: string;
label: string;
checked: boolean;
disabled?: boolean;
title?: string;
onSelect: (event: Event) => void;
keepOpen?: boolean;
onSelect: () => void;
}) {
return html`
<button
type="button"
class="session-menu__item"
role="menuitemradio"
aria-checked=${String(params.checked)}
data-value=${params.value}
data-popover=${params.keepOpen ? nothing : "close"}
aria-pressed=${String(params.checked)}
title=${params.title ?? nothing}
?disabled=${this.submitting || (params.disabled ?? false)}
@click=${params.onSelect}
@@ -964,44 +932,33 @@ class NewSessionPage extends OpenClawLightDomElement {
}
private renderAgentSelect(agents: ReturnType<NewSessionPage["agents"]>) {
const selected = this.selectedAgent();
const label = selected?.identity?.name ?? selected?.name ?? selected?.id ?? this.agentId;
return html`
<details class="new-session-page__select" @toggle=${this.handleMenuToggle}>
<summary
class="new-session-page__trigger"
title=${t("newSession.agent")}
aria-disabled=${String(this.submitting)}
@click=${(event: Event) => {
if (this.submitting) {
event.preventDefault();
}
}}
<wa-select
class="new-session-page__select new-session-page__agent-select"
label=${t("newSession.agent")}
.value=${this.agentId}
?disabled=${this.submitting}
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: string }).value;
if (value) {
this.selectAgentId(value);
}
}}
>
<span slot="start" class="new-session-page__target-icon" aria-hidden="true"
>${icons.bot}</span
>
<span class="new-session-page__target-icon" aria-hidden="true">${icons.bot}</span>
<span class="new-session-page__trigger-label">${label}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</summary>
<div
class="new-session-page__menu"
role="menu"
aria-label=${t("newSession.agent")}
@keydown=${handleMenuNavigation}
>
${agents.map((option) =>
this.renderMenuItem({
label: option.identity?.name ?? option.name ?? option.id,
checked: normalizeAgentId(option.id) === this.agentId,
onSelect: (event) => {
this.selectAgentId(option.id);
this.closeMenuFrom(event);
},
}),
)}
</div>
</details>
${agents.map(
(option) => html`
<wa-option
value=${normalizeAgentId(option.id)}
.label=${option.identity?.name ?? option.name ?? option.id}
>
${option.identity?.name ?? option.name ?? option.id}
</wa-option>
`,
)}
</wa-select>
`;
}
@@ -1017,17 +974,20 @@ class NewSessionPage extends OpenClawLightDomElement {
const worktreeAvailable = this.worktreeAvailable();
const branches = this.branches;
return html`
<details class="new-session-page__select" @toggle=${this.handleMenuToggle}>
<summary
class="new-session-page__trigger"
<span class="new-session-page__select">
<button
id="new-session-where-trigger"
type="button"
class="new-session-page__trigger ${this.wherePopoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${t("newSession.where")}
data-worktree=${String(this.worktree)}
aria-disabled=${String(this.submitting)}
@click=${(event: Event) => {
if (this.submitting) {
event.preventDefault();
}
}}
aria-haspopup="dialog"
aria-expanded=${String(this.wherePopoverOpen)}
?disabled=${this.submitting}
@click=${(event: MouseEvent) =>
this.guardPopoverTransition(event, this.wherePopoverHiding)}
>
<span class="new-session-page__target-icon" aria-hidden="true">${icons.monitor}</span>
<span class="new-session-page__trigger-label">${whereLabel}</span>
@@ -1039,103 +999,119 @@ class NewSessionPage extends OpenClawLightDomElement {
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</summary>
<div
class="new-session-page__menu"
role="menu"
aria-label=${t("newSession.where")}
@keydown=${handleMenuNavigation}
>
${showNodes
? html`
<div class="new-session-page__menu-title">${t("newSession.where")}</div>
${this.renderMenuItem({
label: t("newSession.gateway"),
checked: !this.execNode,
onSelect: (event) => {
this.selectExecNode("");
this.closeMenuFrom(event);
},
})}
${execNodes.map((node) =>
this.renderMenuItem({
label: node.displayName,
checked: this.execNode === node.nodeId,
onSelect: (event) => {
this.selectExecNode(node.nodeId);
this.closeMenuFrom(event);
},
}),
)}
`
: nothing}
${!this.execNode
? html`
${showNodes
? html`<div class="session-menu__separator" role="separator"></div>`
: nothing}
${this.renderMenuItem({
label: t("newSession.worktree"),
checked: this.worktree,
disabled: !worktreeAvailable || customFolder,
title: worktreeAvailable
? t("chat.runControls.newSessionWorktree")
: t("newSession.worktreeUnavailable"),
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__where-popover"
for="new-session-where-trigger"
placement="bottom-start"
without-arrow
@wa-show=${() => {
this.wherePopoverOpen = true;
}}
@wa-hide=${() => {
this.wherePopoverOpen = false;
this.wherePopoverHiding = true;
}}
@wa-after-hide=${() => {
this.wherePopoverHiding = false;
this.restorePopoverTrigger(
"new-session-where-trigger",
".new-session-page__where-popover",
);
}}
>
${showNodes
? html`
<div class="new-session-page__menu-title">${t("newSession.where")}</div>
${this.renderMenuItem({
value: "gateway",
label: t("newSession.gateway"),
checked: !this.execNode,
onSelect: () => {
this.selectExecNode("");
},
})}
${execNodes.map((node) =>
this.renderMenuItem({
value: `node:${node.nodeId}`,
label: node.displayName,
checked: this.execNode === node.nodeId,
onSelect: () => {
// Stays open: enabling reveals the branch/name fields below.
this.worktree = !this.worktree;
if (this.worktree) {
this.maybeLoadBranches();
}
this.selectExecNode(node.nodeId);
},
})}
${this.worktree
? html`
<label class="new-session-page__menu-field">
<span>${t("newSession.baseBranch")}</span>
<input
type="text"
list="new-session-branches"
?disabled=${this.submitting}
placeholder=${this.branchesLoading
? t("common.loading")
: (branches?.defaultBranch ?? t("newSession.baseBranch"))}
.value=${this.baseRef}
@input=${(event: Event) => {
if (this.submitting) {
return;
}
this.baseRefEditGeneration += 1;
this.baseRef = (event.target as HTMLInputElement).value.trim();
}}
/>
<datalist id="new-session-branches">
${(branches?.branches ?? []).map(
(branch) => html`<option value=${branch.name}></option>`,
)}
</datalist>
</label>
<label class="new-session-page__menu-field">
<span>${t("newSession.worktreeName")}</span>
<input
type="text"
?disabled=${this.submitting}
placeholder=${t("newSession.worktreeNamePlaceholder")}
.value=${this.worktreeName}
@input=${(event: Event) => {
if (this.submitting) {
return;
}
this.worktreeName = (event.target as HTMLInputElement).value.trim();
}}
/>
</label>
`
: nothing}
`
: nothing}
</div>
</details>
}),
)}
`
: nothing}
${!this.execNode
? html`
${showNodes
? html`<div class="session-menu__separator" role="separator"></div>`
: nothing}
${this.renderMenuItem({
value: "worktree",
label: t("newSession.worktree"),
checked: this.worktree,
disabled: !worktreeAvailable || customFolder,
title: worktreeAvailable
? t("chat.runControls.newSessionWorktree")
: t("newSession.worktreeUnavailable"),
onSelect: () => {
// Stays open: enabling reveals the branch/name fields below.
this.worktree = !this.worktree;
if (this.worktree) {
this.maybeLoadBranches();
}
},
keepOpen: true,
})}
${this.worktree
? html`
<label class="new-session-page__menu-field">
<span>${t("newSession.baseBranch")}</span>
<input
type="text"
list="new-session-branches"
?disabled=${this.submitting}
placeholder=${this.branchesLoading
? t("common.loading")
: (branches?.defaultBranch ?? t("newSession.baseBranch"))}
.value=${this.baseRef}
@input=${(event: Event) => {
if (this.submitting) {
return;
}
this.baseRefEditGeneration += 1;
this.baseRef = (event.target as HTMLInputElement).value.trim();
}}
/>
<datalist id="new-session-branches">
${(branches?.branches ?? []).map(
(branch) => html`<option value=${branch.name}></option>`,
)}
</datalist>
</label>
<label class="new-session-page__menu-field">
<span>${t("newSession.worktreeName")}</span>
<input
type="text"
?disabled=${this.submitting}
placeholder=${t("newSession.worktreeNamePlaceholder")}
.value=${this.worktreeName}
@input=${(event: Event) => {
if (this.submitting) {
return;
}
this.worktreeName = (event.target as HTMLInputElement).value.trim();
}}
/>
</label>
`
: nothing}
`
: nothing}
</wa-popover>
`;
}
@@ -1150,47 +1126,54 @@ class NewSessionPage extends OpenClawLightDomElement {
? t("newSession.folderPlaceholder")
: folderDisplayName(this.workspacePath()) || t("newSession.folderPlaceholder");
return html`
<details
class="new-session-page__select new-session-page__select--folder"
@toggle=${(event: Event) => {
// Browser state first: handleMenuToggle captures updateComplete for
// its focus hook, which must wait for the render these setters
// schedule (a bare details-attribute flip schedules none).
const details = event.currentTarget as HTMLDetailsElement;
if (details.open) {
this.browserOpen = true;
this.showBrowserRoot();
} else if (this.browserOpen) {
this.closeBrowser();
}
this.handleMenuToggle(event);
}}
>
<summary
<span class="new-session-page__select">
<button
id="new-session-folder-trigger"
type="button"
class="new-session-page__trigger ${browseAvailable
? ""
: "new-session-page__trigger--disabled"}"
: "new-session-page__trigger--disabled"} ${this.folderPopoverHiding
? "new-session-page__trigger--hiding"
: ""}"
title=${browseAvailable ? t("newSession.browse") : t("newSession.browseRequiresAdmin")}
aria-disabled=${String(this.submitting || !browseAvailable)}
@click=${(event: Event) => {
if (this.submitting || !browseAvailable) {
event.preventDefault();
}
}}
aria-haspopup="dialog"
aria-expanded=${String(this.browserOpen)}
?disabled=${this.submitting || !browseAvailable}
@click=${(event: MouseEvent) =>
this.guardPopoverTransition(event, this.folderPopoverHiding)}
>
<span class="new-session-page__target-icon" aria-hidden="true">${icons.folder}</span>
<span class="new-session-page__trigger-label">${label}</span>
<span class="new-session-page__trigger-chevron" aria-hidden="true"
>${icons.chevronDown}</span
>
</summary>
<div
class="new-session-page__menu new-session-page__menu--browser"
@keydown=${handleMenuNavigation}
>
${this.renderBrowser()}
</div>
</details>
</button>
</span>
<wa-popover
class="new-session-page__select new-session-page__select--folder"
for="new-session-folder-trigger"
placement="bottom-start"
without-arrow
@wa-show=${() => {
this.browserOpen = true;
this.showBrowserRoot();
}}
@wa-hide=${() => {
this.folderPopoverHiding = true;
if (this.browserOpen) {
this.closeBrowser();
}
}}
@wa-after-hide=${() => {
this.folderPopoverHiding = false;
this.restorePopoverTrigger(
"new-session-folder-trigger",
".new-session-page__select--folder",
);
}}
>
<div class="new-session-page__browser-menu">${this.renderBrowser()}</div>
</wa-popover>
`;
}
+4 -2
View File
@@ -2,7 +2,6 @@
import { html, nothing } from "lit";
import { icons } from "../../components/icons.ts";
import "../../components/modal-dialog.ts";
import { renderSettingsStatus } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import type { DevicePairSetup, DevicePairSetupAccess } from "../../lib/device-pair-setup.ts";
@@ -127,7 +126,10 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
</div>
<div class="device-pair-setup__meta">
${renderSettingsStatus({ kind: "accent", label: setup.auth })}
<span class="settings-status settings-status--accent">
<span class="settings-status__dot"></span>
${setup.auth}
</span>
<div class="device-pair-setup__gateways">
${gatewayUrls.map(
(gatewayUrl) => html`
+3 -1
View File
@@ -109,7 +109,9 @@ describe("renderWorkspace", () => {
try {
render(renderWorkspace({ host, client: null, connected: false }), host);
expect(state.activeSlug).toBe("empty");
host.querySelector<HTMLButtonElement>('[data-ws="main"]')?.click();
host
.querySelector<HTMLElement>("wa-tab-group")
?.dispatchEvent(new CustomEvent("wa-tab-show", { detail: { name: "main" } }));
expect(new URLSearchParams(window.location.search).get("ws")).toBe("main");
expect(onPopState).toHaveBeenCalledOnce();
} finally {
+50 -74
View File
@@ -6,6 +6,8 @@ import { html, nothing, type TemplateResult } from "lit";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import "../../components/modal-dialog.ts";
import { icons } from "../../components/icons.ts";
import "../../components/web-awesome.ts";
import "../../components/web-awesome-tabs.ts";
import {
loadWidgetManifestView,
type CustomWidgetHostContext,
@@ -316,49 +318,6 @@ function gridMetrics(host: object): { width: number } {
return { width: grid?.clientWidth ?? 0 };
}
/**
* Close the hidden-tabs overflow `<details>` on Escape (#3). Native details close
* on summary click but not on Escape, so wire it explicitly.
*/
function onHiddenTabsKeydown(event: KeyboardEvent): void {
if (event.key !== "Escape") {
return;
}
const details = (event.currentTarget as HTMLElement).closest("details");
if (details?.open) {
event.preventDefault();
details.open = false;
(details.querySelector("summary") as HTMLElement | null)?.focus();
}
}
/**
* When the hidden-tabs overflow opens, arm a one-shot document pointerdown that
* closes it on an outside click (#3); native details never dismiss on outside
* click. Self-removing on close so no listener leaks.
*/
function onHiddenTabsToggle(event: Event): void {
const details = event.currentTarget as HTMLDetailsElement;
if (!details.open) {
return;
}
const onOutside = (pointerEvent: PointerEvent) => {
if (pointerEvent.target instanceof Node && details.contains(pointerEvent.target)) {
return;
}
details.open = false;
document.removeEventListener("pointerdown", onOutside, true);
};
const onClosed = () => {
if (!details.open) {
document.removeEventListener("pointerdown", onOutside, true);
details.removeEventListener("toggle", onClosed);
}
};
document.addEventListener("pointerdown", onOutside, true);
details.addEventListener("toggle", onClosed);
}
/**
* First-visit onboarding banner (#5) teaching the two ways to add a tab: ask the
* agent (primary) or the CLI command (secondary). Dismissible; the flag persists
@@ -404,18 +363,24 @@ function renderTabStrip(state: WorkspaceUiState, workspace: WorkspaceDocument):
const tabs = visibleTabs(workspace);
const hidden = hiddenTabs(workspace);
return html`
<nav class="workspace-tabs" role="tablist" aria-label=${t("workspaces.tabs.label")}>
<wa-tab-group
class="workspace-tabs"
aria-label=${t("workspaces.tabs.label")}
.active=${state.activeSlug}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) =>
navigateToWorkspaceTab(event.detail.name)}
>
${tabs.map((tab) => {
const active = tab.slug === state.activeSlug;
return html`
<button
class="workspace-tab ${active ? "workspace-tab--active" : ""}"
type="button"
role="tab"
aria-selected=${active ? "true" : "false"}
<wa-tab
id=${`workspace-tab-${tab.slug}`}
class="workspace-tab"
panel=${tab.slug}
aria-controls="workspace-tab-panel"
data-test-id="workspace-tab"
data-ws=${tab.slug}
@click=${() => navigateToWorkspaceTab(tab.slug)}
>
${tab.icon && Object.hasOwn(icons, tab.icon)
? html`<span class="workspace-tab__icon" aria-hidden="true"
@@ -423,40 +388,39 @@ function renderTabStrip(state: WorkspaceUiState, workspace: WorkspaceDocument):
>`
: nothing}
<span class="workspace-tab__label">${tab.title}</span>
</button>
</wa-tab>
`;
})}
${hidden.length > 0
? html`
<details
<wa-dropdown
slot="nav"
class="workspace-tabs__hidden"
@toggle=${onHiddenTabsToggle}
@keydown=${onHiddenTabsKeydown}
placement="bottom-end"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
const slug = event.detail.item.value;
if (slug) {
navigateToWorkspaceTab(slug);
}
}}
>
<summary class="workspace-tab workspace-tab--overflow">
<button slot="trigger" class="workspace-tab workspace-tab--overflow" type="button">
<span class="workspace-tab__icon" aria-hidden="true">${icons.eyeOff}</span>
<span class="workspace-tab__label"
>${t("workspaces.tabs.hidden", { count: String(hidden.length) })}</span
>
</summary>
<div class="workspace-tabs__hidden-menu" role="menu">
${hidden.map(
(tab) => html`
<button
class="workspace-tabs__hidden-item"
type="button"
role="menuitem"
@click=${() => navigateToWorkspaceTab(tab.slug)}
>
${tab.title}
</button>
`,
)}
</div>
</details>
</button>
${hidden.map(
(tab) => html`
<wa-dropdown-item class="workspace-tabs__hidden-item" .value=${tab.slug}>
${tab.title}
</wa-dropdown-item>
`,
)}
</wa-dropdown>
`
: nothing}
</nav>
</wa-tab-group>
`;
}
@@ -703,6 +667,10 @@ function makeCallbacks(
viewState.openMenuWidgetId = viewState.openMenuWidgetId === widget.id ? null : widget.id;
requestUpdate();
},
onCloseMenu: () => {
viewState.openMenuWidgetId = null;
requestUpdate();
},
onHide: (widget) => {
viewState.openMenuWidgetId = null;
// Hiding removes the widget from view and persists the hidden flag; distinct
@@ -969,7 +937,15 @@ function renderBody(
return html`
${renderWorkspacesHeader(tab)}
${renderOnboardingBanner(viewState, () => props.onRequestUpdate?.())}
${renderTabStrip(state, workspace)} ${renderGrid(props, state, viewState, workspace, tab)}
${renderTabStrip(state, workspace)}
<wa-tab-panel
id="workspace-tab-panel"
name=${tab.slug}
active
aria-labelledby=${`workspace-tab-${tab.slug}`}
>
${renderGrid(props, state, viewState, workspace, tab)}
</wa-tab-panel>
`;
}
+7 -1
View File
@@ -274,7 +274,13 @@ describe("PluginsPage", () => {
);
expect(page.activeTab).toBe("discover");
expect(page.querySelector("#plugins-tab-discover")?.getAttribute("aria-selected")).toBe("true");
const tabGroup = page.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-tab-group",
);
await tabGroup?.updateComplete;
expect(
page.querySelector<HTMLElement & { active: boolean }>("#plugins-tab-discover")?.active,
).toBe(true);
});
it("routes the skills and workshop hub tabs through navigation", async () => {
+2 -2
View File
@@ -516,8 +516,8 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
await moreButton.click();
}
const workboardMenuItem = sidebar
.getByRole("menu", { exact: true, name: "More" })
.getByRole("menuitem", { exact: true, name: "Workboard" });
.locator("wa-dropdown.sidebar-more-menu")
.locator('wa-dropdown-item[value="workboard"] a');
await workboardMenuItem.waitFor({ state: "visible" });
expect(await workboardMenuItem.getAttribute("href")).toBe("/workboard");
} finally {
+10 -4
View File
@@ -134,7 +134,7 @@ describe("renderPlugins", () => {
];
const container = mount(createProps({ result: createResult(plugins) }));
const filterBar = container.querySelector(".settings-segmented");
expect(filterBar?.getAttribute("aria-label")).toBeTruthy();
expect(normalizedText(filterBar?.querySelector('[slot="label"]') ?? null)).toBeTruthy();
expect(
container.querySelector('[data-plugin-id="telegram"] h3.settings-row__title')?.textContent,
).toContain("Telegram");
@@ -154,11 +154,17 @@ describe("renderPlugins", () => {
];
const onFilterChange = vi.fn();
const container = mount(createProps({ result: createResult(plugins), onFilterChange }));
const chips = container.querySelectorAll<HTMLButtonElement>(
const chips = container.querySelectorAll<HTMLElement>(
".settings-segmented .settings-segmented__btn",
);
expect(chips).toHaveLength(4);
expectDefined(chips[3], "issues filter chip").click();
const issues = expectDefined(chips[3], "issues filter chip");
const group = issues.closest<HTMLElement & { value: string }>("wa-radio-group");
expect(group).not.toBeNull();
if (group) {
group.value = "issues";
group.dispatchEvent(new Event("change", { bubbles: true }));
}
expect(onFilterChange).toHaveBeenCalledWith("issues");
});
@@ -236,7 +242,7 @@ describe("renderPlugins", () => {
}),
);
const detail = container.querySelector<HTMLElement>(".plugins-detail")!;
expect(detail.getAttribute("role")).toBe("dialog");
expect(detail.closest("openclaw-modal-dialog")?.getAttribute("label")).toBe("Workboard");
expect(normalizedText(detail.querySelector(".plugins-detail__title"))).toContain("Workboard");
expect(normalizedText(detail.querySelector(".plugins-detail__meta"))).toContain("workboard");
detail.querySelectorAll<HTMLButtonElement>(".plugins-detail__actions button")[0]?.click();
+11 -18
View File
@@ -7,6 +7,7 @@ import { html, nothing, type TemplateResult } from "lit";
import { live } from "lit/directives/live.js";
import { repeat } from "lit/directives/repeat.js";
import { icons } from "../../components/icons.ts";
import "../../components/modal-dialog.ts";
import {
renderSettingsEmpty,
renderSettingsPage,
@@ -1050,21 +1051,12 @@ function renderDetailOverlay(props: PluginsViewProps) {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key] ?? false;
return html`
<div
class="plugins-detail-backdrop"
@click=${(event: Event) => {
if (event.target === event.currentTarget) {
props.onShowDetails(null);
}
}}
<openclaw-modal-dialog
label=${plugin.name}
style="--openclaw-modal-width: min(580px, calc(100vw - 32px));"
@modal-cancel=${() => props.onShowDetails(null)}
>
<section
class="plugins-detail"
role="dialog"
aria-modal="true"
aria-label=${plugin.name}
data-detail-plugin-id=${plugin.id}
>
<section class="plugins-detail" data-detail-plugin-id=${plugin.id}>
<button
type="button"
class="btn btn--sm btn--icon plugins-detail__close"
@@ -1147,7 +1139,7 @@ function renderDetailOverlay(props: PluginsViewProps) {
</div>
</div>
</section>
</div>
</openclaw-modal-dialog>
`;
}
@@ -1245,10 +1237,11 @@ export function renderPlugins(props: PluginsViewProps) {
</div>`
: nothing}
<div
<wa-tab-panel
id="plugins-hub-panel"
class="plugins-panel"
role="tabpanel"
name=${props.activeTab}
active
aria-labelledby=${`plugins-tab-${props.activeTab}`}
>
${props.loading && !canShowCatalog
@@ -1258,7 +1251,7 @@ export function renderPlugins(props: PluginsViewProps) {
: !props.connected && !canShowCatalog
? renderEmpty(t("pluginsPage.offlineTitle"), t("pluginsPage.offlineBody"))
: renderActivePanel(props)}
</div>
</wa-tab-panel>
${renderDetailOverlay(props)}
`,
{ wide: true },
+1 -3
View File
@@ -1027,15 +1027,13 @@ class SessionsPage extends OpenClawLightDomElement {
return html`
<openclaw-session-menu
.session=${{
key: row.key,
label: normalizeOptionalString(row.label) ?? row.key,
pinned: row.pinned === true,
unread: row.unread === true,
archived: row.archived === true,
category: normalizeOptionalString(row.category) ?? null,
}}
.x=${menu.x}
.y=${menu.y}
.anchor=${menu}
.trigger=${this.sessionMenuTrigger}
.disabled=${this.loading}
.forkDisabled=${row.modelSelectionLocked === true}
+23 -18
View File
@@ -1,6 +1,7 @@
// Workshop page header: self-learning toggle, revision-session toggle, and
// the board/today view switch.
import { html } from "lit";
import "../../components/web-awesome-tabs.ts";
import { t } from "../../i18n/index.ts";
import type { SkillWorkshopState } from "./proposals.ts";
import { renderSelfLearningToggle, type SkillWorkshopSelfLearning } from "./self-learning.ts";
@@ -64,18 +65,24 @@ export function renderSkillWorkshopHeaderControls(
<span class="sw-revision-session-toggle__track" aria-hidden="true"></span>
<span class="sw-revision-session-toggle__label">${useCurrentChatLabel}</span>
</label>
<div
<wa-tab-group
class="sw-mode-switch"
role="tablist"
aria-label=${t("skillWorkshop.header.view")}
data-mode=${state.skillWorkshopMode}
.active=${state.skillWorkshopMode}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => {
if (event.detail.name === "board" || event.detail.name === "today") {
setSkillWorkshopMode(state, event.detail.name, requestUpdate);
}
}}
>
<button
type="button"
class="sw-mode-switch__opt ${state.skillWorkshopMode === "board" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "board" ? "true" : "false"}
@click=${() => setSkillWorkshopMode(state, "board", requestUpdate)}
<wa-tab
id="skill-workshop-mode-tab-board"
class="sw-mode-switch__opt"
panel="board"
aria-controls="skill-workshop-mode-panel"
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<rect x="3" y="4" width="7" height="16" rx="1.5" />
@@ -83,13 +90,12 @@ export function renderSkillWorkshopHeaderControls(
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>${t("skillWorkshop.header.board")}</span>
</button>
<button
type="button"
class="sw-mode-switch__opt ${state.skillWorkshopMode === "today" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "today" ? "true" : "false"}
@click=${() => setSkillWorkshopMode(state, "today", requestUpdate)}
</wa-tab>
<wa-tab
id="skill-workshop-mode-tab-today"
class="sw-mode-switch__opt"
panel="today"
aria-controls="skill-workshop-mode-panel"
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
@@ -98,9 +104,8 @@ export function renderSkillWorkshopHeaderControls(
/>
</svg>
<span>${t("skillWorkshop.header.today")}</span>
</button>
<span class="sw-mode-switch__indicator" aria-hidden="true"></span>
</div>
</wa-tab>
</wa-tab-group>
</div>
`;
}
@@ -0,0 +1,16 @@
import type { ApplicationContext } from "../../app/context.ts";
import type { PluginsHubTab } from "../../components/plugins-hub-tabs.ts";
export function selectPluginsHubTab(
context: Pick<ApplicationContext, "navigate">,
tab: PluginsHubTab,
) {
if (tab === "workshop") {
return;
}
if (tab === "skills") {
context.navigate("skills");
return;
}
context.navigate("plugins", tab === "discover" ? { search: "?tab=discover" } : undefined);
}
+124 -124
View File
@@ -1,4 +1,3 @@
// Skill Workshop page owns its Control UI render glue.
import { consume } from "@lit/context";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing } from "lit";
@@ -6,7 +5,7 @@ import { property } from "lit/decorators.js";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import { applicationContext, type ApplicationGatewaySnapshot } from "../../app/context.ts";
import { loadSettings } from "../../app/settings.ts";
import { renderPluginsHubTabs, type PluginsHubTab } from "../../components/plugins-hub-tabs.ts";
import { renderPluginsHubTabs } from "../../components/plugins-hub-tabs.ts";
import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
import { resolveSessionKey, searchForSession } from "../../lib/sessions/index.ts";
@@ -24,6 +23,7 @@ import type {
SkillWorkshopRenderContext,
SkillWorkshopRevisionRequest,
} from "./page-types.ts";
import { selectPluginsHubTab } from "./plugins-hub-navigation.ts";
import {
countSkillWorkshopProposals,
createSkillWorkshopState,
@@ -96,17 +96,6 @@ async function resolveRevisionSessionKey(
return sessionKey;
}
function selectPluginsHubTab(context: SkillWorkshopPageContext, tab: PluginsHubTab) {
if (tab === "workshop") {
return;
}
if (tab === "skills") {
context.navigate("skills");
return;
}
context.navigate("plugins", tab === "discover" ? { search: "?tab=discover" } : undefined);
}
function renderSkillWorkshopPage(
state: SkillWorkshopState,
renderContext: SkillWorkshopRenderContext,
@@ -142,10 +131,11 @@ function renderSkillWorkshopPage(
onSelect: (tab) => selectPluginsHubTab(context, tab),
})}
</div>
<div
<wa-tab-panel
id="plugins-hub-panel"
class="sw-hub-panel"
role="tabpanel"
name="workshop"
active
aria-labelledby="plugins-tab-workshop"
>
${(() => {
@@ -187,116 +177,126 @@ function renderSkillWorkshopPage(
selectProposal(firstProposal.key);
}
};
return renderSkillWorkshop({
loading: state.skillWorkshopLoading,
error: state.skillWorkshopError,
inspectingKey: state.skillWorkshopInspectingKey,
proposals: state.skillWorkshopProposals,
selectedKey: state.skillWorkshopSelectedKey,
statusFilter: state.skillWorkshopStatusFilter,
query: state.skillWorkshopQuery,
filePreviewKey: state.skillWorkshopFilePreviewKey,
filePreviewQuery: state.skillWorkshopFilePreviewQuery,
queueWidth: state.skillWorkshopQueueWidth,
mode: state.skillWorkshopMode,
actionBusy: state.skillWorkshopActionBusy,
actionNotice: state.skillWorkshopActionNotice,
revisionKey: state.skillWorkshopRevisionKey,
revisionDraft: state.skillWorkshopRevisionDraft,
assistantName: context.config.current.assistantIdentity.name,
workshopAgentName,
selfLearning,
historyScan: state.skillWorkshopHistoryScan,
counts: countSkillWorkshopProposals(state.skillWorkshopProposals),
onRetry: () => {
// Force past the loaded/error latch; the loading guard still
// prevents duplicate in-flight requests.
void loadSkillWorkshopProposals(state, context, { force: true }).finally(
requestUpdate,
);
requestUpdate();
},
onStatusFilterChange: (status) => {
state.skillWorkshopStatusFilter = status;
requestUpdate();
selectVisibleFallback(
filterSkillWorkshopProposals(
state.skillWorkshopProposals,
status,
state.skillWorkshopQuery,
),
);
},
onQueryChange: (query) => {
state.skillWorkshopQuery = query;
requestUpdate();
selectVisibleFallback(
filterSkillWorkshopProposals(
state.skillWorkshopProposals,
state.skillWorkshopStatusFilter,
query,
),
);
},
onFilePreviewQueryChange: (query) => {
state.skillWorkshopFilePreviewQuery = query;
requestUpdate();
},
onQueueWidthChange: (width) => {
state.skillWorkshopQueueWidth = width;
requestUpdate();
},
onModeChange: (mode) => setSkillWorkshopMode(state, mode, requestUpdate),
onSelect: selectProposal,
onPrev: () => selectRelativeProposal(-1),
onNext: () => selectRelativeProposal(1),
onApply: (key) => {
void runSkillWorkshopLifecycleAction(state, context, "apply", key).finally(
requestUpdate,
);
requestUpdate();
},
onRevise: (key) => {
state.skillWorkshopRevisionKey = key;
state.skillWorkshopRevisionDraft = "";
requestUpdate();
},
onReject: (key) => {
void runSkillWorkshopLifecycleAction(state, context, "reject", key).finally(
requestUpdate,
);
requestUpdate();
},
onRevisionDraftChange: (draft) => {
state.skillWorkshopRevisionDraft = draft;
requestUpdate();
},
onRevisionCancel: () => {
state.skillWorkshopRevisionKey = null;
state.skillWorkshopRevisionDraft = "";
requestUpdate();
},
onRevisionSubmit: (key) =>
onRevisionRequest
? void requestSkillWorkshopRevision(state, context, key, onRevisionRequest).finally(
requestUpdate,
)
: undefined,
onPreviewFile: (key, path) => {
state.skillWorkshopSelectedKey = key;
state.skillWorkshopFilePreviewKey = path;
requestUpdate();
},
onClosePreview: () => {
state.skillWorkshopFilePreviewKey = null;
state.skillWorkshopFilePreviewQuery = "";
requestUpdate();
},
onSelfLearningToggle,
onHistoryScan,
});
return html`<wa-tab-panel
id="skill-workshop-mode-panel"
name=${state.skillWorkshopMode}
active
aria-labelledby=${`skill-workshop-mode-tab-${state.skillWorkshopMode}`}
>
${renderSkillWorkshop({
loading: state.skillWorkshopLoading,
error: state.skillWorkshopError,
inspectingKey: state.skillWorkshopInspectingKey,
proposals: state.skillWorkshopProposals,
selectedKey: state.skillWorkshopSelectedKey,
statusFilter: state.skillWorkshopStatusFilter,
query: state.skillWorkshopQuery,
filePreviewKey: state.skillWorkshopFilePreviewKey,
filePreviewQuery: state.skillWorkshopFilePreviewQuery,
queueWidth: state.skillWorkshopQueueWidth,
mode: state.skillWorkshopMode,
actionBusy: state.skillWorkshopActionBusy,
actionNotice: state.skillWorkshopActionNotice,
revisionKey: state.skillWorkshopRevisionKey,
revisionDraft: state.skillWorkshopRevisionDraft,
assistantName: context.config.current.assistantIdentity.name,
workshopAgentName,
selfLearning,
historyScan: state.skillWorkshopHistoryScan,
counts: countSkillWorkshopProposals(state.skillWorkshopProposals),
onRetry: () => {
// Force past the loaded/error latch; the loading guard still
// prevents duplicate in-flight requests.
void loadSkillWorkshopProposals(state, context, { force: true }).finally(
requestUpdate,
);
requestUpdate();
},
onStatusFilterChange: (status) => {
state.skillWorkshopStatusFilter = status;
requestUpdate();
selectVisibleFallback(
filterSkillWorkshopProposals(
state.skillWorkshopProposals,
status,
state.skillWorkshopQuery,
),
);
},
onQueryChange: (query) => {
state.skillWorkshopQuery = query;
requestUpdate();
selectVisibleFallback(
filterSkillWorkshopProposals(
state.skillWorkshopProposals,
state.skillWorkshopStatusFilter,
query,
),
);
},
onFilePreviewQueryChange: (query) => {
state.skillWorkshopFilePreviewQuery = query;
requestUpdate();
},
onQueueWidthChange: (width) => {
state.skillWorkshopQueueWidth = width;
requestUpdate();
},
onModeChange: (mode) => setSkillWorkshopMode(state, mode, requestUpdate),
onSelect: selectProposal,
onPrev: () => selectRelativeProposal(-1),
onNext: () => selectRelativeProposal(1),
onApply: (key) => {
void runSkillWorkshopLifecycleAction(state, context, "apply", key).finally(
requestUpdate,
);
requestUpdate();
},
onRevise: (key) => {
state.skillWorkshopRevisionKey = key;
state.skillWorkshopRevisionDraft = "";
requestUpdate();
},
onReject: (key) => {
void runSkillWorkshopLifecycleAction(state, context, "reject", key).finally(
requestUpdate,
);
requestUpdate();
},
onRevisionDraftChange: (draft) => {
state.skillWorkshopRevisionDraft = draft;
requestUpdate();
},
onRevisionCancel: () => {
state.skillWorkshopRevisionKey = null;
state.skillWorkshopRevisionDraft = "";
requestUpdate();
},
onRevisionSubmit: (key) =>
onRevisionRequest
? void requestSkillWorkshopRevision(
state,
context,
key,
onRevisionRequest,
).finally(requestUpdate)
: undefined,
onPreviewFile: (key, path) => {
state.skillWorkshopSelectedKey = key;
state.skillWorkshopFilePreviewKey = path;
requestUpdate();
},
onClosePreview: () => {
state.skillWorkshopFilePreviewKey = null;
state.skillWorkshopFilePreviewQuery = "";
requestUpdate();
},
onSelfLearningToggle,
onHistoryScan,
})}
</wa-tab-panel>`;
})()}
</div>
</wa-tab-panel>
</section>
`;
}
+7 -2
View File
@@ -360,7 +360,12 @@ class SkillsPage extends OpenClawLightDomElement {
<div class="plugins-hub-tabs-row">
${renderPluginsHubTabs({ active: "skills", onSelect: (tab) => this.selectHubTab(tab) })}
</div>
<div id="plugins-hub-panel" role="tabpanel" aria-labelledby="plugins-tab-skills">
<wa-tab-panel
id="plugins-hub-panel"
name="skills"
active
aria-labelledby="plugins-tab-skills"
>
${renderSkills({
connected: this.connected,
loading: this.skillsLoading || this.agentsLoading,
@@ -411,7 +416,7 @@ class SkillsPage extends OpenClawLightDomElement {
onClawHubInstall: (slug, acknowledgeClawHubRisk, version) =>
void installFromClawHub(this, slug, acknowledgeClawHubRisk, version),
})}
</div>
</wa-tab-panel>
`)}
`;
}
+21 -14
View File
@@ -4,6 +4,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts";
import { getRenderedModalDialog } from "../../test-helpers/modal-dialog.ts";
import { renderSkills } from "./view.ts";
type SkillsProps = Parameters<typeof renderSkills>[0];
@@ -225,15 +226,17 @@ describe("renderSkills", () => {
);
expect(refresh?.disabled).toBe(true);
expect(
Array.from(container.querySelectorAll<HTMLInputElement>(".settings-toggle input")).every(
(toggle) => toggle.disabled,
),
Array.from(
container.querySelectorAll<HTMLElement & { disabled: boolean }>(
"wa-switch.settings-toggle",
),
).every((toggle) => toggle.hasAttribute("disabled")),
).toBe(true);
expect(
container.querySelector<HTMLInputElement>(
'.settings-toggle input[aria-label="Repo Skill enabled"]',
Array.from(container.querySelectorAll("wa-switch.settings-toggle")).find(
(toggle) => normalizeText(toggle) === "Repo Skill enabled",
),
).toBeInstanceOf(HTMLInputElement);
).toBeInstanceOf(HTMLElement);
expect(container.querySelector<HTMLInputElement>('input[type="password"]')?.disabled).toBe(
true,
);
@@ -244,7 +247,7 @@ describe("renderSkills", () => {
expect(mutationButtons.every((button) => button.disabled)).toBe(true);
refresh?.click();
for (const toggle of container.querySelectorAll<HTMLInputElement>(".settings-toggle input")) {
for (const toggle of container.querySelectorAll<HTMLElement>("wa-switch.settings-toggle")) {
toggle.click();
}
for (const button of mutationButtons) {
@@ -277,7 +280,9 @@ describe("renderSkills", () => {
render(renderSkills(createProps({ report, statusFilter: "disabled" })), container);
await Promise.resolve();
const toggles = container.querySelectorAll<HTMLInputElement>(".settings-toggle input");
const toggles = container.querySelectorAll<HTMLElement & { checked: boolean }>(
"wa-switch.settings-toggle",
);
expect(toggles).toHaveLength(2);
const passwordToggle = expectDefined(toggles[0], "password skill toggle");
const appleNotesToggle = expectDefined(toggles[1], "apple notes skill toggle");
@@ -301,7 +306,9 @@ describe("renderSkills", () => {
);
await Promise.resolve();
const updatedToggles = container.querySelectorAll<HTMLInputElement>(".settings-toggle input");
const updatedToggles = container.querySelectorAll<HTMLElement & { checked: boolean }>(
"wa-switch.settings-toggle",
);
expect(updatedToggles).toHaveLength(1);
expect(expectDefined(updatedToggles[0], "updated apple notes skill toggle").checked).toBe(
false,
@@ -354,10 +361,10 @@ describe("renderSkills", () => {
document.body.append(container);
dialogRestores.push(() => container.remove());
await Promise.resolve();
const { dialog } = await getRenderedModalDialog(container);
expect(showModal).toHaveBeenCalledTimes(1);
expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true);
expect(dialog.open).toBe(true);
});
it("opens detail dialogs and routes ClawHub actions", async () => {
@@ -386,10 +393,10 @@ describe("renderSkills", () => {
),
container,
);
await Promise.resolve();
const { dialog } = await getRenderedModalDialog(container);
expect(showModal).toHaveBeenCalledTimes(1);
expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true);
expect(dialog.open).toBe(true);
const closeButton = container.querySelector<HTMLButtonElement>(
".md-preview-dialog__header .btn",
@@ -479,7 +486,7 @@ describe("renderSkills", () => {
);
await Promise.resolve();
expect(showModal).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(showModal).toHaveBeenCalledTimes(1));
expect(
Array.from(container.querySelectorAll(".callout")).map((node) => normalizeText(node)),
).toEqual(["rate limited", "Installed github"]);
+13 -50
View File
@@ -4,12 +4,12 @@
// status instead of pills. The detail/ClawHub dialogs keep their specialized
// markup.
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts";
import { icons } from "../../components/icons.ts";
import { toSanitizedMarkdownHtml } from "../../components/markdown.ts";
import "../../components/modal-dialog.ts";
import {
renderSettingsEmpty,
renderSettingsPage,
@@ -48,21 +48,6 @@ function safeExternalHref(raw?: string): string | null {
return resolveSafeExternalUrl(raw, window.location.href);
}
function showDialogWhenClosed(el?: Element) {
if (!(el instanceof HTMLDialogElement) || el.open) {
return;
}
if (el.isConnected) {
el.showModal();
} else {
queueMicrotask(() => {
if (el.isConnected && !el.open) {
el.showModal();
}
});
}
}
export type SkillsStatusFilter = "all" | "ready" | "needs-setup" | "disabled";
export type SkillDetailTab = "overview" | "card";
@@ -490,28 +475,17 @@ function renderClawHubDetailDialog(props: SkillsProps) {
const detail = props.clawhubDetail;
return html`
<dialog
class="md-preview-dialog"
${ref(showDialogWhenClosed)}
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${props.onClawHubDetailClose}
<openclaw-modal-dialog
label=${detail?.skill?.displayName ?? props.clawhubDetailSlug ?? t("skillsPage.notFound")}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${props.onClawHubDetailClose}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title">
${detail?.skill?.displayName ?? props.clawhubDetailSlug}
</div>
<button
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>
<button class="btn btn--sm" @click=${props.onClawHubDetailClose}>
${t("skillsPage.close")}
</button>
</div>
@@ -567,7 +541,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
: html`<div class="muted">${t("skillsPage.notFound")}</div>`}
</div>
</div>
</dialog>
</openclaw-modal-dialog>
`;
}
@@ -621,16 +595,10 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
props.detailTab === "card" && skill.skillCard?.present ? "card" : "overview";
return html`
<dialog
class="md-preview-dialog"
${ref(showDialogWhenClosed)}
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${props.onDetailClose}
<openclaw-modal-dialog
label=${skill.name}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${props.onDetailClose}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
@@ -642,12 +610,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
${skill.emoji ? html`<span style="font-size: 18px;">${skill.emoji}</span>` : nothing}
<span>${skill.name}</span>
</div>
<button
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>
<button class="btn btn--sm" @click=${props.onDetailClose}>
${t("skillsPage.close")}
</button>
</div>
@@ -790,7 +753,7 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
</div>
</div>
</div>
</dialog>
</openclaw-modal-dialog>
`;
}
+24
View File
@@ -285,6 +285,30 @@ describe("renderUsage", () => {
expect(container.querySelector('input[name="usage-agent-scope"]')).toBeNull();
});
it("keeps filter option values distinct from menu commands", () => {
const container = document.createElement("div");
const onQueryDraftChange = vi.fn();
const session = usageSession("agent:main:main", "main", "clear");
const props = createUsageProps({
data: {
...createUsageProps().data,
sessions: [session],
aggregates: buildAggregatesFromSessions([session]),
},
});
props.callbacks.filters.onQueryDraftChange = onQueryDraftChange;
render(renderUsage(props), container);
const option = [...container.querySelectorAll<HTMLElement>(".usage-filter-option")].find(
(item) => item.textContent?.trim() === "clear",
);
option
?.closest("wa-dropdown")
?.dispatchEvent(new CustomEvent("wa-select", { detail: { item: option }, bubbles: true }));
expect(onQueryDraftChange).toHaveBeenCalledWith(expect.stringContaining("provider:clear"));
});
it("renders provider plans, quotas, and billing independently of session usage", () => {
const container = document.createElement("div");
+107 -121
View File
@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import { renderProviderUsageDetails } from "../../components/provider-usage.ts";
import { renderSettingsPage, renderSettingsSection } from "../../components/settings-ui.ts";
import "../../components/tooltip.ts";
import "../../components/web-awesome.ts";
import { t } from "../../i18n/index.ts";
import "../../styles/usage.css";
import { getUsageCacheRefreshTitle } from "./cache-status.ts";
@@ -176,21 +177,6 @@ function renderProviderUsage(providers: ProviderUsageSnapshot[]) {
);
}
function closeDetailsOnOutsideClick(e: Event) {
const el = e.currentTarget as HTMLDetailsElement;
if (!el.open) {
return;
}
const onClick = (ev: MouseEvent) => {
const path = ev.composedPath();
if (!path.includes(el)) {
el.open = false;
window.removeEventListener("click", onClick, true);
}
};
window.addEventListener("click", onClick, true);
}
export function renderUsage(props: UsageProps) {
const { data, filters, display, detail, callbacks } = props;
const filterActions = callbacks.filters;
@@ -425,65 +411,61 @@ export function renderUsage(props: UsageProps) {
options.length > 0 && options.every((value) => selectedSet.has(normalizeQueryText(value)));
const selectedCount = selected.length;
return html`
<details class="usage-filter-select" @toggle=${closeDetailsOnOutsideClick}>
<summary>
<wa-dropdown
class="usage-filter-select"
placement="bottom-start"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
event.preventDefault();
const value = event.detail.item.value;
if (value === "command:select-all") {
filterActions.onQueryDraftChange(
setQueryTokensForKey(filters.queryDraft, key, options),
);
return;
}
if (value === "command:clear") {
filterActions.onQueryDraftChange(setQueryTokensForKey(filters.queryDraft, key, []));
return;
}
if (value?.startsWith("option:")) {
const optionValue = decodeURIComponent(value.slice("option:".length));
const token = `${key}:${optionValue}`;
const checked = selectedSet.has(normalizeQueryText(optionValue));
filterActions.onQueryDraftChange(
checked
? removeQueryToken(filters.queryDraft, token)
: addQueryToken(filters.queryDraft, token),
);
}
}}
>
<button slot="trigger" type="button" class="usage-filter-trigger">
<span>${label}</span>
${selectedCount > 0
? html`<span class="settings-count">${selectedCount}</span>`
: html` <span class="settings-count">${t("usage.filters.all")}</span> `}
</summary>
<div class="usage-filter-popover">
<div class="usage-filter-actions">
<button
class="btn btn--sm"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
filterActions.onQueryDraftChange(
setQueryTokensForKey(filters.queryDraft, key, options),
);
}}
?disabled=${allSelected}
</button>
<wa-dropdown-item value="command:select-all" ?disabled=${allSelected}>
${t("usage.filters.selectAll")}
</wa-dropdown-item>
<wa-dropdown-item value="command:clear" ?disabled=${selectedCount === 0}>
${t("usage.filters.clear")}
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>
${options.map((value) => {
const checked = selectedSet.has(normalizeQueryText(value));
return html`
<wa-dropdown-item
class="usage-filter-option"
type="checkbox"
value=${`option:${encodeURIComponent(value)}`}
.checked=${checked}
>
${t("usage.filters.selectAll")}
</button>
<button
class="btn btn--sm"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
filterActions.onQueryDraftChange(setQueryTokensForKey(filters.queryDraft, key, []));
}}
?disabled=${selectedCount === 0}
>
${t("usage.filters.clear")}
</button>
</div>
<div class="usage-filter-options">
${options.map((value) => {
const checked = selectedSet.has(normalizeQueryText(value));
return html`
<label class="usage-filter-option">
<input
type="checkbox"
.checked=${checked}
@change=${(e: Event) => {
const target = e.target as HTMLInputElement;
const token = `${key}:${value}`;
filterActions.onQueryDraftChange(
target.checked
? addQueryToken(filters.queryDraft, token)
: removeQueryToken(filters.queryDraft, token),
);
}}
/>
<span>${value}</span>
</label>
`;
})}
</div>
</div>
</details>
${value}
</wa-dropdown-item>
`;
})}
</wa-dropdown>
`;
};
const exportStamp = formatIsoDate(new Date());
@@ -532,58 +514,62 @@ export function renderUsage(props: UsageProps) {
>
${display.headerPinned ? t("usage.filters.pinned") : t("usage.filters.pin")}
</button>
<details class="usage-export-menu" @toggle=${closeDetailsOnOutsideClick}>
<summary class="btn btn--sm">${t("usage.export.label")} </summary>
<div class="usage-export-popover">
<div class="usage-export-list">
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-sessions-${exportStamp}.csv`,
buildSessionsCsv(filteredSessions),
"text/csv",
)}
?disabled=${filteredSessions.length === 0}
>
${t("usage.export.sessionsCsv")}
</button>
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-daily-${exportStamp}.csv`,
buildDailyCsv(filteredDaily),
"text/csv",
)}
?disabled=${filteredDaily.length === 0}
>
${t("usage.export.dailyCsv")}
</button>
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-${exportStamp}.json`,
JSON.stringify(
{
totals: displayTotals,
sessions: filteredSessions,
daily: filteredDaily,
aggregates: activeAggregates,
},
null,
2,
),
"application/json",
)}
?disabled=${filteredSessions.length === 0 && filteredDaily.length === 0}
>
${t("usage.export.json")}
</button>
</div>
</div>
</details>
<wa-dropdown
class="usage-export-menu"
placement="bottom-end"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
switch (event.detail.item.value) {
case "sessions-csv":
downloadTextFile(
`openclaw-usage-sessions-${exportStamp}.csv`,
buildSessionsCsv(filteredSessions),
"text/csv",
);
break;
case "daily-csv":
downloadTextFile(
`openclaw-usage-daily-${exportStamp}.csv`,
buildDailyCsv(filteredDaily),
"text/csv",
);
break;
case "json":
downloadTextFile(
`openclaw-usage-${exportStamp}.json`,
JSON.stringify(
{
totals: displayTotals,
sessions: filteredSessions,
daily: filteredDaily,
aggregates: activeAggregates,
},
null,
2,
),
"application/json",
);
break;
case undefined:
break;
}
}}
>
<button slot="trigger" type="button" class="btn btn--sm">
${t("usage.export.label")}
</button>
<wa-dropdown-item value="sessions-csv" ?disabled=${filteredSessions.length === 0}>
${t("usage.export.sessionsCsv")}
</wa-dropdown-item>
<wa-dropdown-item value="daily-csv" ?disabled=${filteredDaily.length === 0}>
${t("usage.export.dailyCsv")}
</wa-dropdown-item>
<wa-dropdown-item
value="json"
?disabled=${filteredSessions.length === 0 && filteredDaily.length === 0}
>
${t("usage.export.json")}
</wa-dropdown-item>
</wa-dropdown>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More