mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 15:01:17 +08:00
Compare commits
713 Commits
feat/node-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538d36eaaa | ||
|
|
b2c1de77ac | ||
|
|
ccb9f2ca2b | ||
|
|
a4f0e508df | ||
|
|
b8adc11977 | ||
|
|
181238fb53 | ||
|
|
4780546c12 | ||
|
|
766c5b3d32 | ||
|
|
9caff5f873 | ||
|
|
f2530de832 | ||
|
|
75c1790b50 | ||
|
|
b16a43597d | ||
|
|
8b03fd1f5f | ||
|
|
3ffb3609a1 | ||
|
|
5c5391836b | ||
|
|
2a21de6322 | ||
|
|
3c73ff7689 | ||
|
|
57e0bdaabe | ||
|
|
6c35c0d965 | ||
|
|
af79cd6a9d | ||
|
|
3b6bcbfb50 | ||
|
|
e498d39bed | ||
|
|
0c33f4e078 | ||
|
|
47dbc675e9 | ||
|
|
66b91d78fe | ||
|
|
3753c5e2c8 | ||
|
|
9bafa2a2b6 | ||
|
|
1703fbc2ad | ||
|
|
afcbdd7416 | ||
|
|
2b43315933 | ||
|
|
f5935bbca1 | ||
|
|
a1af47e5da | ||
|
|
ed3a0241f3 | ||
|
|
b00e1b2e7b | ||
|
|
cfe5d24889 | ||
|
|
bae607b9f1 | ||
|
|
f08ee9eb54 | ||
|
|
d1cb6cd0b5 | ||
|
|
8291cfc2f4 | ||
|
|
bf27221753 | ||
|
|
88c1af0a2c | ||
|
|
6a0fdea90a | ||
|
|
85840eb10e | ||
|
|
f7f2532cac | ||
|
|
e2524e0438 | ||
|
|
58bab0c276 | ||
|
|
48da8d83d9 | ||
|
|
363c6923a1 | ||
|
|
be617fdd62 | ||
|
|
8dff529587 | ||
|
|
901f963f62 | ||
|
|
cdbf6d95ac | ||
|
|
5d7e0b73a7 | ||
|
|
bab18d567b | ||
|
|
a4e78aec4b | ||
|
|
0f855ea71a | ||
|
|
a7d5d92989 | ||
|
|
6f2b3830f1 | ||
|
|
58b68e92f2 | ||
|
|
dcba17d019 | ||
|
|
c2d825ae53 | ||
|
|
f36e54cd68 | ||
|
|
3dc6ac3802 | ||
|
|
ce015cef57 | ||
|
|
e06f6ffc3e | ||
|
|
3e4b10fa1c | ||
|
|
e5a9c60851 | ||
|
|
677358f4a9 | ||
|
|
9e87d316c7 | ||
|
|
8cba5f7efd | ||
|
|
440f315e83 | ||
|
|
b9d530e292 | ||
|
|
9b85b36d92 | ||
|
|
9fb8d87f91 | ||
|
|
248dfb22ec | ||
|
|
e64f2324b9 | ||
|
|
eae4d284e7 | ||
|
|
3643a68e49 | ||
|
|
a58a6f63ca | ||
|
|
a931884eb5 | ||
|
|
7a3d24e70c | ||
|
|
d6dffd6ef8 | ||
|
|
cf378e4cc8 | ||
|
|
589ea28dab | ||
|
|
451765ad27 | ||
|
|
4f9f7e20d4 | ||
|
|
ebabf5022f | ||
|
|
1de4a3e9ea | ||
|
|
ea3a915cb5 | ||
|
|
ef52798254 | ||
|
|
78f2af9ac9 | ||
|
|
22276e6de0 | ||
|
|
697d2d040c | ||
|
|
0a2cad7e68 | ||
|
|
0bf487e4cb | ||
|
|
a3ab0e2534 | ||
|
|
ab33fe33d1 | ||
|
|
054312672a | ||
|
|
c9f884fb28 | ||
|
|
f8db47e340 | ||
|
|
cd1a90b310 | ||
|
|
fff3b15fd7 | ||
|
|
bb27cbd46d | ||
|
|
8cb018e1f7 | ||
|
|
0566b96927 | ||
|
|
b38e7105ec | ||
|
|
6f35f96274 | ||
|
|
f7aea2ad33 | ||
|
|
97d68b6902 | ||
|
|
2fe7b5e8c9 | ||
|
|
a77d0fdd97 | ||
|
|
607bbe4f5c | ||
|
|
cd9c643dc6 | ||
|
|
6bfd47af38 | ||
|
|
08ae0e6d29 | ||
|
|
443ac732a1 | ||
|
|
0880fd94c6 | ||
|
|
ab41e25b2c | ||
|
|
03ae553ecd | ||
|
|
d943d36677 | ||
|
|
b6c45a9301 | ||
|
|
5ab3104a15 | ||
|
|
d59d3f87b0 | ||
|
|
d801bb5be0 | ||
|
|
3597cfc7bc | ||
|
|
6590f764b5 | ||
|
|
0b0893aa21 | ||
|
|
251bd61e22 | ||
|
|
db24e8e76b | ||
|
|
22466240a5 | ||
|
|
e8348c0dc8 | ||
|
|
690a04f81e | ||
|
|
ed7f259ce7 | ||
|
|
892dbb5ebb | ||
|
|
7e7ea0fed1 | ||
|
|
fa614d0907 | ||
|
|
1d2bebbb41 | ||
|
|
ab645aca31 | ||
|
|
1f0cf074cf | ||
|
|
03f1bf9a4d | ||
|
|
0e1d2b3ef4 | ||
|
|
9b1c0dac68 | ||
|
|
cfaae7761d | ||
|
|
a372429a96 | ||
|
|
a737826320 | ||
|
|
f4098e64e4 | ||
|
|
c45295cc33 | ||
|
|
e7b09fba37 | ||
|
|
a1f1895b1b | ||
|
|
78135c3a29 | ||
|
|
f94d3b1d8c | ||
|
|
586cf18b8d | ||
|
|
c7a9dc1cc2 | ||
|
|
203dee9033 | ||
|
|
480c9a97b6 | ||
|
|
cfeed10b01 | ||
|
|
3006b85db0 | ||
|
|
a4236bd6fa | ||
|
|
ec55179504 | ||
|
|
416008dd10 | ||
|
|
f55433bf31 | ||
|
|
f8f53de45a | ||
|
|
6c9e7de04d | ||
|
|
bcfd7164de | ||
|
|
e32707458d | ||
|
|
7f7614276b | ||
|
|
c40d2c45bf | ||
|
|
4911615e72 | ||
|
|
326c4e0e35 | ||
|
|
06e8a74473 | ||
|
|
801df108f0 | ||
|
|
51b64b8198 | ||
|
|
b804d20da7 | ||
|
|
2b7d7841d2 | ||
|
|
0551af92b0 | ||
|
|
344db67a00 | ||
|
|
08e3846470 | ||
|
|
dbde27af4b | ||
|
|
2bd1c7b1c9 | ||
|
|
3f5e001844 | ||
|
|
1222f7a6bc | ||
|
|
c4bc366a4c | ||
|
|
50437d02c1 | ||
|
|
61bb7d5523 | ||
|
|
3060ebf052 | ||
|
|
ecec1b9a59 | ||
|
|
7f885d5a39 | ||
|
|
3a6696951e | ||
|
|
1d371eb5ae | ||
|
|
e75d7cda8f | ||
|
|
919befbbb6 | ||
|
|
d034e9698a | ||
|
|
51848de462 | ||
|
|
e12141fa9f | ||
|
|
6d2566682a | ||
|
|
154ee9fd23 | ||
|
|
0a37f797f2 | ||
|
|
b45e3028e0 | ||
|
|
983b65b0e0 | ||
|
|
8516f37563 | ||
|
|
e28dd6dd6e | ||
|
|
b32d769069 | ||
|
|
824d5d44cd | ||
|
|
e12136a7bb | ||
|
|
1ce84627e8 | ||
|
|
4dae3b3071 | ||
|
|
5378cf527e | ||
|
|
ba46d00589 | ||
|
|
b6cbb4b861 | ||
|
|
cb5c513d58 | ||
|
|
029e8f0153 | ||
|
|
05c3325b0a | ||
|
|
157da3621a | ||
|
|
ee7cafafeb | ||
|
|
172c3f6064 | ||
|
|
46e12e7aff | ||
|
|
5f7cfd6451 | ||
|
|
bb0384e884 | ||
|
|
e74d98bd65 | ||
|
|
2accfeedc7 | ||
|
|
ba447d5afc | ||
|
|
76435679f5 | ||
|
|
f5c345b3fe | ||
|
|
369793d9ab | ||
|
|
a9706ddef2 | ||
|
|
9564ee25b2 | ||
|
|
c3dca12274 | ||
|
|
441a73c492 | ||
|
|
c575b9782e | ||
|
|
8ac2ffde2a | ||
|
|
d5ef040e65 | ||
|
|
84bcae95a0 | ||
|
|
84275d6608 | ||
|
|
2876906da5 | ||
|
|
368f687735 | ||
|
|
1b6bc2ef7d | ||
|
|
2102166f86 | ||
|
|
f7b6ee48fd | ||
|
|
a5fcab9ff8 | ||
|
|
39e27c8276 | ||
|
|
a78234a5d3 | ||
|
|
1f6eabb09f | ||
|
|
caae4c9109 | ||
|
|
c6bbb55fb5 | ||
|
|
7f4ddf62ea | ||
|
|
f6b6cf6d6c | ||
|
|
5d5bc5c84d | ||
|
|
de4ef48323 | ||
|
|
133585d97f | ||
|
|
a33077d9c6 | ||
|
|
78f67fa85f | ||
|
|
27406dc6fb | ||
|
|
4500f02fe6 | ||
|
|
86792c0319 | ||
|
|
a3e969101c | ||
|
|
5b88ddfb99 | ||
|
|
c5f40275f5 | ||
|
|
4a46da7499 | ||
|
|
ba5fa16907 | ||
|
|
ca40b3cdc6 | ||
|
|
5ecfee04f8 | ||
|
|
125b0fc279 | ||
|
|
aa9c5209fc | ||
|
|
a470daad12 | ||
|
|
f86dd6c0af | ||
|
|
fbcd27e258 | ||
|
|
8ff0a20744 | ||
|
|
bdc317f4a6 | ||
|
|
1c0d7c8a57 | ||
|
|
b6b50d893c | ||
|
|
b5b73bd362 | ||
|
|
3f3b757e50 | ||
|
|
bd7f65d445 | ||
|
|
b8f5950fe3 | ||
|
|
f47f32db46 | ||
|
|
1549172816 | ||
|
|
9f5fc45593 | ||
|
|
bedb3e61c6 | ||
|
|
c2af0475fe | ||
|
|
66a1cfb7be | ||
|
|
457c76964d | ||
|
|
4b19f820e1 | ||
|
|
4bce355318 | ||
|
|
53044e8717 | ||
|
|
98c45aa8b5 | ||
|
|
71f8b9c41e | ||
|
|
59a8137b04 | ||
|
|
a791636160 | ||
|
|
e3d402427c | ||
|
|
f5a7f613ee | ||
|
|
15cb26e6cb | ||
|
|
6ce5182522 | ||
|
|
3f18c71ac4 | ||
|
|
cc2ed8dbf6 | ||
|
|
dfd52e72b3 | ||
|
|
e1e203ce11 | ||
|
|
8086c44043 | ||
|
|
e974d98811 | ||
|
|
c6ee13529f | ||
|
|
7cd7a4f438 | ||
|
|
f2677d55ec | ||
|
|
cd806101cd | ||
|
|
c4b64de017 | ||
|
|
bf5e0e9f10 | ||
|
|
4b2e3656af | ||
|
|
d5f5cb2430 | ||
|
|
77f8b16716 | ||
|
|
a1ffaafc12 | ||
|
|
46c000a34f | ||
|
|
1af55bc665 | ||
|
|
59365959d3 | ||
|
|
198002b579 | ||
|
|
559d723a13 | ||
|
|
44fbe63bcf | ||
|
|
7056222288 | ||
|
|
c13cf91787 | ||
|
|
b8956b6a56 | ||
|
|
4d142b185e | ||
|
|
96c5d33d2b | ||
|
|
a9ef3adeb3 | ||
|
|
e1d18e5d02 | ||
|
|
c682919808 | ||
|
|
6324abbe53 | ||
|
|
eac192c170 | ||
|
|
6f2cb53fc4 | ||
|
|
daab68efc8 | ||
|
|
0b591acd77 | ||
|
|
47cfacbb87 | ||
|
|
9ac685b5fe | ||
|
|
cf152fe76e | ||
|
|
52adf91b6f | ||
|
|
09d6479681 | ||
|
|
98498f2579 | ||
|
|
539f745d12 | ||
|
|
bc996d3dfa | ||
|
|
63fba5d2fe | ||
|
|
e099c01a8c | ||
|
|
125329cde7 | ||
|
|
ec4c79cb38 | ||
|
|
9c2d243803 | ||
|
|
ac9d4ff2f0 | ||
|
|
b77ef4d6df | ||
|
|
f00e7af3e3 | ||
|
|
69a406118c | ||
|
|
ffea7fa647 | ||
|
|
16921dba7d | ||
|
|
97758910fa | ||
|
|
d90a94ad16 | ||
|
|
7b4b238566 | ||
|
|
7a62cd5efc | ||
|
|
6ace7a6ca8 | ||
|
|
cee432f0f0 | ||
|
|
779fb9efe3 | ||
|
|
da98896f0c | ||
|
|
59ed6413d9 | ||
|
|
bb056dca84 | ||
|
|
205d00bf82 | ||
|
|
4ff0aa9969 | ||
|
|
f02750d162 | ||
|
|
2a48a2655b | ||
|
|
e0e3012c84 | ||
|
|
fc2a7be0bc | ||
|
|
10fb3e110d | ||
|
|
a210a53c19 | ||
|
|
51488bf914 | ||
|
|
4af444ab30 | ||
|
|
5b84ebfc56 | ||
|
|
4ee50ce18e | ||
|
|
31c3e0c3f3 | ||
|
|
f5eddc2b6d | ||
|
|
a547010a95 | ||
|
|
0aea58ab66 | ||
|
|
6b2af6c1ee | ||
|
|
0a08625d79 | ||
|
|
74331f632b | ||
|
|
f4a5e5762e | ||
|
|
1098063783 | ||
|
|
b80893f30d | ||
|
|
72b387ad48 | ||
|
|
44a72cde58 | ||
|
|
81312e7aa3 | ||
|
|
53e50ec127 | ||
|
|
485446af8c | ||
|
|
81f4fe6c11 | ||
|
|
a2455fcc09 | ||
|
|
e4583b4f57 | ||
|
|
9413a5aba5 | ||
|
|
b7cafb56fa | ||
|
|
efea9ca0f5 | ||
|
|
98f52dcc00 | ||
|
|
32b0b58868 | ||
|
|
9942428df0 | ||
|
|
f40680c826 | ||
|
|
a6582f787c | ||
|
|
a9a2c34293 | ||
|
|
2ef0d274fa | ||
|
|
dc5c24fbe6 | ||
|
|
0b87990328 | ||
|
|
14f018e794 | ||
|
|
81d099f0e9 | ||
|
|
e8c0d92015 | ||
|
|
67dc71983c | ||
|
|
be537060ce | ||
|
|
ea7e214bd4 | ||
|
|
7478e6e485 | ||
|
|
83a6bce835 | ||
|
|
5c07f7ccf0 | ||
|
|
af50a5959d | ||
|
|
472a30bd3f | ||
|
|
8f6f18b6e7 | ||
|
|
1746319db5 | ||
|
|
19e827c969 | ||
|
|
f1cf898460 | ||
|
|
7e6134cb12 | ||
|
|
2fb5ff3034 | ||
|
|
fbaa5a6f0a | ||
|
|
33cb1c18ac | ||
|
|
0ee7cf970c | ||
|
|
762540aa04 | ||
|
|
73f056a0a4 | ||
|
|
88f6857c2e | ||
|
|
c29cc7f82f | ||
|
|
d4b4a65809 | ||
|
|
f94e4f85f0 | ||
|
|
c72c82726f | ||
|
|
92242f4f68 | ||
|
|
743051d400 | ||
|
|
153a2badb0 | ||
|
|
37aaa5cc2b | ||
|
|
ab7c922825 | ||
|
|
2fc4511eeb | ||
|
|
9313471fa5 | ||
|
|
2f46a27b40 | ||
|
|
8e9c377971 | ||
|
|
6f909f6454 | ||
|
|
092075534e | ||
|
|
04ecc1aae9 | ||
|
|
af4ba6221b | ||
|
|
9cbf18293b | ||
|
|
50aaf1f9b6 | ||
|
|
aa8070a76f | ||
|
|
b1e4b6b65e | ||
|
|
9e29375cec | ||
|
|
3a2f54e6a8 | ||
|
|
e5d1fadea7 | ||
|
|
bbfe8ccaf6 | ||
|
|
a4f7e4cbb9 | ||
|
|
6da3b1f6a3 | ||
|
|
2ab4eaa2b1 | ||
|
|
c965141d67 | ||
|
|
c6972a0664 | ||
|
|
662d366f01 | ||
|
|
aee45f5f73 | ||
|
|
ac9a219692 | ||
|
|
db7d70ae4d | ||
|
|
36d9241cf7 | ||
|
|
d896a4c7a3 | ||
|
|
b3eba2ff38 | ||
|
|
21aa297434 | ||
|
|
4752e9a67d | ||
|
|
ec91dce0b8 | ||
|
|
fbbb88925a | ||
|
|
9235c25d33 | ||
|
|
6ce71737e5 | ||
|
|
935c80d6e1 | ||
|
|
286772e930 | ||
|
|
b19904931e | ||
|
|
415272d17e | ||
|
|
002aa1061b | ||
|
|
8a83c13389 | ||
|
|
a16b6c02ce | ||
|
|
2514980118 | ||
|
|
c85b0ee3db | ||
|
|
1e683ff245 | ||
|
|
fc0b141445 | ||
|
|
a0840cad8f | ||
|
|
03b35b53e3 | ||
|
|
797bcd5bdb | ||
|
|
5a0f9cb03c | ||
|
|
e4de53a460 | ||
|
|
d1fe0184b9 | ||
|
|
da88940c6c | ||
|
|
520992a1de | ||
|
|
00d21a4720 | ||
|
|
3d68f7e5f7 | ||
|
|
ceee4c6b01 | ||
|
|
e22e857ddd | ||
|
|
57bed6ae0c | ||
|
|
0c9ac48d2c | ||
|
|
afa04d6454 | ||
|
|
85343ea546 | ||
|
|
d6dbcb2f4b | ||
|
|
61d121f1ca | ||
|
|
21512a696f | ||
|
|
ea1ef72394 | ||
|
|
7c885528ba | ||
|
|
cec5e36a39 | ||
|
|
e404ce98f5 | ||
|
|
30160933f0 | ||
|
|
8b66003a0b | ||
|
|
12a569109b | ||
|
|
1a3ce7c2a8 | ||
|
|
560b77a4af | ||
|
|
cfd5f1ad13 | ||
|
|
d7759c6a35 | ||
|
|
e0018382eb | ||
|
|
69d1d78649 | ||
|
|
cb5bb9b936 | ||
|
|
bafe17e60b | ||
|
|
613a2835cb | ||
|
|
a59eba3ee1 | ||
|
|
9b1a01e4f9 | ||
|
|
29746cf7a9 | ||
|
|
17ab517047 | ||
|
|
697eeb8bab | ||
|
|
853f1c0d9e | ||
|
|
1447a4507a | ||
|
|
748881e0a8 | ||
|
|
ff83d4d164 | ||
|
|
13078d24ab | ||
|
|
48c19590eb | ||
|
|
72547a1ac6 | ||
|
|
26bc069308 | ||
|
|
57f8d71c50 | ||
|
|
980c91d293 | ||
|
|
6b0ffa2106 | ||
|
|
056421f4f8 | ||
|
|
fb750e6eed | ||
|
|
978fdd7d2a | ||
|
|
74f3baebb7 | ||
|
|
deff9ea180 | ||
|
|
9fd5f9ee7c | ||
|
|
4dd7bc6d88 | ||
|
|
0dbf17471b | ||
|
|
f3abe61b78 | ||
|
|
92cdcae500 | ||
|
|
3cf1bd22f9 | ||
|
|
44cd0ec13f | ||
|
|
d77bac8911 | ||
|
|
1da49dcfd0 | ||
|
|
ee74fff7ad | ||
|
|
1de46bb425 | ||
|
|
e662435067 | ||
|
|
62a6fd8139 | ||
|
|
88158525a7 | ||
|
|
c8bb7330b5 | ||
|
|
8732ef2f28 | ||
|
|
8f6e71087b | ||
|
|
9448f91e6f | ||
|
|
5613a0fb6e | ||
|
|
82710b4f1f | ||
|
|
d23558e691 | ||
|
|
2f00fbf28e | ||
|
|
86872e0880 | ||
|
|
506c2ee181 | ||
|
|
1e6fb5089b | ||
|
|
14690904f0 | ||
|
|
99bb94589b | ||
|
|
de4571da4b | ||
|
|
a4087c54b5 | ||
|
|
4756d6a42a | ||
|
|
9e22b8560c | ||
|
|
c1b49bb1d0 | ||
|
|
d6c0f9ccb8 | ||
|
|
5d350e785a | ||
|
|
de68623ffe | ||
|
|
848f39e70d | ||
|
|
b311fd607f | ||
|
|
8f85f94946 | ||
|
|
5380d11977 | ||
|
|
23716de446 | ||
|
|
efd1a9ace6 | ||
|
|
7ac1eeb122 | ||
|
|
58912f8fd8 | ||
|
|
6868cde4d4 | ||
|
|
3c7c25afd2 | ||
|
|
96e5812426 | ||
|
|
126ebfc997 | ||
|
|
4b151593e2 | ||
|
|
56f652b499 | ||
|
|
5a704d26a1 | ||
|
|
e7bcbd3e7e | ||
|
|
afcf1ddb9d | ||
|
|
85e16da2b4 | ||
|
|
38e142657b | ||
|
|
4dd00347fc | ||
|
|
0973eb61c3 | ||
|
|
e282cb2af5 | ||
|
|
6f419b3853 | ||
|
|
4fa5092cdc | ||
|
|
53a3d58d62 | ||
|
|
cef423d066 | ||
|
|
5cf63f295b | ||
|
|
86d958647f | ||
|
|
12a56d4d46 | ||
|
|
39cc11ad28 | ||
|
|
4df95d3c3f | ||
|
|
b8d08f0cfd | ||
|
|
95d51c5fe8 | ||
|
|
5c6a501269 | ||
|
|
dc4c9030fc | ||
|
|
8ede9e0e07 | ||
|
|
9739249043 | ||
|
|
dbb80f3bb7 | ||
|
|
61d9ac8c5d | ||
|
|
abc00f4c98 | ||
|
|
28737a0b09 | ||
|
|
28b63e69e9 | ||
|
|
5392cb7139 | ||
|
|
55c414ca81 | ||
|
|
74680e3484 | ||
|
|
d6e1ca997b | ||
|
|
c4ed850f9b | ||
|
|
4957e3b02f | ||
|
|
323c8aa87f | ||
|
|
442a2107b5 | ||
|
|
ed52d27d78 | ||
|
|
cb17c84410 | ||
|
|
f57adba400 | ||
|
|
9f6ed16a6d | ||
|
|
99a838fac4 | ||
|
|
064182aff8 | ||
|
|
0f9bb59b73 | ||
|
|
79b6dd049e | ||
|
|
58c663920d | ||
|
|
dd2083c7ec | ||
|
|
29f5e9d35c | ||
|
|
25211167e8 | ||
|
|
ecb6779a16 | ||
|
|
edb920b857 | ||
|
|
b2e320dfb1 | ||
|
|
1bdf210b43 | ||
|
|
d8326f13c3 | ||
|
|
9b30ff181c | ||
|
|
4f79f2419c | ||
|
|
65546f0158 | ||
|
|
6d58ff3562 | ||
|
|
47bae66415 | ||
|
|
f5b6a977d7 | ||
|
|
85e6940202 | ||
|
|
5ba4eeceac | ||
|
|
a628a66e4d | ||
|
|
ef08c83e17 | ||
|
|
b6ce59d367 | ||
|
|
c8665c66ba | ||
|
|
4c3b4f8ad8 | ||
|
|
e6f85453dc | ||
|
|
f1bdc91b64 | ||
|
|
add135d238 | ||
|
|
563dac5989 | ||
|
|
5bc300a1df | ||
|
|
1d19d7ec46 | ||
|
|
87d053c0cb | ||
|
|
5b53cddc75 | ||
|
|
6c48a12562 | ||
|
|
43cee29f70 | ||
|
|
725ddd11cc | ||
|
|
d2d14d5793 | ||
|
|
f25c246f6b | ||
|
|
6486fc1c0d | ||
|
|
81eee47045 | ||
|
|
4499b24781 | ||
|
|
b59b34f9d5 | ||
|
|
912e70acbd | ||
|
|
16147e16e3 | ||
|
|
638be00f4b | ||
|
|
695e09d360 | ||
|
|
69ddcc00e6 | ||
|
|
a18c60e141 | ||
|
|
ec048ae693 | ||
|
|
7675b10223 | ||
|
|
25a1b0c240 | ||
|
|
c006ed5e16 | ||
|
|
b4e048e60a | ||
|
|
f365568f1b | ||
|
|
e2c23d8a5e | ||
|
|
408ba4c8a0 | ||
|
|
4995907541 | ||
|
|
8cb093e7a9 | ||
|
|
3e29885c83 | ||
|
|
867d7898df | ||
|
|
fa46138047 | ||
|
|
c135624c69 | ||
|
|
048f307695 | ||
|
|
feffb6d02f | ||
|
|
a16c6ca94b | ||
|
|
7fb748462e | ||
|
|
50dcaad71a | ||
|
|
7a7ca15776 | ||
|
|
bf19d198d9 | ||
|
|
eaad487c42 | ||
|
|
12ade5c5e8 | ||
|
|
076bf2a361 | ||
|
|
0156de5c34 | ||
|
|
646eb00112 | ||
|
|
06f95f9a65 | ||
|
|
9a78886c78 | ||
|
|
66212260ef | ||
|
|
dda0a98b76 | ||
|
|
c71d3e45a1 | ||
|
|
986025afe4 | ||
|
|
0d393ba6b4 | ||
|
|
0de924b35c | ||
|
|
4a47a9db98 | ||
|
|
5fa55d93f7 | ||
|
|
64008398d1 | ||
|
|
5c362884f3 | ||
|
|
71b09b99f8 | ||
|
|
3f31b62cd4 | ||
|
|
4927388580 | ||
|
|
19da9d8832 | ||
|
|
bea27678b4 | ||
|
|
ba28f7b018 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -574,6 +574,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/openshell/**"
|
||||
"extensions: parallel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/parallel/**"
|
||||
"extensions: perplexity":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
14
.github/pull_request_template.md
vendored
14
.github/pull_request_template.md
vendored
@@ -2,19 +2,14 @@
|
||||
|
||||
What problem does this PR solve?
|
||||
|
||||
|
||||
Why does this matter now?
|
||||
|
||||
|
||||
What is the intended outcome?
|
||||
|
||||
|
||||
What is intentionally out of scope?
|
||||
|
||||
|
||||
What does success look like?
|
||||
|
||||
|
||||
What should reviewers focus on?
|
||||
|
||||
<details>
|
||||
@@ -75,13 +70,10 @@ Be mindful of private information like IP addresses, API keys, phone numbers, no
|
||||
|
||||
Which commands did you run?
|
||||
|
||||
|
||||
What regression coverage was added or updated?
|
||||
|
||||
|
||||
What failed before this fix, if known?
|
||||
|
||||
|
||||
If no test was added, why not?
|
||||
|
||||
<details>
|
||||
@@ -95,16 +87,12 @@ List focused commands, not every incidental check. CI is useful support, but ext
|
||||
|
||||
Did user-visible behavior change? (`Yes/No`)
|
||||
|
||||
|
||||
Did config, environment, or migration behavior change? (`Yes/No`)
|
||||
|
||||
|
||||
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
|
||||
|
||||
|
||||
What is the highest-risk area?
|
||||
|
||||
|
||||
How is that risk mitigated?
|
||||
|
||||
<details>
|
||||
@@ -118,10 +106,8 @@ Use this for author judgment that is not obvious from the diff. ClawSweeper can
|
||||
|
||||
What is the next action?
|
||||
|
||||
|
||||
What is still waiting on author, maintainer, CI, or external proof?
|
||||
|
||||
|
||||
Which bot or reviewer comments were addressed?
|
||||
|
||||
<details>
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -722,7 +722,7 @@ jobs:
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
start_check "gateway-watch" \
|
||||
node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
|
||||
node scripts/check-gateway-watch-regression.mjs --skip-build
|
||||
fi
|
||||
|
||||
for index in "${!pids[@]}"; do
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
@@ -46,6 +46,6 @@ jobs:
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
60
.github/workflows/codeql-critical-quality.yml
vendored
60
.github/workflows/codeql-critical-quality.yml
vendored
@@ -342,13 +342,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/core-auth-secrets"
|
||||
|
||||
@@ -365,13 +365,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/config-boundary"
|
||||
|
||||
@@ -388,13 +388,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/gateway-runtime-boundary"
|
||||
|
||||
@@ -411,13 +411,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/channel-runtime-boundary"
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
@@ -518,13 +518,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/agent-runtime-boundary"
|
||||
|
||||
@@ -541,13 +541,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
|
||||
|
||||
@@ -564,13 +564,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/memory-runtime-boundary"
|
||||
|
||||
@@ -587,13 +587,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/session-diagnostics-boundary"
|
||||
|
||||
@@ -610,13 +610,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
|
||||
|
||||
@@ -633,13 +633,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/provider-runtime-boundary"
|
||||
|
||||
@@ -655,13 +655,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/ui-control-plane"
|
||||
|
||||
@@ -677,13 +677,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/web-media-runtime-boundary"
|
||||
|
||||
@@ -700,13 +700,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
@@ -723,12 +723,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-package-contract"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -101,12 +101,12 @@ jobs:
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-security-high/${{ matrix.category }}"
|
||||
|
||||
14
.github/workflows/docker-release.yml
vendored
14
.github/workflows/docker-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
|
||||
2
.github/workflows/docs-agent.yml
vendored
2
.github/workflows/docs-agent.yml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Run Codex docs agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
|
||||
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}
|
||||
|
||||
11
.github/workflows/full-release-validation.yml
vendored
11
.github/workflows/full-release-validation.yml
vendored
@@ -1139,7 +1139,16 @@ jobs:
|
||||
|
||||
summary:
|
||||
name: Verify full validation
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram, performance]
|
||||
needs:
|
||||
[
|
||||
resolve_target,
|
||||
docker_runtime_assets_preflight,
|
||||
normal_ci,
|
||||
plugin_prerelease,
|
||||
release_checks,
|
||||
npm_telegram,
|
||||
performance,
|
||||
]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
4
.github/workflows/macos-release.yml
vendored
4
.github/workflows/macos-release.yml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
public_release_branch:
|
||||
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.D
|
||||
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.PATCH
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PUBLIC_RELEASE_BRANCH}" != "main" && ! "${PUBLIC_RELEASE_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "public_release_branch must be main or release/YYYY.M.D, got ${PUBLIC_RELEASE_BRANCH}." >&2
|
||||
echo "public_release_branch must be main or release/YYYY.M.PATCH, got ${PUBLIC_RELEASE_BRANCH}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
|
||||
@@ -445,7 +445,7 @@ jobs:
|
||||
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Run Codex Mantis Telegram agent
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
|
||||
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}
|
||||
|
||||
@@ -887,7 +887,7 @@ jobs:
|
||||
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -897,7 +897,7 @@ jobs:
|
||||
with:
|
||||
name: docker-e2e-${{ matrix.chunk_id }}
|
||||
path: .artifacts/docker-tests/
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
|
||||
plan_docker_lane_groups:
|
||||
needs: validate_selected_ref
|
||||
@@ -1147,7 +1147,7 @@ jobs:
|
||||
summary=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}/summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1157,7 +1157,7 @@ jobs:
|
||||
with:
|
||||
name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}
|
||||
path: .artifacts/docker-tests/
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
|
||||
validate_docker_openwebui:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
@@ -1274,7 +1274,7 @@ jobs:
|
||||
summary=".artifacts/docker-tests/release-openwebui/summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1284,7 +1284,7 @@ jobs:
|
||||
with:
|
||||
name: docker-e2e-openwebui
|
||||
path: .artifacts/docker-tests/
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
|
||||
prepare_docker_e2e_image:
|
||||
needs: validate_selected_ref
|
||||
@@ -1918,7 +1918,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-core
|
||||
label: Native live gateway core
|
||||
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
|
||||
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2038,7 +2038,7 @@ jobs:
|
||||
profiles: full
|
||||
- suite_id: native-live-src-gateway-backends
|
||||
label: Native live gateway backends
|
||||
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
|
||||
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
2
.github/workflows/openclaw-npm-release.yml
vendored
2
.github/workflows/openclaw-npm-release.yml
vendored
@@ -391,7 +391,7 @@ jobs:
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
52
.github/workflows/openclaw-performance.yml
vendored
52
.github/workflows/openclaw-performance.yml
vendored
@@ -244,8 +244,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane cannot run without live evidence." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
kova setup --ci --json
|
||||
kova setup --non-interactive --auth env-only --provider openai --env-var OPENAI_API_KEY --json
|
||||
@@ -262,11 +262,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p "$REPORT_DIR" "$BUNDLE_DIR" "$SUMMARY_DIR"
|
||||
|
||||
if [[ "$MATRIX_LIVE" == "true" && -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repeat="$REQUESTED_REPEAT"
|
||||
if [[ "$MATRIX_REPEAT" != "input" ]]; then
|
||||
repeat="$MATRIX_REPEAT"
|
||||
@@ -309,24 +304,7 @@ jobs:
|
||||
report_md="${report_json%.json}.md"
|
||||
effective_status="$status"
|
||||
if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then
|
||||
if REPORT_JSON="$report_json" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_JSON, "utf8"));
|
||||
const statuses = report.summary?.statuses ?? {};
|
||||
const nonPassStatuses = Object.entries(statuses)
|
||||
.filter(([status, count]) => status !== "PASS" && Number(count) > 0);
|
||||
const baselineRegressionCount =
|
||||
Number(report.baseline?.comparison?.regressionCount ?? report.gate?.baseline?.regressionCount ?? 0);
|
||||
const gate = report.gate;
|
||||
const toleratedPartial =
|
||||
gate?.verdict === "PARTIAL" &&
|
||||
Number(gate.blockingCount ?? 0) === 0 &&
|
||||
baselineRegressionCount === 0 &&
|
||||
nonPassStatuses.length === 0;
|
||||
if (!toleratedPartial) {
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
if node "$PERFORMANCE_HELPER_DIR/scripts/lib/kova-report-gate.mjs" "$report_json"
|
||||
then
|
||||
effective_status=0
|
||||
{
|
||||
@@ -377,6 +355,28 @@ jobs:
|
||||
exit "$effective_status"
|
||||
fi
|
||||
|
||||
- name: Validate Kova evidence
|
||||
if: ${{ always() && steps.lane.outputs.run == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=0
|
||||
if ! find "$REPORT_DIR" -maxdepth 1 -type f -name '*.json' -size +0c -print -quit | grep -q .; then
|
||||
echo "::error::Kova JSON report is missing for ${LANE_ID}."
|
||||
missing=1
|
||||
fi
|
||||
if [[ ! -s "$BUNDLE_DIR/bundle.json" ]]; then
|
||||
echo "::error::Kova bundle evidence is missing for ${LANE_ID}."
|
||||
missing=1
|
||||
fi
|
||||
if [[ ! -s "$SUMMARY_DIR/${LANE_ID}.md" ]]; then
|
||||
echo "::error::Kova summary evidence is missing for ${LANE_ID}."
|
||||
missing=1
|
||||
fi
|
||||
if [[ "$missing" != "0" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch previous source performance baseline
|
||||
if: ${{ steps.lane.outputs.run == 'true' && matrix.lane == 'mock-provider' && steps.clawgrit.outputs.present == 'true' }}
|
||||
env:
|
||||
@@ -547,7 +547,7 @@ jobs:
|
||||
.artifacts/kova/bundles/${{ matrix.lane }}
|
||||
.artifacts/kova/summaries/${{ matrix.lane }}.md
|
||||
.artifacts/openclaw-performance/source/${{ matrix.lane }}
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }}
|
||||
|
||||
- name: Prepare clawgrit reports checkout
|
||||
|
||||
457
.github/workflows/openclaw-release-checks.yml
vendored
457
.github/workflows/openclaw-release-checks.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -346,6 +346,7 @@ jobs:
|
||||
discord_selected=false
|
||||
whatsapp_selected=false
|
||||
slack_selected=false
|
||||
disabled_required_lanes=()
|
||||
|
||||
IFS=', ' read -r -a filter_tokens <<< "$filter"
|
||||
for token in "${filter_tokens[@]}"; do
|
||||
@@ -361,6 +362,9 @@ jobs:
|
||||
discord_selected="$qa_live_discord_ci_enabled"
|
||||
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
|
||||
slack_selected="$qa_live_slack_ci_enabled"
|
||||
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
|
||||
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
|
||||
[[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack")
|
||||
;;
|
||||
qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack)
|
||||
qa_filter_seen=true
|
||||
@@ -368,6 +372,8 @@ jobs:
|
||||
telegram_selected=true
|
||||
discord_selected="$qa_live_discord_ci_enabled"
|
||||
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
|
||||
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
|
||||
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
|
||||
;;
|
||||
qa-live-matrix|qa-matrix|matrix)
|
||||
qa_filter_seen=true
|
||||
@@ -380,18 +386,27 @@ jobs:
|
||||
qa-live-discord|qa-discord|discord)
|
||||
qa_filter_seen=true
|
||||
discord_selected="$qa_live_discord_ci_enabled"
|
||||
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
|
||||
;;
|
||||
qa-live-whatsapp|qa-whatsapp|whatsapp)
|
||||
qa_filter_seen=true
|
||||
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
|
||||
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
|
||||
;;
|
||||
qa-live-slack|qa-slack|slack)
|
||||
qa_filter_seen=true
|
||||
slack_selected="$qa_live_slack_ci_enabled"
|
||||
[[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${#disabled_required_lanes[@]}" -gt 0 ]]; then
|
||||
echo "live_suite_filter explicitly requested disabled QA live lane(s): ${disabled_required_lanes[*]}" >&2
|
||||
echo "Enable the matching OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED repo variable or remove the lane from live_suite_filter." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$qa_filter_seen" == "true" ]]; then
|
||||
qa_live_matrix_enabled="$matrix_selected"
|
||||
qa_live_telegram_enabled="$telegram_selected"
|
||||
@@ -801,6 +816,7 @@ jobs:
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Run parity lane
|
||||
id: run_lane
|
||||
env:
|
||||
QA_PARITY_LANE: ${{ matrix.lane }}
|
||||
QA_PARITY_OUTPUT_DIR: ${{ matrix.output_dir }}
|
||||
@@ -831,6 +847,7 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/${QA_PARITY_OUTPUT_DIR}"
|
||||
|
||||
- name: Upload parity lane artifacts
|
||||
id: upload_parity_lane_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -839,6 +856,52 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_parity_lane_release_checks
|
||||
RELEASE_CHECK_VARIANT: ${{ matrix.lane }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_parity_lane_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}-${RELEASE_CHECK_VARIANT}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'variant=%s\n' "$RELEASE_CHECK_VARIANT"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_parity_lane_release_checks-${{ matrix.lane }}.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_lab_parity_report_release_checks:
|
||||
name: Run QA Lab parity report
|
||||
needs: [resolve_target, qa_lab_parity_lane_release_checks]
|
||||
@@ -879,6 +942,7 @@ jobs:
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Generate parity report
|
||||
id: generate_report
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
@@ -889,6 +953,7 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
id: upload_parity_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -897,6 +962,50 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_parity_report_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.generate_report.outcome }} ${{ steps.upload_parity_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-parity-report-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_parity_report_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_lab_runtime_parity_release_checks:
|
||||
name: Run QA Lab runtime parity lane
|
||||
needs: [resolve_target]
|
||||
@@ -950,6 +1059,7 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity"
|
||||
|
||||
- name: Run standard runtime parity tier
|
||||
id: runtime_parity_standard_lane
|
||||
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -977,6 +1087,7 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
id: generate_runtime_parity_report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -987,6 +1098,7 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-report
|
||||
|
||||
- name: Generate standard runtime parity report
|
||||
id: generate_runtime_parity_standard_report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -997,6 +1109,7 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Generate soak runtime parity report
|
||||
id: generate_runtime_parity_soak_report
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1012,6 +1125,7 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
id: upload_runtime_parity_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1020,6 +1134,50 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_runtime_parity_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.runtime_parity_standard_lane.outcome }} ${{ steps.runtime_parity_soak_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.generate_runtime_parity_standard_report.outcome }} ${{ steps.generate_runtime_parity_soak_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
runtime_tool_coverage_release_checks:
|
||||
name: Enforce QA Lab runtime tool coverage
|
||||
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
|
||||
@@ -1141,6 +1299,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
id: upload_matrix_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1149,6 +1308,50 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_matrix_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_matrix_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_matrix_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_telegram_release_checks:
|
||||
name: Run QA Lab live Telegram lane
|
||||
needs: [resolve_target]
|
||||
@@ -1237,6 +1440,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
id: upload_telegram_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1245,10 +1449,54 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_telegram_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_telegram_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_telegram_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_discord_release_checks:
|
||||
name: Run QA Lab live Discord lane
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true'
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
@@ -1332,6 +1580,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
id: upload_discord_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1340,10 +1589,54 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_discord_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_discord_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_discord_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_whatsapp_release_checks:
|
||||
name: Run QA Lab live WhatsApp lane
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true'
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
@@ -1430,6 +1723,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
id: upload_whatsapp_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1438,10 +1732,54 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_whatsapp_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_whatsapp_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_whatsapp_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_slack_release_checks:
|
||||
name: Run QA Lab live Slack lane
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true'
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
@@ -1525,6 +1863,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
id: upload_slack_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1533,6 +1872,50 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_slack_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_slack_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_slack_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
summary:
|
||||
name: Verify release checks
|
||||
needs:
|
||||
@@ -1553,9 +1936,19 @@ jobs:
|
||||
- qa_live_slack_release_checks
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
permissions: {}
|
||||
permissions:
|
||||
actions: read
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download advisory status artifacts
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-check-status-*
|
||||
path: .artifacts/release-check-status
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify release check results
|
||||
shell: bash
|
||||
env:
|
||||
@@ -1567,6 +1960,49 @@ jobs:
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha=true
|
||||
fi
|
||||
release_check_result() {
|
||||
local name="$1"
|
||||
local fallback="$2"
|
||||
local status_dir=".artifacts/release-check-status"
|
||||
local saw=0
|
||||
local saw_failure=0
|
||||
local saw_cancelled=0
|
||||
if [[ -d "$status_dir" ]]; then
|
||||
while IFS= read -r -d '' file; do
|
||||
saw=1
|
||||
status="$(sed -n 's/^status=//p' "$file" | tail -n 1)"
|
||||
case "$status" in
|
||||
success|skipped) ;;
|
||||
cancelled) saw_cancelled=1 ;;
|
||||
failure|"") saw_failure=1 ;;
|
||||
*) saw_failure=1 ;;
|
||||
esac
|
||||
done < <(find "$status_dir" -type f -name "${name}*.env" -print0)
|
||||
fi
|
||||
if [[ "$saw_failure" == "1" ]]; then
|
||||
printf 'failure\n'
|
||||
elif [[ "$saw_cancelled" == "1" ]]; then
|
||||
printf 'cancelled\n'
|
||||
elif [[ "$fallback" != "success" && "$fallback" != "skipped" ]]; then
|
||||
printf '%s\n' "$fallback"
|
||||
elif [[ "$saw" == "1" ]]; then
|
||||
printf 'success\n'
|
||||
elif [[ "$fallback" == "success" ]]; then
|
||||
printf 'failure\n'
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
advisory_status_override_allowed() {
|
||||
case "$1" in
|
||||
qa_lab_parity_lane_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
for item in \
|
||||
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
|
||||
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
|
||||
@@ -1585,7 +2021,12 @@ jobs:
|
||||
"qa_live_slack_release_checks=${{ needs.qa_live_slack_release_checks.result }}"
|
||||
do
|
||||
name="${item%%=*}"
|
||||
result="${item#*=}"
|
||||
raw_result="${item#*=}"
|
||||
if advisory_status_override_allowed "$name"; then
|
||||
result="$(release_check_result "$name" "$raw_result")"
|
||||
else
|
||||
result="$raw_result"
|
||||
fi
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$tideclaw_alpha" == "true" ]]; then
|
||||
case "$name" in
|
||||
@@ -1596,10 +2037,6 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ "$name" == qa_* ]]; then
|
||||
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
|
||||
continue
|
||||
fi
|
||||
echo "::error::${name} ended with ${result}"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/test-performance-agent.yml
vendored
2
.github/workflows/test-performance-agent.yml
vendored
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Run Codex test performance agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/test-performance-agent.md
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.5
|
||||
|
||||
### Highlights
|
||||
|
||||
- QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw `<thinking>` content from leaking into channel replies. (#89913, #90132) Thanks @openperf.
|
||||
- MCP tool results now coerce `resource_link`, `resource`, `audio`, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.
|
||||
- Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for `message_start`, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.
|
||||
- Parallel is now a bundled `web_search` provider with `PARALLEL_API_KEY` discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.
|
||||
- Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.
|
||||
- Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)
|
||||
- Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)
|
||||
- macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.
|
||||
- Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.
|
||||
|
||||
### Changes
|
||||
|
||||
- Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded `api.parallel.ai/v1/search` support. (#85158) Thanks @NormallyGaussian.
|
||||
- Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)
|
||||
- Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.
|
||||
- Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.
|
||||
- Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, while iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, and unavailable Talk controls reachable.
|
||||
- Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)
|
||||
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
|
||||
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
|
||||
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
|
||||
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
|
||||
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
|
||||
- Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.
|
||||
- Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.
|
||||
- TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.
|
||||
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
|
||||
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
|
||||
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
|
||||
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
|
||||
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
|
||||
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
|
||||
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
|
||||
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
|
||||
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
|
||||
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
|
||||
|
||||
## 2026.6.2
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/** User-selectable app theme mode for Android appearance settings. */
|
||||
enum class AppearanceThemeMode(
|
||||
val rawValue: String,
|
||||
val displayLabel: String,
|
||||
) {
|
||||
System(rawValue = "system", displayLabel = "System"),
|
||||
Dark(rawValue = "dark", displayLabel = "Dark"),
|
||||
Light(rawValue = "light", displayLabel = "Light"),
|
||||
;
|
||||
|
||||
fun isDark(systemDark: Boolean): Boolean =
|
||||
when (this) {
|
||||
System -> systemDark
|
||||
Dark -> true
|
||||
Light -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark
|
||||
|
||||
fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,36 @@ package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.ui.OpenClawTheme
|
||||
import ai.openclaw.app.ui.RootScreen
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
|
||||
@@ -21,18 +39,89 @@ import kotlinx.coroutines.launch
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private var initializedViewModel: MainViewModel? = null
|
||||
private var didAttachRuntimeUi = false
|
||||
private var didStartNodeService = false
|
||||
private var didStartViewModelCollectors = false
|
||||
private var foreground = false
|
||||
private var pendingIntent: Intent? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleAssistantIntent(intent)
|
||||
pendingIntent = intent
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
|
||||
setContent {
|
||||
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
withContext(Dispatchers.Default) {
|
||||
(application as NodeApp).prefs
|
||||
}
|
||||
val readyViewModel = viewModel
|
||||
activateViewModel(readyViewModel)
|
||||
activeViewModel = readyViewModel
|
||||
}
|
||||
|
||||
val currentViewModel = activeViewModel
|
||||
if (currentViewModel == null) {
|
||||
OpenClawTheme {
|
||||
StartupSurface()
|
||||
}
|
||||
} else {
|
||||
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
|
||||
OpenClawTheme(themeMode = appearanceThemeMode) {
|
||||
RootScreen(viewModel = currentViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
foreground = true
|
||||
initializedViewModel?.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
foreground = false
|
||||
initializedViewModel?.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingIntent = intent
|
||||
initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
|
||||
*/
|
||||
private fun activateViewModel(readyViewModel: MainViewModel) {
|
||||
if (initializedViewModel != null) return
|
||||
initializedViewModel = readyViewModel
|
||||
readyViewModel.setForeground(foreground)
|
||||
startViewModelCollectors(readyViewModel)
|
||||
pendingIntent?.let { initialIntent ->
|
||||
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
|
||||
pendingIntent = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
|
||||
*/
|
||||
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
|
||||
if (didStartViewModelCollectors) return
|
||||
didStartViewModelCollectors = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.preventSleep.collect { enabled ->
|
||||
readyViewModel.preventSleep.collect { enabled ->
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
@@ -44,10 +133,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.runtimeInitialized.collect { ready ->
|
||||
readyViewModel.runtimeInitialized.collect { ready ->
|
||||
if (!ready || didAttachRuntimeUi) return@collect
|
||||
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
|
||||
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
didAttachRuntimeUi = true
|
||||
if (!didStartNodeService) {
|
||||
NodeForegroundService.start(this@MainActivity)
|
||||
@@ -56,36 +145,15 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OpenClawTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleAssistantIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
|
||||
*/
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
private fun handleAssistantIntent(
|
||||
viewModel: MainViewModel,
|
||||
intent: Intent?,
|
||||
) {
|
||||
parseHomeDestinationIntent(intent)?.let { destination ->
|
||||
viewModel.requestHomeDestination(destination)
|
||||
return
|
||||
@@ -94,3 +162,23 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.handleAssistantLaunch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartupSurface() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Black,
|
||||
contentColor = Color.White,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "OPENCLAW",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
@@ -14,6 +16,7 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -21,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
|
||||
@@ -32,7 +36,11 @@ class MainViewModel(
|
||||
private val nodeApp = app as NodeApp
|
||||
private val prefs = nodeApp.prefs
|
||||
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||
private var foreground = true
|
||||
|
||||
@Volatile private var foreground = false
|
||||
|
||||
@Volatile private var runtimeStartupQueued = false
|
||||
|
||||
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
|
||||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
private val _startOnboardingAtGatewaySetup = MutableStateFlow(false)
|
||||
@@ -53,6 +61,19 @@ class MainViewModel(
|
||||
return runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the node runtime off the main thread so fresh installs can render
|
||||
* the shell before encrypted prefs, device identity, and gateway setup warm up.
|
||||
*/
|
||||
private fun queueRuntimeStartup() {
|
||||
if (runtimeRef.value != null || runtimeStartupQueued) return
|
||||
runtimeStartupQueued = true
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
runCatching { ensureRuntime() }
|
||||
runtimeStartupQueued = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
|
||||
*/
|
||||
@@ -91,6 +112,7 @@ class MainViewModel(
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
@@ -150,6 +172,7 @@ class MainViewModel(
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = prefs.appearanceThemeMode
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
|
||||
@@ -180,12 +203,6 @@ class MainViewModel(
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
|
||||
init {
|
||||
if (prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
|
||||
@@ -213,13 +230,10 @@ class MainViewModel(
|
||||
*/
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
val runtime =
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
} else {
|
||||
runtimeRef.value
|
||||
}
|
||||
runtime?.setForeground(value)
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
queueRuntimeStartup()
|
||||
}
|
||||
runtimeRef.value?.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
@@ -270,9 +284,51 @@ class MainViewModel(
|
||||
prefs.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
|
||||
fun resetGatewaySetupAuth() {
|
||||
ensureRuntime().resetGatewaySetupAuth()
|
||||
/** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */
|
||||
private fun resetGatewaySetupAuth() {
|
||||
runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime()
|
||||
}
|
||||
|
||||
private fun resetGatewaySetupAuthWithoutRuntime() {
|
||||
prefs.clearGatewaySetupAuth()
|
||||
val deviceId = DeviceIdentityStore(nodeApp).loadOrCreate().deviceId
|
||||
val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
deviceAuthStore.clearToken(deviceId, "node")
|
||||
deviceAuthStore.clearToken(deviceId, "operator")
|
||||
}
|
||||
|
||||
fun saveGatewayConfigAndConnect(
|
||||
host: String,
|
||||
port: Int,
|
||||
tls: Boolean,
|
||||
token: String,
|
||||
bootstrapToken: String,
|
||||
password: String,
|
||||
resetSetupAuth: Boolean,
|
||||
) {
|
||||
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
|
||||
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
if (resetSetupAuth) {
|
||||
resetGatewaySetupAuth()
|
||||
}
|
||||
prefs.setManualEnabled(true)
|
||||
prefs.setManualHost(host)
|
||||
prefs.setManualPort(port)
|
||||
prefs.setManualTls(tls)
|
||||
prefs.setGatewayBootstrapToken(bootstrapToken)
|
||||
prefs.setGatewayToken(token)
|
||||
prefs.setGatewayPassword(password)
|
||||
ensureRuntime()
|
||||
.connect(
|
||||
GatewayEndpoint.manual(host = host, port = port),
|
||||
NodeRuntime.GatewayConnectAuth(
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken = bootstrapToken.ifEmpty { null },
|
||||
password = password.ifEmpty { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
|
||||
@@ -285,10 +341,12 @@ class MainViewModel(
|
||||
|
||||
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
|
||||
fun pairNewGateway() {
|
||||
runtimeRef.value?.disconnect()
|
||||
resetGatewaySetupAuth()
|
||||
_startOnboardingAtGatewaySetup.value = true
|
||||
prefs.setOnboardingCompleted(false)
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
runtimeRef.value?.disconnect()
|
||||
resetGatewaySetupAuth()
|
||||
prefs.setOnboardingCompleted(false)
|
||||
_startOnboardingAtGatewaySetup.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
|
||||
@@ -383,14 +441,30 @@ class MainViewModel(
|
||||
ensureRuntime().setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
|
||||
prefs.setAppearanceThemeMode(mode)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
}
|
||||
}
|
||||
|
||||
fun startGatewayDiscovery() {
|
||||
queueRuntimeStartup()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectInBackground(endpoint: GatewayEndpoint) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
|
||||
@@ -78,6 +78,25 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
/**
|
||||
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
|
||||
*/
|
||||
data class GatewayConnectionProblem(
|
||||
val code: String?,
|
||||
val message: String,
|
||||
val reason: String?,
|
||||
val requestId: String?,
|
||||
val recommendedNextStep: String?,
|
||||
val pauseReconnect: Boolean,
|
||||
val retryable: Boolean,
|
||||
) {
|
||||
val isPairingRequired: Boolean = code == "PAIRING_REQUIRED"
|
||||
val canAutoRetry: Boolean =
|
||||
isPairingRequired &&
|
||||
(
|
||||
retryable ||
|
||||
!pauseReconnect ||
|
||||
recommendedNextStep == "wait_then_retry"
|
||||
)
|
||||
}
|
||||
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
@@ -285,6 +304,8 @@ class NodeRuntime(
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
@@ -410,6 +431,7 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { hello ->
|
||||
_gatewayConnectionProblem.value = null
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
_serverName.value = hello.serverName
|
||||
@@ -457,6 +479,7 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(false)
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onEvent = { event, payloadJson ->
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
},
|
||||
@@ -468,6 +491,7 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = {
|
||||
_gatewayConnectionProblem.value = null
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
@@ -493,6 +517,7 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
|
||||
@@ -687,6 +712,23 @@ class NodeRuntime(
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun handleGatewayConnectFailure(
|
||||
error: GatewaySession.ErrorShape,
|
||||
pauseReconnect: Boolean,
|
||||
) {
|
||||
val details = error.details
|
||||
_gatewayConnectionProblem.value =
|
||||
GatewayConnectionProblem(
|
||||
code = details?.code ?: error.code,
|
||||
message = error.message,
|
||||
reason = details?.reason,
|
||||
requestId = details?.requestId,
|
||||
recommendedNextStep = details?.recommendedNextStep,
|
||||
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
|
||||
retryable = details?.retryable == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
@@ -1410,11 +1452,14 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
_statusText.value = "Failed: no cached gateway endpoint"
|
||||
return
|
||||
}
|
||||
val endpoint = connectedEndpoint
|
||||
if (endpoint == null) {
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
?: run {
|
||||
_statusText.value = "Failed: no saved gateway endpoint"
|
||||
}
|
||||
return
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
@@ -1524,6 +1569,7 @@ class NodeRuntime(
|
||||
connectAttemptId: Long,
|
||||
) {
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return
|
||||
_gatewayConnectionProblem.value = null
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
@@ -1620,6 +1666,7 @@ class NodeRuntime(
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_gatewayConnectionProblem.value = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
@@ -1858,7 +1905,7 @@ class NodeRuntime(
|
||||
return
|
||||
}
|
||||
try {
|
||||
val modelsRes = operatorSession.request("models.list", """{"view":"all"}""")
|
||||
val modelsRes = operatorSession.request("models.list", "{}")
|
||||
val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull()
|
||||
_modelCatalog.value = parseGatewayModels(modelsRoot?.get("models") as? JsonArray)
|
||||
|
||||
@@ -2085,6 +2132,7 @@ class NodeRuntime(
|
||||
id = id,
|
||||
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
|
||||
provider = provider,
|
||||
available = obj.optionalBoolean("available"),
|
||||
supportsVision = "image" in inputTypes,
|
||||
supportsAudio = "audio" in inputTypes,
|
||||
supportsDocuments = "document" in inputTypes,
|
||||
@@ -2701,6 +2749,7 @@ data class GatewayModelSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val provider: String,
|
||||
val available: Boolean?,
|
||||
val supportsVision: Boolean,
|
||||
val supportsAudio: Boolean,
|
||||
val supportsDocuments: Boolean,
|
||||
@@ -2883,6 +2932,15 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
|
||||
|
||||
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
|
||||
state
|
||||
.cronStatus("lastStatus")
|
||||
|
||||
@@ -53,6 +53,7 @@ class PermissionRequester internal constructor(
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
private const val appearanceThemeModeKey = "appearance.themeMode"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -181,6 +182,10 @@ class SecurePrefs(
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
|
||||
private val _appearanceThemeMode =
|
||||
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
|
||||
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
@@ -525,6 +530,11 @@ class SecurePrefs(
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
|
||||
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
|
||||
_appearanceThemeMode.value = mode
|
||||
}
|
||||
|
||||
private fun loadNotificationForwardingPackages(): Set<String> {
|
||||
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
|
||||
if (raw.isNullOrEmpty()) {
|
||||
|
||||
@@ -61,9 +61,11 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
// Drops stale history responses after session switches or refresh races.
|
||||
private val historyLoadGeneration = AtomicLong(0)
|
||||
|
||||
@@ -225,6 +227,7 @@ class ChatController(
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
idempotencyKey = "$runId:user",
|
||||
)
|
||||
optimisticMessagesByRunId[runId] = optimisticMessage
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
@@ -350,6 +353,7 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
_historyLoading.value = false
|
||||
@@ -422,10 +426,8 @@ class ChatController(
|
||||
}
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
clearPendingRuns(clearOptimisticMessages = false)
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
@@ -455,6 +457,7 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
@@ -561,12 +564,14 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRuns() {
|
||||
private fun clearPendingRuns(clearOptimisticMessages: Boolean = true) {
|
||||
for ((_, job) in pendingRunTimeoutJobs) {
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
optimisticMessagesByRunId.clear()
|
||||
if (clearOptimisticMessages) {
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
@@ -578,6 +583,15 @@ class ChatController(
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun prunePersistedOptimisticMessages(incoming: List<ChatMessage>) {
|
||||
val retained =
|
||||
retainUnmatchedOptimisticMessages(
|
||||
incoming = incoming,
|
||||
optimistic = optimisticMessagesByRunId.values,
|
||||
).toSet()
|
||||
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -592,13 +606,14 @@ class ChatController(
|
||||
array.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseChatMessageContent) ?: emptyList()
|
||||
val content = parseChatMessageContents(obj)
|
||||
val ts = obj["timestamp"].asLongOrNull()
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = content,
|
||||
timestampMs = ts,
|
||||
idempotencyKey = obj["idempotencyKey"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -674,6 +689,19 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseChatMessageContents(obj: JsonObject): List<ChatMessageContent> {
|
||||
obj["content"].asArrayOrNull()?.let { content ->
|
||||
return content.mapNotNull(::parseChatMessageContent)
|
||||
}
|
||||
obj["content"].asStringOrNull()?.let { text ->
|
||||
return listOf(ChatMessageContent(type = "text", text = text))
|
||||
}
|
||||
obj["text"].asStringOrNull()?.let { text ->
|
||||
return listOf(ChatMessageContent(type = "text", text = text))
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
internal data class MainSessionState(
|
||||
val currentSessionKey: String,
|
||||
val appliedMainSessionKey: String,
|
||||
@@ -732,29 +760,41 @@ internal fun mergeOptimisticMessages(
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
val missingOptimistic = retainUnmatchedOptimisticMessages(incoming = incoming, optimistic = optimistic)
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
internal fun retainUnmatchedOptimisticMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
optimistic: Collection<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return emptyList()
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
return optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
|
||||
*/
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val idempotencyKey = message.idempotencyKey?.trim().orEmpty()
|
||||
if (idempotencyKey.isNotEmpty()) {
|
||||
return listOf(message.role.trim().lowercase(), idempotencyKey).joinToString(separator = "|")
|
||||
}
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
@@ -767,6 +807,10 @@ private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
val optimisticIdempotencyKey = optimistic.idempotencyKey?.trim().orEmpty()
|
||||
if (optimisticIdempotencyKey.isNotEmpty()) {
|
||||
return incoming.idempotencyKey?.trim() == optimisticIdempotencyKey
|
||||
}
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
|
||||
@@ -8,6 +8,7 @@ data class ChatMessage(
|
||||
val role: String,
|
||||
val content: List<ChatMessageContent>,
|
||||
val timestampMs: Long?,
|
||||
val idempotencyKey: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,10 +66,12 @@ class GatewayDiscovery(
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
|
||||
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
|
||||
/** Short diagnostic text shown by connect UI while discovery is running. */
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ data class GatewayConnectErrorDetails(
|
||||
val recommendedNextStep: String?,
|
||||
val pauseReconnect: Boolean? = null,
|
||||
val reason: String? = null,
|
||||
val requestId: String? = null,
|
||||
val retryable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -120,6 +122,7 @@ class GatewaySession(
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (GatewayHelloSummary) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onConnectFailure: (error: ErrorShape, pauseReconnect: Boolean) -> Unit = { _, _ -> },
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
@@ -127,6 +130,7 @@ class GatewaySession(
|
||||
private companion object {
|
||||
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
|
||||
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
|
||||
private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -923,6 +927,8 @@ class GatewaySession(
|
||||
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
|
||||
reason = it["reason"].asStringOrNull(),
|
||||
requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()),
|
||||
retryable = it["retryable"].asBooleanOrNull() == true,
|
||||
)
|
||||
}
|
||||
ErrorShape(code, msg, details)
|
||||
@@ -948,6 +954,11 @@ class GatewaySession(
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun normalizePairingRequestId(requestId: String?): String? {
|
||||
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) }
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String =
|
||||
try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
@@ -1061,10 +1072,14 @@ class GatewaySession(
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||
if (
|
||||
err is GatewayConnectFailure &&
|
||||
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
|
||||
) {
|
||||
val gatewayConnectFailure = err as? GatewayConnectFailure
|
||||
val pauseForAuthFailure =
|
||||
gatewayConnectFailure
|
||||
?.let { shouldPauseReconnectAfterAuthFailure(it.gatewayError) } == true
|
||||
if (gatewayConnectFailure != null) {
|
||||
onConnectFailure(gatewayConnectFailure.gatewayError, pauseForAuthFailure)
|
||||
}
|
||||
if (pauseForAuthFailure) {
|
||||
reconnectPausedForAuthFailure = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ private const val MAX_DEVICE_APPS_LIMIT = 200
|
||||
private const val DEVICE_APPS_SYSTEM_FLAGS =
|
||||
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
|
||||
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
|
||||
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
|
||||
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean = (appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
|
||||
|
||||
internal data class DeviceAppEntry(
|
||||
val label: String,
|
||||
|
||||
@@ -297,17 +297,15 @@ private fun CommandSectionLabel(title: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds provider quick-action metadata from current gateway/catalog state. */
|
||||
private fun providerCommandSubtitle(
|
||||
internal fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
if (!isConnected) return "Connect Gateway to view providers"
|
||||
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
return "No ready providers"
|
||||
}
|
||||
|
||||
/** Falls back to the canonical main-session label when gateway display names are blank. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -66,6 +66,7 @@ private enum class ConnectInputMode {
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
@@ -147,13 +148,10 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||
val pairingRequired = !isConnected && gatewayStatusLooksLikePairing(statusText)
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
|
||||
PairingAutoRetryEffect(enabled = pairingRequired) {
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
val showDiagnostics = !isConnected && (gatewayConnectionProblem != null || gatewayStatusHasDiagnostics(statusText))
|
||||
val pairingRequired = !isConnected && (gatewayConnectionProblem?.isPairingRequired == true || gatewayStatusLooksLikePairing(statusText))
|
||||
val pairingInstruction = gatewayPairingInstruction(gatewayConnectionProblem)
|
||||
val statusLabel = gatewayStatusForDisplay(gatewayConnectionProblem?.message ?: statusText)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
@@ -291,27 +289,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
|
||||
validationText = null
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
// Setup-code auth should replace old bootstrap/shared credentials;
|
||||
// manual reconnects keep existing typed credentials.
|
||||
viewModel.resetGatewaySetupAuth()
|
||||
}
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
if (config.token.isNotBlank()) {
|
||||
viewModel.setGatewayToken(config.token)
|
||||
} else if (config.bootstrapToken.isNotBlank()) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = inputMode == ConnectInputMode.SetupCode,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
@@ -341,7 +326,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
if (pairingRequired) {
|
||||
Text(
|
||||
"Approve this phone on the gateway. OpenClaw retries automatically while this screen stays open.",
|
||||
pairingInstruction,
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
@@ -590,6 +575,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gatewayPairingInstruction(problem: GatewayConnectionProblem?): String =
|
||||
if (problem?.canAutoRetry == true) {
|
||||
"Approve this phone on the gateway. OpenClaw will reconnect automatically."
|
||||
} else {
|
||||
"Approve this phone on the gateway, then retry the connection."
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MethodChip(
|
||||
label: String,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L
|
||||
internal const val PAIRING_AUTO_RETRY_MS = 4_000L
|
||||
|
||||
/** Retries pairing-only gateway refreshes while the screen is visible and started. */
|
||||
@Composable
|
||||
internal fun PairingAutoRetryEffect(
|
||||
enabled: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var lifecycleStarted by
|
||||
remember(lifecycleOwner) {
|
||||
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, _ ->
|
||||
lifecycleStarted = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(enabled, lifecycleStarted) {
|
||||
if (!enabled || !lifecycleStarted) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Give the gateway a short settling window before the first retry so an
|
||||
// approval response is not immediately chased by a redundant reconnect.
|
||||
delay(PAIRING_INITIAL_AUTO_RETRY_MS)
|
||||
while (true) {
|
||||
onRetry()
|
||||
delay(PAIRING_AUTO_RETRY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,27 +41,27 @@ internal data class MobileColors(
|
||||
|
||||
internal fun lightMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFFF6F7FA),
|
||||
surfaceStrong = Color(0xFFECEEF3),
|
||||
surface = Color(0xFFFAFBFC),
|
||||
surfaceStrong = Color(0xFFEFF3F8),
|
||||
cardSurface = Color(0xFFFFFFFF),
|
||||
border = Color(0xFFE5E7EC),
|
||||
borderStrong = Color(0xFFD6DAE2),
|
||||
text = Color(0xFF17181C),
|
||||
textSecondary = Color(0xFF5D6472),
|
||||
textTertiary = Color(0xFF99A0AE),
|
||||
accent = Color(0xFF1D5DD8),
|
||||
accentSoft = Color(0xFFECF3FF),
|
||||
accentBorderStrong = Color(0xFF184DAF),
|
||||
success = Color(0xFF2F8C5A),
|
||||
successSoft = Color(0xFFEEF9F3),
|
||||
warning = Color(0xFFC8841A),
|
||||
warningSoft = Color(0xFFFFF8EC),
|
||||
danger = Color(0xFFD04B4B),
|
||||
dangerSoft = Color(0xFFFFF2F2),
|
||||
codeBg = Color(0xFF15171B),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
border = Color(0xFFDDE3EC),
|
||||
borderStrong = Color(0xFFC7D0DC),
|
||||
text = Color(0xFF16181D),
|
||||
textSecondary = Color(0xFF505B6A),
|
||||
textTertiary = Color(0xFF8E98A7),
|
||||
accent = Color(0xFF1B5ACB),
|
||||
accentSoft = Color(0xFFEAF2FF),
|
||||
accentBorderStrong = Color(0xFF174CA9),
|
||||
success = Color(0xFF287F52),
|
||||
successSoft = Color(0xFFEAF7F0),
|
||||
warning = Color(0xFFAF7418),
|
||||
warningSoft = Color(0xFFFFF4DF),
|
||||
danger = Color(0xFFC94343),
|
||||
dangerSoft = Color(0xFFFFECEC),
|
||||
codeBg = Color(0xFFEFF3F8),
|
||||
codeText = Color(0xFF172033),
|
||||
codeBorder = Color(0xFFD7DDE7),
|
||||
codeAccent = Color(0xFF287F52),
|
||||
chipBorderConnected = Color(0xFFCFEBD8),
|
||||
chipBorderConnecting = Color(0xFFD5E2FA),
|
||||
chipBorderWarning = Color(0xFFEED8B8),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawErrorState
|
||||
@@ -31,24 +32,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
@@ -88,10 +96,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -120,14 +131,19 @@ fun OnboardingFlow(
|
||||
viewModel: MainViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawDesignTheme {
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
ClawDesignTheme(dark = onboardingDark) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val gateways by viewModel.gateways.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
val savedToken by viewModel.gatewayToken.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
|
||||
@@ -142,9 +158,12 @@ fun OnboardingFlow(
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var setupError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
var attemptedGatewayName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||
var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
|
||||
|
||||
OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome)
|
||||
|
||||
val qrScannerOptions =
|
||||
remember {
|
||||
GmsBarcodeScannerOptions
|
||||
@@ -163,6 +182,12 @@ fun OnboardingFlow(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(step) {
|
||||
if (step == OnboardingStep.Gateway) {
|
||||
viewModel.startGatewayDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(ready, attemptedConnect) {
|
||||
if (attemptedConnect && ready) {
|
||||
step = OnboardingStep.Permissions
|
||||
@@ -203,10 +228,12 @@ fun OnboardingFlow(
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Welcome ->
|
||||
WelcomeScreen(
|
||||
modifier = modifier,
|
||||
onConnect = { step = OnboardingStep.Gateway },
|
||||
)
|
||||
ClawDesignTheme(dark = true) {
|
||||
WelcomeScreen(
|
||||
modifier = modifier,
|
||||
onConnect = { step = OnboardingStep.Gateway },
|
||||
)
|
||||
}
|
||||
OnboardingStep.Gateway ->
|
||||
GatewaySetupScreen(
|
||||
modifier = modifier,
|
||||
@@ -217,6 +244,8 @@ fun OnboardingFlow(
|
||||
token = token,
|
||||
password = password,
|
||||
nearbyGatewayName = gateways.firstOrNull()?.name,
|
||||
discoveryStatusText = discoveryStatusText,
|
||||
discoveryStarted = runtimeInitialized,
|
||||
error = setupError,
|
||||
onBack = { step = OnboardingStep.Welcome },
|
||||
onScan = {
|
||||
@@ -253,8 +282,10 @@ fun OnboardingFlow(
|
||||
onPasswordChange = { password = it },
|
||||
onUseNearby = {
|
||||
val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen
|
||||
attemptedGatewayName = endpoint.name
|
||||
attemptedConnect = true
|
||||
viewModel.connect(endpoint)
|
||||
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
|
||||
viewModel.connectInBackground(endpoint)
|
||||
step = OnboardingStep.Recovery
|
||||
},
|
||||
onPair = {
|
||||
@@ -273,23 +304,17 @@ fun OnboardingFlow(
|
||||
}
|
||||
|
||||
setupError = null
|
||||
attemptedGatewayName = null
|
||||
attemptedConnect = true
|
||||
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
|
||||
// Setup-code pairing replaces any stale shared credentials before
|
||||
// the bootstrap token is stored for the first authenticated connect.
|
||||
viewModel.resetGatewaySetupAuth()
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
viewModel.setGatewayToken(config.token)
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = true,
|
||||
)
|
||||
step = OnboardingStep.Recovery
|
||||
},
|
||||
@@ -299,11 +324,11 @@ fun OnboardingFlow(
|
||||
modifier = modifier,
|
||||
statusText = statusText,
|
||||
serverName = serverName,
|
||||
attemptedGatewayName = attemptedGatewayName,
|
||||
remoteAddress = remoteAddress,
|
||||
ready = ready,
|
||||
attemptedConnect = attemptedConnect,
|
||||
gatewayConnectionProblem = gatewayConnectionProblem,
|
||||
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
|
||||
onAutoRetry = viewModel::refreshGatewayConnection,
|
||||
onBack = { step = OnboardingStep.Gateway },
|
||||
onRetry = {
|
||||
attemptedConnect = true
|
||||
@@ -317,11 +342,14 @@ fun OnboardingFlow(
|
||||
token = token,
|
||||
password = password,
|
||||
) ?: return@GatewayRecoveryScreen
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = false,
|
||||
)
|
||||
},
|
||||
onEdit = { step = OnboardingStep.Gateway },
|
||||
@@ -346,20 +374,39 @@ private fun WelcomeScreen(
|
||||
onConnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 18.dp)) {
|
||||
val welcomeBackground =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color(0xFFFF4D4D),
|
||||
Color(0xFFD73332),
|
||||
Color(0xFF991B1B),
|
||||
Color(0xFF260707),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(welcomeBackground)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 24.dp, vertical = 18.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(96.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
WelcomeLogo()
|
||||
Text(
|
||||
text = "OPENCLAW",
|
||||
style = ClawTheme.type.display.copy(fontSize = 34.sp, lineHeight = 38.sp, fontWeight = FontWeight.Black),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "Your AI command center.\nPrivate. Local. Under your control.",
|
||||
text = "Your personal AI assistant.\nExfoliate! Exfoliate!",
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -370,19 +417,26 @@ private fun WelcomeScreen(
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
HeroPrimaryAction(title = "Connect Gateway", onClick = onConnect)
|
||||
OutlinedAction(title = "Enter setup code", icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, onClick = onConnect)
|
||||
Surface(onClick = onConnect, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Text(text = "Already have a setup? ", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Sign in", style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold), color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(104.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeLogo() {
|
||||
Surface(
|
||||
modifier = Modifier.size(82.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
contentColor = Color.Unspecified,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
|
||||
Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeHorizon() {
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||
@@ -428,6 +482,8 @@ private fun GatewaySetupScreen(
|
||||
token: String,
|
||||
password: String,
|
||||
nearbyGatewayName: String?,
|
||||
discoveryStatusText: String,
|
||||
discoveryStarted: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
onScan: () -> Unit,
|
||||
@@ -442,6 +498,29 @@ private fun GatewaySetupScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var advancedOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var nearbySearchTimedOut by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(nearbyGatewayName, discoveryStatusText, discoveryStarted) {
|
||||
if (!nearbyGatewayName.isNullOrBlank()) {
|
||||
nearbySearchTimedOut = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!discoveryStarted) {
|
||||
nearbySearchTimedOut = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
nearbySearchTimedOut = false
|
||||
delay(5_000)
|
||||
nearbySearchTimedOut = true
|
||||
}
|
||||
|
||||
val nearbyGateway =
|
||||
nearbyGatewayUiState(
|
||||
nearbyGatewayName = nearbyGatewayName,
|
||||
discoveryStatusText = discoveryStatusText,
|
||||
discoveryStarted = discoveryStarted,
|
||||
searchTimedOut = nearbySearchTimedOut,
|
||||
)
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
@@ -461,9 +540,9 @@ private fun GatewaySetupScreen(
|
||||
GatewayOption(
|
||||
icon = Icons.Default.WifiTethering,
|
||||
title = "Nearby gateway",
|
||||
subtitle = nearbyGatewayName ?: "Discovery ready",
|
||||
status = nearbyGatewayName?.let { "Found" },
|
||||
onClick = onUseNearby,
|
||||
subtitle = nearbyGateway.subtitle,
|
||||
status = nearbyGateway.status,
|
||||
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
|
||||
)
|
||||
}
|
||||
item {
|
||||
@@ -527,20 +606,19 @@ private fun GatewaySetupScreen(
|
||||
private fun GatewayRecoveryScreen(
|
||||
statusText: String,
|
||||
serverName: String?,
|
||||
attemptedGatewayName: String?,
|
||||
remoteAddress: String?,
|
||||
ready: Boolean,
|
||||
attemptedConnect: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
connectSettling: Boolean,
|
||||
onAutoRetry: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling)
|
||||
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
|
||||
val context = LocalContext.current
|
||||
PairingAutoRetryEffect(enabled = recoveryState.canAutoRetry && attemptedConnect, onRetry = onAutoRetry)
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
@@ -551,6 +629,7 @@ private fun GatewayRecoveryScreen(
|
||||
imageVector =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
|
||||
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
|
||||
@@ -560,6 +639,7 @@ private fun GatewayRecoveryScreen(
|
||||
tint =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
|
||||
@@ -577,12 +657,16 @@ private fun GatewayRecoveryScreen(
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Text(text = serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
|
||||
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
|
||||
}
|
||||
ClawStatusPill(
|
||||
text =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> "Healthy"
|
||||
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
|
||||
GatewayRecoveryUiState.Pairing -> "Pairing"
|
||||
GatewayRecoveryUiState.Finishing -> "Connecting"
|
||||
GatewayRecoveryUiState.Failed -> "Needs attention"
|
||||
@@ -590,6 +674,7 @@ private fun GatewayRecoveryScreen(
|
||||
status =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawStatus.Success
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
|
||||
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
|
||||
GatewayRecoveryUiState.Failed -> ClawStatus.Warning
|
||||
@@ -606,7 +691,42 @@ private fun GatewayRecoveryScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit)
|
||||
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready) })
|
||||
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready, gatewayConnectionProblem) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApprovalCommandBlock(
|
||||
command: String,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SelectionContainer(modifier = Modifier.weight(1f)) {
|
||||
Text(text = command, style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace), color = ClawTheme.colors.text)
|
||||
}
|
||||
Surface(
|
||||
onClick = onCopy,
|
||||
modifier = Modifier.size(36.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = "Copy approval command", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,7 +823,7 @@ private fun GatewayOption(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
onClick: (() -> Unit)?,
|
||||
status: String? = null,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
@@ -712,9 +832,12 @@ private fun GatewayOption(
|
||||
subtitle = subtitle,
|
||||
metadata = status,
|
||||
leading = { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = ClawTheme.colors.text) },
|
||||
trailing = {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
},
|
||||
trailing =
|
||||
onClick?.let {
|
||||
{
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -890,38 +1013,80 @@ private fun PermissionContinueButton(onClick: () -> Unit) {
|
||||
internal enum class GatewayRecoveryUiState(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val canAutoRetry: Boolean,
|
||||
) {
|
||||
Connected(
|
||||
title = "Connected",
|
||||
message = "Your Gateway is ready.",
|
||||
canAutoRetry = false,
|
||||
),
|
||||
ApprovalRequired(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approve this phone on the gateway.\nThen retry the connection.",
|
||||
),
|
||||
Pairing(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
|
||||
canAutoRetry = true,
|
||||
),
|
||||
Finishing(
|
||||
title = "Finishing Setup",
|
||||
message = "Gateway approved this phone.\nOpenClaw is bringing the node online.",
|
||||
canAutoRetry = true,
|
||||
title = "Connecting Gateway",
|
||||
message = "OpenClaw is checking gateway and node access.",
|
||||
),
|
||||
Failed(
|
||||
title = "Connection issue",
|
||||
message = "We could not reach your Gateway.\nLet's fix this.",
|
||||
canAutoRetry = false,
|
||||
),
|
||||
}
|
||||
|
||||
internal data class NearbyGatewayUiState(
|
||||
val subtitle: String,
|
||||
val status: String?,
|
||||
val canConnect: Boolean,
|
||||
)
|
||||
|
||||
/** Maps best-effort discovery into row copy and clickability for onboarding. */
|
||||
internal fun nearbyGatewayUiState(
|
||||
nearbyGatewayName: String?,
|
||||
discoveryStatusText: String,
|
||||
discoveryStarted: Boolean = true,
|
||||
searchTimedOut: Boolean = false,
|
||||
): NearbyGatewayUiState {
|
||||
val name = nearbyGatewayName?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
if (name != null) {
|
||||
return NearbyGatewayUiState(subtitle = name, status = "Found", canConnect = true)
|
||||
}
|
||||
if (!discoveryStarted) {
|
||||
return NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false)
|
||||
}
|
||||
|
||||
val status = discoveryStatusText.trim()
|
||||
val searching =
|
||||
status.isEmpty() ||
|
||||
status.equals("Searching…", ignoreCase = true) ||
|
||||
status.contains("Searching", ignoreCase = true) ||
|
||||
status.endsWith("?", ignoreCase = true)
|
||||
return if (searching) {
|
||||
if (searchTimedOut) {
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
|
||||
} else {
|
||||
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false)
|
||||
}
|
||||
} else {
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Derives recovery screen state from gateway/node readiness and transient status text. */
|
||||
internal fun gatewayRecoveryUiState(
|
||||
ready: Boolean,
|
||||
statusText: String,
|
||||
connectSettling: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem? = null,
|
||||
): GatewayRecoveryUiState =
|
||||
when {
|
||||
ready -> GatewayRecoveryUiState.Connected
|
||||
gatewayConnectionProblem?.isPairingRequired == true &&
|
||||
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
|
||||
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
|
||||
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
|
||||
connectSettling -> GatewayRecoveryUiState.Finishing
|
||||
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
|
||||
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
|
||||
@@ -934,6 +1099,18 @@ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
|
||||
return lower.contains("operator offline") || lower.contains("node offline")
|
||||
}
|
||||
|
||||
internal fun recoveryGatewayName(
|
||||
serverName: String?,
|
||||
attemptedGatewayName: String?,
|
||||
): String =
|
||||
serverName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: attemptedGatewayName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Home Gateway"
|
||||
|
||||
private data class GatewayConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
@@ -993,11 +1170,16 @@ private fun recoveryGatewayDetail(
|
||||
ready: Boolean,
|
||||
remoteAddress: String?,
|
||||
statusText: String,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
): String =
|
||||
remoteAddress
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: if (ready) {
|
||||
"Ready for chat and voice"
|
||||
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
|
||||
?.let { "Gateway approval is pending. Run this on the gateway host:" }
|
||||
?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry."
|
||||
} else if (statusText.contains("operator offline", ignoreCase = true)) {
|
||||
"Gateway paired. Waiting for operator access."
|
||||
} else if (gatewayStatusLooksLikePairing(statusText)) {
|
||||
@@ -1006,6 +1188,25 @@ private fun recoveryGatewayDetail(
|
||||
"Gateway unreachable"
|
||||
}
|
||||
|
||||
private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? {
|
||||
if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null
|
||||
val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return if (requestId != null) {
|
||||
"openclaw devices approve $requestId"
|
||||
} else {
|
||||
"openclaw devices list"
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyApprovalCommand(
|
||||
context: Context,
|
||||
command: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw pairing approval command", command))
|
||||
Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/** Copies the onboarding recovery snapshot for support without including credentials. */
|
||||
private fun copyGatewayDiagnostic(
|
||||
context: Context,
|
||||
@@ -1013,11 +1214,16 @@ private fun copyGatewayDiagnostic(
|
||||
serverName: String?,
|
||||
remoteAddress: String?,
|
||||
ready: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
) {
|
||||
val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem)
|
||||
val diagnostic =
|
||||
listOf(
|
||||
listOfNotNull(
|
||||
"OpenClaw Android gateway diagnostic",
|
||||
"Status: $statusText",
|
||||
gatewayConnectionProblem?.message?.let { "Gateway problem: $it" },
|
||||
gatewayConnectionProblem?.requestId?.let { "Pairing request: $it" },
|
||||
approvalCommand?.let { "Approval command: $it" },
|
||||
"Gateway: ${serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"}",
|
||||
"Address: ${remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"}",
|
||||
"Ready: ${if (ready) "yes" else "no"}",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -8,34 +9,51 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
|
||||
*/
|
||||
@Composable
|
||||
fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
fun OpenClawTheme(
|
||||
themeMode: AppearanceThemeMode = AppearanceThemeMode.Dark,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val isDark = themeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
OpenClawSystemBarAppearance(lightAppearance = !isDark)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
WindowCompat
|
||||
.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightStatusBars = !isDark
|
||||
.isAppearanceLightStatusBars = lightAppearance
|
||||
WindowCompat
|
||||
.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightNavigationBars = lightAppearance
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,9 +62,9 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val isDark = LocalOpenClawDarkTheme.current
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
|
||||
// Light mode keeps overlays away from pure-white glare on the app canvas.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.providerDisplayName
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
@@ -17,27 +16,20 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -46,25 +38,20 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** Android providers/models browser backed by the gateway catalog. */
|
||||
/** Android provider readiness screen backed by the configured gateway model view. */
|
||||
@Composable
|
||||
internal fun ProvidersModelsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAddProvider: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
@@ -72,9 +59,6 @@ internal fun ProvidersModelsScreen(
|
||||
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
|
||||
val errorText by viewModel.modelCatalogErrorText.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val modelGroups = sortedModelGroups(models)
|
||||
val setupRows = providerSetupRows(providerRows)
|
||||
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -100,12 +84,11 @@ internal fun ProvidersModelsScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
|
||||
text = "Review provider readiness\nand configured models.",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
@@ -119,26 +102,17 @@ internal fun ProvidersModelsScreen(
|
||||
providerRows = providerRows,
|
||||
modelCount = models.size,
|
||||
onRefresh = viewModel::refreshModelCatalog,
|
||||
onSetup = onAddProvider,
|
||||
refreshing = refreshing,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Provider setup")
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Connected providers")
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isConnected && providerRows.isEmpty()) {
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness.")
|
||||
} else {
|
||||
ProviderList(rows = providerRows, refreshing = refreshing)
|
||||
}
|
||||
@@ -151,50 +125,12 @@ internal fun ProvidersModelsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Model catalog")
|
||||
}
|
||||
|
||||
if (modelGroups.isEmpty()) {
|
||||
item {
|
||||
ModelCatalogEmpty(
|
||||
title = if (refreshing) "Loading models" else "No models loaded",
|
||||
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(modelGroups, key = { it.first }) { entry ->
|
||||
val expanded = expandedModelProviders.contains(entry.first)
|
||||
ModelGroup(
|
||||
provider = entry.first,
|
||||
models = entry.second,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedModelProviders =
|
||||
if (expanded) {
|
||||
expandedModelProviders - entry.first
|
||||
} else {
|
||||
expandedModelProviders + entry.first
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProviderSetupRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
internal data class ProviderRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
@@ -202,28 +138,28 @@ private data class ProviderRow(
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
/** Combines auth-provider readiness rows with catalog-only providers. */
|
||||
private fun providerRows(
|
||||
/** Combines gateway auth-provider readiness with configured model providers. */
|
||||
internal fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
// Static/catalog-only providers may expose models without a matching auth
|
||||
// provider row; keep them visible as ready providers.
|
||||
val missingAuthRows =
|
||||
providers
|
||||
.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs attention",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
val authProviderIds = authRows.mapTo(mutableSetOf()) { it.id.trim().lowercase() }
|
||||
val configuredModelRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.filter { provider -> provider.trim().lowercase() !in authProviderIds }
|
||||
.map { provider ->
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
@@ -233,33 +169,9 @@ private fun providerRows(
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
return (authRows + configuredModelRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
}
|
||||
|
||||
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
|
||||
val byId = providerRows.associateBy { it.id.trim().lowercase() }
|
||||
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
|
||||
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
|
||||
ProviderSetupRow(
|
||||
id = id,
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSetupSubtitle(
|
||||
id: String,
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
@@ -270,14 +182,6 @@ internal fun modelProviderReady(status: String): Boolean {
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
/** Groups models by provider using the same display priority as provider rows. */
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
.groupBy { it.provider }
|
||||
.entries
|
||||
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
|
||||
.map { it.key to it.value }
|
||||
|
||||
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
|
||||
|
||||
private fun providerPriority(provider: String): Int =
|
||||
@@ -299,7 +203,15 @@ private fun ProviderList(
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
ProviderListRow(
|
||||
ProviderRow(
|
||||
id = "loading",
|
||||
name = "Provider catalog",
|
||||
status = if (refreshing) "Loading" else "No providers",
|
||||
ready = false,
|
||||
modelCount = 0,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
@@ -320,7 +232,6 @@ private fun ProviderOverviewPanel(
|
||||
modelCount: Int,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
@@ -329,17 +240,14 @@ private fun ProviderOverviewPanel(
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Needs", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
|
||||
text = if (isConnected) "Refresh to recheck provider readiness from your Gateway." else "Connect your Gateway to view provider readiness.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,55 +272,13 @@ private fun ProviderMetricTile(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupList(
|
||||
rows: List<ProviderSetupRow>,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
ProviderSetupListRow(row = row, onClick = onSetup)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupListRow(
|
||||
row: ProviderSetupRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderListRow(row: ProviderRow) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "No configured models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
@@ -439,78 +305,6 @@ private fun providerInitials(value: String): String =
|
||||
.joinToString("")
|
||||
.ifBlank { "AI" }
|
||||
|
||||
@Composable
|
||||
private fun ModelCatalogEmpty(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelGroup(
|
||||
provider: String,
|
||||
models: List<GatewayModelSummary>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = providerDisplayName(provider))
|
||||
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ProviderMiniTag(text = "${models.size} models")
|
||||
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
val visibleModels = if (expanded) models else models.take(3)
|
||||
visibleModels.forEachIndexed { index, model ->
|
||||
ModelRow(model)
|
||||
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
if (models.size > visibleModels.size) {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
|
||||
/** Derives compact capability chips for model catalog rows. */
|
||||
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
|
||||
buildList {
|
||||
if (model.supportsReasoning) add("Reasoning")
|
||||
if (model.supportsVision) add("Vision")
|
||||
if (model.supportsAudio) add("Voice")
|
||||
if (model.supportsDocuments) add("Docs")
|
||||
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
|
||||
if (isEmpty()) add("Fast")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
@@ -538,39 +332,3 @@ private fun ProviderHeaderIconButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderAddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ private fun SessionRow(
|
||||
compact: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayCronJobSummary
|
||||
@@ -8,7 +9,6 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
@@ -147,7 +147,7 @@ internal fun SettingsDetailScreen(
|
||||
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack)
|
||||
SettingsRoute.Appearance -> AppearanceSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
}
|
||||
@@ -897,18 +897,14 @@ private fun GatewaySettingsScreen(
|
||||
.orEmpty()
|
||||
.ifEmpty { passwordInput.trim() }
|
||||
validationText = null
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(endpointConfig.host)
|
||||
viewModel.setManualPort(endpointConfig.port)
|
||||
viewModel.setManualTls(endpointConfig.tls)
|
||||
viewModel.setGatewayBootstrapToken(bootstrapToken)
|
||||
viewModel.setGatewayToken(token)
|
||||
viewModel.setGatewayPassword(password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port),
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken = bootstrapToken.ifEmpty { null },
|
||||
password = password.ifEmpty { null },
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = endpointConfig.host,
|
||||
port = endpointConfig.port,
|
||||
tls = endpointConfig.tls,
|
||||
token = token,
|
||||
bootstrapToken = bootstrapToken,
|
||||
password = password,
|
||||
resetSetupAuth = setup != null,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -919,22 +915,40 @@ private fun GatewaySettingsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceSettingsScreen(onBack: () -> Unit) {
|
||||
private fun AppearanceSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val themeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
|
||||
SettingsDetailFrame(title = "Appearance", subtitle = "A calm, high-contrast OpenClaw interface.", icon = Icons.Default.Palette, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Theme", "Dark"),
|
||||
SettingsMetric("Theme", appearanceThemeSummary(themeMode)),
|
||||
SettingsMetric("Contrast", "High"),
|
||||
SettingsMetric("Typography", "Readable"),
|
||||
),
|
||||
)
|
||||
ClawPanel {
|
||||
Text(text = "OpenClaw uses a fixed premium dark theme so it stays consistent across devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Theme", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ClawSegmentedControl(
|
||||
options = appearanceThemeOptions(),
|
||||
selected = appearanceThemeSummary(themeMode),
|
||||
onSelect = { selected -> viewModel.setAppearanceThemeMode(appearanceThemeModeForLabel(selected)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun appearanceThemeSummary(mode: AppearanceThemeMode): String = mode.displayLabel
|
||||
|
||||
internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entries.map { it.displayLabel }
|
||||
|
||||
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
|
||||
|
||||
/** Converts raw gateway connection text into stable settings metric labels. */
|
||||
private fun gatewayStatusLabel(
|
||||
statusText: String,
|
||||
@@ -971,7 +985,7 @@ private fun AboutSettingsScreen(
|
||||
listOf(
|
||||
SettingsMetric("Android App", BuildConfig.VERSION_NAME),
|
||||
SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()),
|
||||
SettingsMetric("Channel", "Play"),
|
||||
SettingsMetric("Channel", androidDistributionChannel()),
|
||||
SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"),
|
||||
),
|
||||
)
|
||||
@@ -994,6 +1008,14 @@ private fun AboutSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): String =
|
||||
when (flavor.trim()) {
|
||||
"play" -> "Play"
|
||||
"thirdParty" -> "Third-party"
|
||||
"" -> "Unknown"
|
||||
else -> flavor.trim()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutStatusRow(
|
||||
title: String,
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -103,7 +104,10 @@ private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Setting
|
||||
private val shellContentInsets: WindowInsets
|
||||
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
|
||||
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
|
||||
internal fun shellBottomNavVisible(
|
||||
keyboardVisible: Boolean,
|
||||
commandOpen: Boolean,
|
||||
): Boolean = !keyboardVisible && !commandOpen
|
||||
|
||||
/** Main post-onboarding shell that owns top-level Android navigation state. */
|
||||
@Composable
|
||||
@@ -111,13 +115,18 @@ fun ShellScreen(
|
||||
viewModel: MainViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawDesignTheme {
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val shellDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
OpenClawSystemBarAppearance(lightAppearance = !shellDark)
|
||||
ClawDesignTheme(dark = shellDark) {
|
||||
var activeTab by rememberSaveable { mutableStateOf(Tab.Overview) }
|
||||
var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) }
|
||||
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var commandOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var voiceScreenWasActive by rememberSaveable { mutableStateOf(false) }
|
||||
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
|
||||
LaunchedEffect(requestedHomeDestination) {
|
||||
val destination = requestedHomeDestination ?: return@LaunchedEffect
|
||||
@@ -138,8 +147,12 @@ fun ShellScreen(
|
||||
viewModel.clearRequestedHomeDestination()
|
||||
}
|
||||
|
||||
LaunchedEffect(activeTab) {
|
||||
viewModel.setVoiceScreenActive(activeTab == Tab.Voice)
|
||||
LaunchedEffect(activeTab, runtimeInitialized) {
|
||||
val voiceScreenActive = activeTab == Tab.Voice
|
||||
if (voiceScreenActive || voiceScreenWasActive || runtimeInitialized) {
|
||||
viewModel.setVoiceScreenActive(voiceScreenActive)
|
||||
}
|
||||
voiceScreenWasActive = voiceScreenActive
|
||||
}
|
||||
|
||||
BackHandler(enabled = activeTab != Tab.Overview) {
|
||||
@@ -213,11 +226,6 @@ fun ShellScreen(
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
@@ -342,7 +350,7 @@ private fun OverviewScreen(
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
@@ -455,13 +463,12 @@ private fun OverviewScreen(
|
||||
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = "Model setup",
|
||||
subtitle = "Provider readiness",
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
else -> "No ready"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
@@ -541,6 +548,7 @@ internal fun homeAttentionRows(
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
expiringProviderCount: Int = 0,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
@@ -564,7 +572,7 @@ internal fun homeAttentionRows(
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.Settings, SettingsRoute.Gateway)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -747,7 +755,7 @@ private fun RecentSessionRowContent(
|
||||
metadata: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -845,6 +853,7 @@ private fun SettingsShellScreen(
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -906,7 +915,7 @@ private fun SettingsShellScreen(
|
||||
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
|
||||
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
|
||||
SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
|
||||
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
|
||||
),
|
||||
|
||||
@@ -97,6 +97,7 @@ fun VoiceScreen(
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeStatusText by viewModel.talkModeStatusText.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
|
||||
@@ -119,6 +120,16 @@ fun VoiceScreen(
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val voiceActive = micEnabled || micIsSending || talkModeEnabled
|
||||
val gatewayReady = gatewayStatus.isVoiceGatewayReady()
|
||||
val voiceAttentionStatus =
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = talkModeStatusText,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micEnabled = micEnabled,
|
||||
micIsSending = micIsSending,
|
||||
talkModeEnabled = talkModeEnabled,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
)
|
||||
val activeStatus =
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = gatewayStatus,
|
||||
@@ -128,6 +139,7 @@ fun VoiceScreen(
|
||||
micIsSending = micIsSending,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
voiceAttentionStatus = voiceAttentionStatus,
|
||||
)
|
||||
|
||||
if (talkModeEnabled) {
|
||||
@@ -169,7 +181,7 @@ fun VoiceScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onOpenCommand = onOpenCommand,
|
||||
@@ -184,6 +196,7 @@ fun VoiceScreen(
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
micLiveTranscript = micLiveTranscript,
|
||||
gatewayReady = gatewayReady,
|
||||
voiceAttentionStatus = voiceAttentionStatus,
|
||||
onStartTalk = {
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Talk,
|
||||
@@ -242,7 +255,9 @@ private fun DictationScreen(
|
||||
) {
|
||||
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
|
||||
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
|
||||
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
|
||||
val providerAttentionStatus = voiceRuntimeAttentionStatus(statusText)
|
||||
val displayStatusText = providerAttentionStatus ?: statusText
|
||||
val speechProviderReady = providerAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -278,7 +293,7 @@ private fun DictationScreen(
|
||||
DictationWaveform(active = listening || sending)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = displayStatusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,13 +313,20 @@ private fun DictationScreen(
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = providerAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
sending -> "Sending"
|
||||
providerAttentionStatus != null -> "Attention"
|
||||
speechProviderReady -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
@@ -312,6 +334,7 @@ private fun DictationScreen(
|
||||
color =
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
providerAttentionStatus != null -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
},
|
||||
@@ -324,6 +347,7 @@ private fun DictationScreen(
|
||||
.background(
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
providerAttentionStatus != null -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
@@ -594,6 +618,7 @@ private fun VoiceHero(
|
||||
talkModeSpeaking: Boolean,
|
||||
micLiveTranscript: String?,
|
||||
gatewayReady: Boolean,
|
||||
voiceAttentionStatus: String?,
|
||||
onStartTalk: () -> Unit,
|
||||
onStartDictation: () -> Unit,
|
||||
onConnectGateway: () -> Unit,
|
||||
@@ -616,6 +641,7 @@ private fun VoiceHero(
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
voiceAttentionStatus != null -> voiceAttentionStatus
|
||||
talkModeSpeaking -> "OpenClaw is replying"
|
||||
talkModeListening -> "Listening"
|
||||
talkModeEnabled -> "Talk is live"
|
||||
@@ -672,7 +698,7 @@ private fun VoiceHero(
|
||||
)
|
||||
}
|
||||
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus)
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus, voiceAttentionStatus = voiceAttentionStatus)
|
||||
|
||||
VoicePrimaryAction(
|
||||
text =
|
||||
@@ -734,8 +760,11 @@ private fun VoiceModeRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
val ready = gatewayStatus.isVoiceGatewayReady()
|
||||
private fun VoiceProviderCard(
|
||||
gatewayStatus: String,
|
||||
voiceAttentionStatus: String?,
|
||||
) {
|
||||
val ready = voiceAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
@@ -761,7 +790,13 @@ private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = voiceAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(
|
||||
@@ -769,9 +804,25 @@ private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
.background(
|
||||
when {
|
||||
ready -> ClawTheme.colors.success
|
||||
voiceAttentionStatus != null -> ClawTheme.colors.warning
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
ready -> "Ready"
|
||||
voiceAttentionStatus != null -> "Attention"
|
||||
else -> "Offline"
|
||||
},
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -968,7 +1019,7 @@ private fun runVoiceAction(
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceStatusLabel(
|
||||
internal fun voiceStatusLabel(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micStatusText: String,
|
||||
@@ -976,8 +1027,10 @@ private fun voiceStatusLabel(
|
||||
micIsSending: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
voiceAttentionStatus: String?,
|
||||
): String =
|
||||
when {
|
||||
voiceAttentionStatus != null -> voiceAttentionStatus
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
|
||||
@@ -988,6 +1041,69 @@ private fun voiceStatusLabel(
|
||||
else -> "Ready to talk"
|
||||
}
|
||||
|
||||
internal fun voiceAttentionStatus(
|
||||
talkModeStatusText: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micEnabled: Boolean,
|
||||
micIsSending: Boolean,
|
||||
talkModeEnabled: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
): String? {
|
||||
if (voiceCaptureMode != VoiceCaptureMode.Off || micEnabled || micIsSending) return null
|
||||
if (talkModeEnabled || talkModeListening || talkModeSpeaking) return null
|
||||
val status = talkModeStatusText.trim()
|
||||
if (status.isBlank()) return null
|
||||
val lower = status.lowercase()
|
||||
if (lower == "off" || lower == "ready" || lower == "listening" || lower == "connecting…") return null
|
||||
return status
|
||||
.takeIf {
|
||||
lower.contains("failed") ||
|
||||
lower.contains("unavailable") ||
|
||||
lower.contains("permission required") ||
|
||||
lower.contains("not connected") ||
|
||||
lower.contains("error")
|
||||
}?.let(::userFacingVoiceAttentionStatus)
|
||||
}
|
||||
|
||||
internal fun voiceRuntimeAttentionStatus(statusText: String): String? {
|
||||
val status = statusText.trim()
|
||||
if (status.isBlank()) return null
|
||||
val lower = status.lowercase()
|
||||
return status
|
||||
.takeIf {
|
||||
lower.contains("transcription unavailable") ||
|
||||
lower.contains("provider unavailable") ||
|
||||
(lower.contains("provider") && lower.contains("not configured")) ||
|
||||
lower.contains("no realtime transcription provider") ||
|
||||
lower.contains("failed")
|
||||
}?.let(::userFacingVoiceAttentionStatus)
|
||||
}
|
||||
|
||||
private fun userFacingVoiceAttentionStatus(status: String): String {
|
||||
val normalized =
|
||||
status
|
||||
.removePrefix("Start failed:")
|
||||
.trim()
|
||||
.removePrefix("Transcription unavailable:")
|
||||
.trim()
|
||||
.removePrefix("UNAVAILABLE:")
|
||||
.trim()
|
||||
.removePrefix("Error:")
|
||||
.trim()
|
||||
val lower = normalized.lowercase()
|
||||
if (lower.contains("realtime voice provider") && lower.contains("not configured")) {
|
||||
return "Realtime voice provider is not configured."
|
||||
}
|
||||
if (lower.contains("no realtime transcription provider")) {
|
||||
return "Realtime transcription provider is not configured."
|
||||
}
|
||||
if (lower.contains("microphone permission required")) {
|
||||
return "Microphone permission is required."
|
||||
}
|
||||
return if (normalized.length <= 90) normalized else "${normalized.take(87)}..."
|
||||
}
|
||||
|
||||
private fun String.isVoiceGatewayReady(): Boolean {
|
||||
val status = lowercase()
|
||||
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -40,17 +40,19 @@ fun ChatMessageListCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
val timeline =
|
||||
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
|
||||
buildChatTimeline(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
)
|
||||
}
|
||||
|
||||
// New list items/tool rows should animate into view, but token streaming should not restart
|
||||
// that animation on every delta.
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
|
||||
timeline.scrollTargetIndex?.let { index ->
|
||||
listState.animateScrollToItem(index = index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,32 +66,17 @@ fun ChatMessageListCard(
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
||||
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message -> ChatMessageBubble(message = item.message)
|
||||
is ChatTimelineItem.PendingTools -> ChatPendingToolsBubble(toolCalls = item.toolCalls)
|
||||
is ChatTimelineItem.StreamingAssistant -> ChatStreamingAssistantBubble(text = item.text)
|
||||
ChatTimelineItem.Thinking -> ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatMessageBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||
if (timeline.items.isEmpty()) {
|
||||
if (historyLoading) {
|
||||
LoadingChatHint(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -406,15 +406,19 @@ private fun ChatMessageList(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
val timeline =
|
||||
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
|
||||
buildChatTimeline(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
|
||||
timeline.scrollTargetIndex?.let { index ->
|
||||
listState.animateScrollToItem(index = index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,30 +430,29 @@ private fun ChatMessageList(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
|
||||
) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null)
|
||||
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message ->
|
||||
ChatBubble(
|
||||
role = item.message.role,
|
||||
live = false,
|
||||
content = item.message.content,
|
||||
timestampMs = item.message.timestampMs,
|
||||
)
|
||||
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
|
||||
is ChatTimelineItem.StreamingAssistant ->
|
||||
ChatBubble(
|
||||
role = "assistant",
|
||||
live = true,
|
||||
content = listOf(ChatMessageContent(text = item.text)),
|
||||
timestampMs = null,
|
||||
)
|
||||
ChatTimelineItem.Thinking -> ChatThinkingBubble()
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ToolBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "thinking") {
|
||||
ChatThinkingBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
|
||||
if (timeline.items.isEmpty()) {
|
||||
if (historyLoading) {
|
||||
ClawLoadingState(title = "Loading session", modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
|
||||
internal sealed class ChatTimelineItem {
|
||||
data class Message(
|
||||
val message: ChatMessage,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
data class StreamingAssistant(
|
||||
val text: String,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
data class PendingTools(
|
||||
val toolCalls: List<ChatPendingToolCall>,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
object Thinking : ChatTimelineItem()
|
||||
}
|
||||
|
||||
internal data class ChatTimeline(
|
||||
val items: List<ChatTimelineItem>,
|
||||
val scrollTargetIndex: Int?,
|
||||
)
|
||||
|
||||
internal fun buildChatTimeline(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
): ChatTimeline {
|
||||
val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val hasActiveRun = pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || stream != null
|
||||
val items =
|
||||
buildList {
|
||||
if (stream != null) add(ChatTimelineItem.StreamingAssistant(stream))
|
||||
if (pendingToolCalls.isNotEmpty()) add(ChatTimelineItem.PendingTools(pendingToolCalls))
|
||||
if (pendingRunCount > 0) add(ChatTimelineItem.Thinking)
|
||||
messages.asReversed().forEach { message -> add(ChatTimelineItem.Message(message)) }
|
||||
}
|
||||
if (items.isEmpty()) return ChatTimeline(items = items, scrollTargetIndex = null)
|
||||
|
||||
// In reverseLayout, index 0 is bottom-most. During an active run, keep the prompt
|
||||
// anchored so streaming/tool rows do not immediately push the just-sent message away.
|
||||
val activePromptIndex =
|
||||
if (hasActiveRun) {
|
||||
items.indexOfFirst { item ->
|
||||
item is ChatTimelineItem.Message &&
|
||||
item.message.role
|
||||
.trim()
|
||||
.equals("user", ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
return ChatTimeline(
|
||||
items = items,
|
||||
scrollTargetIndex = activePromptIndex.takeIf { it >= 0 } ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun chatTimelineItemKey(item: ChatTimelineItem): String =
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message -> "message:${item.message.id}"
|
||||
is ChatTimelineItem.PendingTools -> "tools"
|
||||
is ChatTimelineItem.StreamingAssistant -> "stream"
|
||||
ChatTimelineItem.Thinking -> "thinking"
|
||||
}
|
||||
@@ -82,7 +82,12 @@ fun resolveCompactSessionChoices(
|
||||
)
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index }
|
||||
val pinnedRank =
|
||||
listOf(mainKey, current)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.withIndex()
|
||||
.associate { it.value to it.index }
|
||||
val unpinnedRank = pinnedRank.size
|
||||
|
||||
return allChoices
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import ai.openclaw.app.ui.LocalMobileColors
|
||||
import ai.openclaw.app.ui.darkMobileColors
|
||||
import ai.openclaw.app.ui.lightMobileColors
|
||||
import ai.openclaw.app.ui.mobileFontFamily
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -110,22 +113,22 @@ private val ClawDarkColors =
|
||||
|
||||
private val ClawLightColors =
|
||||
ClawColors(
|
||||
canvas = Color(0xFFF7F7F7),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
canvas = Color(0xFFFAFBFC),
|
||||
surface = Color(0xFFFFFEFB),
|
||||
surfaceRaised = Color(0xFFFFFFFF),
|
||||
surfacePressed = Color(0xFFEDEDED),
|
||||
border = Color(0xFFE0E0E0),
|
||||
borderStrong = Color(0xFFBDBDBD),
|
||||
text = Color(0xFF070707),
|
||||
textMuted = Color(0xFF595959),
|
||||
textSubtle = Color(0xFF8A8A8A),
|
||||
primary = Color(0xFF050505),
|
||||
surfacePressed = Color(0xFFE9EDF3),
|
||||
border = Color(0xFFDDE3EC),
|
||||
borderStrong = Color(0xFFC7D0DC),
|
||||
text = Color(0xFF111318),
|
||||
textMuted = Color(0xFF505865),
|
||||
textSubtle = Color(0xFF8993A2),
|
||||
primary = Color(0xFF111827),
|
||||
primaryText = Color(0xFFFFFFFF),
|
||||
success = Color(0xFF157A3E),
|
||||
successSoft = Color(0xFFEAF8EF),
|
||||
warning = Color(0xFF9A6A12),
|
||||
warningSoft = Color(0xFFFFF5DD),
|
||||
danger = Color(0xFFB42323),
|
||||
success = Color(0xFF217747),
|
||||
successSoft = Color(0xFFE9F7EF),
|
||||
warning = Color(0xFFA56F17),
|
||||
warningSoft = Color(0xFFFFF3DC),
|
||||
danger = Color(0xFFB82929),
|
||||
dangerSoft = Color(0xFFFFE9E9),
|
||||
)
|
||||
|
||||
@@ -168,10 +171,12 @@ internal fun ClawDesignTheme(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (dark) ClawDarkColors else ClawLightColors
|
||||
val mobileColors = if (dark) darkMobileColors() else lightMobileColors()
|
||||
val typography = clawTypography(mobileFontFamily)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalClawColors provides colors,
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalClawSpacing provides ClawSpacing(),
|
||||
LocalClawRadii provides ClawRadii(),
|
||||
LocalClawTypography provides typography,
|
||||
|
||||
@@ -104,6 +104,7 @@ class MicCaptureManager(
|
||||
private val messageQueue = ArrayDeque<String>()
|
||||
private val messageQueueLock = Any()
|
||||
private var flushedPartialTranscript: String? = null
|
||||
|
||||
// Correlates chat events with the idempotency key generated before sendChat returns.
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingAssistantEntryId: String? = null
|
||||
|
||||
@@ -168,6 +168,7 @@ class TalkModeManager internal constructor(
|
||||
@Volatile private var realtimeSessionId: String? = null
|
||||
private var realtimeCaptureJob: Job? = null
|
||||
private var realtimeAppendJob: Job? = null
|
||||
|
||||
// Realtime tool calls can complete before their chat final arrives; cache by call/run id until both sides meet.
|
||||
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
|
||||
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
|
||||
|
||||
67
apps/android/app/src/main/res/drawable/openclaw_logo.xml
Normal file
67
apps/android/app/src/main/res/drawable/openclaw_logo.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
<path android:pathData="M60,10 C30,10 15,35 15,55 C15,75 30,95 45,100 L45,110 L55,110 L55,100 C55,100 60,102 65,100 L65,110 L75,110 L75,100 C90,95 105,75 105,55 C105,35 90,10 60,10Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M20,45 C5,40 0,50 5,60 C10,70 20,65 25,55 C28,48 25,45 20,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M100,45 C115,40 120,50 115,60 C110,70 100,65 95,55 C92,48 95,45 100,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M45,15 Q35,5 30,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M75,15 Q85,5 90,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M45,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M75,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M46,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M76,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
</vector>
|
||||
@@ -294,6 +294,38 @@ class GatewayBootstrapAuthTest {
|
||||
assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
prefs.setManualEnabled(true)
|
||||
prefs.setManualHost("127.0.0.1")
|
||||
prefs.setManualPort(18789)
|
||||
prefs.setManualTls(false)
|
||||
prefs.setGatewayToken("shared-token")
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
|
||||
runtime.connect(
|
||||
GatewayEndpoint.manual(host = "127.0.0.1", port = 18789),
|
||||
NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
runtime.disconnect()
|
||||
assertNull(desiredConnection(runtime, "nodeSession"))
|
||||
|
||||
runtime.refreshGatewayConnection()
|
||||
|
||||
val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection")
|
||||
val endpoint = readField<GatewayEndpoint>(desired, "endpoint")
|
||||
assertEquals("127.0.0.1", endpoint.host)
|
||||
assertEquals(18789, endpoint.port)
|
||||
assertEquals("shared-token", readField<String?>(desired, "token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -77,6 +77,31 @@ class SecurePrefsTest {
|
||||
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeMode_defaultsDarkForExistingInstalls() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
assertEquals(AppearanceThemeMode.Dark, prefs.appearanceThemeMode.value)
|
||||
assertFalse(plainPrefs.contains("appearance.themeMode"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setAppearanceThemeMode_persistsSelectedMode() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
prefs.setAppearanceThemeMode(AppearanceThemeMode.Light)
|
||||
|
||||
assertEquals(AppearanceThemeMode.Light, prefs.appearanceThemeMode.value)
|
||||
assertEquals("light", plainPrefs.getString("appearance.themeMode", null))
|
||||
assertEquals(AppearanceThemeMode.Light, SecurePrefs(context).appearanceThemeMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerMessageIdentityTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parseChatMessageContentsReadsGatewayStringContent() {
|
||||
val obj =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{"role":"user","content":"Hello","idempotencyKey":"run-1:user"}
|
||||
""".trimIndent(),
|
||||
).jsonObject
|
||||
|
||||
val content = parseChatMessageContents(obj)
|
||||
|
||||
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hello")), content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatMessageContentsFallsBackToTopLevelText() {
|
||||
val obj =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{"role":"assistant","text":"Hi there"}
|
||||
""".trimIndent(),
|
||||
).jsonObject
|
||||
|
||||
val content = parseChatMessageContents(obj)
|
||||
|
||||
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hi there")), content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
|
||||
val previous =
|
||||
@@ -101,6 +135,62 @@ class ChatControllerMessageIdentityTest {
|
||||
assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val assistant =
|
||||
ChatMessage(
|
||||
id = "remote-assistant",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Received.")),
|
||||
timestampMs = 2000L,
|
||||
)
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("local-user"), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesDropsGatewayPersistedUserTurn() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
idempotencyKey = "run-1:user",
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 500L)
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(emptyList<String>(), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
idempotencyKey = "run-2:user",
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user")
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("local-user"), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
|
||||
val user =
|
||||
|
||||
@@ -20,6 +20,7 @@ import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -233,7 +234,75 @@ class GatewaySessionReconnectTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createReconnectHarness(): ReconnectHarness {
|
||||
@Test
|
||||
fun pairingRequiredFailureNotifiesPauseReconnectProblem() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
|
||||
val server =
|
||||
startGatewayServer(json = json) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
webSocket.send(
|
||||
"""
|
||||
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createReconnectHarness { error, pauseReconnect ->
|
||||
connectFailure.complete(error to pauseReconnect)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
|
||||
|
||||
assertEquals("PAIRING_REQUIRED", error.details?.code)
|
||||
assertEquals("not-paired", error.details?.reason)
|
||||
assertEquals("request-1", error.details?.requestId)
|
||||
assertTrue(pauseReconnect)
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingRequiredFailureDropsUnsafeRequestId() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
|
||||
val server =
|
||||
startGatewayServer(json = json) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
webSocket.send(
|
||||
"""
|
||||
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createReconnectHarness { error, pauseReconnect ->
|
||||
connectFailure.complete(error to pauseReconnect)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
|
||||
|
||||
assertEquals("PAIRING_REQUIRED", error.details?.code)
|
||||
assertEquals("not-paired", error.details?.reason)
|
||||
assertNull(error.details?.requestId)
|
||||
assertTrue(pauseReconnect)
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createReconnectHarness(
|
||||
onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> },
|
||||
): ReconnectHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
@@ -243,6 +312,7 @@ class GatewaySessionReconnectTest {
|
||||
deviceAuthStore = ReconnectDeviceAuthStore(),
|
||||
onConnected = {},
|
||||
onDisconnected = { _ -> },
|
||||
onConnectFailure = onConnectFailure,
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -26,6 +27,53 @@ class OnboardingFlowLogicTest {
|
||||
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayFoundStateIsConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Studio Gateway", status = "Found", canConnect = true),
|
||||
nearbyGatewayUiState(nearbyGatewayName = "Studio Gateway", discoveryStatusText = "Searching…", discoveryStarted = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayBeforeDiscoveryStartsIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching…", discoveryStarted = false, searchTimedOut = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewaySearchingStateIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayTimedOutSearchShowsEmptyState() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…", searchTimedOut = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayEmptyResultStateIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Local: 0 • Wide: 0"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryGatewayNamePrefersServerThenAttemptedGateway() {
|
||||
assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway"))
|
||||
assertEquals("Discovered Gateway", recoveryGatewayName(serverName = null, attemptedGatewayName = "Discovered Gateway"))
|
||||
assertEquals("Home Gateway", recoveryGatewayName(serverName = " ", attemptedGatewayName = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsPairingStateForPairingRequiredGatewayStatus() {
|
||||
assertEquals(
|
||||
@@ -50,6 +98,50 @@ class OnboardingFlowLogicTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsApprovalRequiredForPausedPairingProblem() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.ApprovalRequired,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connecting…",
|
||||
connectSettling = false,
|
||||
gatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = "PAIRING_REQUIRED",
|
||||
message = "pairing required: device approval is required",
|
||||
reason = "not-paired",
|
||||
requestId = "request-1",
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = true,
|
||||
retryable = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsPairingForRetryablePairingProblem() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.Pairing,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connecting…",
|
||||
connectSettling = false,
|
||||
gatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = "PAIRING_REQUIRED",
|
||||
message = "pairing required: device approval is required",
|
||||
reason = "not-paired",
|
||||
requestId = "request-1",
|
||||
recommendedNextStep = "wait_then_retry",
|
||||
pauseReconnect = false,
|
||||
retryable = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsFinishingStateWhileGatewayConnectionSettles() {
|
||||
assertEquals(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -10,8 +13,55 @@ class ProviderModelStatusTest {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiringProviderStatusIsNotFullyReady() {
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerRowsIncludeConfiguredModelProvidersWithoutAuthRows() {
|
||||
val rows =
|
||||
providerRows(
|
||||
providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(
|
||||
id = "openai",
|
||||
displayName = "OpenAI",
|
||||
status = "ok",
|
||||
profileCount = 1,
|
||||
),
|
||||
),
|
||||
models =
|
||||
listOf(
|
||||
model(provider = "openai", id = "gpt-5.5"),
|
||||
model(provider = "byteplus", id = "seed-1-8-251228"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "byteplus"), rows.map { it.id })
|
||||
assertEquals(1, rows.first { it.id == "openai" }.modelCount)
|
||||
assertEquals(1, rows.first { it.id == "byteplus" }.modelCount)
|
||||
assertTrue(rows.first { it.id == "byteplus" }.ready)
|
||||
}
|
||||
|
||||
private fun model(
|
||||
provider: String,
|
||||
id: String,
|
||||
): GatewayModelSummary =
|
||||
GatewayModelSummary(
|
||||
id = id,
|
||||
name = id,
|
||||
provider = provider,
|
||||
supportsVision = false,
|
||||
supportsAudio = false,
|
||||
supportsDocuments = false,
|
||||
supportsReasoning = false,
|
||||
contextTokens = null,
|
||||
available = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsScreensTest {
|
||||
@Test
|
||||
fun androidDistributionChannelUsesBuildFlavorLabels() {
|
||||
assertEquals("Play", androidDistributionChannel("play"))
|
||||
assertEquals("Third-party", androidDistributionChannel("thirdParty"))
|
||||
assertEquals("Unknown", androidDistributionChannel(""))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
@@ -17,6 +18,28 @@ class ShellScreenLogicTest {
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeModeDefaultsToDarkForExistingInstalls() {
|
||||
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null))
|
||||
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeLabelsRoundTripFromSettingsOptions() {
|
||||
assertEquals(listOf("System", "Dark", "Light"), appearanceThemeOptions())
|
||||
assertEquals(AppearanceThemeMode.System, appearanceThemeModeForLabel("System"))
|
||||
assertEquals(AppearanceThemeMode.Dark, appearanceThemeModeForLabel("Dark"))
|
||||
assertEquals(AppearanceThemeMode.Light, appearanceThemeModeForLabel("Light"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeModeResolvesAgainstSystemPreference() {
|
||||
assertFalse(AppearanceThemeMode.System.isDark(systemDark = false))
|
||||
assertTrue(AppearanceThemeMode.System.isDark(systemDark = true))
|
||||
assertTrue(AppearanceThemeMode.Dark.isDark(systemDark = false))
|
||||
assertFalse(AppearanceThemeMode.Light.isDark(systemDark = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
|
||||
val rows =
|
||||
@@ -76,6 +99,9 @@ class ShellScreenLogicTest {
|
||||
)
|
||||
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
val providersRow = rows.single { it.title == "Providers" }
|
||||
assertEquals(Tab.Settings, providersRow.tab)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceScreenLogicTest {
|
||||
@Test
|
||||
fun voiceAttentionStatusKeepsFailedTalkStartVisibleAfterModeStops() {
|
||||
val attention =
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: Error: Realtime voice provider \"openai\" is not configured",
|
||||
voiceCaptureMode = VoiceCaptureMode.Off,
|
||||
micEnabled = false,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
)
|
||||
|
||||
assertEquals("Realtime voice provider is not configured.", attention)
|
||||
assertEquals(
|
||||
attention,
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = "Online",
|
||||
voiceCaptureMode = VoiceCaptureMode.Off,
|
||||
micStatusText = "Mic off",
|
||||
micQueuedMessages = 0,
|
||||
micIsSending = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
voiceAttentionStatus = attention,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceAttentionStatusDoesNotOverrideActiveTalkState() {
|
||||
assertNull(
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: provider unavailable",
|
||||
voiceCaptureMode = VoiceCaptureMode.TalkMode,
|
||||
micEnabled = false,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = true,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceAttentionStatusDoesNotOverrideDictationState() {
|
||||
assertNull(
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: provider unavailable",
|
||||
voiceCaptureMode = VoiceCaptureMode.ManualMic,
|
||||
micEnabled = true,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRuntimeAttentionStatusSanitizesTranscriptionProviderFailures() {
|
||||
assertEquals(
|
||||
"Realtime transcription provider is not configured.",
|
||||
voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatTimelineTest {
|
||||
@Test
|
||||
fun activeRunAnchorsNewestUserPromptInsteadOfThinkingRow() {
|
||||
val user = textMessage(id = "user-1", role = "user", text = "hello")
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(user),
|
||||
pendingRunCount = 1,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("thinking", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(1, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeRunAnchorsNewestUserPromptWhileAssistantStreams() {
|
||||
val olderAssistant = textMessage(id = "assistant-1", role = "assistant", text = "previous")
|
||||
val user = textMessage(id = "user-1", role = "user", text = "next")
|
||||
val tool =
|
||||
ChatPendingToolCall(
|
||||
toolCallId = "tool-1",
|
||||
name = "memory.search",
|
||||
startedAtMs = 1000L,
|
||||
)
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(olderAssistant, user),
|
||||
pendingRunCount = 1,
|
||||
pendingToolCalls = listOf(tool),
|
||||
streamingAssistantText = "streaming",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("stream", "tools", "thinking", "message:user-1", "message:assistant-1"),
|
||||
timeline.items.map(::chatTimelineItemKey),
|
||||
)
|
||||
assertEquals(3, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finishedRunAnchorsNewestPersistedMessage() {
|
||||
val user = textMessage(id = "user-1", role = "user", text = "hello")
|
||||
val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done")
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(user, assistant),
|
||||
pendingRunCount = 0,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("message:assistant-1", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(0, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTimelineHasNoScrollTarget() {
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = emptyList(),
|
||||
pendingRunCount = 0,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(null, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
private fun textMessage(
|
||||
id: String,
|
||||
role: String,
|
||||
text: String,
|
||||
): ChatMessage =
|
||||
ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = listOf(ChatMessageContent(type = "text", text = text)),
|
||||
timestampMs = null,
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
[versions]
|
||||
agp = "9.2.0"
|
||||
agp = "9.2.1"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.04.01"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
@@ -13,14 +13,14 @@ androidx-uiautomator = "2.4.0-beta02"
|
||||
androidx-webkit = "1.15.0"
|
||||
bcprov = "1.84"
|
||||
commonmark = "0.28.0"
|
||||
coroutines = "1.10.2"
|
||||
dnsjava = "3.6.4"
|
||||
coroutines = "1.11.0"
|
||||
dnsjava = "3.6.5"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "6.0.3"
|
||||
junit-vintage = "6.1.0"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
material = "1.13.0"
|
||||
material = "1.14.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
robolectric = "4.16.1"
|
||||
|
||||
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,7 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
2
apps/android/gradlew
vendored
2
apps/android/gradlew
vendored
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
31
apps/android/gradlew.bat
vendored
31
apps/android/gradlew.bat
vendored
@@ -23,8 +23,8 @@
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -65,7 +65,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
@@ -73,21 +73,10 @@ goto fail
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
OpenClaw is now available on iPhone.
|
||||
|
||||
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.
|
||||
|
||||
## 2026.6.1 - 2026-06-01
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenClaw iOS (Super Alpha)
|
||||
|
||||
This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad.
|
||||
This iOS app is super-alpha and internal-use only. The first public App Store release targets iPhone and connects to an OpenClaw Gateway as a `role: node`.
|
||||
|
||||
## Distribution Status
|
||||
|
||||
@@ -34,7 +34,7 @@ open OpenClaw.xcodeproj
|
||||
|
||||
3. In Xcode:
|
||||
- Scheme: `OpenClaw`
|
||||
- Destination: connected iPhone or iPad (recommended for real behavior)
|
||||
- Destination: connected iPhone (recommended for real behavior)
|
||||
- Build configuration: `Debug`
|
||||
- Run (`Product` -> `Run`)
|
||||
4. If signing fails on a personal team:
|
||||
@@ -251,7 +251,7 @@ gateway can only send pushes for iOS devices that paired with that gateway.
|
||||
|
||||
## Computer Use Relationship
|
||||
|
||||
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
|
||||
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
|
||||
|
||||
## Location Automation Use Case (Testing)
|
||||
|
||||
|
||||
253
apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift
Normal file
253
apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawProtocol
|
||||
|
||||
enum AppleReviewDemoMode {
|
||||
static let setupCode = "APPLE-REVIEW-DEMO"
|
||||
static let gatewayName = "Apple Review Demo Gateway"
|
||||
static let gatewayAddress = "Local demo mode"
|
||||
static let gatewayID = "apple-review-demo"
|
||||
|
||||
static func isSetupCode(_ value: String) -> Bool {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.localizedCaseInsensitiveCompare(self.setupCode) == .orderedSame
|
||||
}
|
||||
|
||||
static var agents: [AgentSummary] {
|
||||
[
|
||||
AgentSummary(
|
||||
id: "main",
|
||||
name: "Main",
|
||||
identity: ["emoji": AnyCodable("OC")],
|
||||
workspace: "Apple Review Demo",
|
||||
model: ["provider": AnyCodable("demo"), "model": AnyCodable("local-demo")],
|
||||
agentruntime: ["kind": AnyCodable("local")],
|
||||
thinkinglevels: nil,
|
||||
thinkingoptions: ["auto", "low", "medium"],
|
||||
thinkingdefault: "auto"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
|
||||
private let store = AppleReviewDemoChatStore()
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label _: String?,
|
||||
parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
try await self.store.createSession(key: key)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
try await self.store.history(sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "local-demo",
|
||||
name: "Apple Review Demo",
|
||||
provider: "demo",
|
||||
contextWindow: 128_000),
|
||||
]
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
try await self.store.sendMessage(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
runId: idempotencyKey)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey _: String, runId _: String) async throws {}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
try await self.store.sessions()
|
||||
}
|
||||
|
||||
func setSessionModel(sessionKey _: String, model _: String?) async throws {}
|
||||
|
||||
func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
continuation.yield(.health(ok: true))
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
func resetSession(sessionKey _: String) async throws {
|
||||
await self.store.reset()
|
||||
}
|
||||
|
||||
func compactSession(sessionKey _: String) async throws {}
|
||||
}
|
||||
|
||||
private actor AppleReviewDemoChatStore {
|
||||
private let sessionKey = "main"
|
||||
private var messages: [OpenClawChatMessage]
|
||||
|
||||
init() {
|
||||
self.messages = AppleReviewDemoChatStore.seedMessages()
|
||||
}
|
||||
|
||||
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
|
||||
try Self.decode(
|
||||
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
|
||||
as: OpenClawChatCreateSessionResponse.self)
|
||||
}
|
||||
|
||||
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
|
||||
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
|
||||
return try Self.decode(
|
||||
HistoryPayload(
|
||||
sessionKey: normalizedSessionKey,
|
||||
sessionId: "apple-review-demo-\(normalizedSessionKey)",
|
||||
messages: self.messages,
|
||||
thinkingLevel: "auto"),
|
||||
as: OpenClawChatHistoryPayload.self)
|
||||
}
|
||||
|
||||
func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
self.messages.append(Self.message(role: "user", text: message, timestamp: now))
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\""
|
||||
self.messages.append(
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
|
||||
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
|
||||
OpenClaw Gateway.
|
||||
""",
|
||||
timestamp: now + 1))
|
||||
return try Self.decode(
|
||||
SendPayload(runId: runId, status: "ok"),
|
||||
as: OpenClawChatSendResponse.self)
|
||||
}
|
||||
|
||||
func sessions() throws -> OpenClawChatSessionsListResponse {
|
||||
let entry = OpenClawChatSessionEntry(
|
||||
key: self.sessionKey,
|
||||
kind: "chat",
|
||||
displayName: "Apple Review Demo",
|
||||
surface: "ios",
|
||||
subject: "Gateway review flow",
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
sessionId: "apple-review-demo-main",
|
||||
systemSent: true,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: "auto",
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingDefault: "auto")
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "demo",
|
||||
model: "local-demo",
|
||||
contextTokens: 128_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
|
||||
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
|
||||
],
|
||||
thinkingOptions: ["auto", "low", "medium"],
|
||||
thinkingDefault: "auto",
|
||||
mainSessionKey: self.sessionKey),
|
||||
sessions: [entry])
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.messages = Self.seedMessages()
|
||||
}
|
||||
|
||||
private static func seedMessages() -> [OpenClawChatMessage] {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
return [
|
||||
self.message(
|
||||
role: "assistant",
|
||||
text: """
|
||||
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
|
||||
without a private Gateway.
|
||||
""",
|
||||
timestamp: now),
|
||||
]
|
||||
}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
|
||||
OpenClawChatMessage(
|
||||
role: role,
|
||||
content: [
|
||||
OpenClawChatMessageContent(
|
||||
type: "text",
|
||||
text: text,
|
||||
mimeType: nil,
|
||||
fileName: nil,
|
||||
content: nil),
|
||||
],
|
||||
timestamp: timestamp)
|
||||
}
|
||||
|
||||
private static func normalizedSessionKey(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
private struct HistoryPayload: Encodable {
|
||||
var sessionKey: String
|
||||
var sessionId: String?
|
||||
var messages: [OpenClawChatMessage]?
|
||||
var thinkingLevel: String?
|
||||
}
|
||||
|
||||
private struct SendPayload: Encodable {
|
||||
var runId: String
|
||||
var status: String
|
||||
}
|
||||
|
||||
private struct CreateSessionPayload: Encodable {
|
||||
var ok: Bool?
|
||||
var key: String
|
||||
var sessionId: String?
|
||||
}
|
||||
}
|
||||
@@ -332,11 +332,14 @@ struct ConfigPatchParams: Encodable {
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
case liveGatewayUnavailable
|
||||
case missingConfigHash
|
||||
case invalidPatchPayload
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .liveGatewayUnavailable:
|
||||
"Connect a live gateway to edit agent skills."
|
||||
case .missingConfigHash:
|
||||
"Config hash missing; refresh and retry."
|
||||
case .invalidPatchPayload:
|
||||
|
||||
@@ -99,14 +99,14 @@ extension AgentProTab {
|
||||
} label: {
|
||||
Label("Run", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
.disabled(busy || !self.liveGatewayConnected)
|
||||
|
||||
Button {
|
||||
Task { await self.setCronJob(job, enabled: !job.enabled) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
.disabled(busy || !self.liveGatewayConnected)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
@@ -149,7 +149,7 @@ extension AgentProTab {
|
||||
success: String,
|
||||
action: () async throws -> Void) async
|
||||
{
|
||||
guard self.gatewayConnected else { return }
|
||||
guard self.liveGatewayConnected else { return }
|
||||
self.cronActionBusyIDs.insert(job.id)
|
||||
self.cronActionStatusText = nil
|
||||
defer { self.cronActionBusyIDs.remove(job.id) }
|
||||
|
||||
@@ -58,16 +58,9 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
||||
guard self.gatewayConnected else { return .idle }
|
||||
guard self.gatewayConnected else { return .ready }
|
||||
if agent.id == self.activeAgentID { return .online }
|
||||
if self.cronJobsContain(agentID: agent.id) { return .busy }
|
||||
return .idle
|
||||
}
|
||||
|
||||
func cronJobsContain(agentID: String) -> Bool {
|
||||
self.recentCronJobs.contains { job in
|
||||
self.normalized(job.agentid) == agentID && job.enabled
|
||||
}
|
||||
return .ready
|
||||
}
|
||||
|
||||
func modelLabel(for agent: AgentSummary) -> String? {
|
||||
@@ -124,7 +117,7 @@ extension AgentProTab {
|
||||
@MainActor
|
||||
func refreshOverview(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
guard self.liveGatewayConnected else {
|
||||
self.overview = nil
|
||||
self.overviewErrorText = nil
|
||||
self.overviewLoading = false
|
||||
|
||||
@@ -285,7 +285,7 @@ extension AgentProTab {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isActive ? "Active agent" : "Make active agent")
|
||||
.accessibilityLabel(isActive ? "Default agent" : "Set default agent")
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
@@ -514,10 +514,8 @@ extension AgentProTab {
|
||||
true
|
||||
case .online:
|
||||
self.agentRosterState(for: agent) == .online
|
||||
case .busy:
|
||||
self.agentRosterState(for: agent) == .busy
|
||||
case .idle:
|
||||
self.agentRosterState(for: agent) == .idle
|
||||
case .ready:
|
||||
self.agentRosterState(for: agent) == .ready
|
||||
}
|
||||
|
||||
guard matchesFilter else { return false }
|
||||
@@ -544,6 +542,12 @@ extension AgentProTab {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
self.gatewayConnected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var searchFieldFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ extension AgentProTab {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.clawHubLoading || !self.gatewayConnected)
|
||||
.disabled(self.clawHubLoading || !self.liveGatewayConnected)
|
||||
.accessibilityLabel("Search ClawHub")
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
var skillPolicySummary: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode keeps live skill changes disabled." }
|
||||
guard self.gatewayConnected else { return "Connect a gateway to edit skills." }
|
||||
guard let filter = self.agentSkillFilter else {
|
||||
return "All available skills are allowed for this agent."
|
||||
@@ -439,14 +440,13 @@ extension AgentProTab {
|
||||
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(
|
||||
self.skillEditorToggleRow(
|
||||
"Enabled globally",
|
||||
isOn: Binding(
|
||||
get: { skill.isGloballyEnabled },
|
||||
set: { enabled in
|
||||
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
|
||||
}))
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
isOn: skill.isGloballyEnabled,
|
||||
disabled: self.isSkillConfigBusy(skill))
|
||||
{ enabled in
|
||||
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
|
||||
}
|
||||
|
||||
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -480,6 +480,43 @@ extension AgentProTab {
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorToggleRow(
|
||||
_ title: String,
|
||||
isOn: Bool,
|
||||
disabled: Bool,
|
||||
onToggle: @escaping (Bool) -> Void) -> some View
|
||||
{
|
||||
// Native Toggle rows in this sheet can ignore visible-row taps on iOS 26.
|
||||
// Keep the switch semantics explicit so the control always dispatches the mutation.
|
||||
Button {
|
||||
onToggle(!isOn)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
self.skillEditorSwitchIndicator(isOn: isOn)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(isOn ? "On" : "Off")
|
||||
}
|
||||
|
||||
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(2)
|
||||
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -565,7 +602,7 @@ extension AgentProTab {
|
||||
|
||||
@MainActor
|
||||
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
guard self.liveGatewayConnected else { return }
|
||||
self.skillMutationBusyKeys.insert(busyKey)
|
||||
self.skillMutationErrorText = nil
|
||||
self.skillMutationStatusText = nil
|
||||
@@ -640,7 +677,7 @@ extension AgentProTab {
|
||||
|
||||
@MainActor
|
||||
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
guard self.liveGatewayConnected else { return }
|
||||
self.clawHubInstallSlug = result.slug
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubInstallSlug = nil }
|
||||
@@ -656,7 +693,7 @@ extension AgentProTab {
|
||||
|
||||
@MainActor
|
||||
func searchClawHubSkills() async {
|
||||
guard self.gatewayConnected else { return }
|
||||
guard self.liveGatewayConnected else { return }
|
||||
self.clawHubLoading = true
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubLoading = false }
|
||||
@@ -675,6 +712,7 @@ extension AgentProTab {
|
||||
_ skill: SkillStatusEntryLite,
|
||||
action: () async throws -> String) async
|
||||
{
|
||||
guard self.liveGatewayConnected else { return }
|
||||
let key = skill.effectiveSkillKey
|
||||
self.skillConfigBusyKeys.insert(key)
|
||||
self.skillConfigMessages[key] = nil
|
||||
@@ -697,6 +735,9 @@ extension AgentProTab {
|
||||
params: some Encodable,
|
||||
timeoutSeconds: Int) async throws -> Data
|
||||
{
|
||||
guard self.liveGatewayConnected else {
|
||||
throw SkillMutationError.liveGatewayUnavailable
|
||||
}
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
@@ -708,6 +749,9 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
|
||||
guard self.liveGatewayConnected else {
|
||||
throw SkillMutationError.liveGatewayUnavailable
|
||||
}
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: "config.get",
|
||||
paramsJSON: "{}",
|
||||
|
||||
@@ -63,8 +63,7 @@ struct AgentProTab: View {
|
||||
enum AgentRosterFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
case ready
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
@@ -74,8 +73,7 @@ struct AgentProTab: View {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
case .ready: "Ready"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,22 +88,19 @@ struct AgentProTab: View {
|
||||
|
||||
enum AgentRosterState: Equatable {
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
case ready
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
case .ready: "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .busy: OpenClawBrand.warn
|
||||
case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +137,6 @@ struct AgentProTab: View {
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: AgentRoute.self) { route in
|
||||
|
||||
@@ -6,6 +6,7 @@ struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -24,8 +25,10 @@ struct ChatProTab: View {
|
||||
assistantAvatarTint: OpenClawBrand.accent,
|
||||
showsAssistantAvatars: false,
|
||||
composerChrome: .clean,
|
||||
messagePlaceholder: "Message \(self.agentDisplayName)...",
|
||||
isComposerEnabled: self.gatewayConnected,
|
||||
messagePlaceholder: self.messagePlaceholder,
|
||||
talkControl: self.talkControl)
|
||||
.id(ObjectIdentifier(viewModel))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ProCard {
|
||||
@@ -41,7 +44,9 @@ struct ChatProTab: View {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task {
|
||||
@@ -50,6 +55,10 @@ struct ChatProTab: View {
|
||||
.onChange(of: self.appModel.chatSessionKey) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
@@ -99,10 +108,29 @@ struct ChatProTab: View {
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
guard let viewModel else {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
diagnosticsLog: { message in
|
||||
GatewayDiagnostics.log(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
|
||||
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: usesDemoTransport
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
@@ -112,7 +140,7 @@ struct ChatProTab: View {
|
||||
return
|
||||
}
|
||||
guard viewModel.sessionKey != sessionKey else { return }
|
||||
viewModel.switchSession(to: sessionKey)
|
||||
viewModel.syncSession(to: sessionKey)
|
||||
}
|
||||
|
||||
private var talkControl: OpenClawChatTalkControl {
|
||||
@@ -130,8 +158,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var activeAgentID: String {
|
||||
self.normalized(self.appModel.selectedAgentId)
|
||||
?? self.normalized(self.appModel.gatewayDefaultAgentId)
|
||||
self.normalized(self.appModel.chatAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
@@ -156,8 +183,14 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected &&
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
@@ -169,7 +202,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var agentDisplayName: String {
|
||||
self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName
|
||||
self.normalized(self.activeAgent?.name) ?? self.appModel.chatAgentName
|
||||
}
|
||||
|
||||
private var agentBadge: String {
|
||||
|
||||
@@ -126,7 +126,7 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let progress = self.item.progress else {
|
||||
guard let progress = item.progress else {
|
||||
return self.item.state
|
||||
}
|
||||
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
|
||||
@@ -144,41 +144,31 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandApprovalRow: View {
|
||||
let item: CommandCenterTab.ApprovalItem
|
||||
struct CommandViewMoreRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.item.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.item.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("View More")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.item.priority)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.item.color.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,32 +240,3 @@ struct CommandTaskRow: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandLiveActivityRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProStatusDot(color: self.color)
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(self.value)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.08))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
fileprivate static let recentSessionsFetchLimit = 200
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
|
||||
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
@@ -26,15 +29,6 @@ struct CommandCenterTab: View {
|
||||
let route: WorkRoute
|
||||
}
|
||||
|
||||
struct ApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let priority: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
@@ -44,10 +38,8 @@ struct CommandCenterTab: View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.pendingApprovals
|
||||
self.activeTasks
|
||||
self.liveActivity
|
||||
self.startWorkAction
|
||||
self.defaultChatSessionSection
|
||||
self.recentSessions
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
@@ -56,8 +48,8 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task(id: self.activeSessionsRefreshID) {
|
||||
await self.refreshActiveSessionsIfNeeded()
|
||||
.task(id: self.recentSessionsRefreshID) {
|
||||
await self.refreshRecentSessionsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,158 +144,61 @@ struct CommandCenterTab: View {
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private var pendingApprovals: some View {
|
||||
self.pendingApprovalsContent
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var pendingApprovalsContent: some View {
|
||||
CommandPanel(
|
||||
tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn,
|
||||
isProminent: self.pendingApproval != nil,
|
||||
padding: self.pendingApproval == nil ? 11 : 13)
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
private var defaultChatSessionSection: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Pending approvals",
|
||||
value: self.pendingApproval == nil ? nil : "Review requests ›",
|
||||
color: OpenClawBrand.accentHot,
|
||||
badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)")
|
||||
|
||||
if self.approvalItems.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "No approvals waiting",
|
||||
detail: self
|
||||
.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
|
||||
CommandApprovalRow(item: item)
|
||||
if index < self.approvalItems.count - 1 {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.approvalRowsFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let pendingApproval {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||||
} label: {
|
||||
Label("Allow", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
if pendingApproval.allowsAllowAlways {
|
||||
Button {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
} label: {
|
||||
Label("Always", systemImage: "checkmark.shield")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||||
} label: {
|
||||
Label("Deny", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeTasks: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Active sessions",
|
||||
value: self.activeSessionsSummaryText,
|
||||
color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.visibleActiveSessionRows) { item in
|
||||
Button {
|
||||
self.open(item.route)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var liveActivity: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Live activity",
|
||||
title: "Agent session",
|
||||
value: nil,
|
||||
color: OpenClawBrand.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
CommandLiveActivityRow(
|
||||
title: self.liveActivityTitle,
|
||||
value: self.liveActivityValue,
|
||||
color: self.liveActivityColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
Button {
|
||||
self.open(.chat(nil))
|
||||
} label: {
|
||||
CommandSessionRow(item: self.defaultChatWorkItem)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var startWorkAction: some View {
|
||||
CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) {
|
||||
Button(action: self.openChat) {
|
||||
Label("Start work", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.fill(LinearGradient(
|
||||
colors: [OpenClawBrand.accentHot, OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8)
|
||||
private var recentSessions: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Recent sessions",
|
||||
value: nil,
|
||||
color: .secondary)
|
||||
|
||||
if self.recentSessionPreviewRows.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: self.gatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
|
||||
title: self.gatewayConnected ? "No recent sessions" : "Gateway offline",
|
||||
detail: self
|
||||
.gatewayConnected ? "Start a chat and it will appear here." : "Connect to the gateway.")
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.recentSessionPreviewRows) { item in
|
||||
Button {
|
||||
self.open(item.route)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if self.hasMoreRecentSessions {
|
||||
NavigationLink {
|
||||
CommandSessionsScreen(openChat: self.openChat)
|
||||
} label: {
|
||||
CommandViewMoreRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
@@ -378,164 +273,69 @@ struct CommandCenterTab: View {
|
||||
return "\(self.appModel.gatewayAgents.count)"
|
||||
}
|
||||
|
||||
private var activeSessionsSummaryText: String {
|
||||
let count = self.activeSessionRows.count
|
||||
if count == 0 {
|
||||
return self.gatewayConnected ? "No sessions" : "Offline"
|
||||
}
|
||||
if self.sessionWorkItems.isEmpty {
|
||||
return self.gatewayConnected ? "\(count) ready" : "Offline"
|
||||
}
|
||||
return "\(count) \(count == 1 ? "session" : "sessions")"
|
||||
private var defaultChatWorkItem: WorkItem {
|
||||
let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey
|
||||
return WorkItem(
|
||||
id: "default-chat",
|
||||
icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: self.appModel.activeAgentName,
|
||||
detail: self.defaultChatActivityText,
|
||||
state: isOpen ? "open" : "default",
|
||||
trailing: "chat",
|
||||
color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(nil))
|
||||
}
|
||||
|
||||
private var approvalItems: [ApprovalItem] {
|
||||
if let pendingApproval {
|
||||
return [
|
||||
ApprovalItem(
|
||||
id: "pending-real",
|
||||
icon: "terminal.fill",
|
||||
title: pendingApproval.commandPreview ?? "Review gateway action",
|
||||
detail: "Agent: \(self.appModel.activeAgentName)",
|
||||
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
|
||||
color: OpenClawBrand.danger),
|
||||
ApprovalItem(
|
||||
id: "pending-context",
|
||||
icon: "doc.text.fill",
|
||||
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
|
||||
detail: "Gateway request",
|
||||
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
|
||||
color: OpenClawBrand.warn),
|
||||
]
|
||||
private var defaultChatActivityText: String {
|
||||
guard let updatedAt = defaultChatSessionEntry?.updatedAt, updatedAt > 0 else {
|
||||
return "No recent activity"
|
||||
}
|
||||
|
||||
return []
|
||||
return Self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
|
||||
private var approvalRowsFill: Color {
|
||||
self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022)
|
||||
}
|
||||
|
||||
private var activeSessionRows: [WorkItem] {
|
||||
private var recentSessionRows: [WorkItem] {
|
||||
self.sessionItems
|
||||
}
|
||||
|
||||
private var visibleActiveSessionRows: [WorkItem] {
|
||||
Array(self.activeSessionRows.prefix(3))
|
||||
private var recentSessionPreviewRows: [WorkItem] {
|
||||
Array(self.recentSessionRows.prefix(3))
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) {
|
||||
return "\(Self.sessionTitle(session)) updated"
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "Approval waiting"
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText
|
||||
private var hasMoreRecentSessions: Bool {
|
||||
self.sessionWorkItems.count > self.recentSessionPreviewRows.count
|
||||
}
|
||||
|
||||
private var liveActivityValue: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }),
|
||||
let updatedAt = session.updatedAt,
|
||||
updatedAt > 0
|
||||
{
|
||||
return Self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "review"
|
||||
}
|
||||
return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue
|
||||
}
|
||||
|
||||
private var liveActivityColor: Color {
|
||||
if self.pendingApproval != nil { return OpenClawBrand.warn }
|
||||
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
private var gatewayDisplayStatusValue: String {
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? self.gatewayStateText : status
|
||||
}
|
||||
|
||||
private var activeSessionsRefreshID: String {
|
||||
private var recentSessionsRefreshID: String {
|
||||
[
|
||||
self.appModel.isOperatorGatewayConnected ? "connected" : "offline",
|
||||
self.sessionListMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
let liveItems = self.sessionWorkItems
|
||||
if !liveItems.isEmpty { return liveItems }
|
||||
return self.defaultSessionItems
|
||||
self.sessionWorkItems
|
||||
}
|
||||
|
||||
private var sessionWorkItems: [WorkItem] {
|
||||
let currentSessionKey = self.appModel.chatSessionKey
|
||||
return self.activeChatSessions
|
||||
.filter { !Self.isHiddenInternalSession($0.key) }
|
||||
.prefix(4)
|
||||
return self.recentChatSessions
|
||||
.filter { Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.map { session in
|
||||
let isCurrent = session.key == currentSessionKey
|
||||
return WorkItem(
|
||||
id: "chat-session-\(session.key)",
|
||||
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: Self.sessionTitle(session),
|
||||
detail: Self.sessionDetail(session),
|
||||
state: isCurrent ? "current" : "recent",
|
||||
trailing: "chat",
|
||||
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(session.key))
|
||||
Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultSessionItems: [WorkItem] {
|
||||
[
|
||||
WorkItem(
|
||||
id: "main-chat",
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Main chat",
|
||||
detail: self.appModel.activeAgentName,
|
||||
state: self.gatewayConnected ? "ready" : "offline",
|
||||
trailing: "session",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .chat(self.appModel.chatSessionKey)),
|
||||
WorkItem(
|
||||
id: "talk-mode",
|
||||
icon: "waveform",
|
||||
title: "Talk",
|
||||
detail: self.appModel.talkMode.statusText,
|
||||
state: self.appModel.talkMode.isEnabled ? "active" : "off",
|
||||
trailing: "voice",
|
||||
color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "device-capture",
|
||||
icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display",
|
||||
title: "Device capture",
|
||||
detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools",
|
||||
state: self.appModel.screenRecordActive ? "running" : "idle",
|
||||
trailing: "device",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "agent-roster",
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable",
|
||||
state: self.gatewayConnected ? "online" : "offline",
|
||||
trailing: "gateway",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
]
|
||||
}
|
||||
|
||||
private func open(_ route: WorkRoute) {
|
||||
switch route {
|
||||
case let .chat(sessionKey):
|
||||
@@ -546,42 +346,55 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshActiveSessionsIfNeeded() async {
|
||||
private func refreshRecentSessionsIfNeeded() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
if !self.activeChatSessions.isEmpty {
|
||||
self.activeChatSessions = []
|
||||
guard self.sessionListAvailable else {
|
||||
if self.defaultChatSessionEntry != nil {
|
||||
self.defaultChatSessionEntry = nil
|
||||
}
|
||||
if !self.recentChatSessions.isEmpty {
|
||||
self.recentChatSessions = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: 12)
|
||||
self.activeChatSessions = Self.sessionChoices(
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
}
|
||||
self.recentChatSessions = Self.sessionChoices(
|
||||
response.sessions,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
currentSessionKey: self.appModel.chatSessionKey,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey)
|
||||
} catch {
|
||||
self.activeChatSessions = []
|
||||
self.defaultChatSessionEntry = nil
|
||||
self.recentChatSessions = []
|
||||
}
|
||||
}
|
||||
|
||||
private static func sessionChoices(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
currentSessionKey: String) -> [OpenClawChatSessionEntry]
|
||||
currentSessionKey: String,
|
||||
defaultSessionKey: String) -> [OpenClawChatSessionEntry]
|
||||
{
|
||||
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
||||
if let current = sorted.first(where: { $0.key == currentSessionKey }) {
|
||||
if Self.isRecentChatSession(currentSessionKey, defaultSessionKey: defaultSessionKey),
|
||||
let current = sorted.first(where: { $0.key == currentSessionKey })
|
||||
{
|
||||
result.append(current)
|
||||
included.insert(current.key)
|
||||
}
|
||||
|
||||
for session in sorted {
|
||||
guard !included.contains(session.key) else { continue }
|
||||
guard !Self.isHiddenInternalSession(session.key) else { continue }
|
||||
guard Self.isRecentChatSession(session.key, defaultSessionKey: defaultSessionKey) else { continue }
|
||||
result.append(session)
|
||||
included.insert(session.key)
|
||||
if result.count >= 4 { break }
|
||||
@@ -590,7 +403,24 @@ struct CommandCenterTab: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
||||
fileprivate static func sessionWorkItem(
|
||||
for session: OpenClawChatSessionEntry,
|
||||
currentSessionKey: String) -> WorkItem
|
||||
{
|
||||
let isCurrent = session.key == currentSessionKey
|
||||
return WorkItem(
|
||||
id: "chat-session-\(session.key)",
|
||||
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: Self.sessionTitle(session),
|
||||
detail: Self.sessionDetail(session),
|
||||
state: isCurrent ? "open" : "recent",
|
||||
trailing: "chat",
|
||||
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(session.key))
|
||||
}
|
||||
|
||||
fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let title = redactedSessionTitle(for: session.key) {
|
||||
return title
|
||||
}
|
||||
@@ -606,7 +436,7 @@ struct CommandCenterTab: View {
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func redactedSessionTitle(for key: String) -> String? {
|
||||
fileprivate static func redactedSessionTitle(for key: String) -> String? {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = trimmed.lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -625,7 +455,7 @@ struct CommandCenterTab: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func humanizedSessionKey(_ key: String) -> String? {
|
||||
fileprivate static func humanizedSessionKey(_ key: String) -> String? {
|
||||
let words = key
|
||||
.replacingOccurrences(of: "_", with: "-")
|
||||
.split(separator: "-")
|
||||
@@ -645,14 +475,14 @@ struct CommandCenterTab: View {
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
|
||||
fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
return self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
|
||||
fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
|
||||
let date = Date(timeIntervalSince1970: milliseconds / 1000)
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .numeric
|
||||
@@ -660,12 +490,53 @@ struct CommandCenterTab: View {
|
||||
return formatter.localizedString(for: date, relativeTo: .now)
|
||||
}
|
||||
|
||||
private static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||
fileprivate nonisolated static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
||||
}
|
||||
|
||||
nonisolated static func isRecentChatSession(_ key: String, defaultSessionKey: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if trimmed == defaultSessionKey { return false }
|
||||
let normalized = trimmed.lowercased()
|
||||
let defaultBase = self.sessionBaseKey(defaultSessionKey)
|
||||
if !normalized.contains(":"),
|
||||
self.isDirectSessionBase(normalized, defaultBase: defaultBase)
|
||||
{
|
||||
return false
|
||||
}
|
||||
if self.isHiddenInternalSession(trimmed) { return false }
|
||||
return !self.isAgentDeviceSession(trimmed, defaultSessionKey: defaultSessionKey)
|
||||
}
|
||||
|
||||
private nonisolated static func isAgentDeviceSession(_ key: String, defaultSessionKey: String) -> Bool {
|
||||
let parts = key
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return false }
|
||||
guard parts.count == 3 || parts[3].lowercased() == "thread" else { return false }
|
||||
|
||||
let base = String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let defaultKey = self.sessionBaseKey(defaultSessionKey)
|
||||
return self.isDirectSessionBase(base, defaultBase: defaultKey)
|
||||
}
|
||||
|
||||
private nonisolated static func isDirectSessionBase(_ base: String, defaultBase: String) -> Bool {
|
||||
base == defaultBase || base == "main" || base == "global" || base.hasPrefix("node-")
|
||||
}
|
||||
|
||||
private nonisolated static func sessionBaseKey(_ key: String) -> String {
|
||||
let parts = key
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0].lowercased() == "agent" else {
|
||||
return key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
return String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private var gatewaySubtitle: String {
|
||||
if let server = normalized(appModel.gatewayServerName) {
|
||||
return "\(self.appModel.activeAgentName) on \(server)"
|
||||
@@ -676,10 +547,6 @@ struct CommandCenterTab: View {
|
||||
return self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
||||
self.appModel.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
Self.normalized(value)
|
||||
}
|
||||
@@ -690,3 +557,166 @@ struct CommandCenterTab: View {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommandSessionsScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let openChat: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.sessionsPanel
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var sessionsPanel: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Recent sessions")
|
||||
.font(.subheadline.weight(.bold))
|
||||
Spacer(minLength: 8)
|
||||
if self.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
if let loadErrorText {
|
||||
CommandEmptyStateRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: self.appModel
|
||||
.isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
|
||||
title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline",
|
||||
detail: self.appModel
|
||||
.isCommandSessionListAvailable ? "Start a chat and it will appear here." :
|
||||
"Connect to the gateway.")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.sessionRows) { item in
|
||||
Button {
|
||||
self.open(item)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var headerDetail: String {
|
||||
if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" }
|
||||
let count = self.sessionRows.count
|
||||
if count == 0 {
|
||||
return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline"
|
||||
}
|
||||
return "\(count) \(count == 1 ? "session" : "sessions")"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
self.appModel.commandSessionListMode
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.dismiss()
|
||||
self.openChat()
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.appModel.isCommandSessionListAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
if self.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@ struct OpenClawProBackground: View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
OpenClawBrand.accent.opacity(0.02),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 260)
|
||||
.frame(height: 620)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
struct SettingsProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@@ -59,6 +57,7 @@ struct SettingsProTab: View {
|
||||
@State var notificationActionText = "Request Access"
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -71,9 +70,9 @@ struct SettingsProTab: View {
|
||||
self.gatewaySection
|
||||
self.settingsListSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
@@ -131,12 +130,20 @@ struct SettingsProTab: View {
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
|
||||
@@ -42,9 +42,9 @@ extension SettingsProTab {
|
||||
self.diagnosticCheckRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway Link",
|
||||
detail: self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
detail: self.gatewayStatusDetail,
|
||||
value: self.gatewayStatusValue,
|
||||
color: self.gatewayStatusColor)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "dot.radiowaves.left.and.right",
|
||||
@@ -56,9 +56,9 @@ extension SettingsProTab {
|
||||
self.diagnosticCheckRow(
|
||||
icon: "waveform",
|
||||
title: "Talk Config",
|
||||
detail: self.appModel.talkMode.gatewayTalkTransportLabel,
|
||||
value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing",
|
||||
color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary)
|
||||
detail: self.gatewayTalkConfigDetail,
|
||||
value: self.gatewayTalkConfigValue,
|
||||
color: self.gatewayTalkConfigColor)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "bell",
|
||||
@@ -132,6 +132,7 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
func reconnectGateway() async {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isReconnectingGateway else { return }
|
||||
self.isReconnectingGateway = true
|
||||
defer { self.isReconnectingGateway = false }
|
||||
@@ -153,16 +154,18 @@ extension SettingsProTab {
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
if !self.appModel.isAppleReviewDemoModeEnabled {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count,
|
||||
talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
|
||||
notificationStatusText: self.notificationStatusText)
|
||||
self.diagnosticsIssueCount = issueCount
|
||||
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
||||
@@ -220,6 +223,14 @@ extension SettingsProTab {
|
||||
return false
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return false
|
||||
}
|
||||
|
||||
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return false
|
||||
@@ -272,6 +283,15 @@ extension SettingsProTab {
|
||||
Task { await self.connectAfterScannedGatewayLink() }
|
||||
}
|
||||
|
||||
func handleScannedSetupCode(_ code: String) {
|
||||
guard AppleReviewDemoMode.isSetupCode(code) else { return }
|
||||
self.showQRScanner = false
|
||||
self.setupCode = ""
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
}
|
||||
|
||||
func connectAfterScannedGatewayLink() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
@@ -473,6 +493,7 @@ extension SettingsProTab {
|
||||
func title(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Gateway"
|
||||
case .approvals: "Approvals"
|
||||
case .permissions: "Permissions"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
@@ -589,6 +610,21 @@ extension SettingsProTab {
|
||||
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
var gatewayTalkActiveVoiceDetail: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
var gatewayTalkLastIssueDetail: String? {
|
||||
let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return detail.isEmpty ? nil : detail
|
||||
}
|
||||
|
||||
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
@@ -602,7 +638,53 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
var gatewayStatusDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" }
|
||||
return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
var gatewayStatusValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.gatewayConnected ? "online" : "offline"
|
||||
}
|
||||
|
||||
var gatewayStatusColor: Color {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent }
|
||||
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
var gatewayDiagnosticConnected: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected
|
||||
}
|
||||
|
||||
var gatewayDiagnosticTalkConfigLoaded: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded
|
||||
}
|
||||
|
||||
var approvalEmptyDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled {
|
||||
return "Live gateway requests are disabled in demo mode."
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
|
||||
}
|
||||
|
||||
var gatewayTalkConfigDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" }
|
||||
return self.appModel.talkMode.gatewayTalkTransportLabel
|
||||
}
|
||||
|
||||
var gatewayTalkConfigValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing"
|
||||
}
|
||||
|
||||
var gatewayTalkConfigColor: Color {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return .secondary }
|
||||
return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
var gatewayAddress: String {
|
||||
@@ -621,6 +703,34 @@ extension SettingsProTab {
|
||||
return "\(enabled) enabled"
|
||||
}
|
||||
|
||||
var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
||||
self.appModel.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
var approvalsDetail: String {
|
||||
self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
|
||||
}
|
||||
|
||||
var approvalItems: [SettingsApprovalItem] {
|
||||
guard let pendingApproval else { return [] }
|
||||
return [
|
||||
SettingsApprovalItem(
|
||||
id: "pending-real",
|
||||
icon: "terminal.fill",
|
||||
title: pendingApproval.commandPreview ?? "Review gateway action",
|
||||
detail: "Agent: \(self.appModel.activeAgentName)",
|
||||
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
|
||||
color: OpenClawBrand.danger),
|
||||
SettingsApprovalItem(
|
||||
id: "pending-context",
|
||||
icon: "doc.text.fill",
|
||||
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
|
||||
detail: "Gateway request",
|
||||
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
|
||||
color: OpenClawBrand.warn),
|
||||
]
|
||||
}
|
||||
|
||||
var voiceDetail: String {
|
||||
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
|
||||
if self.talkEnabled { return "Talk on" }
|
||||
@@ -633,6 +743,7 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
var diagnosticsHealthValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
if self.gatewayConnected { return "ready" }
|
||||
if self.gatewayController.gateways.isEmpty { return "check" }
|
||||
return "partial"
|
||||
|
||||
@@ -37,6 +37,8 @@ extension SettingsProTab {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
self.gatewayConnectionRow
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
@@ -58,14 +60,14 @@ extension SettingsProTab {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "antenna.radiowaves.left.and.right",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
color: self.gatewayStatusColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Connection")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText)
|
||||
Text(self.gatewayStatusDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
.foregroundStyle(self.gatewayStatusColor)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
@@ -98,7 +100,8 @@ extension SettingsProTab {
|
||||
title: "Reconnect",
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
color: OpenClawBrand.warn,
|
||||
isBusy: self.isReconnectingGateway)
|
||||
isBusy: self.isReconnectingGateway,
|
||||
isDisabled: self.appModel.isAppleReviewDemoModeEnabled)
|
||||
{
|
||||
Task { await self.reconnectGateway() }
|
||||
}
|
||||
@@ -116,6 +119,13 @@ extension SettingsProTab {
|
||||
|
||||
var settingsListSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
self.settingsListRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.approvalsDetail,
|
||||
route: .approvals,
|
||||
color: self.pendingApproval == nil ? .secondary : OpenClawBrand.warn,
|
||||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||||
self.settingsListRow(
|
||||
icon: "person.2",
|
||||
title: "Permissions",
|
||||
@@ -154,11 +164,13 @@ extension SettingsProTab {
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
route: SettingsRoute) -> some View
|
||||
route: SettingsRoute,
|
||||
color: Color = .secondary,
|
||||
badgeValue: String? = nil) -> some View
|
||||
{
|
||||
NavigationLink(value: route) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
@@ -168,6 +180,9 @@ extension SettingsProTab {
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let badgeValue {
|
||||
ProValuePill(value: badgeValue, color: color)
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -187,6 +202,8 @@ extension SettingsProTab {
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
case .approvals:
|
||||
self.approvalsDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .voice:
|
||||
@@ -201,9 +218,9 @@ extension SettingsProTab {
|
||||
self.aboutDestination
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -218,9 +235,9 @@ extension SettingsProTab {
|
||||
self.detailStatusCard(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
detail: self.gatewayStatusDetail,
|
||||
value: self.gatewayStatusValue,
|
||||
color: self.gatewayStatusColor)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Address", value: self.gatewayAddress)
|
||||
@@ -229,7 +246,7 @@ extension SettingsProTab {
|
||||
Divider()
|
||||
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
|
||||
Divider()
|
||||
self.detailRow("Active Agent", value: self.appModel.activeAgentName)
|
||||
self.detailRow("Default Agent", value: self.appModel.activeAgentName)
|
||||
Divider()
|
||||
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
}
|
||||
@@ -239,15 +256,98 @@ extension SettingsProTab {
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.manualGatewayCard
|
||||
self.deviceIdentityCard
|
||||
self.agentSelectionCard
|
||||
self.gatewaySetupCard
|
||||
self.discoveredGatewaysCard
|
||||
self.manualGatewayCard
|
||||
self.gatewayAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var approvalsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
|
||||
"Review the pending gateway action.",
|
||||
value: self.pendingApproval == nil ? "clear" : "1 waiting",
|
||||
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
self.approvalsReviewCard
|
||||
}
|
||||
}
|
||||
|
||||
var approvalsReviewCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let pendingApproval {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
|
||||
SettingsApprovalRow(item: item)
|
||||
if index < self.approvalItems.count - 1 {
|
||||
Divider().padding(.leading, 46)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
|
||||
Text(errorText)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||||
} label: {
|
||||
Label("Allow", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
if pendingApproval.allowsAllowAlways {
|
||||
Button {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
} label: {
|
||||
Label("Always", systemImage: "checkmark.shield")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||||
} label: {
|
||||
Label("Deny", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "checkmark.shield.fill", color: OpenClawBrand.ok)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("No approvals waiting")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.approvalEmptyDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var permissionsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.toggleCard(
|
||||
@@ -290,19 +390,7 @@ extension SettingsProTab {
|
||||
title: "Health Check",
|
||||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||||
value: self.diagnosticsHealthValue,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActionButton(
|
||||
@@ -316,6 +404,18 @@ extension SettingsProTab {
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
self.diagnosticsAdvancedCard
|
||||
}
|
||||
}
|
||||
@@ -404,6 +504,7 @@ extension SettingsProTab {
|
||||
icon: String,
|
||||
color: Color,
|
||||
isBusy: Bool,
|
||||
isDisabled: Bool = false,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
@@ -425,7 +526,7 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
.disabled(isBusy || isDisabled)
|
||||
}
|
||||
|
||||
func toggleCard(
|
||||
@@ -497,7 +598,7 @@ extension SettingsProTab {
|
||||
var agentSelectionCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Active Agent")
|
||||
Text("Default Agent")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Agent", selection: self.$selectedAgentPickerId) {
|
||||
Text("Default").tag("")
|
||||
@@ -508,7 +609,7 @@ extension SettingsProTab {
|
||||
Text(name.isEmpty ? agent.id : name).tag(agent.id)
|
||||
}
|
||||
}
|
||||
Text("Controls which agent Chat and Talk use.")
|
||||
Text("Used for new Chat and Talk sessions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -610,7 +711,7 @@ extension SettingsProTab {
|
||||
var manualGatewayCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
self.settingsButtonToggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
@@ -618,7 +719,7 @@ extension SettingsProTab {
|
||||
TextField("Port", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
self.settingsButtonToggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
self.gatewayActionButton(
|
||||
title: "Connect Manual",
|
||||
icon: "network",
|
||||
@@ -637,16 +738,21 @@ extension SettingsProTab {
|
||||
var gatewayAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
self.settingsButtonToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Reset Onboarding", role: .destructive) {
|
||||
Button(role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
} label: {
|
||||
Label("Reset Onboarding", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
@@ -659,8 +765,13 @@ extension SettingsProTab {
|
||||
self.appModel.setVoiceWakeEnabled(enabled)
|
||||
}
|
||||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else {
|
||||
self.talkEnabled = false
|
||||
return
|
||||
}
|
||||
self.appModel.setTalkEnabled(enabled)
|
||||
}
|
||||
.disabled(self.appModel.isAppleReviewDemoModeEnabled)
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
@@ -681,26 +792,44 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
var talkVoiceSettingsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.gatewayConnected,
|
||||
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
{
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: issue,
|
||||
onOpenSettings: nil,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
}
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
|
||||
if let issue = self.gatewayTalkLastIssueDetail {
|
||||
Divider()
|
||||
self.detailRow("Last Voice Issue", value: issue)
|
||||
}
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
@@ -738,11 +867,10 @@ extension SettingsProTab {
|
||||
var diagnosticsAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||||
}
|
||||
Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
self.settingsButtonToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||||
}
|
||||
self.settingsButtonToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
NavigationLink {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
} label: {
|
||||
@@ -790,6 +918,44 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
|
||||
func settingsButtonToggle(
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
// Settings switch rows need full-width taps; wrapping Toggle crashes this NavigationStack on iOS 26.
|
||||
Button {
|
||||
isOn.wrappedValue.toggle()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
self.settingsSwitchIndicator(isOn: isOn.wrappedValue)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
|
||||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||||
onChange?(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func settingsSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(2)
|
||||
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleSettingsRow(title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case approvals
|
||||
case permissions
|
||||
case voice
|
||||
case diagnostics
|
||||
@@ -16,6 +17,52 @@ enum SettingsLayout {
|
||||
static let rowHeight: CGFloat = 58
|
||||
}
|
||||
|
||||
struct SettingsApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let priority: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct SettingsApprovalRow: View {
|
||||
let item: SettingsApprovalItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.item.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.item.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.item.priority)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.item.color.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
case gatewayOffline
|
||||
case discoveryUnavailable
|
||||
|
||||
@@ -8,13 +8,16 @@ struct TalkProTab: View {
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
@State private var showTalkIssueDetails = false
|
||||
var openSettings: () -> Void
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isDemoMode: self.appModel.isAppleReviewDemoModeEnabled,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
@@ -28,6 +31,15 @@ struct TalkProTab: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openSettings,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
@@ -36,7 +48,6 @@ struct TalkProTab: View {
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
@@ -61,6 +72,14 @@ struct TalkProTab: View {
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openSettings)
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
@@ -148,7 +167,7 @@ struct TalkProTab: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.chatAgentName)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
@@ -172,9 +191,21 @@ struct TalkProTab: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Configured",
|
||||
value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Active now",
|
||||
value: self.activeModeText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
if let issueText = self.talkIssueText {
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "exclamationmark.triangle.fill", title: "Last issue", value: issueText)
|
||||
}
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
@@ -191,13 +222,9 @@ struct TalkProTab: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
self.controlToggleRow("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||||
Divider().padding(.leading, 14)
|
||||
Toggle("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
self.controlToggleRow("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
Divider().padding(.leading, 14)
|
||||
Button(action: self.openSettings) {
|
||||
HStack {
|
||||
@@ -217,6 +244,25 @@ struct TalkProTab: View {
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func controlToggleRow(_ title: String, isOn: Binding<Bool>) -> some View {
|
||||
Toggle(title, isOn: isOn)
|
||||
.contentShape(Rectangle())
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.overlay {
|
||||
// Keep Toggle semantics for accessibility while making the full visual row tappable.
|
||||
Button {
|
||||
isOn.wrappedValue.toggle()
|
||||
} label: {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
@@ -267,12 +313,18 @@ struct TalkProTab: View {
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var fallbackIssue: TalkRuntimeIssue? {
|
||||
guard self.gatewayConnected else { return nil }
|
||||
return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
private var headerSubtitle: String {
|
||||
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.chatAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent }
|
||||
if agent.isEmpty { return mode }
|
||||
return "\(agent) • \(mode)"
|
||||
@@ -281,11 +333,15 @@ struct TalkProTab: View {
|
||||
private var heroSubtitle: String {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
|
||||
return "Open Voice settings after the gateway loads Talk configuration."
|
||||
}
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
return "Routes voice to \(self.appModel.activeAgentName)."
|
||||
return "Routes voice to \(self.appModel.chatAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
@@ -296,6 +352,21 @@ struct TalkProTab: View {
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var activeModeText: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var talkIssueText: String? {
|
||||
let text = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
@@ -309,15 +380,28 @@ struct TalkProTab: View {
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
if self.appModel.isAppleReviewDemoModeEnabled,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var talkSpeakerphoneBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.talkSpeakerphoneEnabled },
|
||||
set: { enabled in
|
||||
self.talkSpeakerphoneEnabled = enabled
|
||||
self.appModel.setTalkSpeakerphoneEnabled(enabled)
|
||||
})
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch self.state.primaryAction {
|
||||
case .start:
|
||||
@@ -335,7 +419,9 @@ struct TalkProTab: View {
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
self.talkEnabled = true
|
||||
self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey)
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
@@ -363,8 +449,10 @@ enum TalkProWaveformMode: Equatable {
|
||||
|
||||
struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isDemoMode: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isConfigLoaded: Bool
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
@@ -375,6 +463,7 @@ struct TalkProState: Equatable {
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if self.isDemoMode { return "Demo mode only" }
|
||||
if !self.gatewayConnected { return "Gateway offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
@@ -390,6 +479,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "Voice config unavailable" }
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
@@ -399,6 +489,7 @@ struct TalkProState: Equatable {
|
||||
}
|
||||
|
||||
var chipText: String {
|
||||
if self.isDemoMode { return "Demo" }
|
||||
if !self.gatewayConnected { return "Offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
@@ -412,6 +503,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "Config" }
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.isEnabled { return "Ready" }
|
||||
@@ -419,6 +511,7 @@ struct TalkProState: Equatable {
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
if self.isDemoMode { return "waveform.slash" }
|
||||
if !self.gatewayConnected { return "wifi.slash" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
@@ -432,6 +525,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "exclamationmark.triangle.fill" }
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
@@ -440,6 +534,7 @@ struct TalkProState: Equatable {
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.isDemoMode { return .secondary }
|
||||
if !self.gatewayConnected { return .secondary }
|
||||
switch self.permissionState {
|
||||
case .requestFailed, .loadFailed:
|
||||
@@ -447,11 +542,13 @@ struct TalkProState: Equatable {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
if !self.isConfigLoaded { return OpenClawBrand.warn }
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
|
||||
var primaryAction: TalkProPrimaryAction {
|
||||
if self.isDemoMode { return .waiting }
|
||||
if !self.gatewayConnected { return .openSettings }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
@@ -471,7 +568,7 @@ struct TalkProState: Equatable {
|
||||
case .stop: "Stop Talk"
|
||||
case .enablePermission: "Enable Talk"
|
||||
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
|
||||
case .waiting: "Waiting for Approval"
|
||||
case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +578,7 @@ struct TalkProState: Equatable {
|
||||
case .stop: "stop.fill"
|
||||
case .enablePermission: "key.fill"
|
||||
case .openSettings: "gearshape.fill"
|
||||
case .waiting: "hourglass"
|
||||
case .waiting: self.isDemoMode ? "lock.fill" : "hourglass"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +606,7 @@ struct TalkProState: Equatable {
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
|
||||
if self.isDemoMode { return .still }
|
||||
if !self.gatewayConnected { return .still }
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
@@ -518,6 +616,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return .still }
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
|
||||
142
apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift
Normal file
142
apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct TalkRuntimeIssueBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.issue.fallbackBannerOwnerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(self.issue.displayMessage)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(self.tint)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let onOpenSettings {
|
||||
Button("Open Settings", action: onOpenSettings)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThickMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.16 : 0.07), radius: 16, y: 7)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
.orange
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRuntimeIssueDetailsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
|
||||
@State private var copyFeedback: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.issue.displayMessage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Technical details") {
|
||||
Text(verbatim: self.issue.technicalDetails)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Button("Copy diagnostics") {
|
||||
UIPasteboard.general.string = self.issue.technicalDetails
|
||||
self.copyFeedback = "Copied diagnostics"
|
||||
}
|
||||
}
|
||||
|
||||
if let copyFeedback {
|
||||
Section {
|
||||
Text(copyFeedback)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Talk fallback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if let onOpenSettings {
|
||||
Button("Open Settings") {
|
||||
self.dismiss()
|
||||
onOpenSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ struct GatewayQuickSetupSheet: View {
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connecting)
|
||||
|
||||
Toggle("Don’t show this again", isOn: self.$quickSetupDismissed)
|
||||
self.fullRowToggle("Don’t show this again", isOn: self.$quickSetupDismissed)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
|
||||
@@ -135,6 +135,23 @@ struct GatewayQuickSetupSheet: View {
|
||||
self.gatewayController.gateways.first
|
||||
}
|
||||
|
||||
private func fullRowToggle(_ title: String, isOn: Binding<Bool>) -> some View {
|
||||
Toggle(title, isOn: isOn)
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
// Keep Toggle semantics for accessibility while making the full visual row tappable.
|
||||
Button {
|
||||
isOn.wrappedValue.toggle()
|
||||
} label: {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
problem.canTrustRotatedCertificate ? "Trust certificate" : "Connect"
|
||||
}
|
||||
|
||||
@@ -107,12 +107,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -111,6 +111,7 @@ final class NodeAppModel {
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
@@ -125,6 +126,7 @@ final class NodeAppModel {
|
||||
var gatewayPairingPaused: Bool = false
|
||||
var gatewayPairingRequestId: String?
|
||||
private(set) var lastGatewayProblem: GatewayConnectionProblem?
|
||||
private var operatorGatewayProblem: GatewayConnectionProblem?
|
||||
var gatewayDisplayStatusText: String {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
@@ -457,6 +459,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
// Foreground recovery must actively restart the saved gateway config.
|
||||
@@ -548,6 +551,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -585,6 +589,12 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
func setTalkEnabled(_ enabled: Bool) {
|
||||
if self.isAppleReviewDemoModeEnabled {
|
||||
UserDefaults.standard.set(false, forKey: "talk.enabled")
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
|
||||
if enabled {
|
||||
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
|
||||
@@ -630,6 +640,7 @@ final class NodeAppModel {
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
@@ -760,6 +771,7 @@ final class NodeAppModel {
|
||||
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
|
||||
self.selectedAgentId = nil
|
||||
self.focusedChatSessionKey = nil
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
@@ -777,13 +789,19 @@ final class NodeAppModel {
|
||||
|
||||
func setSelectedAgentId(_ agentId: String?) {
|
||||
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed
|
||||
let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId
|
||||
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if stableID.isEmpty {
|
||||
self.selectedAgentId = trimmed.isEmpty ? nil : trimmed
|
||||
self.selectedAgentId = nextSelectedAgentId
|
||||
} else {
|
||||
self.selectedAgentId = trimmed.isEmpty ? nil : trimmed
|
||||
self.selectedAgentId = nextSelectedAgentId
|
||||
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
|
||||
}
|
||||
if selectedAgentChanged {
|
||||
self.focusedChatSessionKey = nil
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
@@ -902,6 +920,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
@@ -1835,9 +1854,13 @@ extension NodeAppModel {
|
||||
{
|
||||
return focused
|
||||
}
|
||||
return self.defaultChatSessionKey
|
||||
}
|
||||
|
||||
var defaultChatSessionKey: String {
|
||||
// Keep chat aligned with the gateway's resolved main session key.
|
||||
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
|
||||
return self.mainSessionKey
|
||||
self.mainSessionKey
|
||||
}
|
||||
|
||||
func openChat(sessionKey: String?) {
|
||||
@@ -1851,11 +1874,30 @@ extension NodeAppModel {
|
||||
self.talkMode.updateMainSessionKey(self.chatSessionKey)
|
||||
}
|
||||
|
||||
var chatAgentId: String {
|
||||
if let sessionAgentId = SessionKey.agentId(from: self.chatSessionKey) {
|
||||
return sessionAgentId
|
||||
}
|
||||
return self.selectedOrDefaultAgentId
|
||||
}
|
||||
|
||||
var chatAgentName: String {
|
||||
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
|
||||
}
|
||||
|
||||
private var selectedOrDefaultAgentId: String {
|
||||
let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedId = agentId.isEmpty ? defaultId : agentId
|
||||
if resolvedId.isEmpty { return "Main" }
|
||||
return agentId.isEmpty ? defaultId : agentId
|
||||
}
|
||||
|
||||
private func agentDisplayName(for agentId: String, fallback: String) -> String {
|
||||
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if resolvedId.isEmpty { return fallback }
|
||||
if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) {
|
||||
let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? match.id : name
|
||||
@@ -1926,6 +1968,7 @@ extension NodeAppModel {
|
||||
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -1949,10 +1992,12 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func disconnectGateway() {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@@ -1983,10 +2028,12 @@ extension NodeAppModel {
|
||||
|
||||
extension NodeAppModel {
|
||||
private func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
@@ -2001,17 +2048,30 @@ extension NodeAppModel {
|
||||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
self.focusedChatSessionKey = nil
|
||||
self.homeCanvasRevision &+= 1
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
private func clearGatewayConnectionProblem() {
|
||||
if let operatorGatewayProblem {
|
||||
self.lastGatewayProblem = operatorGatewayProblem
|
||||
if operatorGatewayProblem.needsPairingApproval {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = operatorGatewayProblem.requestId
|
||||
} else {
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
|
||||
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
@@ -2036,6 +2096,38 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.operatorGatewayProblem = problem
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
if problem.needsPairingApproval {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = problem.requestId
|
||||
}
|
||||
if problem.needsPairingApproval || problem.pauseReconnect {
|
||||
LiveActivityManager.shared.showAttention(
|
||||
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
|
||||
agentName: self.activeAgentName,
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearOperatorGatewayConnectionProblemIfCurrent() {
|
||||
guard let operatorGatewayProblem else { return }
|
||||
self.operatorGatewayProblem = nil
|
||||
guard self.lastGatewayProblem == operatorGatewayProblem else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
if self.gatewayServerName != nil {
|
||||
self.gatewayStatusText = "Connected"
|
||||
}
|
||||
if self.gatewayConnected {
|
||||
LiveActivityManager.shared.handleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
guard let lastGatewayProblem else { return false }
|
||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||
@@ -2213,13 +2305,19 @@ extension NodeAppModel {
|
||||
fallbackPassword: password)
|
||||
let effectiveClientId =
|
||||
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId
|
||||
let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest
|
||||
let operatorOptions = self.makeOperatorConnectOptions(
|
||||
clientId: effectiveClientId,
|
||||
displayName: nodeOptions.clientDisplayName,
|
||||
includeAdminScope: self.shouldRequestOperatorAdminScope(
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password,
|
||||
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
|
||||
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password),
|
||||
forceExplicitScopes: self.forceOperatorTalkPermissionUpgradeRequest)
|
||||
password: reconnectAuth.password,
|
||||
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
|
||||
forceExplicitScopes: talkPermissionUpgradeRequest)
|
||||
|
||||
do {
|
||||
try await self.operatorGateway.connect(
|
||||
@@ -2231,11 +2329,15 @@ extension NodeAppModel {
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
self.setOperatorConnected(true)
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
self.talkMode.updateGatewayConnected(true)
|
||||
return true
|
||||
}
|
||||
guard shouldUseConnection else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
await self.talkMode.reloadConfig()
|
||||
@@ -2250,6 +2352,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
@@ -2272,12 +2375,14 @@ extension NodeAppModel {
|
||||
} catch {
|
||||
attempt += 1
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem = await MainActor.run {
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
if nextProblem.kind == .pairingScopeUpgradeRequired {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = nextProblem.requestId
|
||||
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
||||
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
||||
}
|
||||
if talkPermissionUpgradeRequest, nextProblem.kind == .pairingScopeUpgradeRequired {
|
||||
self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId)
|
||||
}
|
||||
}
|
||||
@@ -2338,6 +2443,7 @@ extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
@@ -2364,7 +2470,8 @@ extension NodeAppModel {
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
let shouldUseConnection = await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return false }
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
@@ -2372,7 +2479,9 @@ extension NodeAppModel {
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
LiveActivityManager.shared.handleReconnect()
|
||||
return true
|
||||
}
|
||||
guard shouldUseConnection else { return }
|
||||
let usedBootstrapToken =
|
||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -2421,6 +2530,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
@@ -2466,10 +2576,11 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let problem = await MainActor.run {
|
||||
let problem: GatewayConnectionProblem? = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return nil }
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
@@ -2510,6 +2621,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isAppleReviewDemoModeEnabled else { return }
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
@@ -2527,7 +2639,11 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
||||
private func shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedOperatorScopes = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
@@ -2535,14 +2651,19 @@ extension NodeAppModel {
|
||||
return Self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes)
|
||||
storedOperatorScopes: storedOperatorScopes,
|
||||
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
||||
}
|
||||
|
||||
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
storedOperatorScopes: [String],
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
if forceTalkPermissionUpgradeRequest {
|
||||
return storedOperatorScopes.contains("operator.approvals")
|
||||
}
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
@@ -2554,13 +2675,53 @@ extension NodeAppModel {
|
||||
return storedOperatorScopes.contains("operator.approvals")
|
||||
}
|
||||
|
||||
private func shouldRequestOperatorAdminScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedOperatorScopes = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
.scopes ?? []
|
||||
return Self.shouldRequestOperatorAdminScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes,
|
||||
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
||||
}
|
||||
|
||||
fileprivate nonisolated static func shouldRequestOperatorAdminScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String],
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
if forceTalkPermissionUpgradeRequest {
|
||||
return false
|
||||
}
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
}
|
||||
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPassword.isEmpty {
|
||||
return true
|
||||
}
|
||||
return storedOperatorScopes.contains("operator.admin")
|
||||
}
|
||||
|
||||
private func makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeAdminScope: Bool = false,
|
||||
includeApprovalScope: Bool,
|
||||
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
|
||||
{
|
||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||
if includeAdminScope {
|
||||
scopes.append("operator.admin")
|
||||
}
|
||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||
// approved before iOS requested operator.approvals by default.
|
||||
if includeApprovalScope {
|
||||
@@ -2599,6 +2760,52 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.gatewayServerName = AppleReviewDemoMode.gatewayName
|
||||
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
|
||||
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(false)
|
||||
UserDefaults.standard.set(false, forKey: "talk.enabled")
|
||||
UserDefaults.standard.set(false, forKey: "talk.background.enabled")
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = AppleReviewDemoMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private struct PendingForegroundNodeAction: Decodable {
|
||||
var id: String
|
||||
@@ -4425,6 +4632,10 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = connected
|
||||
}
|
||||
|
||||
func _test_isGatewayConnected() -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
@@ -4441,12 +4652,14 @@ extension NodeAppModel {
|
||||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeAdminScope: Bool = false,
|
||||
includeApprovalScope: Bool,
|
||||
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
|
||||
{
|
||||
self.makeOperatorConnectOptions(
|
||||
clientId: clientId,
|
||||
displayName: displayName,
|
||||
includeAdminScope: includeAdminScope,
|
||||
includeApprovalScope: includeApprovalScope,
|
||||
forceExplicitScopes: forceExplicitScopes)
|
||||
}
|
||||
@@ -4459,6 +4672,18 @@ extension NodeAppModel {
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
func _test_applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
self.applyOperatorGatewayConnectionProblem(problem)
|
||||
}
|
||||
|
||||
func _test_clearOperatorGatewayConnectionProblemIfCurrent() {
|
||||
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
||||
}
|
||||
|
||||
func _test_clearGatewayConnectionProblem() {
|
||||
self.clearGatewayConnectionProblem()
|
||||
}
|
||||
|
||||
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
|
||||
self.pendingExecApprovalPrompt
|
||||
}
|
||||
@@ -4552,12 +4777,27 @@ extension NodeAppModel {
|
||||
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]) -> Bool
|
||||
storedOperatorScopes: [String],
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes)
|
||||
storedOperatorScopes: storedOperatorScopes,
|
||||
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldRequestOperatorAdminScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String],
|
||||
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
||||
{
|
||||
self.shouldRequestOperatorAdminScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes,
|
||||
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clearingBootstrapToken(
|
||||
|
||||
@@ -255,6 +255,13 @@ private struct ManualEntryStep: View {
|
||||
return
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return
|
||||
}
|
||||
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return
|
||||
|
||||
@@ -7,9 +7,7 @@ struct OnboardingIntroStep: View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3")
|
||||
.font(.system(size: 60, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
OpenClawProMark(size: 64, shadowRadius: 14)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("Welcome to OpenClaw")
|
||||
@@ -181,6 +179,7 @@ struct OnboardingModeRow: View {
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ struct OnboardingWizardView: View {
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var lastPairingAutoResumeAttemptAt: Date?
|
||||
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupCodeStatus: String?
|
||||
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
let allowSkip: Bool
|
||||
@@ -172,6 +174,9 @@ struct OnboardingWizardView: View {
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
@@ -208,6 +213,10 @@ struct OnboardingWizardView: View {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if AppleReviewDemoMode.isSetupCode(message) {
|
||||
self.handleScannedSetupCode(message)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
@@ -272,7 +281,7 @@ struct OnboardingWizardView: View {
|
||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||
self.didMarkCompleted = true
|
||||
}
|
||||
self.onClose()
|
||||
self.step = .success
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
guard newValue == .active else { return }
|
||||
@@ -301,6 +310,8 @@ struct OnboardingWizardView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var modeStep: some View {
|
||||
self.setupCodeSection
|
||||
|
||||
Section("Connection Mode") {
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.homeNetwork.title,
|
||||
@@ -318,16 +329,7 @@ struct OnboardingWizardView: View {
|
||||
self.selectMode(.remoteDomain)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Developer mode",
|
||||
isOn: Binding(
|
||||
get: { self.developerModeEnabled },
|
||||
set: { newValue in
|
||||
self.developerModeEnabled = newValue
|
||||
if !newValue, self.selectedMode == .developerLocal {
|
||||
self.selectedMode = nil
|
||||
}
|
||||
}))
|
||||
self.developerModeToggleRow
|
||||
|
||||
if self.developerModeEnabled {
|
||||
OnboardingModeRow(
|
||||
@@ -348,6 +350,49 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var developerModeToggleRow: some View {
|
||||
self.onboardingButtonToggle(
|
||||
"Developer mode",
|
||||
isOn: Binding(
|
||||
get: { self.developerModeEnabled },
|
||||
set: { enabled in
|
||||
self.developerModeEnabled = enabled
|
||||
if !enabled, self.selectedMode == .developerLocal {
|
||||
self.selectedMode = nil
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private func onboardingButtonToggle(_ title: String, isOn: Binding<Bool>) -> some View {
|
||||
// Onboarding Form switch rows need full-width taps; native Toggle only hits the switch edge on iOS 26.
|
||||
Button {
|
||||
isOn.wrappedValue.toggle()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
self.onboardingSwitchIndicator(isOn: isOn.wrappedValue)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
|
||||
}
|
||||
|
||||
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(2)
|
||||
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectStep: some View {
|
||||
if let selectedMode {
|
||||
@@ -440,7 +485,7 @@ struct OnboardingWizardView: View {
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
|
||||
self.manualConnectButton
|
||||
} header: {
|
||||
Text("Developer Local")
|
||||
@@ -570,6 +615,44 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
|
||||
extension OnboardingWizardView {
|
||||
private var setupCodeSection: some View {
|
||||
Section {
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onSubmit {
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "setup-code" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Applying...")
|
||||
}
|
||||
} else {
|
||||
Text("Apply Setup Code")
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| self.connectingGatewayID != nil)
|
||||
|
||||
if let setupCodeStatus, !setupCodeStatus.isEmpty {
|
||||
Text(setupCodeStatus)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Setup Code")
|
||||
} footer: {
|
||||
Text("Use this if you received a setup code instead of a QR code.")
|
||||
}
|
||||
}
|
||||
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
@@ -577,7 +660,7 @@ extension OnboardingWizardView {
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
@@ -608,9 +691,49 @@ extension OnboardingWizardView {
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
|
||||
private func applySetupCodeAndConnect() async {
|
||||
self.setupCodeStatus = nil
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupCodeStatus = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.setupCode = ""
|
||||
self.setupCodeStatus = "Apple Review demo mode enabled."
|
||||
self.handleScannedSetupCode(raw)
|
||||
return
|
||||
}
|
||||
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "setup-code"
|
||||
self.applyGatewayLink(link)
|
||||
self.setupCode = ""
|
||||
self.setupCodeStatus = "Setup code applied. Connecting..."
|
||||
self.connectMessage = "Connecting via setup code..."
|
||||
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..."
|
||||
self.step = .connect
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
self.applyGatewayLink(link)
|
||||
self.setupCodeStatus = nil
|
||||
self.showQRScanner = false
|
||||
self.connectMessage = "Connecting via QR code..."
|
||||
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..."
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
|
||||
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualHost = link.host
|
||||
self.manualPort = link.port
|
||||
self.manualPortText = String(link.port)
|
||||
self.manualTLS = link.tls
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.hasBootstrapToken {
|
||||
@@ -627,13 +750,19 @@ extension OnboardingWizardView {
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||
self.showQRScanner = false
|
||||
self.connectMessage = "Connecting via QR code…"
|
||||
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…"
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
|
||||
}
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
|
||||
private func handleScannedSetupCode(_ code: String) {
|
||||
guard AppleReviewDemoMode.isSetupCode(code) else { return }
|
||||
self.showQRScanner = false
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = "Apple Review demo mode enabled."
|
||||
self.statusLine = "Apple Review demo mode enabled."
|
||||
self.selectedMode = .homeNetwork
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
}
|
||||
|
||||
private func openQRScannerFromOnboarding() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import VisionKit
|
||||
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onGatewayLink: (GatewayConnectDeepLink) -> Void
|
||||
let onSetupCode: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@@ -72,6 +73,13 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
}
|
||||
return
|
||||
}
|
||||
if AppleReviewDemoMode.isSetupCode(payload) {
|
||||
self.handled = true
|
||||
Task { @MainActor in
|
||||
self.parent.onSetupCode(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,6 @@ struct RootTabs: View {
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
OnboardingStateStore.markCompleted(mode: nil)
|
||||
|
||||
@@ -14,6 +14,15 @@ enum SessionKey {
|
||||
return "agent:\(trimmedAgent):\(normalizedBase)"
|
||||
}
|
||||
|
||||
static func agentId(from value: String?) -> String? {
|
||||
let parts = (value ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil }
|
||||
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return agentId.isEmpty ? nil : agentId
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
|
||||
@@ -103,6 +103,12 @@ final class RealtimeTalkRelaySession {
|
||||
let failed: Bool
|
||||
}
|
||||
|
||||
private enum StartupWaitResult {
|
||||
case ready
|
||||
case failed(TalkRuntimeIssue)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private nonisolated static let expectedInputEncoding = "pcm16"
|
||||
private nonisolated static let expectedOutputEncoding = "pcm16"
|
||||
private nonisolated static let defaultSampleRateHz = 24000
|
||||
@@ -110,16 +116,23 @@ final class RealtimeTalkRelaySession {
|
||||
private nonisolated static let bargeInRmsThreshold: Float = 0.08
|
||||
private nonisolated static let bargeInCooldownMs: Double = 900
|
||||
private nonisolated static let minOutputBeforeBargeInMs: Double = 250
|
||||
private nonisolated static let startupReadyTimeoutSeconds = 12
|
||||
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
private let pcmPlayer: PCMStreamingAudioPlaying
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
|
||||
private let onStatus: (String) -> Void
|
||||
private let onIssue: (TalkRuntimeIssue) -> Void
|
||||
private let onSpeakingChanged: (Bool) -> Void
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var relaySessionId: String?
|
||||
private var hasReceivedReady = false
|
||||
private var hasReceivedFailure = false
|
||||
private var startupIssue: TalkRuntimeIssue?
|
||||
private var startupWaiter: CheckedContinuation<StartupWaitResult, Never>?
|
||||
private var pendingPreRelayEvents: [EventFrame] = []
|
||||
private var inputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
|
||||
private var outputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
|
||||
private var eventTask: Task<Void, Never>?
|
||||
@@ -151,34 +164,53 @@ final class RealtimeTalkRelaySession {
|
||||
options: Options,
|
||||
pcmPlayer: PCMStreamingAudioPlaying,
|
||||
onStatus: @escaping (String) -> Void,
|
||||
onIssue: @escaping (TalkRuntimeIssue) -> Void = { _ in },
|
||||
onSpeakingChanged: @escaping (Bool) -> Void)
|
||||
{
|
||||
self.gateway = gateway
|
||||
self.options = options
|
||||
self.pcmPlayer = pcmPlayer
|
||||
self.onStatus = onStatus
|
||||
self.onIssue = onIssue
|
||||
self.onSpeakingChanged = onSpeakingChanged
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
self.isClosed = false
|
||||
self.hasReceivedReady = false
|
||||
self.hasReceivedFailure = false
|
||||
self.startupIssue = nil
|
||||
self.startupWaiter = nil
|
||||
self.pendingPreRelayEvents.removeAll()
|
||||
self.onStatus("Connecting realtime…")
|
||||
let result = try await self.createRelaySession()
|
||||
guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!relaySessionId.isEmpty
|
||||
else {
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session",
|
||||
])
|
||||
}
|
||||
self.relaySessionId = relaySessionId
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
do {
|
||||
let result = try await self.createRelaySession()
|
||||
guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!relaySessionId.isEmpty
|
||||
else {
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session",
|
||||
])
|
||||
}
|
||||
self.relaySessionId = relaySessionId
|
||||
self.audioSender = RealtimeAudioSender(gateway: self.gateway, relaySessionId: relaySessionId)
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
self.configureAudioContract(result.audio)
|
||||
try self.startMicrophonePump()
|
||||
self.onStatus("Listening (Realtime)")
|
||||
self.onStatus("Waiting for realtime…")
|
||||
await self.drainPendingPreRelayEvents()
|
||||
switch await self.waitForStartupResult(timeoutSeconds: Self.startupReadyTimeoutSeconds) {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(issue):
|
||||
self.close(sendClose: true)
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: issue.displayMessage,
|
||||
])
|
||||
case .cancelled:
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
let createdRelaySessionId = self.relaySessionId
|
||||
self.close(sendClose: false)
|
||||
@@ -196,6 +228,7 @@ final class RealtimeTalkRelaySession {
|
||||
private func close(sendClose: Bool) {
|
||||
guard !self.isClosed else { return }
|
||||
self.isClosed = true
|
||||
self.finishStartupWait(.cancelled)
|
||||
self.stopMicrophonePump()
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = nil
|
||||
@@ -299,14 +332,21 @@ final class RealtimeTalkRelaySession {
|
||||
guard event.event == "talk.event",
|
||||
let payload = event.payload?.dictionaryValue
|
||||
else { return }
|
||||
if let relaySessionId,
|
||||
payload["relaySessionId"]?.stringValue != relaySessionId
|
||||
{
|
||||
guard let relaySessionId else {
|
||||
self.pendingPreRelayEvents.append(event)
|
||||
if self.pendingPreRelayEvents.count > 200 {
|
||||
self.pendingPreRelayEvents.removeFirst(self.pendingPreRelayEvents.count - 200)
|
||||
}
|
||||
return
|
||||
}
|
||||
if payload["relaySessionId"]?.stringValue != relaySessionId {
|
||||
return
|
||||
}
|
||||
guard let type = payload["type"]?.stringValue else { return }
|
||||
switch type {
|
||||
case "ready":
|
||||
self.hasReceivedReady = true
|
||||
self.finishStartupWait(.ready)
|
||||
self.onStatus("Listening (Realtime)")
|
||||
case "audio":
|
||||
guard let base64 = payload["audioBase64"]?.stringValue,
|
||||
@@ -331,17 +371,107 @@ final class RealtimeTalkRelaySession {
|
||||
await self.handleToolCall(payload)
|
||||
case "error":
|
||||
let message = payload["message"]?.stringValue ?? "Realtime failed"
|
||||
let issue = Self.issue(
|
||||
payload: payload,
|
||||
fallbackMessage: message,
|
||||
fallbackProvider: self.options.provider,
|
||||
fallbackModel: self.options.model)
|
||||
GatewayDiagnostics.log("talk realtime: error=\(Self.safeLogMessage(message))")
|
||||
self.hasReceivedFailure = true
|
||||
self.startupIssue = issue
|
||||
self.onIssue(issue)
|
||||
self.finishStartupWait(.failed(issue))
|
||||
self.onStatus(message)
|
||||
case "close":
|
||||
GatewayDiagnostics.log("talk realtime: close")
|
||||
self.onStatus("Ready")
|
||||
if self.hasReceivedReady {
|
||||
self.onStatus("Ready")
|
||||
} else if !self.hasReceivedFailure {
|
||||
let issue = TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: "Realtime closed before it became ready.",
|
||||
provider: self.options.provider,
|
||||
model: self.options.model,
|
||||
transport: "gateway-relay",
|
||||
phase: "connect")
|
||||
self.onIssue(issue)
|
||||
self.startupIssue = issue
|
||||
self.finishStartupWait(.failed(issue))
|
||||
self.onStatus("Realtime failed before connecting")
|
||||
}
|
||||
self.close(sendClose: false)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForStartupResult(timeoutSeconds: Int) async -> StartupWaitResult {
|
||||
if self.isClosed { return .cancelled }
|
||||
if self.hasReceivedReady { return .ready }
|
||||
if let startupIssue { return .failed(startupIssue) }
|
||||
return await withCheckedContinuation { continuation in
|
||||
if self.isClosed {
|
||||
continuation.resume(returning: .cancelled)
|
||||
return
|
||||
}
|
||||
self.startupWaiter = continuation
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(max(0, timeoutSeconds)) * 1_000_000_000)
|
||||
await self?.timeoutStartupWaiterIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func drainPendingPreRelayEvents() async {
|
||||
let pendingEvents = self.pendingPreRelayEvents
|
||||
self.pendingPreRelayEvents.removeAll()
|
||||
for event in pendingEvents {
|
||||
await self.handleGatewayEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStartupWait(_ result: StartupWaitResult) {
|
||||
guard let waiter = self.startupWaiter else { return }
|
||||
self.startupWaiter = nil
|
||||
waiter.resume(returning: result)
|
||||
}
|
||||
|
||||
private func timeoutStartupWaiterIfNeeded() {
|
||||
guard !self.isClosed, self.startupWaiter != nil, !self.hasReceivedReady, self.startupIssue == nil else {
|
||||
return
|
||||
}
|
||||
let issue = TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: "Realtime did not become ready in time.",
|
||||
provider: self.options.provider,
|
||||
model: self.options.model,
|
||||
transport: "gateway-relay",
|
||||
phase: "connect")
|
||||
self.hasReceivedFailure = true
|
||||
self.startupIssue = issue
|
||||
self.onIssue(issue)
|
||||
self.onStatus(issue.displayMessage)
|
||||
self.finishStartupWait(.failed(issue))
|
||||
}
|
||||
|
||||
private static func issue(
|
||||
payload: [String: AnyCodable],
|
||||
fallbackMessage: String,
|
||||
fallbackProvider: String?,
|
||||
fallbackModel: String?) -> TalkRuntimeIssue
|
||||
{
|
||||
let provider = payload["provider"]?.stringValue ?? fallbackProvider
|
||||
let model = payload["model"]?.stringValue ?? fallbackModel
|
||||
let transport = payload["transport"]?.stringValue ?? "gateway-relay"
|
||||
let phase = payload["phase"]?.stringValue
|
||||
return TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: fallbackMessage,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func recordOutputAudioChunk(byteCount: Int) {
|
||||
self.outputAudioChunkCount += 1
|
||||
self.outputAudioByteCount += byteCount
|
||||
@@ -804,6 +934,25 @@ final class RealtimeTalkRelaySession {
|
||||
}
|
||||
|
||||
extension RealtimeTalkRelaySession {
|
||||
func _test_setRelaySessionId(_ relaySessionId: String) {
|
||||
self.relaySessionId = relaySessionId
|
||||
}
|
||||
|
||||
func _test_handleGatewayEvent(_ event: EventFrame) async {
|
||||
await self.handleGatewayEvent(event)
|
||||
}
|
||||
|
||||
func _test_waitForStartupCancelled(timeoutSeconds: Int) async -> Bool {
|
||||
if case .cancelled = await self.waitForStartupResult(timeoutSeconds: timeoutSeconds) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func _test_startupReadyTimeoutSeconds() -> Int {
|
||||
Self.startupReadyTimeoutSeconds
|
||||
}
|
||||
|
||||
func _test_markOutputAudioStarted(nowMs: Double) {
|
||||
self.markOutputAudioStarted(byteCount: 4800, nowMs: nowMs)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,96 @@ enum TalkModeExecutionMode {
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
struct TalkRuntimeIssue: Equatable {
|
||||
enum Code: String {
|
||||
case realtimeUnavailable = "realtime_unavailable"
|
||||
}
|
||||
|
||||
let code: Code
|
||||
let message: String
|
||||
let provider: String?
|
||||
let model: String?
|
||||
let transport: String?
|
||||
let phase: String?
|
||||
let occurredAt: Date
|
||||
|
||||
init(
|
||||
code: Code,
|
||||
message: String,
|
||||
provider: String? = nil,
|
||||
model: String? = nil,
|
||||
transport: String? = nil,
|
||||
phase: String? = nil,
|
||||
occurredAt: Date = Date())
|
||||
{
|
||||
self.code = code
|
||||
self.message = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.provider = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.model = model?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.transport = transport?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.phase = phase?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.occurredAt = occurredAt
|
||||
}
|
||||
|
||||
var displayMessage: String {
|
||||
if !self.message.isEmpty { return self.message }
|
||||
return "Realtime voice did not start."
|
||||
}
|
||||
|
||||
var fallbackStatusText: String {
|
||||
"Listening (iOS Speech fallback)"
|
||||
}
|
||||
|
||||
var fallbackBannerTitle: String {
|
||||
"Using iOS Speech fallback"
|
||||
}
|
||||
|
||||
var fallbackBannerOwnerLabel: String {
|
||||
"Fallback active"
|
||||
}
|
||||
|
||||
var fallbackBannerMessage: String {
|
||||
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS."
|
||||
}
|
||||
|
||||
var technicalDetails: String {
|
||||
var lines = [
|
||||
"code: \(self.code.rawValue)",
|
||||
"message: \(self.displayMessage)",
|
||||
]
|
||||
if let provider, !provider.isEmpty { lines.append("provider: \(provider)") }
|
||||
if let model, !model.isEmpty { lines.append("model: \(model)") }
|
||||
if let transport, !transport.isEmpty { lines.append("transport: \(transport)") }
|
||||
if let phase, !phase.isEmpty { lines.append("phase: \(phase)") }
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
var diagnosticSummary: String {
|
||||
var parts = [self.displayMessage]
|
||||
if let provider, !provider.isEmpty { parts.append("provider: \(provider)") }
|
||||
if let model, !model.isEmpty { parts.append("model: \(model)") }
|
||||
if let transport, !transport.isEmpty { parts.append("transport: \(transport)") }
|
||||
if let phase, !phase.isEmpty { parts.append("phase: \(phase)") }
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
static func realtimeUnavailable(
|
||||
message: String,
|
||||
provider: String? = nil,
|
||||
model: String? = nil,
|
||||
transport: String? = nil,
|
||||
phase: String? = nil) -> TalkRuntimeIssue
|
||||
{
|
||||
TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: message,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkVoiceModeDescriptor: Equatable {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
|
||||
@@ -60,6 +60,10 @@ final class TalkModeManager: NSObject {
|
||||
var gatewayTalkVoiceModeTitle: String = "Not loaded"
|
||||
var gatewayTalkVoiceModeSubtitle: String?
|
||||
var gatewayTalkVoiceModeAccessibilityValue: String = "Not loaded"
|
||||
var gatewayTalkActiveModeTitle: String = "Not active"
|
||||
var gatewayTalkActiveModeSubtitle: String?
|
||||
var gatewayTalkLastIssueText: String?
|
||||
var gatewayTalkCurrentFallbackIssue: TalkRuntimeIssue?
|
||||
var gatewayTalkPermissionState: TalkGatewayPermissionState = .unknown
|
||||
|
||||
var isGatewayConnected: Bool {
|
||||
@@ -77,6 +81,12 @@ final class TalkModeManager: NSObject {
|
||||
case pushToTalk
|
||||
}
|
||||
|
||||
private enum RealtimeStartResult {
|
||||
case started
|
||||
case unavailable(TalkRuntimeIssue)
|
||||
case ignored
|
||||
}
|
||||
|
||||
private var isStarting = false
|
||||
private var startAttemptID = 0
|
||||
private var captureMode: CaptureMode = .idle
|
||||
@@ -129,6 +139,8 @@ final class TalkModeManager: NSObject {
|
||||
voiceId: nil,
|
||||
transport: nil,
|
||||
isRealtime: false)
|
||||
private var pendingRealtimeIssue: TalkRuntimeIssue?
|
||||
private var realtimeRelayStartIssue: TalkRuntimeIssue?
|
||||
private var apiKey: String?
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var interruptOnSpeech: Bool = true
|
||||
@@ -192,6 +204,8 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
} else {
|
||||
self.stopRealtimeSession()
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
if self.isEnabled, !self.isSpeaking {
|
||||
self.statusText = "Offline"
|
||||
}
|
||||
@@ -299,11 +313,15 @@ final class TalkModeManager: NSObject {
|
||||
return
|
||||
}
|
||||
if self.realtimeWebRTCEnabled {
|
||||
let started = self.executionMode == .realtimeRelay
|
||||
let realtimeStart = self.executionMode == .realtimeRelay
|
||||
? await self.startRealtimeRelayIfAvailable()
|
||||
: await self.startRealtimeIfAvailable()
|
||||
if started {
|
||||
switch realtimeStart {
|
||||
case .started, .ignored:
|
||||
return
|
||||
case let .unavailable(issue):
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +342,11 @@ final class TalkModeManager: NSObject {
|
||||
self.captureMode = .continuous
|
||||
try self.startRecognition()
|
||||
self.isListening = true
|
||||
self.statusText = "Listening"
|
||||
if let issue = self.pendingRealtimeIssue {
|
||||
self.markNativeFallbackActive(after: issue)
|
||||
} else {
|
||||
self.markNativeTalkActive()
|
||||
}
|
||||
self.startSilenceMonitor()
|
||||
await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey)
|
||||
self.logger.info("listening")
|
||||
@@ -379,6 +401,11 @@ final class TalkModeManager: NSObject {
|
||||
self.isPushToTalkActive = false
|
||||
self.captureMode = .idle
|
||||
self.statusText = "Off"
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
self.silenceTask?.cancel()
|
||||
@@ -425,6 +452,8 @@ final class TalkModeManager: NSObject {
|
||||
self.isPushToTalkActive = false
|
||||
self.captureMode = .idle
|
||||
self.statusText = "Paused"
|
||||
self.gatewayTalkActiveModeTitle = "Paused"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
self.silenceTask?.cancel()
|
||||
@@ -1047,8 +1076,10 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func startRealtimeIfAvailable() async -> Bool {
|
||||
guard let gateway else { return false }
|
||||
private func startRealtimeIfAvailable() async -> RealtimeStartResult {
|
||||
guard let gateway else {
|
||||
return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start"))
|
||||
}
|
||||
let startedAt = Self.nowSeconds()
|
||||
if self.prefetchedRealtimeSession == nil, let prefetchTask = self.realtimePrefetchTask {
|
||||
GatewayDiagnostics.log("talk.timeline realtime awaiting in-flight prefetch")
|
||||
@@ -1069,49 +1100,53 @@ final class TalkModeManager: NSObject {
|
||||
prefetchedSession: prefetchedSession)
|
||||
guard self.realtimeSession === session, self.isEnabled else {
|
||||
session.stop()
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
self.isListening = true
|
||||
self.captureMode = .continuous
|
||||
self.statusText = "Listening"
|
||||
self.markRealtimeActive()
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime start ready elapsedMs=\(Self.elapsedMs(since: startedAt))")
|
||||
GatewayDiagnostics.log("talk realtime: started direct OpenAI WebRTC session")
|
||||
return true
|
||||
return .started
|
||||
} catch {
|
||||
guard self.realtimeSession === session, self.isEnabled else {
|
||||
session.stop()
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
self.stopRealtimeSession()
|
||||
let issue = self.realtimeIssue(from: error, phase: "start")
|
||||
GatewayDiagnostics
|
||||
.log("talk realtime: unavailable; falling back to speech pipeline error=\(error.localizedDescription)")
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
return false
|
||||
return .unavailable(issue)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRealtimeRelayIfAvailable() async -> Bool {
|
||||
guard let gateway else { return false }
|
||||
private func startRealtimeRelayIfAvailable() async -> RealtimeStartResult {
|
||||
guard let gateway else {
|
||||
return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start"))
|
||||
}
|
||||
guard self.foregroundAudioCaptureAllowed else {
|
||||
self.statusText = "Paused"
|
||||
GatewayDiagnostics.log("talk realtime ignored: app backgrounded")
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
if self.realtimeRelaySession != nil {
|
||||
self.captureMode = .continuous
|
||||
self.isListening = true
|
||||
GatewayDiagnostics.log("talk realtime ignored: already active")
|
||||
return true
|
||||
return .started
|
||||
}
|
||||
guard !self.realtimeRelayStartInFlight else {
|
||||
GatewayDiagnostics.log("talk realtime ignored: already starting")
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
self.realtimeRelayStartInFlight = true
|
||||
defer { self.realtimeRelayStartInFlight = false }
|
||||
self.prepareRealtimeRelayStart()
|
||||
GatewayDiagnostics.log("talk.timeline realtime relay start attempt sessionKey=\(self.mainSessionKey)")
|
||||
let startedAt = Self.nowSeconds()
|
||||
let relaySession = RealtimeTalkRelaySession(
|
||||
@@ -1124,13 +1159,15 @@ final class TalkModeManager: NSObject {
|
||||
pcmPlayer: self.pcmPlayer,
|
||||
onStatus: { [weak self] status in
|
||||
guard let self else { return }
|
||||
self.statusText = status
|
||||
self.isListening = status.localizedCaseInsensitiveContains("listening")
|
||||
if status.localizedCaseInsensitiveContains("thinking") {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
self.handleRealtimeRelayStatus(status)
|
||||
},
|
||||
onIssue: { [weak self] issue in
|
||||
guard let self else { return }
|
||||
self.realtimeRelayStartIssue = issue
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.gatewayTalkActiveModeTitle = "Realtime unavailable"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
},
|
||||
onSpeakingChanged: { [weak self] speaking in
|
||||
guard let self else { return }
|
||||
@@ -1145,23 +1182,35 @@ final class TalkModeManager: NSObject {
|
||||
try await relaySession.start()
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
if let issue = self.realtimeRelayStartIssue {
|
||||
self.realtimeRelaySession = nil
|
||||
relaySession.stop()
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start unavailable elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "issue=\(issue.code.rawValue)")
|
||||
return .unavailable(issue)
|
||||
}
|
||||
self.isListening = true
|
||||
self.captureMode = .continuous
|
||||
self.realtimeRelayStartIssue = nil
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start ready elapsedMs=\(Self.elapsedMs(since: startedAt))")
|
||||
return true
|
||||
return .started
|
||||
} catch {
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
return true
|
||||
return .ignored
|
||||
}
|
||||
self.realtimeRelaySession = nil
|
||||
let issue = self.realtimeRelayStartIssue
|
||||
?? self.realtimeIssue(from: error, phase: "start")
|
||||
self.realtimeRelayStartIssue = nil
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
return false
|
||||
return .unavailable(issue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2363,6 +2412,103 @@ extension TalkModeManager {
|
||||
self.gatewayTalkVoiceModeAccessibilityValue = descriptor.accessibilityValue
|
||||
}
|
||||
|
||||
private func markRealtimeActive() {
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.gatewayTalkActiveModeTitle = self.configuredVoiceModeDescriptor.title
|
||||
self.gatewayTalkActiveModeSubtitle = self.configuredVoiceModeDescriptor.subtitle
|
||||
self.statusText = "Listening (Realtime)"
|
||||
}
|
||||
|
||||
private func handleRealtimeRelayStatus(_ status: String) {
|
||||
if status == "Listening (Realtime)" {
|
||||
self.markRealtimeActive()
|
||||
} else {
|
||||
self.statusText = status
|
||||
if status == "Ready" {
|
||||
self.realtimeRelaySession = nil
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
}
|
||||
self.isListening = status.localizedCaseInsensitiveContains("listening")
|
||||
if status.localizedCaseInsensitiveContains("thinking") {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareRealtimeRelayStart() {
|
||||
self.realtimeRelayStartIssue = nil
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
}
|
||||
|
||||
private func markNativeTalkActive() {
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkActiveModeTitle = "iOS Speech + TTS"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.statusText = "Listening"
|
||||
}
|
||||
|
||||
private func markNativeFallbackActive(after issue: TalkRuntimeIssue) {
|
||||
self.gatewayTalkActiveModeTitle = "iOS Speech fallback"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
self.gatewayTalkCurrentFallbackIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.statusText = issue.fallbackStatusText
|
||||
}
|
||||
|
||||
private func realtimeIssue(message: String, phase: String) -> TalkRuntimeIssue {
|
||||
TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: message,
|
||||
provider: self.realtimeProvider,
|
||||
model: self.realtimeModelId,
|
||||
transport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc",
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue {
|
||||
if let gatewayError = error as? GatewayResponseError,
|
||||
let issue = Self.talkRuntimeIssue(
|
||||
from: gatewayError,
|
||||
fallbackProvider: self.realtimeProvider,
|
||||
fallbackModel: self.realtimeModelId,
|
||||
fallbackTransport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc",
|
||||
fallbackPhase: phase)
|
||||
{
|
||||
return issue
|
||||
}
|
||||
return self.realtimeIssue(message: error.localizedDescription, phase: phase)
|
||||
}
|
||||
|
||||
private static func talkRuntimeIssue(
|
||||
from gatewayError: GatewayResponseError,
|
||||
fallbackProvider: String?,
|
||||
fallbackModel: String?,
|
||||
fallbackTransport: String?,
|
||||
fallbackPhase: String) -> TalkRuntimeIssue?
|
||||
{
|
||||
guard let rawIssue = gatewayError.details["talkIssue"]?.dictionaryValue else { return nil }
|
||||
let message = rawIssue["message"]?.stringValue ?? gatewayError.message
|
||||
let provider = rawIssue["provider"]?.stringValue ?? fallbackProvider
|
||||
let model = rawIssue["model"]?.stringValue ?? fallbackModel
|
||||
let transport = rawIssue["transport"]?.stringValue ?? fallbackTransport
|
||||
let phase = rawIssue["phase"]?.stringValue ?? fallbackPhase
|
||||
return TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: message,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func restoreConfiguredVoiceModeDescriptor() {
|
||||
self.applyVoiceModeDescriptor(self.configuredVoiceModeDescriptor)
|
||||
}
|
||||
@@ -2836,7 +2982,11 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didChangeStatus status: String) {
|
||||
guard session === self.realtimeSession else { return }
|
||||
GatewayDiagnostics.log("talk.timeline realtime status=\(status)")
|
||||
self.statusText = status
|
||||
if status == "Listening" {
|
||||
self.markRealtimeActive()
|
||||
} else {
|
||||
self.statusText = status
|
||||
}
|
||||
self.isListening = status == "Listening"
|
||||
self.isSpeaking = status == "Speaking"
|
||||
if status == "Thinking" {
|
||||
@@ -2877,6 +3027,8 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
if self.isEnabled {
|
||||
self.statusText = self.gatewayConnected ? "Ready" : "Offline"
|
||||
}
|
||||
@@ -2909,6 +3061,49 @@ extension TalkModeManager {
|
||||
self.gatewayTalkUsesRealtimeRelay
|
||||
}
|
||||
|
||||
func _test_markNativeFallbackActive(after issue: TalkRuntimeIssue) {
|
||||
self.markNativeFallbackActive(after: issue)
|
||||
}
|
||||
|
||||
func _test_recordRealtimeIssue(_ issue: TalkRuntimeIssue) {
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.gatewayTalkActiveModeTitle = "Realtime unavailable"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
}
|
||||
|
||||
func _test_handleRealtimeRelayStatus(_ status: String) {
|
||||
self.handleRealtimeRelayStatus(status)
|
||||
}
|
||||
|
||||
func _test_prepareRealtimeRelayStart() {
|
||||
self.prepareRealtimeRelayStart()
|
||||
}
|
||||
|
||||
func _test_realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue {
|
||||
self.realtimeIssue(from: error, phase: phase)
|
||||
}
|
||||
|
||||
func _test_hasPendingRealtimeIssue() -> Bool {
|
||||
self.pendingRealtimeIssue != nil
|
||||
}
|
||||
|
||||
func _test_gatewayTalkActiveModeTitle() -> String {
|
||||
self.gatewayTalkActiveModeTitle
|
||||
}
|
||||
|
||||
func _test_gatewayTalkActiveModeSubtitle() -> String? {
|
||||
self.gatewayTalkActiveModeSubtitle
|
||||
}
|
||||
|
||||
func _test_gatewayTalkLastIssueText() -> String? {
|
||||
self.gatewayTalkLastIssueText
|
||||
}
|
||||
|
||||
func _test_gatewayTalkCurrentFallbackIssue() -> TalkRuntimeIssue? {
|
||||
self.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
func _test_seedTranscript(_ transcript: String) {
|
||||
self.lastTranscript = transcript
|
||||
self.lastHeard = Date()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Sources/Calendar/CalendarService.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Capabilities/NodeCapabilityRouter.swift
|
||||
Sources/Chat/AppleReviewDemoChatTransport.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/Contacts/ContactsService.swift
|
||||
Sources/Device/DeviceInfoHelper.swift
|
||||
@@ -20,6 +21,7 @@ Sources/Design/SettingsProTab.swift
|
||||
Sources/Design/SettingsProTabSupport.swift
|
||||
Sources/Design/SettingsProTabSections.swift
|
||||
Sources/Design/SettingsProTabActions.swift
|
||||
Sources/Design/TalkRuntimeIssueBanner.swift
|
||||
Sources/Design/CommandCenterSupport.swift
|
||||
Sources/Design/AgentProTab+Overview.swift
|
||||
Sources/Design/AgentProTab+Destinations.swift
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user