mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
cae29a0792
commit
8590655190
Generated
+270
-12
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Route-local registration keeps the popover implementation out of startup.
|
||||
import "@awesome.me/webawesome/dist/components/popover/popover.js";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
: {},
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;">
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user