mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afeb51806f | ||
|
|
af1d9151ec | ||
|
|
f6f90dc279 | ||
|
|
92539bbd1b | ||
|
|
311a613c0c | ||
|
|
e76d093c57 | ||
|
|
7a95eed7c2 | ||
|
|
96795f99d1 | ||
|
|
e6b77215fd | ||
|
|
403bfcab50 | ||
|
|
df5ee95898 | ||
|
|
14396181b8 | ||
|
|
0076b8816a | ||
|
|
99928cebeb | ||
|
|
de1fb49951 | ||
|
|
5c8e238eef | ||
|
|
68a1a47322 | ||
|
|
0f5a7f143c | ||
|
|
4a6dcda402 | ||
|
|
6e6c32627b | ||
|
|
d30e6f1c37 | ||
|
|
7c64489c69 | ||
|
|
73f622326f | ||
|
|
95cf355b25 | ||
|
|
54d5fe65b4 | ||
|
|
9237eb82b6 | ||
|
|
62a95e31b0 | ||
|
|
a1bbcd7194 | ||
|
|
b6f0a8ebbd | ||
|
|
23ce2a7af7 | ||
|
|
c880b7ad73 | ||
|
|
499f7f2e44 | ||
|
|
53086911fd | ||
|
|
850c4b5db5 | ||
|
|
c57062b788 | ||
|
|
26e5b6ad01 | ||
|
|
010b761286 | ||
|
|
519f94232f | ||
|
|
d0e0d4e4f3 | ||
|
|
d451290d46 | ||
|
|
29c859b7b0 | ||
|
|
91035ddd8e | ||
|
|
4eb0b6e1c3 | ||
|
|
3cff8c9ffc | ||
|
|
0003bef4e7 | ||
|
|
b82a97dd1d | ||
|
|
6968f1dca3 | ||
|
|
ecd14ab332 | ||
|
|
174ea10844 | ||
|
|
c87628398f | ||
|
|
e02e62c310 | ||
|
|
fee799a7ed | ||
|
|
1e7379c29d | ||
|
|
0b579f2110 | ||
|
|
1ea049d9c2 | ||
|
|
8e5d9e815d | ||
|
|
cba9390d2b | ||
|
|
34ca109ec4 | ||
|
|
4c7386f921 | ||
|
|
224c6e61d3 | ||
|
|
b44e9af7e5 | ||
|
|
e7a8a1320d | ||
|
|
7f51ad178b | ||
|
|
8664212c45 | ||
|
|
4ec73b2441 | ||
|
|
acd567721c | ||
|
|
a72c392b68 | ||
|
|
2e15cc9d59 | ||
|
|
eaba743bc5 | ||
|
|
157809c4d7 | ||
|
|
1447b62908 | ||
|
|
d3b5111ca8 | ||
|
|
a306a07f82 | ||
|
|
15101b87f4 | ||
|
|
6f989b09c5 | ||
|
|
110c5ece63 | ||
|
|
7837083eba | ||
|
|
f14ce69798 | ||
|
|
bfda54539b | ||
|
|
ee20852d1b | ||
|
|
a7a256adc1 | ||
|
|
0466fef9d6 | ||
|
|
dc68694cdb | ||
|
|
a1029786ad | ||
|
|
50a658e434 | ||
|
|
9a4f56e4cf | ||
|
|
06634777c1 | ||
|
|
7bd9c13f01 | ||
|
|
107456d605 | ||
|
|
f19c00f141 | ||
|
|
2ba719ebab | ||
|
|
7cb64f55e0 | ||
|
|
201423138e | ||
|
|
787fe8a0bf | ||
|
|
9d0890f92a | ||
|
|
79efac68a3 | ||
|
|
608527502a | ||
|
|
61baf002e9 | ||
|
|
0f89cec5ea | ||
|
|
6482375b72 | ||
|
|
5f2eac7053 | ||
|
|
f61118b8fe | ||
|
|
8ecdc894a8 | ||
|
|
faab493b02 | ||
|
|
d7bd736a85 | ||
|
|
4e3a1aea57 | ||
|
|
afa8b710f4 | ||
|
|
02702c8378 | ||
|
|
863d07be20 | ||
|
|
2b105dd575 | ||
|
|
7902c9321d | ||
|
|
7e2a14ef91 | ||
|
|
7135b32e14 | ||
|
|
acc954a506 | ||
|
|
4e440421af | ||
|
|
a9928d3ef5 | ||
|
|
74b9dd77b6 | ||
|
|
1e8fc36577 | ||
|
|
ec814165dd | ||
|
|
4b4b976fcd | ||
|
|
aad432d693 | ||
|
|
a6807da7f8 | ||
|
|
bf24e28377 | ||
|
|
31d3b7c0e7 | ||
|
|
63fbad01d3 | ||
|
|
d2e91a8a11 | ||
|
|
aeb6431f73 | ||
|
|
98ee87101b | ||
|
|
27b86732c2 | ||
|
|
912bbd86ae | ||
|
|
939975881e | ||
|
|
746d06800a | ||
|
|
bd31c9e10e | ||
|
|
f55083fa62 | ||
|
|
23b429d3d5 | ||
|
|
a87adced3c | ||
|
|
573f58a1c2 | ||
|
|
b7d1e95c50 | ||
|
|
a8fcd8c1c8 | ||
|
|
3307be74d9 | ||
|
|
639736f5fa | ||
|
|
2c7ad4973e | ||
|
|
eefcb09038 | ||
|
|
02a1741a3d | ||
|
|
0f2ce6529f | ||
|
|
9bc6344fa1 | ||
|
|
dbced6638a | ||
|
|
253faa132f | ||
|
|
606d782379 | ||
|
|
ad3d06343d | ||
|
|
ac5f37f40b | ||
|
|
9b5c870bd3 | ||
|
|
2b29e2d92f | ||
|
|
bafd9db24d | ||
|
|
8ff0b64884 | ||
|
|
8de3dba0ff | ||
|
|
d2ec3a1d68 | ||
|
|
9b70f121c0 | ||
|
|
a71a850213 | ||
|
|
95d06df682 | ||
|
|
addd1125d9 | ||
|
|
08cf2b4585 | ||
|
|
052708f6f4 | ||
|
|
554e881a69 | ||
|
|
4e6db19690 | ||
|
|
044936cd5b | ||
|
|
48f9b9418a | ||
|
|
8ac66a4b18 | ||
|
|
b99c08a52c | ||
|
|
61e943beb3 | ||
|
|
9e9d9e6e44 | ||
|
|
f7c6048fa4 | ||
|
|
42ee48f214 | ||
|
|
6acdf3edb3 | ||
|
|
5d63d8e29a | ||
|
|
26ef771c7a | ||
|
|
bf53799e8a | ||
|
|
ee9d976622 | ||
|
|
d5ba463964 | ||
|
|
4762cd33d6 | ||
|
|
9838a27280 | ||
|
|
484bca854f | ||
|
|
9ff0b20589 | ||
|
|
a2eaf80601 | ||
|
|
cf2a8b061b | ||
|
|
fcd6180976 | ||
|
|
6cbffaa78b | ||
|
|
9ee254b2ec | ||
|
|
cae565c158 | ||
|
|
3255803636 | ||
|
|
c8ac697010 | ||
|
|
8d8bccd334 | ||
|
|
3b0b9c0546 | ||
|
|
3608146829 | ||
|
|
3b3c9dba57 | ||
|
|
e1c2f46e8a | ||
|
|
798c7ce85a | ||
|
|
2ade3cd50d | ||
|
|
da348d552e | ||
|
|
96431bc31f | ||
|
|
2b56585f8f | ||
|
|
4ea38a7a22 | ||
|
|
5b8a9c0cd5 | ||
|
|
1f4d811ac3 | ||
|
|
7cab46ac3a | ||
|
|
0a38be6852 | ||
|
|
0ae11ebdef | ||
|
|
fdb99115d8 | ||
|
|
2bcac618d7 | ||
|
|
25184026bc | ||
|
|
3a1d04ae23 | ||
|
|
e73221c8b7 | ||
|
|
bd9923d644 | ||
|
|
6892b5a035 | ||
|
|
885ad5627f | ||
|
|
e702eb082e | ||
|
|
ef1d287fc0 | ||
|
|
ebd73457da | ||
|
|
3f037fa745 | ||
|
|
f83b84b896 | ||
|
|
be59e66976 | ||
|
|
d924d998a0 | ||
|
|
8b04b164dc | ||
|
|
118fe34378 | ||
|
|
170771fc3a | ||
|
|
06fc964bc9 | ||
|
|
6643299111 | ||
|
|
72ce3d8d93 | ||
|
|
0aad3bd186 | ||
|
|
1858974dc7 | ||
|
|
ac2f6695c5 | ||
|
|
69d0185ff6 | ||
|
|
4d8caaa944 | ||
|
|
5e40ed682b | ||
|
|
1284d1c6c1 | ||
|
|
3093f03585 | ||
|
|
f39f06896f | ||
|
|
cb1a0bfd18 | ||
|
|
c757d24483 | ||
|
|
c38b0801cc | ||
|
|
8d42210d7c | ||
|
|
191772931f | ||
|
|
56a53237f4 | ||
|
|
c045230307 | ||
|
|
946255ce89 | ||
|
|
397605ab13 | ||
|
|
a753893797 | ||
|
|
23bf5dd464 | ||
|
|
c600b12693 | ||
|
|
d2cf3addc7 | ||
|
|
f1951c5d1f | ||
|
|
82a9d6829e | ||
|
|
7981c96d70 | ||
|
|
ac1b2ad45d | ||
|
|
f00bb1ea85 | ||
|
|
68b7ea6c07 | ||
|
|
d239e346f6 | ||
|
|
5527e8e805 | ||
|
|
cf7ee9e400 | ||
|
|
c580472167 | ||
|
|
462fd5e281 | ||
|
|
8ddad32b4b | ||
|
|
2bc0d2f010 | ||
|
|
97a6d617f2 | ||
|
|
13a86545d3 | ||
|
|
8d67305f88 | ||
|
|
052fdc25ca | ||
|
|
754c95c038 | ||
|
|
78686f68a3 | ||
|
|
6d43964c5b | ||
|
|
4df899342b | ||
|
|
0a227840d1 | ||
|
|
9c55693279 | ||
|
|
82e6002d1d | ||
|
|
8b1e5c8700 | ||
|
|
21e1965d5a | ||
|
|
e7d6b0f5c6 | ||
|
|
f55a0c6f0d | ||
|
|
69305cbf5c | ||
|
|
0b3f501db0 | ||
|
|
61e10c95fc | ||
|
|
40b32c421e | ||
|
|
5d6d8c8504 | ||
|
|
af13bc76be | ||
|
|
e0bdcf70df | ||
|
|
ff379b4244 | ||
|
|
d60d0b0ae8 | ||
|
|
e7334831e9 | ||
|
|
d25edd2541 | ||
|
|
1f23350262 | ||
|
|
59fa0357fd | ||
|
|
6f15baefac | ||
|
|
60c787e02e | ||
|
|
185731e586 | ||
|
|
dd896f9ea1 | ||
|
|
bddb8000d2 | ||
|
|
dad8195b3a | ||
|
|
224321fbd0 | ||
|
|
46e50ee5a5 | ||
|
|
d98610825a | ||
|
|
d52fec85a3 | ||
|
|
046f575e2d | ||
|
|
06e6075d88 | ||
|
|
c675d754cc | ||
|
|
c8ccc01740 | ||
|
|
5f6315c079 | ||
|
|
b2fa320188 | ||
|
|
7e4f600b03 | ||
|
|
be6df9e2f5 | ||
|
|
5a6a93eced | ||
|
|
041585203b | ||
|
|
f3c536d2ff | ||
|
|
e8f7c150b4 | ||
|
|
2250de1ab9 | ||
|
|
4d767f3db4 | ||
|
|
192c53357a | ||
|
|
ab9951af9e | ||
|
|
615d5a9981 | ||
|
|
adf228b344 | ||
|
|
d308d0de1b | ||
|
|
9a69481e8d | ||
|
|
40259bea22 | ||
|
|
2abd87e646 | ||
|
|
5401c3538b | ||
|
|
0eb641c960 | ||
|
|
9656efb5fe | ||
|
|
c3593566b3 | ||
|
|
43cc7a315c | ||
|
|
3556ffe771 | ||
|
|
673996e9fc | ||
|
|
5ea0ee26fc | ||
|
|
cb494af294 | ||
|
|
b09d045d14 | ||
|
|
01414ef99b | ||
|
|
aaaf863c11 | ||
|
|
a0ce934d1e | ||
|
|
284e65d912 | ||
|
|
2a9c3f5ede | ||
|
|
d7f49d1f54 | ||
|
|
555af11724 | ||
|
|
633d602718 | ||
|
|
02bd3d95b4 | ||
|
|
0da7fe8326 | ||
|
|
c9a358c779 | ||
|
|
0256e836ec | ||
|
|
1c19a04833 | ||
|
|
1d30640be6 | ||
|
|
176e41c0ad | ||
|
|
cb52340db8 | ||
|
|
cf66ff24fa | ||
|
|
bca450eff0 | ||
|
|
4bea752508 | ||
|
|
758a9e1fe1 | ||
|
|
94166c3c08 | ||
|
|
68460194c4 | ||
|
|
832fe95906 | ||
|
|
f7bfa62b7f | ||
|
|
fd9338fd61 | ||
|
|
0f24a334f6 | ||
|
|
d6d36a9c05 | ||
|
|
aae563c0b3 | ||
|
|
72e6e360bb | ||
|
|
715630d366 | ||
|
|
9d5d2a3c1c | ||
|
|
356037ae62 | ||
|
|
fae1bf2c74 | ||
|
|
ba611da664 | ||
|
|
097a49d2aa | ||
|
|
bbc2d87404 | ||
|
|
d916d0c78d | ||
|
|
76852fe134 | ||
|
|
53dea11006 | ||
|
|
808e24ff2e | ||
|
|
bc9b7f2abb | ||
|
|
59dc039d33 | ||
|
|
0dd1923ab1 | ||
|
|
df11ddd1b7 | ||
|
|
1e3ef27a31 | ||
|
|
b9d1ffc606 | ||
|
|
e4a72782c0 | ||
|
|
c545e6ae26 | ||
|
|
5efbb7384f | ||
|
|
f54c763538 | ||
|
|
427658bc86 | ||
|
|
8a8daad590 | ||
|
|
1976a217eb | ||
|
|
a81a46583f | ||
|
|
af3d3805e5 | ||
|
|
20e99f6913 | ||
|
|
35d2476974 | ||
|
|
20ccf46d2b | ||
|
|
6a9d1acb8c | ||
|
|
3ee319d763 | ||
|
|
4f1769a05f | ||
|
|
c9df209e92 | ||
|
|
aa5ff7bd4b | ||
|
|
a11719a643 | ||
|
|
759986a138 | ||
|
|
009017ed51 | ||
|
|
995bcf432e | ||
|
|
e8a8a4e120 | ||
|
|
5e7cdac33e | ||
|
|
534dcd85d6 | ||
|
|
e2b8f08a9c | ||
|
|
2f0cf143bc | ||
|
|
fb320db1f3 | ||
|
|
59ff9af911 | ||
|
|
0b12cf418a | ||
|
|
fde0c93879 | ||
|
|
d6a3cb7c38 | ||
|
|
ad05ecc9b7 | ||
|
|
76bc768da5 | ||
|
|
e5b6e08471 | ||
|
|
ed818c1b05 | ||
|
|
2eb11f8c70 | ||
|
|
0993db1078 | ||
|
|
7c8d679c7c | ||
|
|
3e824fc319 | ||
|
|
eb1500ef8f | ||
|
|
8ceb23fb6e | ||
|
|
23ea7aa2e5 | ||
|
|
fe0680a3ce | ||
|
|
aa1f951797 | ||
|
|
63d12145bc | ||
|
|
57e80a942f | ||
|
|
80130b0aed | ||
|
|
33b3a4a6bf | ||
|
|
0b6f5c9d0f | ||
|
|
0b80deec92 | ||
|
|
70da2df7fc | ||
|
|
4fda67d03c | ||
|
|
950bba82a9 | ||
|
|
3fe39f7c47 | ||
|
|
4204d61212 | ||
|
|
45d8859075 | ||
|
|
615544e2b4 | ||
|
|
86aa67279e | ||
|
|
6b788b43fb | ||
|
|
9008ab1a03 | ||
|
|
5d7651430f | ||
|
|
6641a92f11 | ||
|
|
7da9b0acd0 | ||
|
|
dde60a4246 | ||
|
|
f6b7b429d9 | ||
|
|
63575c9e06 | ||
|
|
d9dd4ab55c | ||
|
|
b03f7f6ebf | ||
|
|
fef4c09ef7 | ||
|
|
f6500d8782 | ||
|
|
5c9209b1b2 | ||
|
|
04abf2469d | ||
|
|
61ae3b77b9 | ||
|
|
85fd0a2417 | ||
|
|
2ae7d211ab | ||
|
|
6e4e2ed348 | ||
|
|
7754911ac1 | ||
|
|
a015f7fea4 | ||
|
|
1b6868ab92 | ||
|
|
8fcf663b3a | ||
|
|
96518c298c | ||
|
|
217e01126a | ||
|
|
08177734e3 | ||
|
|
6f059b850a | ||
|
|
cef150d8fe | ||
|
|
5ccacbc5ee | ||
|
|
667451bcb2 | ||
|
|
51a152d7cc | ||
|
|
93451f819d | ||
|
|
4ecd3080f8 | ||
|
|
84e2daf4f4 | ||
|
|
4411b5d486 | ||
|
|
9ae6bf8ba3 | ||
|
|
74b33fbfda | ||
|
|
ed9d9b91e1 | ||
|
|
02e05b6d76 | ||
|
|
369bfda9af | ||
|
|
34b5da781e | ||
|
|
8c7a32a361 | ||
|
|
7f1c94372e | ||
|
|
f0a57255fc | ||
|
|
23cdda5e0b | ||
|
|
2af3af95a3 | ||
|
|
28ff9a4887 | ||
|
|
12c3aac637 | ||
|
|
0536f049ab | ||
|
|
8a34a55cfc | ||
|
|
cb81cb5a6d | ||
|
|
ee975ed517 | ||
|
|
55f259646f | ||
|
|
41f3780bd0 | ||
|
|
bf432d905b | ||
|
|
07502b7f78 | ||
|
|
10309ab4c4 | ||
|
|
cb8c6b63b0 | ||
|
|
7aa41f537b | ||
|
|
baa8cbaa05 | ||
|
|
5dac4f6d67 | ||
|
|
0526a88dad | ||
|
|
cc2019b5cc | ||
|
|
f2ea581a47 | ||
|
|
0d54d0f374 | ||
|
|
d363773727 | ||
|
|
3370155d13 | ||
|
|
7056884afc | ||
|
|
65f18ea656 | ||
|
|
91395b769d | ||
|
|
2f606fad14 | ||
|
|
5afceb9356 | ||
|
|
ca421bca5f | ||
|
|
216da47c47 | ||
|
|
326cfc6ce4 | ||
|
|
ec82916e33 | ||
|
|
029b717ba3 | ||
|
|
54922b9760 | ||
|
|
22317b8cdf | ||
|
|
d8c4f2f07a | ||
|
|
f60ebbe231 | ||
|
|
28c6ae72f3 | ||
|
|
66dadfc3a0 | ||
|
|
256a62fd40 | ||
|
|
d4aadbbda5 | ||
|
|
fb364bef87 | ||
|
|
9af091209e | ||
|
|
dbdc2482b4 | ||
|
|
ba8ccc0085 | ||
|
|
e3a7ad9a73 | ||
|
|
135abaaa9f | ||
|
|
d7783747e7 | ||
|
|
5a60b74c2a | ||
|
|
4debe25ef4 | ||
|
|
e11741c13a | ||
|
|
e3a7d38f1e | ||
|
|
43f42a17eb | ||
|
|
c1f8fe5e17 | ||
|
|
a5300f997b | ||
|
|
845f883d89 | ||
|
|
5da004b09c | ||
|
|
a29db659e8 | ||
|
|
33f50d4bba | ||
|
|
1be1b579e1 | ||
|
|
153f0d0cdb | ||
|
|
6ddb595366 | ||
|
|
8596273b24 | ||
|
|
d9374a4015 | ||
|
|
736ba3ad75 | ||
|
|
80d033fa80 | ||
|
|
c4a11bf379 | ||
|
|
7abf3e72fb | ||
|
|
e50755589b | ||
|
|
2495efc23e | ||
|
|
89d4c20a0a | ||
|
|
8d5c0cbc53 | ||
|
|
3167eaa54f | ||
|
|
8eeb379910 | ||
|
|
506a5f48db | ||
|
|
15552a3c1d | ||
|
|
6a0b5a94a6 | ||
|
|
5d7f726096 | ||
|
|
ec81fd702e | ||
|
|
6bbb66efe7 | ||
|
|
105ff37131 | ||
|
|
a430ea8900 | ||
|
|
1b2a465923 | ||
|
|
29577e091e | ||
|
|
c103c2cd05 | ||
|
|
06a440de75 | ||
|
|
0416e312ff | ||
|
|
3818c6cafc | ||
|
|
2ba4ce8821 | ||
|
|
b193d426b3 | ||
|
|
5b78ad1889 | ||
|
|
75a32c5331 | ||
|
|
1b04e2ff72 | ||
|
|
a93440ff3e | ||
|
|
54215902fd | ||
|
|
2f2d0194c6 | ||
|
|
5dd5efce96 | ||
|
|
ce3c5103fd | ||
|
|
b1326ef41e | ||
|
|
5ae787efd2 | ||
|
|
948cd3e176 | ||
|
|
e3b4d2a1a9 | ||
|
|
a8c91c4c11 | ||
|
|
0456f97f64 | ||
|
|
5873ebee69 | ||
|
|
905977386e | ||
|
|
237bdbddfb | ||
|
|
a0dcd1f90e | ||
|
|
d4499008a7 | ||
|
|
d75a9d8d52 | ||
|
|
b3130ec1fd | ||
|
|
ec0cbda3a9 | ||
|
|
be7c7efb4b | ||
|
|
f3cdb9d3e1 | ||
|
|
d87ddb54b7 | ||
|
|
89b9a296a1 | ||
|
|
dcc82866c7 | ||
|
|
1ba4dbd26a | ||
|
|
9d3be13f83 | ||
|
|
0c96d3c863 | ||
|
|
cda3954506 | ||
|
|
1349b9d8ab | ||
|
|
9dc723c9f6 | ||
|
|
4b54f5e414 | ||
|
|
a135b0ca81 | ||
|
|
234e624e18 | ||
|
|
d4b97d543f | ||
|
|
bd6ec239c9 | ||
|
|
1b910f84a3 | ||
|
|
6d79fe9086 | ||
|
|
dde3588065 | ||
|
|
77cf4c83b8 | ||
|
|
f6aca44764 | ||
|
|
b469f9e707 | ||
|
|
8b1b01e351 | ||
|
|
8c2512cc1d | ||
|
|
b9f6442085 | ||
|
|
2940ad690e | ||
|
|
3967a7160e | ||
|
|
b8637e133b | ||
|
|
528c997cd2 | ||
|
|
0ec3d67eeb | ||
|
|
48f06c4276 | ||
|
|
0d884e0b79 | ||
|
|
a40aaa7614 | ||
|
|
6f25d1ecb2 | ||
|
|
605c481fad | ||
|
|
f4f9965855 | ||
|
|
10bdacf625 | ||
|
|
24a2d9f47c | ||
|
|
c9e7a3659b | ||
|
|
74868106f0 | ||
|
|
a1cd445a78 | ||
|
|
56a334f44d | ||
|
|
fa010d90ce | ||
|
|
3401643586 | ||
|
|
7cfd4238c5 | ||
|
|
bf54e8c45c | ||
|
|
ac95619904 | ||
|
|
8cef3524a0 | ||
|
|
d18cab405a | ||
|
|
fd38efb48d | ||
|
|
f3470b2e4f | ||
|
|
671dd22232 | ||
|
|
e4a7f4399a | ||
|
|
29715aeeff | ||
|
|
112618ce41 | ||
|
|
df9f4d932d | ||
|
|
eb067be473 | ||
|
|
03d9638d89 | ||
|
|
ed9b285bcb | ||
|
|
025c095484 | ||
|
|
8026a8c685 | ||
|
|
b99ffc8e01 | ||
|
|
49034f6ab8 | ||
|
|
4b2eb60264 | ||
|
|
81bde42d3e | ||
|
|
40a2ee3bdf | ||
|
|
e6115f6743 | ||
|
|
c32c421644 | ||
|
|
65feb79e0c | ||
|
|
af0ca59cc3 | ||
|
|
604b73c057 | ||
|
|
c8140dce10 | ||
|
|
f450f61113 | ||
|
|
2b5f68bd17 | ||
|
|
d106710082 | ||
|
|
9606bebe3e | ||
|
|
f3a638cd8a | ||
|
|
1167d6ef15 | ||
|
|
14fb425e94 | ||
|
|
6e4a756531 | ||
|
|
d3a697f9e4 | ||
|
|
d138d8bcc7 | ||
|
|
1be16fc92c | ||
|
|
238cdebcb3 | ||
|
|
ab33bad6bd | ||
|
|
fa292b0f34 | ||
|
|
59bb5e8f81 | ||
|
|
b2b0b99f03 | ||
|
|
8882478ade | ||
|
|
5968a44f96 | ||
|
|
76c56778a6 | ||
|
|
199442e40f | ||
|
|
eebc3ee919 | ||
|
|
bc89a87cba | ||
|
|
0acc680a2c | ||
|
|
c987c8cc74 | ||
|
|
4f23e75c2b | ||
|
|
483d536214 | ||
|
|
aaa65796e1 | ||
|
|
60a203456c | ||
|
|
584492567d | ||
|
|
030e780fe9 | ||
|
|
8f489b5022 | ||
|
|
da7b4bd128 | ||
|
|
009c4af0c9 | ||
|
|
2d254a08e4 | ||
|
|
d8ce905c16 | ||
|
|
ce79c6d354 | ||
|
|
1a113589b8 | ||
|
|
b31b711884 | ||
|
|
0889e925a4 | ||
|
|
cdf7aabde2 | ||
|
|
90a3c2892c | ||
|
|
b79c3e4fbf | ||
|
|
ad640fd41f | ||
|
|
656ff9b2ca | ||
|
|
c60c33f5be | ||
|
|
a05af40bc4 | ||
|
|
ece0d4e073 | ||
|
|
c87781577f | ||
|
|
5b81febdab | ||
|
|
83144f4013 | ||
|
|
ab5e0652a9 | ||
|
|
27356b6a73 | ||
|
|
9fe85032d4 | ||
|
|
4bed5f2233 | ||
|
|
a3047b38d5 | ||
|
|
edacf1a8c2 | ||
|
|
9c27591edf | ||
|
|
9280fa1771 | ||
|
|
ec370d75b9 | ||
|
|
624cb84f9e | ||
|
|
95407074ef | ||
|
|
acb19f4eeb | ||
|
|
13f7c041de | ||
|
|
057856b3fb | ||
|
|
07d34a3d25 | ||
|
|
7ee1d00223 | ||
|
|
1f347727c7 | ||
|
|
2f916b7e76 | ||
|
|
f7049182ec | ||
|
|
b99886d001 | ||
|
|
e8447a7a77 | ||
|
|
f0cdc2892a | ||
|
|
1f390dd0e8 | ||
|
|
9efaffd28c | ||
|
|
34cd2b99d2 | ||
|
|
2129bb0569 | ||
|
|
7720361e39 | ||
|
|
7e92a3cc3a | ||
|
|
ced56ef72d | ||
|
|
535993a271 | ||
|
|
0cfc3f021d | ||
|
|
6279485ad7 | ||
|
|
8e9d1c41fd | ||
|
|
9349ea6916 | ||
|
|
c13a3d1054 | ||
|
|
806a3df631 | ||
|
|
948b2b45d2 | ||
|
|
f3525db383 | ||
|
|
1466e44233 | ||
|
|
99c09ae7e3 | ||
|
|
0ef88e9064 | ||
|
|
5fcae79fa0 | ||
|
|
e17f31b76e | ||
|
|
3e520a9b2d | ||
|
|
0987f47623 | ||
|
|
bf159d0b56 | ||
|
|
b16dba2dbb | ||
|
|
3bafeb7bbb | ||
|
|
8c6a425e78 | ||
|
|
d519a7620e | ||
|
|
edc500849d | ||
|
|
158eeffcde | ||
|
|
12b64da913 | ||
|
|
b53310f214 | ||
|
|
1d564bd4d5 | ||
|
|
0028c4883f | ||
|
|
da5b171fc4 | ||
|
|
92b6b4ae4e | ||
|
|
e72e5020ed | ||
|
|
ff55d5c0e3 | ||
|
|
4a46b81b9a | ||
|
|
2163c0889b | ||
|
|
656862d775 | ||
|
|
2dd6fc6ddf | ||
|
|
e6fc472145 | ||
|
|
b093abac8f | ||
|
|
a2911e81e0 | ||
|
|
bc4e826a2b | ||
|
|
7e7c7eda09 | ||
|
|
5bac71fd7b | ||
|
|
6de3bcbab3 | ||
|
|
5eec1c8733 | ||
|
|
126acdfa38 | ||
|
|
83cf4dcc4d | ||
|
|
f3c2695179 | ||
|
|
46b78a7dfe | ||
|
|
c58805afea | ||
|
|
7aa97d31ad | ||
|
|
cb66b4895c | ||
|
|
71068eab91 | ||
|
|
6ad1df07ff | ||
|
|
d26572c27c | ||
|
|
a64a6a8aa3 | ||
|
|
76c8a07e57 | ||
|
|
0173f3a913 | ||
|
|
f10a456613 | ||
|
|
658bc7ce46 | ||
|
|
dcf6e19126 | ||
|
|
b61e4a77bd | ||
|
|
2a7852f86a | ||
|
|
05cf7a8b53 | ||
|
|
51eea606e7 | ||
|
|
07b176ac6d | ||
|
|
ce54916648 | ||
|
|
962396cf7e | ||
|
|
ddbbb37d36 | ||
|
|
e19fbdcc00 | ||
|
|
87b40dfe87 | ||
|
|
6a84a4d3b9 | ||
|
|
61ec6a4b75 | ||
|
|
f60c0a74da | ||
|
|
33304cbccd | ||
|
|
23280359a0 | ||
|
|
4997a0f2e1 | ||
|
|
44876d2607 | ||
|
|
917e036e65 | ||
|
|
11b6c52bf8 | ||
|
|
8ecdf392ad | ||
|
|
f2a475a2a5 | ||
|
|
05d2d209a8 | ||
|
|
b9fc0a51ea | ||
|
|
b11f474de1 | ||
|
|
9eff550330 | ||
|
|
47166a3792 | ||
|
|
287d0804b9 | ||
|
|
82f89adb5a | ||
|
|
48f7af9bbb | ||
|
|
7eb4f2e650 | ||
|
|
f7a47d4a6d | ||
|
|
4fbc385e2e | ||
|
|
1e7a4f10f3 | ||
|
|
bf3168e012 | ||
|
|
b71d11cf5a | ||
|
|
d68ed3b8a6 | ||
|
|
d3dd9189cb | ||
|
|
2392e4936f | ||
|
|
b535111280 | ||
|
|
559b71d772 | ||
|
|
e36dd55905 | ||
|
|
8dbb673f51 | ||
|
|
d692e25ea4 | ||
|
|
4da0a184c3 | ||
|
|
bb4dbca0e0 | ||
|
|
c29a39cd9a | ||
|
|
94817d4c9d | ||
|
|
eb9200f5a5 | ||
|
|
043e618342 | ||
|
|
f1532d55dc | ||
|
|
f853ac3c02 | ||
|
|
d3c5f671f5 | ||
|
|
051239b633 | ||
|
|
faacd02423 | ||
|
|
1b15d6a25c | ||
|
|
28325fce0e | ||
|
|
037cabee13 | ||
|
|
7ab2f40411 | ||
|
|
ba7b19e26a | ||
|
|
50313c8d15 | ||
|
|
1b253a0ee2 | ||
|
|
13a5bfdd6c | ||
|
|
05f2c32388 | ||
|
|
b977698273 | ||
|
|
83ceb82290 | ||
|
|
5026ccdb67 | ||
|
|
c9c1e2de92 | ||
|
|
27b5880041 | ||
|
|
2419ca27ea | ||
|
|
3208664815 | ||
|
|
ff45677e29 | ||
|
|
a2072126d8 | ||
|
|
01613306d0 | ||
|
|
a9111d1174 | ||
|
|
8bdc4d35b9 | ||
|
|
9ada141766 | ||
|
|
6e7f27a73d | ||
|
|
2db7acf6c8 | ||
|
|
881b2f2f26 | ||
|
|
01d9b1730e | ||
|
|
62180a99d6 | ||
|
|
79af368a51 | ||
|
|
f9461704d9 | ||
|
|
803a707f1a | ||
|
|
53d515bb36 | ||
|
|
916457fd26 | ||
|
|
e8e87e819d | ||
|
|
b8bd45c9ad | ||
|
|
bfe398cc89 | ||
|
|
50ff43d8cd | ||
|
|
c486e3d2ee | ||
|
|
32addaecfb | ||
|
|
9e56c32768 | ||
|
|
02b25d8bc2 | ||
|
|
027e7dc083 | ||
|
|
315c95b366 | ||
|
|
e5a48d9c77 | ||
|
|
7ba4295565 | ||
|
|
dffb6973cc | ||
|
|
3569f3a417 | ||
|
|
b0bc394b03 | ||
|
|
f899bdfc0c | ||
|
|
cd08ab4fa8 | ||
|
|
c8ce403f8f | ||
|
|
eb6d6f4b32 | ||
|
|
74c8359bae | ||
|
|
0e72bc79dc | ||
|
|
b51f31b5c0 | ||
|
|
4707413f86 | ||
|
|
6da94c9498 | ||
|
|
200cc9895a | ||
|
|
dcf1ff80c6 | ||
|
|
d5b084e328 | ||
|
|
0398bf0d13 | ||
|
|
91fe06db61 | ||
|
|
85ff986569 | ||
|
|
d9e453a1be | ||
|
|
ad1b457b8e | ||
|
|
0ef1321e83 | ||
|
|
cfd92861e2 | ||
|
|
45367085bf | ||
|
|
a40b10020c | ||
|
|
49c14762b7 | ||
|
|
fea81e3aa5 | ||
|
|
62a8c3f79e | ||
|
|
e89419af24 | ||
|
|
b54e8dcaa3 | ||
|
|
5d1ad2ae8c | ||
|
|
dbb6f83acd | ||
|
|
582f31fd67 | ||
|
|
c202f97035 | ||
|
|
9ae96d8225 | ||
|
|
2909ef120f | ||
|
|
1280c0f759 | ||
|
|
48e5b99067 | ||
|
|
3e625b91d9 | ||
|
|
d02457ee78 | ||
|
|
98a57ded7b | ||
|
|
bc8249c307 | ||
|
|
726036bd15 | ||
|
|
c23d40f74c | ||
|
|
7409342b70 | ||
|
|
dbaea84333 | ||
|
|
bb06f5b4b6 | ||
|
|
be67a498ad | ||
|
|
ad3d742aeb | ||
|
|
6d404d8009 | ||
|
|
341f7209cd | ||
|
|
5584350ff0 |
No files matched your search
@@ -17,7 +17,7 @@ jobs:
|
||||
cache-impact:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
race:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # informational — stdlib vulns need a Go patch release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/accounts/**'
|
||||
- '.github/workflows/deploy-accounts-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/crash-report/**'
|
||||
- '.github/workflows/deploy-crash-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-crash-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npx wrangler deploy
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/forum/**'
|
||||
- '.github/workflows/deploy-forum-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
|
||||
# suite, and a run --metrics-capable agent live.
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -8,7 +8,8 @@ name: Release desktop
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
@@ -42,7 +43,7 @@ jobs:
|
||||
name: cache hit guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -77,7 +78,7 @@ jobs:
|
||||
run:
|
||||
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -229,7 +230,7 @@ jobs:
|
||||
# approve); canary uses the open `canary` environment so maintainers self-serve.
|
||||
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
@@ -260,9 +261,9 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary is R2-only and never appears on the GitHub releases page; the mirror
|
||||
# job picks up the signed dist via the canary-dist artifact below. Stable
|
||||
# publishes a GitHub release as usual.
|
||||
# Canary never appears on the GitHub releases page; the mirror job picks up
|
||||
# the signed dist via the canary-dist artifact below. Stable publishes a
|
||||
# GitHub release as usual.
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
@@ -285,14 +286,14 @@ jobs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # gh release download
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
|
||||
if: ${{ github.repository_owner == 'esengine' }}
|
||||
env:
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
@@ -366,6 +367,53 @@ jobs:
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
.download_page == "https://reasonix.io/#start" and
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
pointer=""
|
||||
else
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
|
||||
done
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" assets/latest.json --clobber
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
name: cache hit guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
# canary dispatches run free.
|
||||
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: cache hit guard
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# `release` environment so only esengine can approve it going public.
|
||||
environment: release
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
@@ -48,3 +48,23 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
|
||||
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
@@ -4,6 +4,15 @@ All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
|
||||
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
|
||||
branch.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Agent runtime defaults now leave both executor and dedicated planner tool-call
|
||||
rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now
|
||||
come from the user/global config only; project `reasonix.toml` does not
|
||||
override them.
|
||||
|
||||
## [1.0.0] — 2026-06-03
|
||||
|
||||
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
|
||||
|
||||
@@ -55,11 +55,14 @@
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek (flash/pro) and MiMo ship as presets;
|
||||
any OpenAI-compatible endpoint is a config entry, not new code. Optionally run
|
||||
- **Multi-model & composable.** DeepSeek ships as a preset; any
|
||||
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
|
||||
two models together (executor + planner) in separate, cache-stable sessions.
|
||||
- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC
|
||||
(MCP-compatible). Built-in tools self-register at compile time.
|
||||
- **Cache-aware context maintenance.** Startup injects a small stable environment
|
||||
summary, stale tool output is snipped/pruned before summary compaction, and the
|
||||
built-in tool schema contract is documented for regression review.
|
||||
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
|
||||
to six targets with one command. The only dependency is a TOML parser.
|
||||
|
||||
@@ -90,10 +93,10 @@ make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
|
||||
|
||||
```sh
|
||||
reasonix setup # config wizard → ./reasonix.toml
|
||||
export DEEPSEEK_API_KEY=sk-... # or let setup save it to the credential store
|
||||
export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env
|
||||
reasonix # then run /init to generate AGENTS.md (project memory)
|
||||
reasonix run "implement the TODOs in main.go"
|
||||
reasonix run --model mimo-pro "add unit tests for this function"
|
||||
reasonix run --model deepseek-pro "add unit tests for this function"
|
||||
echo "explain this code" | reasonix run
|
||||
```
|
||||
|
||||
@@ -116,10 +119,13 @@ Resolution order is **flag > `./reasonix.toml` > the user config file >
|
||||
built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at
|
||||
`~/.reasonix/config.toml` on macOS/Linux and
|
||||
`%AppData%\reasonix\config.toml` on Windows. See
|
||||
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details. Secrets come from the environment via `api_key_env`, are
|
||||
never written to config files, and new keys default to the OS credential store
|
||||
with a Reasonix-owned file fallback. Project `.env` files are read as a
|
||||
compatibility override, but Reasonix does not write new keys there. Permissions, the sandbox, plugins (MCP), slash
|
||||
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the
|
||||
full `config.toml` / `.env` structure. Provider entries name secrets with
|
||||
`api_key_env`; the secret values themselves live in Reasonix's global
|
||||
`<Reasonix home>/.env`, shared by CLI and desktop. Project `.env` files are not
|
||||
provider-key runtime fallbacks, but still feed workspace-scoped, non-provider
|
||||
`${VAR}` expansion for MCP/plugin settings without importing Reasonix control
|
||||
variables. Permissions, the sandbox, plugins (MCP), slash
|
||||
commands, `@` references, and two-model setup are all in the
|
||||
**[Guide](./docs/GUIDE.md)**.
|
||||
|
||||
@@ -131,6 +137,8 @@ commands, `@` references, and two-model setup are all in the
|
||||
from the desktop app, then use approvals, YOLO, and commands from IM.
|
||||
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
|
||||
data types, and roadmap.
|
||||
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
|
||||
names, read-only flags, and schema snapshot guard.
|
||||
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
|
||||
TypeScript releases to the 1.0 Go rewrite.
|
||||
- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit
|
||||
|
||||
+11
-7
@@ -53,10 +53,12 @@
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
|
||||
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
|
||||
内置工具在编译期自注册。
|
||||
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
|
||||
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
|
||||
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
|
||||
唯一依赖是一个 TOML 解析库。
|
||||
|
||||
@@ -86,10 +88,10 @@ make cross # -> dist/(darwin|linux|windows × amd64|arm64)
|
||||
|
||||
```sh
|
||||
reasonix setup # 配置向导 → ./reasonix.toml
|
||||
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到凭据存储
|
||||
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env
|
||||
reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
|
||||
reasonix run "把 main.go 里的 TODO 实现掉"
|
||||
reasonix run --model mimo-pro "给这个函数补单元测试"
|
||||
reasonix run --model deepseek-pro "给这个函数补单元测试"
|
||||
echo "解释这段代码" | reasonix run
|
||||
```
|
||||
|
||||
@@ -111,10 +113,10 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从
|
||||
**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`,
|
||||
Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见
|
||||
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
|
||||
密钥经环境变量通过 `api_key_env` 注入,绝不写入配置文件;新密钥默认优先保存到系统凭据存储,
|
||||
不可用时才 fallback 到 Reasonix 管理的凭据文件。项目 `.env` 只作为兼容覆盖读取,
|
||||
Reasonix 不会把新密钥写入项目 `.env`。权限、沙盒、插件(MCP)、
|
||||
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml`
|
||||
和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在
|
||||
CLI 与桌面端共用的 Reasonix 全局 `<Reasonix home>/.env`;项目 `.env` 不再作为
|
||||
provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、
|
||||
斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。
|
||||
|
||||
## 文档
|
||||
@@ -124,6 +126,8 @@ Reasonix 不会把新密钥写入项目 `.env`。权限、沙盒、插件(MCP)
|
||||
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
|
||||
Bot,以及 IM 里的审批、YOLO 和命令交互。
|
||||
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
|
||||
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
|
||||
read-only 标记和 schema 快照保护。
|
||||
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
|
||||
- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网
|
||||
(Esc-Esc / `/rewind`)。
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Reasonix security fixes are prioritized for the currently developed Go rewrite
|
||||
and the current 1.x release line.
|
||||
|
||||
| Version or branch | Security support |
|
||||
| --- | --- |
|
||||
| `main-v2` / 1.x releases | Supported |
|
||||
| `v1` / 0.x legacy branch | Critical fixes only, where practical |
|
||||
| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream |
|
||||
|
||||
If you are unsure whether a version is affected, report against the newest
|
||||
released 1.x version and include the exact version or commit you tested.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues privately. Do not open a public issue with exploit
|
||||
details, secrets, crash dumps, or proof-of-concept payloads.
|
||||
|
||||
Preferred reporting path:
|
||||
|
||||
1. Use GitHub private vulnerability reporting for this repository, if available.
|
||||
2. If private reporting is not available to you, open a minimal public issue
|
||||
asking for a private maintainer contact path. Do not include exploit details
|
||||
in that issue.
|
||||
|
||||
Please include:
|
||||
|
||||
- Affected Reasonix version, commit, operating system, and installation method.
|
||||
- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot
|
||||
gateway, MCP plugin, built-in tool, updater, or configuration loading.
|
||||
- Clear reproduction steps using dummy credentials and non-sensitive files.
|
||||
- The expected impact, such as secret disclosure, arbitrary file access,
|
||||
command execution, sandbox escape, authentication bypass, or supply-chain risk.
|
||||
- Any relevant logs with API keys, tokens, local paths, and personal data
|
||||
redacted.
|
||||
|
||||
Do not send real provider API keys, bot credentials, OAuth tokens, private
|
||||
workspace files, or third-party user data.
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
Reasonix is a local coding agent. Many features intentionally operate on the
|
||||
user's local machine and workspace, including file reads, file writes, shell
|
||||
commands, MCP plugins, language servers, bot sessions, and model-provider
|
||||
requests. A finding is security-relevant when it crosses a supported boundary or
|
||||
bypasses an explicit guard.
|
||||
|
||||
Supported boundaries include:
|
||||
|
||||
- Workspace confinement for file operations that are documented or implemented
|
||||
as workspace-scoped.
|
||||
- Permission checks for tool calls, shell commands, file writes, and approvals.
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
|
||||
The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
when they bypass the intended boundary:
|
||||
|
||||
- Reading or writing files outside the configured workspace without explicit
|
||||
local-user intent.
|
||||
- Following symlinks or path traversal to escape workspace confinement.
|
||||
- Running shell commands or external tools without the required permission gate.
|
||||
- Leaking credentials, environment variables, prompt history, local files, or bot
|
||||
messages to logs, model providers, MCP servers, crash reports, or telemetry.
|
||||
- Allowing a website to drive the local HTTP server through CSRF, CORS, or
|
||||
content-type bypasses.
|
||||
- Letting a bot user outside the configured allowlist submit prompts, approve
|
||||
tools, or access a project workspace.
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
in a prompt. This is intentional local functionality, but implementations must
|
||||
preserve these invariants:
|
||||
|
||||
- In workspace-scoped sessions, relative and absolute paths must resolve under
|
||||
the active workspace root before file content is read or attached.
|
||||
- Path traversal such as `..` must not escape the workspace root.
|
||||
- Symlinks must not be usable to bypass the intended workspace boundary.
|
||||
- Unscoped local CLI compatibility must not be exposed to remote, bot, or
|
||||
browser-controlled inputs unless an equivalent workspace boundary is applied.
|
||||
- File content should be size-limited and binary content should not be dumped as
|
||||
prompt text.
|
||||
|
||||
Static analysis alerts about path expressions should be triaged against these
|
||||
rules: user-controlled path data is expected, but the access must either stay
|
||||
inside the configured workspace or be clearly limited to trusted local CLI use.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following reports are usually out of scope unless they demonstrate a bypass
|
||||
of one of the boundaries above:
|
||||
|
||||
- A local user intentionally asks Reasonix to read, edit, or send their own
|
||||
files to a configured model provider.
|
||||
- A local user installs or enables a malicious MCP server, hook, slash command,
|
||||
language server, or shell command and then grants it permission.
|
||||
- A configured model provider, proxy, or MCP server receives data the user
|
||||
intentionally sent to it.
|
||||
- Denial-of-service issues that only affect the local user's own session and do
|
||||
not corrupt files, leak secrets, or bypass permissions.
|
||||
- Issues requiring administrator/root access on the user's machine before
|
||||
interacting with Reasonix.
|
||||
- Vulnerabilities in third-party services, models, proxies, or plugins that are
|
||||
not caused by Reasonix behavior.
|
||||
|
||||
## Coordinated Disclosure
|
||||
|
||||
This is a community-maintained project. Maintainers will make a best-effort
|
||||
assessment, ask follow-up questions when needed, and coordinate fixes before
|
||||
public disclosure for confirmed vulnerabilities.
|
||||
|
||||
Please give maintainers reasonable time to investigate and release a fix before
|
||||
publishing exploit details. If you plan to disclose on a timeline, include that
|
||||
timeline in your initial report.
|
||||
+10
-40
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
fields := splitShellFields(testCmd)
|
||||
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
|
||||
if err != nil {
|
||||
return false, "invalid test command: " + err.Error()
|
||||
}
|
||||
fields := test.Argv
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
if len(test.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), test.Env...)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
|
||||
func splitShellFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inSingle, inDouble := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case inDouble:
|
||||
if c == '"' {
|
||||
inDouble = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
|
||||
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnv(t *testing.T) {
|
||||
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
|
||||
t.Fatalf("REASONIX_E2E_ENV = %q", got)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
|
||||
if !ok {
|
||||
t.Fatalf("runTests failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
|
||||
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
|
||||
if ok {
|
||||
t.Fatal("runTests accepted dynamic env assignment")
|
||||
}
|
||||
if !strings.Contains(out, "invalid test command: shell expansion") {
|
||||
t.Fatalf("output = %q, want shell expansion rejection", out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeE2EFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
+31
-9
@@ -101,7 +101,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
|
||||
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
|
||||
and attaches the current desktop manifest to the matching CLI release for old
|
||||
clients that still ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
@@ -109,9 +111,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
|
||||
an update banner when a newer version is published; **Settings → Software update**
|
||||
has a manual check. Self-update behavior by platform:
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
|
||||
the CLI release line. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
@@ -183,11 +188,18 @@ the **native** webview per OS, so the rough edges are platform-specific. What's
|
||||
handled here, and what to reach for if a target misbehaves:
|
||||
|
||||
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
|
||||
GPU driver. `main.go` sets `WebviewGpuPolicy: OnDemand` to avoid blank/flickering
|
||||
webviews from forced compositing. If artifacts persist, launch with
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before
|
||||
release; the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent
|
||||
there).
|
||||
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
|
||||
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
|
||||
that cannot access `/dev/dri`. If artifacts persist, launch with
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
|
||||
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
|
||||
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
|
||||
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
|
||||
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
|
||||
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
|
||||
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
|
||||
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
|
||||
or `GDK_BACKEND=x11` (forces XWayland).
|
||||
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
|
||||
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
|
||||
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
|
||||
@@ -240,3 +252,13 @@ Settings > Updates > "Share aggregate quality metrics", or by setting
|
||||
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
|
||||
counts and preference buckets; they never include conversations, prompts, keys,
|
||||
paths, base URLs, or file contents.
|
||||
|
||||
When Memory v5 is enabled, the same aggregate metrics pipeline may include only
|
||||
content-free count/size buckets such as injection on/off, compiled-token bucket,
|
||||
IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
|
||||
memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
|
||||
file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
|
||||
is controlled from Settings > General > "Memory v5" and shares the user/global
|
||||
`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
|
||||
CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
|
||||
or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
|
||||
+2040
-339
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -32,7 +33,7 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
|
||||
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path, want string) {
|
||||
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
@@ -132,6 +138,314 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures := tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures == 0 {
|
||||
t.Fatal("autosave failure should be recorded and retried")
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove blocked dir: %v", err)
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures = tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures != 0 {
|
||||
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID == "" || tab.TopicID == originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
found := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == tab.TopicID {
|
||||
found = true
|
||||
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !found {
|
||||
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]interface{}(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, _ := appWithTab(t, path)
|
||||
a.tabs["target_tab"] = &WorkspaceTab{
|
||||
ID: "target_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabOrder = []string{"test_tab", "target_tab"}
|
||||
|
||||
err := a.SetActiveTab("target_tab")
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
||||
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if a.activeTabID != "test_tab" {
|
||||
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
target := filepath.Join(t.TempDir(), "target.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
||||
if err := sess.Save(target); err != nil {
|
||||
t.Fatalf("save target: %v", err)
|
||||
}
|
||||
loaded, err := agent.LoadSession(target)
|
||||
if err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
||||
t.Fatalf("rebind error = %v, want persistence failure", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
||||
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
||||
// longer write the session file — even when it is in flight at the moment the
|
||||
@@ -187,6 +501,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs[survivor.ID] = survivor
|
||||
a.tabOrder = []string{tab.ID, survivor.ID}
|
||||
|
||||
err := a.CloseTab(tab.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
||||
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if _, ok := a.tabs[tab.ID]; !ok {
|
||||
t.Fatal("tab was removed even though its session could not be saved")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
||||
// closing/drain logic is per-tab and must not leak to other tabs.
|
||||
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
@@ -221,3 +563,99 @@ func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "delete-open.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "delete_open",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"delete_open": tab},
|
||||
activeTabID: "delete_open",
|
||||
}
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteSession(path); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_clear_removed_runtime"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trash-open-topic.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
UpdatedAt: time.Now(),
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "trash_open",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
|
||||
tabOrder: []string{"trash_open", "survivor"},
|
||||
activeTabID: "trash_open",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("topic session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
@@ -67,6 +71,12 @@ func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SessionPath == "" {
|
||||
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
|
||||
}
|
||||
if _, err := os.Stat(first.SessionPath); err != nil {
|
||||
t.Fatalf("pre-created blank session should exist: %v", err)
|
||||
}
|
||||
second, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -79,6 +89,85 @@ func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
app.tabs["blank"] = &WorkspaceTab{
|
||||
ID: "blank",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: sessionPath,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabOrder = []string{"blank"}
|
||||
app.activeTabID = "blank"
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.ID != "blank" {
|
||||
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
dir := desktopSessionDir(globalRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
stubPath := filepath.Join(dir, "empty-stub.jsonl")
|
||||
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write empty stub: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
@@ -102,6 +191,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -254,6 +558,36 @@ func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := robustTempDir(t)
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == topic.ID {
|
||||
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
|
||||
}
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession skips the snapshot when the current tab has no real conversation content.
|
||||
|
||||
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
@@ -272,3 +606,105 @@ func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldTopicID := "topic_old"
|
||||
oldTopicTitle := "Old topic"
|
||||
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
|
||||
sess := &agent.Session{}
|
||||
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.Scope = "project"
|
||||
tab.WorkspaceRoot = projectRoot
|
||||
tab.TopicID = oldTopicID
|
||||
tab.TopicTitle = oldTopicTitle
|
||||
tab.SessionPath = oldPath
|
||||
app.projectTreeChangedHook = func() {}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == oldTopicID {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
newPath := ctrl.SessionPath()
|
||||
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
|
||||
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write new session: %v", err)
|
||||
}
|
||||
if !app.maybeAutoTitleTopic(tab) {
|
||||
t.Fatalf("new session should auto-title its fresh topic")
|
||||
}
|
||||
|
||||
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
|
||||
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
|
||||
}
|
||||
newMeta, ok, err := agent.LoadBranchMeta(newPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
|
||||
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
app := NewApp()
|
||||
app.projectTreeChangedHook = func() {}
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.TopicID = "topic_old"
|
||||
tab.TopicTitle = "Old topic"
|
||||
|
||||
// Block desktopConfigDir-backed topic-index writes without affecting the
|
||||
// session directory, which exercises the post-NewSession repair failure path.
|
||||
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir desktop config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
|
||||
t.Fatalf("block desktop config dir: %v", err)
|
||||
}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == "topic_old" {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
|
||||
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
|
||||
}
|
||||
}
|
||||
+2109
-71
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
@@ -73,6 +79,51 @@ func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePastedImage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
|
||||
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
||||
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
@@ -225,3 +276,152 @@ func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
|
||||
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
workspace := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutside := outside
|
||||
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
|
||||
expectedOutside = resolved
|
||||
}
|
||||
expectedDisplayPath := filepath.ToSlash(expectedOutside)
|
||||
if err := os.Chdir(workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || !got.IsDir {
|
||||
t.Fatalf("got %+v, want workspace directory ref", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
|
||||
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
|
||||
}
|
||||
if got.DisplayPath != expectedDisplayPath {
|
||||
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
|
||||
}
|
||||
|
||||
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
|
||||
!strings.Contains(block, "sub/") ||
|
||||
!strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "test/test-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"test"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "External")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
sessionDirB := desktopSessionDir(projectB)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project B sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
|
||||
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
|
||||
oldCtrl := control.New(control.Options{
|
||||
SessionDir: sessionDirB,
|
||||
SessionPath: sessionPathB,
|
||||
WorkspaceRoot: projectB,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "project",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
TopicID: "topic_external_ref",
|
||||
TopicTitle: "External ref",
|
||||
SessionPath: sessionPathA,
|
||||
Ready: true,
|
||||
model: "test/test-model",
|
||||
Ctrl: oldCtrl,
|
||||
sink: &tabEventSink{tabID: "project", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
})
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if tab.Ctrl == oldCtrl {
|
||||
t.Fatal("stale controller was reused for external folder ref")
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
resolver, ok := tab.Ctrl.(interface {
|
||||
ResolveScopedRefs(context.Context, string) (string, []string)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
|
||||
}
|
||||
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,7 @@ type BotConnectionView struct {
|
||||
Model string `json:"model"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Access BotAccessView `json:"access"`
|
||||
Credential BotConnectionCredentialView `json:"credential"`
|
||||
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
|
||||
LastError string `json:"lastError"`
|
||||
@@ -164,6 +166,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(result.UserID),
|
||||
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -518,6 +521,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
|
||||
Label: label,
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(userID),
|
||||
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -545,6 +549,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
if conn.Status == "" {
|
||||
conn.Status = "connected"
|
||||
}
|
||||
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
|
||||
conn.ToolApprovalMode = "ask"
|
||||
}
|
||||
if conn.ID == "" {
|
||||
conn.ID = connectionID(conn.Provider, conn.Domain)
|
||||
}
|
||||
@@ -556,6 +563,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
for i, existing := range c.Bot.Connections {
|
||||
if existing.ID == conn.ID {
|
||||
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
|
||||
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
|
||||
conn.Access = existing.Access
|
||||
}
|
||||
c.Bot.Connections[i] = conn
|
||||
replaced = true
|
||||
break
|
||||
@@ -697,6 +707,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
|
||||
return BotConnectionView{
|
||||
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
|
||||
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
|
||||
Access: botAccessViewFromConfig(conn.Access),
|
||||
Credential: BotConnectionCredentialView{
|
||||
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
|
||||
SecretSet: botCredentialSecretSet(conn),
|
||||
@@ -763,8 +774,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
|
||||
Enabled: view.Enabled,
|
||||
Status: strings.TrimSpace(view.Status),
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
|
||||
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: strings.TrimSpace(view.Credential.AppID),
|
||||
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
|
||||
@@ -873,6 +885,15 @@ func connectionID(provider, domain string) string {
|
||||
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
|
||||
}
|
||||
|
||||
func botInstallAccess(userID string) config.BotAccessConfig {
|
||||
userID = strings.TrimSpace(userID)
|
||||
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
|
||||
if userID != "" {
|
||||
access.Users = []string{userID}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func randomInstallID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const botForwardSendTimeout = 30 * time.Second
|
||||
const botForwardQueueSize = 64
|
||||
|
||||
// ── Forward target ──────────────────────────────────────────────────────────
|
||||
|
||||
// botForwardTarget identifies one remote chat to send forwarded events to.
|
||||
type botForwardTarget struct {
|
||||
ConnID string
|
||||
Domain string
|
||||
ChatID string
|
||||
ChatType bot.ChatType
|
||||
}
|
||||
|
||||
// ── Event forwarder ─────────────────────────────────────────────────────────
|
||||
|
||||
// botEventForwarder implements event.Sink and forwards relevant events to
|
||||
// connected bot channels through the desktopBotRuntime. It is attached to a
|
||||
// tabEventSink when a heartbeat task should push AI output to IM channels.
|
||||
//
|
||||
// It accumulates Text events and sends them as complete messages on TurnDone
|
||||
// (and occasionally during generation when the buffer grows large enough), so
|
||||
// the remote side sees progressive streaming output rather than one big blob.
|
||||
type botEventForwarder struct {
|
||||
runtime *desktopBotRuntime
|
||||
targets []botForwardTarget
|
||||
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
queueMu sync.Mutex
|
||||
queue chan string
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newBotEventForwarder creates a forwarder that sends to all given targets.
|
||||
// runtime may be nil — Emit calls are then no-ops.
|
||||
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
|
||||
f := &botEventForwarder{
|
||||
runtime: runtime,
|
||||
targets: targets,
|
||||
queue: make(chan string, botForwardQueueSize),
|
||||
}
|
||||
go f.run()
|
||||
return f
|
||||
}
|
||||
|
||||
// Emit implements event.Sink. It forwards text and lifecycle events to the
|
||||
// connected bot channels; reasoning, tool dispatch, and other internal events
|
||||
// are dropped to avoid noisy IM output.
|
||||
func (f *botEventForwarder) Emit(e event.Event) {
|
||||
if f.runtime == nil || len(f.targets) == 0 {
|
||||
return
|
||||
}
|
||||
switch e.Kind {
|
||||
case event.TurnStarted:
|
||||
f.mu.Lock()
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
case event.Text:
|
||||
f.mu.Lock()
|
||||
f.buf.WriteString(e.Text)
|
||||
size := f.buf.Len()
|
||||
f.mu.Unlock()
|
||||
// Flush opportunistically when the buffer crosses a threshold, so long
|
||||
// streams (e.g. "tell me three jokes") produce multiple messages.
|
||||
if size >= 400 {
|
||||
f.flush()
|
||||
}
|
||||
|
||||
case event.TurnDone:
|
||||
f.flush()
|
||||
f.Close()
|
||||
|
||||
case event.ApprovalRequest:
|
||||
// The heartbeat turn belongs to the desktop tab controller, not the bot
|
||||
// gateway session, so remote /approve replies cannot satisfy this ID.
|
||||
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
|
||||
text += "\n请回到桌面窗口处理。"
|
||||
f.sendToAll(text)
|
||||
|
||||
case event.AskRequest:
|
||||
var qb strings.Builder
|
||||
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
|
||||
for i, q := range e.Ask.Questions {
|
||||
if i > 0 {
|
||||
qb.WriteString("\n")
|
||||
}
|
||||
qb.WriteString(q.Prompt)
|
||||
}
|
||||
qb.WriteString("\n请回到桌面窗口处理。")
|
||||
f.sendToAll(qb.String())
|
||||
|
||||
case event.Notice:
|
||||
if e.Level == event.LevelWarn {
|
||||
f.sendToAll("⚠️ " + e.Text)
|
||||
}
|
||||
|
||||
case event.CompactionStarted:
|
||||
f.sendToAll("🔄 正在压缩上下文...")
|
||||
}
|
||||
}
|
||||
|
||||
// flush sends the accumulated buffer as one message per target channel.
|
||||
func (f *botEventForwarder) flush() {
|
||||
f.mu.Lock()
|
||||
text := strings.TrimSpace(f.buf.String())
|
||||
if text == "" {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
f.sendToAll(text)
|
||||
}
|
||||
|
||||
// sendToAll dispatches text to every target channel. Errors are logged and
|
||||
// non-fatal; a failed target does not block other targets.
|
||||
func (f *botEventForwarder) sendToAll(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if f.runtime == nil || len(f.targets) == 0 || text == "" {
|
||||
return
|
||||
}
|
||||
f.queueMu.Lock()
|
||||
defer f.queueMu.Unlock()
|
||||
if f.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.queue <- text:
|
||||
default:
|
||||
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) run() {
|
||||
for text := range f.queue {
|
||||
f.sendToAllNow(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) sendToAllNow(text string) {
|
||||
for _, tgt := range f.targets {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
|
||||
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
|
||||
ChatID: tgt.ChatID,
|
||||
ChatType: tgt.ChatType,
|
||||
Text: text,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) Close() {
|
||||
f.closeOnce.Do(func() {
|
||||
f.flush()
|
||||
f.queueMu.Lock()
|
||||
f.closed = true
|
||||
close(f.queue)
|
||||
f.queueMu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type desktopForwardTestAdapter struct {
|
||||
platform bot.Platform
|
||||
name string
|
||||
messages chan bot.InboundMessage
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
sent chan bot.OutboundMessage
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
|
||||
return &desktopForwardTestAdapter{
|
||||
platform: bot.PlatformFeishu,
|
||||
name: "forward-test",
|
||||
messages: make(chan bot.InboundMessage),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
sent: make(chan bot.OutboundMessage, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
|
||||
func (a *desktopForwardTestAdapter) Name() string { return a.name }
|
||||
func (a *desktopForwardTestAdapter) Start(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Stop() error { return nil }
|
||||
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.messages
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
a.once.Do(func() { close(a.entered) })
|
||||
select {
|
||||
case <-a.release:
|
||||
case <-ctx.Done():
|
||||
return bot.SendResult{}, ctx.Err()
|
||||
}
|
||||
a.sent <- msg
|
||||
return bot.SendResult{MessageID: "sent"}, nil
|
||||
}
|
||||
|
||||
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
|
||||
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
|
||||
ID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
Platform: bot.PlatformFeishu,
|
||||
Adapter: adapter,
|
||||
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
return &desktopBotRuntime{gw: gw}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Emit blocked behind slow bot send")
|
||||
}
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("adapter send did not start")
|
||||
}
|
||||
|
||||
forwarder.Close()
|
||||
close(adapter.release)
|
||||
select {
|
||||
case <-adapter.sent:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("queued bot message was not sent after adapter release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
close(adapter.release)
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "approval-1",
|
||||
Tool: "shell",
|
||||
Subject: "run command",
|
||||
}})
|
||||
forwarder.Close()
|
||||
|
||||
select {
|
||||
case msg := <-adapter.sent:
|
||||
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
|
||||
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "桌面") {
|
||||
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("approval notice was not sent")
|
||||
}
|
||||
}
|
||||
+134
-4
@@ -33,6 +33,30 @@ func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
|
||||
}
|
||||
|
||||
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(qq.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformQQ]; !ok {
|
||||
channels[bot.PlatformQQ] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
|
||||
connectionChannels[string(bot.PlatformQQ)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -92,13 +116,33 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
modelName := botruntime.ModelName(cfg, "")
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
Channels: channels,
|
||||
ConnectionChannels: connectionChannels,
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: plan.Enabled,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
@@ -108,6 +152,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
@@ -206,8 +260,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
if !cfg.Bot.Enabled {
|
||||
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
|
||||
}
|
||||
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
|
||||
}
|
||||
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
|
||||
if len(unknown) > 0 {
|
||||
@@ -242,3 +296,79 @@ func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
defer r.mu.Unlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// updateConnectionToolApprovalMode updates a connection's tool approval mode
|
||||
// on the running gateway without restarting. Returns true if updated, false if
|
||||
// the gateway is not running or the connection is unknown.
|
||||
func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.gw == nil {
|
||||
return false
|
||||
}
|
||||
mode = normalizeBotConnectionToolApprovalMode(mode)
|
||||
// Update ConnectionChannels in the internal GatewayConfig so new sessions
|
||||
// pick up the mode. Existing sessions are updated by the gateway directly.
|
||||
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
// SendToAdapter sends a message through the running gateway's adapter
|
||||
// identified by connID. Returns an error if the gateway is not running
|
||||
// or no matching adapter is found.
|
||||
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
r.mu.Lock()
|
||||
gw := r.gw
|
||||
r.mu.Unlock()
|
||||
if gw == nil {
|
||||
return bot.SendResult{}, nil // gateway not running — silent no-op
|
||||
}
|
||||
return gw.SendToAdapter(ctx, connID, domain, msg)
|
||||
}
|
||||
|
||||
// Running returns true if the bot gateway is currently active.
|
||||
func (r *desktopBotRuntime) Running() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.gw != nil
|
||||
}
|
||||
|
||||
// ForwardTargets returns the list of bot forward targets derived from the
|
||||
// current config's bot connections and their session mappings. Each mapping
|
||||
// produces one target (connID + chatID + chatType) for event forwarding.
|
||||
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
var targets []botForwardTarget
|
||||
seen := make(map[botForwardTarget]bool)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
connID := botruntime.ConnectionRuntimeID(conn)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
for _, sm := range conn.SessionMappings {
|
||||
remoteID := strings.TrimSpace(sm.RemoteID)
|
||||
if remoteID == "" {
|
||||
continue
|
||||
}
|
||||
chatType := bot.ChatDM
|
||||
if sm.ChatType != "" {
|
||||
chatType = bot.ChatType(sm.ChatType)
|
||||
}
|
||||
target := botForwardTarget{
|
||||
ConnID: connID,
|
||||
Domain: domain,
|
||||
ChatID: remoteID,
|
||||
ChatType: chatType,
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = false
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
||||
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
||||
Model: "qq-model",
|
||||
ToolApprovalMode: "auto",
|
||||
WorkspaceRoot: "/tmp/qq-project",
|
||||
}, nil, nil)
|
||||
|
||||
channel, ok := channels[bot.PlatformQQ]
|
||||
if !ok {
|
||||
t.Fatalf("platform QQ channel missing: %+v", channels)
|
||||
}
|
||||
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
||||
}
|
||||
connectionChannel, ok := connectionChannels["qq"]
|
||||
if !ok {
|
||||
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
||||
}
|
||||
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = false
|
||||
@@ -63,6 +102,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Enabled: true,
|
||||
SessionMappings: []config.BotConnectionSessionMapping{
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
|
||||
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
|
||||
},
|
||||
}}
|
||||
|
||||
targets := newDesktopBotRuntime().ForwardTargets(cfg)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
|
||||
if seen[key] {
|
||||
t.Fatalf("duplicate target %q in %+v", key, targets)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
|
||||
t.Fatalf("targets = %+v, want group and dm targets", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
|
||||
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil ||
|
||||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
|
||||
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(metas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload []struct {
|
||||
Files json.RawMessage `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, item := range payload {
|
||||
if string(item.Files) == "null" {
|
||||
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
|
||||
}
|
||||
if len(item.Files) == 0 || item.Files[0] != '[' {
|
||||
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
|
||||
// reverts files touched in that turn or any later one, so a turn with no file
|
||||
// changes of its own can still rewind code when a later turn changed files. The
|
||||
@@ -68,4 +90,65 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
if got[2] {
|
||||
t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != 1 {
|
||||
t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
|
||||
}
|
||||
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
|
||||
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
|
||||
}
|
||||
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
|
||||
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
|
||||
}
|
||||
if len(metas[2].Files) != 0 {
|
||||
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
|
||||
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
|
||||
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
|
||||
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
|
||||
}
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
if len(metas[0].Files) != checkpointFilePreviewLimit {
|
||||
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
|
||||
}
|
||||
if !metas[0].FilesTruncated {
|
||||
t.Fatal("turn 0 should mark file preview as truncated")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
|
||||
// release tag used in download URLs (e.g. "desktop-v1.1.0").
|
||||
func genManifest(dir, version, tag string) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if repo == "" {
|
||||
repo = "esengine/reasonix"
|
||||
if repo == "" || repo == "esengine/reasonix" {
|
||||
repo = "esengine/DeepSeek-Reasonix"
|
||||
}
|
||||
m := update.Manifest{
|
||||
Version: version,
|
||||
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
|
||||
DownloadPage: "https://reasonix.io/#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
|
||||
if m.Version != "v1.2.0" {
|
||||
t.Fatalf("version = %q, want v1.2.0", m.Version)
|
||||
}
|
||||
if m.DownloadPage != "https://reasonix.io/#start" {
|
||||
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
|
||||
}
|
||||
if len(m.Platforms) != 5 {
|
||||
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("windows-amd64 missing")
|
||||
}
|
||||
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
if win.URL != wantURL {
|
||||
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
|
||||
}
|
||||
|
||||
+2
-70
@@ -9,21 +9,8 @@ import (
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// credentialsPath is the file fallback for the reasonix-owned global credential
|
||||
// store. The settings panel writes through config.SetCredential instead, so
|
||||
// systems with a usable keyring do not need to store secrets in this file.
|
||||
func credentialsPath() string {
|
||||
if p := config.UserCredentialsPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".env")
|
||||
}
|
||||
return ".env"
|
||||
}
|
||||
|
||||
// upsertDotEnv stores KEY=value in the configured global credential store and
|
||||
// applies it to the running process so a rebuild picks it up without a restart.
|
||||
// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
|
||||
// running process so a rebuild picks it up without a restart.
|
||||
func upsertDotEnv(key, value string) error {
|
||||
_, err := config.SetCredential(key, value)
|
||||
return err
|
||||
@@ -144,58 +131,3 @@ func removeEnvFile(path, key string) error {
|
||||
}
|
||||
return os.Unsetenv(key)
|
||||
}
|
||||
|
||||
// promoteProviderKeysToCredentials copies any configured provider api_key_env that
|
||||
// currently resolves (from a project .env, ~/.env, or the OS env) into the global
|
||||
// credential store when it isn't there yet, so a key set for one workspace follows
|
||||
// the user across every project. Promoted keys are then stripped from ~/.env so the
|
||||
// credential store is the single source of truth; a project's own .env is
|
||||
// user-owned and left untouched.
|
||||
func promoteProviderKeysToCredentials(cfg *config.Config) {
|
||||
for _, p := range cfg.Providers {
|
||||
env := strings.TrimSpace(p.APIKeyEnv)
|
||||
if env == "" || config.CredentialStored(env) {
|
||||
continue
|
||||
}
|
||||
val := os.Getenv(env)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := config.SetCredential(env, val); err != nil {
|
||||
continue
|
||||
}
|
||||
removeHomeEnvKey(env)
|
||||
}
|
||||
}
|
||||
|
||||
// removeHomeEnvKey deletes a single KEY=value assignment from ~/.env (the legacy
|
||||
// fallback the old migration wrote to), leaving every other line intact. No-op when
|
||||
// ~/.env is absent or the credentials store resolves to ~/.env itself.
|
||||
func removeHomeEnvKey(key string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, ".env")
|
||||
if sameConfigPath(path, credentialsPath()) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var kept []string
|
||||
removed := false
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
check := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
|
||||
if k, _, ok := strings.Cut(check, "="); ok && strings.TrimSpace(k) == key {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
if !removed {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)
|
||||
}
|
||||
+7
-49
@@ -65,63 +65,21 @@ func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key
|
||||
// that resolves only from a project .env / ~/.env is copied into the global
|
||||
// credentials store, removed from ~/.env, and that unrelated env vars are ignored.
|
||||
func TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv(t *testing.T) {
|
||||
func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
t.Setenv("NPM_TOKEN", "secret")
|
||||
|
||||
promoteProviderKeysToCredentials(config.Default())
|
||||
|
||||
cred, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("credentials not written: %v", err)
|
||||
if _, err := config.LoadForRoot(t.TempDir()); err != nil {
|
||||
t.Fatalf("LoadForRoot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-test") {
|
||||
t.Errorf("provider key not promoted to credentials:\n%s", cred)
|
||||
if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
|
||||
t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
|
||||
}
|
||||
if strings.Contains(string(cred), "NPM_TOKEN") {
|
||||
t.Errorf("non-provider env var must not be promoted:\n%s", cred)
|
||||
}
|
||||
|
||||
rest, _ := os.ReadFile(homeEnv)
|
||||
if strings.Contains(string(rest), "DEEPSEEK_API_KEY") {
|
||||
t.Errorf("promoted key must be stripped from ~/.env:\n%s", rest)
|
||||
}
|
||||
if !strings.Contains(string(rest), "NPM_TOKEN=secret") {
|
||||
t.Errorf("unrelated ~/.env line must survive:\n%s", rest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteProviderKeysLeavesExistingCredentialsKey proves promotion never
|
||||
// overwrites a key already in the credentials store and leaves ~/.env untouched.
|
||||
func TestPromoteProviderKeysLeavesExistingCredentialsKey(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserCredentialsPath(), []byte("DEEPSEEK_API_KEY=sk-global\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-global")
|
||||
|
||||
promoteProviderKeysToCredentials(config.Default())
|
||||
|
||||
cred, _ := os.ReadFile(config.UserCredentialsPath())
|
||||
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-global") {
|
||||
t.Errorf("existing credentials key was changed:\n%s", cred)
|
||||
}
|
||||
if data, err := os.Stat(homeEnv); err != nil || data.Size() == 0 {
|
||||
t.Errorf("~/.env should be untouched when key already global, err=%v", err)
|
||||
if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
|
||||
t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,6 @@ wailsjs
|
||||
|
||||
# Wails dev install-checksum cache
|
||||
package.json.md5
|
||||
|
||||
# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
|
||||
package-lock.json
|
||||
Generated
-4662
File diff suppressed because it is too large.
Load diff
@@ -10,31 +10,34 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"gsap": "^3.15.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"katex": "^0.17.0",
|
||||
"lucide-react": "^1.18.0",
|
||||
"lucide-react": "^1.21.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0"
|
||||
"remark-math": "^6.0.0",
|
||||
"svg-pan-zoom": "^3.6.2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"terser": "^5.48.0",
|
||||
"tsx": "^4.22.4",
|
||||
|
||||
Generated
+865
-359
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
|
||||
}).outputText;
|
||||
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
||||
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
|
||||
const {
|
||||
dismissedTodoKeyForScope,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
shouldOpenTodoPanelByDefault,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const completedTodos = [
|
||||
{ content: "Inspect the report", status: "completed" },
|
||||
@@ -42,6 +51,66 @@ assert.equal(
|
||||
false,
|
||||
"a user dismissal still hides that exact todo list",
|
||||
);
|
||||
const completedKey = todoDismissalKey(completedTodos);
|
||||
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"a completed todo dismissal hides the list in the session where it was closed",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
|
||||
true,
|
||||
"a completed todo dismissal in one session does not hide another session's list",
|
||||
);
|
||||
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"closing another session's todo does not forget the first session dismissal",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-a",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
}),
|
||||
"session:/sessions/a.jsonl",
|
||||
"a restored history session uses its session path as the todo panel scope",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-b",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
eventChannel: "desktop-events",
|
||||
}),
|
||||
"tab:tab-b",
|
||||
"a stale active-tab fallback must not scope dismissal to the previous session",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-c",
|
||||
activeTab: {
|
||||
id: "tab-c",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
},
|
||||
}),
|
||||
"tab:tab-c",
|
||||
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
|
||||
);
|
||||
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
|
||||
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
|
||||
|
||||
@@ -61,6 +130,36 @@ assert.notEqual(
|
||||
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"real progress produces a fresh dismissal key",
|
||||
);
|
||||
assert.equal(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"status progress stays in the same todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
|
||||
"status progress keeps the same scoped open-state key",
|
||||
);
|
||||
assert.notEqual(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
|
||||
"the same task list in a different session gets isolated open state",
|
||||
);
|
||||
assert.notEqual(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
|
||||
"new task content creates a new todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(activeTodos),
|
||||
true,
|
||||
"new incomplete todo batches open by default",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(completedTodos),
|
||||
false,
|
||||
"completed todo batches collapse by default",
|
||||
);
|
||||
|
||||
const iterations = 200_000;
|
||||
const started = performance.now();
|
||||
|
||||
+831
-302
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,11 @@ import { fileURLToPath } from "node:url";
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.tsx"), "utf8");
|
||||
const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8");
|
||||
const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8");
|
||||
const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8");
|
||||
const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8");
|
||||
const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
let passed = 0;
|
||||
@@ -64,6 +69,18 @@ ok(
|
||||
"AppChrome markup includes classic tab strip containers",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
|
||||
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
|
||||
"AppChrome exposes the classic drag rail on macOS and Windows",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
|
||||
"right dock starts collapsed on launch",
|
||||
);
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app-chrome__tab-strip", "overflow") === "hidden",
|
||||
"AppChrome tab strip clips tabs to the available chrome width",
|
||||
@@ -110,7 +127,7 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
/\{!workbenchChromeHidden && \(/.test(appSource),
|
||||
/\{!appChromeHidden && \(/.test(appSource),
|
||||
"workbench skips rendering the top AppChrome row",
|
||||
);
|
||||
|
||||
@@ -119,6 +136,104 @@ ok(
|
||||
"workbench keeps chrome controls in the topic bar",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const \[transcriptRevealSignal, setTranscriptRevealSignal\] = useState\(0\);/.test(appSource) &&
|
||||
/revealActiveSignal=\{tabRevealSignal\}/.test(appSource) &&
|
||||
/revealSignal=\{transcriptRevealSignal\}/.test(appSource),
|
||||
"transcript bottom reveal is decoupled from tab-strip reveal",
|
||||
);
|
||||
|
||||
const tabsReorderBlock = appSource.match(/const handleTabsReorder = useCallback\([\s\S]*?\n \}, \[refreshTabMetas, reorderTabs\]\);/)?.[0] ?? "";
|
||||
ok(
|
||||
/setTabRevealSignal/.test(tabsReorderBlock) && !/setTranscriptRevealSignal/.test(tabsReorderBlock),
|
||||
"tab reordering refreshes the tab strip without snapping the transcript",
|
||||
);
|
||||
|
||||
ok(
|
||||
/aria-label=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource) &&
|
||||
/title=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource),
|
||||
"jump-to-bottom affordance uses localized transcript text",
|
||||
);
|
||||
|
||||
ok(
|
||||
/setActive\(items\.length > 0 \? 0 : -1\)/.test(commandPaletteSource),
|
||||
"command palette highlights the first item when opened with an empty query",
|
||||
);
|
||||
|
||||
ok(
|
||||
/topicShortcutIndexFromEvent\(event, desktopPlatform\)/.test(appSource) &&
|
||||
/useTopicShortcuts\(!sidebarCollapsed, desktopPlatform\)/.test(appSource),
|
||||
"topic shortcuts use the resolved desktop platform",
|
||||
);
|
||||
|
||||
ok(
|
||||
/topicShortcutLabel\(shortcutIndex, shortcutPlatform\)/.test(projectTreeSource),
|
||||
"topic shortcut badges render the platform-specific modifier",
|
||||
);
|
||||
|
||||
ok(
|
||||
/if \(!enabled\) hideBadges\(\);/.test(topicShortcutsSource) &&
|
||||
/if \(heldRef\.current\) hideBadges\(\);/.test(topicShortcutsSource) &&
|
||||
/window\.removeEventListener\("blur", onBlur\);\s*hideBadges\(\);/.test(topicShortcutsSource),
|
||||
"topic shortcut badge state is cleared when disabled, interrupted, or cleaned up",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const \[rewindCommitting, setRewindCommitting\] = useState\(false\);/.test(appSource) &&
|
||||
/rewindStateRef\.current = null;/.test(appSource) &&
|
||||
/setRewindCommitting\(true\);/.test(appSource),
|
||||
"committing optimistic rewind clears undo state before awaiting the backend",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const controllerReady = state\.meta\?\.ready === true && !state\.backendActivationPending;/.test(appSource) &&
|
||||
/if \(!controllerReady\) return;\s*void commitThenSend\(text\)\.catch/.test(appSource) &&
|
||||
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
|
||||
/submitDisabled=\{!controllerReady\}/.test(appSource),
|
||||
"welcome prompts and composer submit share the controller readiness gate",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{transcriptHydrating\}/.test(appSource),
|
||||
"Welcome is suppressed only until transcript history has loaded",
|
||||
);
|
||||
|
||||
const navigationBlock = appSource.match(/const runNavigationRequest = useCallback\([\s\S]*?\n \}, \[[^\]]*singleSurfaceLayout[^\]]*\]\);/)?.[0] ?? "";
|
||||
ok(
|
||||
/const navigationRunningRef = useRef\(false\);/.test(appSource) &&
|
||||
/const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
|
||||
/const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
|
||||
/const latest = \(\) => request\.seq === navigationSeqRef\.current;/.test(appSource) &&
|
||||
/return activateTopic\(scope, workspaceRoot, topicId/.test(appSource) &&
|
||||
/return openTopicSession\(scope, workspaceRoot, topicId/.test(appSource) &&
|
||||
/return openGlobalTab\(topicId\)/.test(appSource) &&
|
||||
/return openProjectTab\(workspaceRoot, topicId\)/.test(appSource) &&
|
||||
/enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
|
||||
!/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
|
||||
/const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
|
||||
/if \(!latest\(\)\) return;[\s\S]*seedActiveTabMeta\(openedTab\);[\s\S]*void refreshLatestTabMetas\(\);/.test(navigationBlock),
|
||||
"desktop navigation coalesces pending requests, ignores stale results, and seeds active tab metadata before background refresh",
|
||||
);
|
||||
|
||||
ok(
|
||||
/return enqueueNavigation\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}\);/.test(appSource) &&
|
||||
/enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: scope === "project" \? workspaceRoot : "" \}\)/.test(appSource) &&
|
||||
/return enqueueNavigation\(\{ kind: "sidebar-im", connection \}\);/.test(appSource) &&
|
||||
/return enqueueNavigation\(\{ kind: "resume-session", session \}\);/.test(appSource),
|
||||
"topic, blank, IM, and history navigation all use the shared coalescing path",
|
||||
);
|
||||
|
||||
ok(
|
||||
!/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
|
||||
"history navigation does not re-resume a session that OpenTopicSession already pinned",
|
||||
);
|
||||
|
||||
ok(
|
||||
/<HeartbeatPanel[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
|
||||
"heartbeat topic navigation uses the guarded open-topic path",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".app--darwin .app-chrome--tabs",
|
||||
":root[data-theme-style] .app--darwin .app-chrome--tabs",
|
||||
@@ -143,6 +258,37 @@ for (const selector of [
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
|
||||
]) {
|
||||
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
|
||||
ok(
|
||||
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
|
||||
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
|
||||
paddingRight.includes("--chrome-panel-control-size") &&
|
||||
!paddingRight.includes("--chrome-right-toggle-offset"),
|
||||
`${selector} keeps titlebar tools fixed beside the Windows controls`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
]) {
|
||||
ok(
|
||||
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
|
||||
`${selector} stays fixed outside the Windows window controls`,
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
|
||||
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
|
||||
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".layout--workbench-chrome-hidden",
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden",
|
||||
@@ -193,8 +339,8 @@ for (const selector of [
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar", "background") === "var(--chat-bg)",
|
||||
"workbench topic bar uses a flat chat background",
|
||||
finalDeclaration(":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar", "background") === "var(--bg-elev)",
|
||||
"workbench topic bar uses elevated background for light-mode white",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// Run: tsx src/__tests__/approval-modal-file-reference.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WireApproval } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await act(async () => {
|
||||
await flushTimers(20);
|
||||
});
|
||||
}
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
return dom;
|
||||
}
|
||||
|
||||
function mockApp(methods: Partial<AppBindings>) {
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const revisions: string[] = [];
|
||||
const activeStates: boolean[] = [];
|
||||
const approval: WireApproval = {
|
||||
id: "plan-approval",
|
||||
tool: "exit_plan_mode",
|
||||
subject: "Plan ready",
|
||||
};
|
||||
let currentProps: Parameters<typeof ApprovalModal>[0] = {
|
||||
approval,
|
||||
cwd: "/repo",
|
||||
onAnswer: () => undefined,
|
||||
onRevisePlan: (text) => revisions.push(text),
|
||||
onExitPlan: () => undefined,
|
||||
onStop: () => undefined,
|
||||
onRevisionActiveChange: (active) => activeStates.push(active),
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof ApprovalModal>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ApprovalModal {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, revisions, activeStates, rerender: paint };
|
||||
}
|
||||
|
||||
console.log("\napproval modal file references");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, revisions, rerender } = await renderApproval();
|
||||
|
||||
const reviseButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Revise plan")) as HTMLButtonElement | undefined;
|
||||
if (!reviseButton) throw new Error("revise button did not render");
|
||||
|
||||
await act(async () => {
|
||||
reviseButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("plan revision textarea did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 1, text: "please inspect @" } });
|
||||
await waitFor("plan revision @ text opens file suggestions", () => document.body.textContent?.includes("README.md") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("README.md") === true, "plan revision @ text opens file suggestions");
|
||||
|
||||
const readmeButton = Array.from(document.querySelectorAll(".slashmenu__item")).find((button) => button.textContent?.includes("README.md")) as HTMLButtonElement | undefined;
|
||||
if (!readmeButton) throw new Error("README file suggestion did not render");
|
||||
|
||||
await act(async () => {
|
||||
readmeButton.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(textarea.value, "please inspect @README.md ", "file suggestion completes inline in the plan revision");
|
||||
|
||||
const sendButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Send update")) as HTMLButtonElement | undefined;
|
||||
if (!sendButton) throw new Error("send revision button did not render");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers(220);
|
||||
});
|
||||
|
||||
eq(revisions.join(","), "please inspect @README.md", "submitted plan revision keeps the selected file reference");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, activeStates, rerender } = await renderApproval();
|
||||
|
||||
const reviseButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Revise plan")) as HTMLButtonElement | undefined;
|
||||
if (!reviseButton) throw new Error("revise button did not render");
|
||||
|
||||
await act(async () => {
|
||||
reviseButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("plan revision textarea did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 2, text: "@src/main.go" } });
|
||||
|
||||
eq(textarea.value, "@src/main.go", "workspace add-reference insert request targets the plan revision input");
|
||||
ok(activeStates.includes(true), "plan revision reports itself as the active workspace insertion target");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "tool-approval",
|
||||
tool: "bash",
|
||||
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
},
|
||||
});
|
||||
|
||||
const subject = document.querySelector(".approval-subject");
|
||||
ok(subject != null, "tool approval shows its full subject by default");
|
||||
eq(
|
||||
subject?.textContent,
|
||||
"npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
"default-open tool approval keeps the complete subject visible",
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
|
||||
ok(text.includes("Deny"), "fresh-human approval shows deny");
|
||||
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
if (!allowButton) throw new Error("allow once button did not render");
|
||||
|
||||
await act(async () => {
|
||||
allowButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,121 @@
|
||||
// Run: tsx src/__tests__/ask-card-layout.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { AskCard } from "../components/AskCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { QuestionAnswer, WireAsk } from "../lib/types";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = styles;
|
||||
document.head.appendChild(style);
|
||||
return dom;
|
||||
}
|
||||
|
||||
console.log("\nask card layout");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const answers: QuestionAnswer[][] = [];
|
||||
const ask: WireAsk = {
|
||||
id: "ask-superpowers-decision",
|
||||
questions: [
|
||||
{
|
||||
id: "decision",
|
||||
header: "Review",
|
||||
prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
|
||||
options: [
|
||||
{ label: "Full alignment", description: "Reuse the archive flow and keep behavior consistent." },
|
||||
{ label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LocaleProvider, null,
|
||||
React.createElement(AskCard, {
|
||||
ask,
|
||||
onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next),
|
||||
onDismiss: () => undefined,
|
||||
onStop: () => undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
|
||||
const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
|
||||
if (!card || !meta) throw new Error("ask prompt shelf did not render");
|
||||
|
||||
eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
|
||||
|
||||
const computed = window.getComputedStyle(meta);
|
||||
eq(computed.whiteSpace, "normal", "ask question can wrap instead of staying on one line");
|
||||
eq(computed.overflow, "visible", "ask question is not clipped by the prompt shelf");
|
||||
eq(computed.textOverflow, "clip", "ask question does not render as an ellipsis-only preview");
|
||||
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
|
||||
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -26,6 +26,10 @@ function entry(name: string, isDir = false): DirEntry {
|
||||
return { name, isDir };
|
||||
}
|
||||
|
||||
function externalEntry(path: string, displayName: string): DirEntry {
|
||||
return { name: displayName, path, displayName, isDir: false };
|
||||
}
|
||||
|
||||
console.log("\nat-matches filter");
|
||||
|
||||
// 1. Nested file surfaces when fragment matches an intermediate segment.
|
||||
@@ -92,5 +96,26 @@ console.log("\nat-matches filter");
|
||||
);
|
||||
}
|
||||
|
||||
// 6. External-folder search results can match against a friendly display name
|
||||
// even when the submitted path is an opaque session token.
|
||||
{
|
||||
const entries: DirEntry[] = [];
|
||||
const searchEntries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "Folder With Spaces/src/outside.txt")];
|
||||
const got = filterAtMatches(entries, searchEntries, "spaces");
|
||||
eq(
|
||||
got.map((e) => e.path),
|
||||
["__reasonix_external_folder/abc/Folder/src/outside.txt"],
|
||||
"external display name participates in filtering while preserving token path",
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Dedup uses the submitted path when present.
|
||||
{
|
||||
const entries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "outside.txt")];
|
||||
const searchEntries = [externalEntry("__reasonix_external_folder/abc/Folder/src/outside.txt", "Folder With Spaces/src/outside.txt")];
|
||||
const got = filterAtMatches(entries, searchEntries, "outside");
|
||||
eq(got.length, 1, "external entries with the same submitted path dedup to one");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/bridge-drag-rejection.test.ts
|
||||
|
||||
import { isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
|
||||
import { isTransientWailsIPCError, isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -58,6 +58,21 @@ eq(
|
||||
false,
|
||||
"keeps ErrorEvent invalid argument visible without a recent native file drag",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError")),
|
||||
true,
|
||||
"suppresses Wails IPC calls made before the websocket is open",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new TypeError("Cannot read properties of null (reading 'send')")),
|
||||
true,
|
||||
"suppresses Wails IPC calls made after the websocket is torn down",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new Error("backend returned an application error")),
|
||||
false,
|
||||
"keeps ordinary bridge failures visible",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Run: tsx src/__tests__/bridge-mock-approval-mode.test.ts
|
||||
|
||||
import { mockToolApprovalModeAfterModeChange } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nbridge mock approval mode");
|
||||
|
||||
eq(
|
||||
mockToolApprovalModeAfterModeChange("auto", "plan"),
|
||||
"auto",
|
||||
"legacy plan switch keeps explicit auto approval mode",
|
||||
);
|
||||
eq(
|
||||
mockToolApprovalModeAfterModeChange("auto", "normal"),
|
||||
"auto",
|
||||
"legacy normal switch keeps explicit auto approval mode",
|
||||
);
|
||||
eq(
|
||||
mockToolApprovalModeAfterModeChange("ask", "yolo"),
|
||||
"yolo",
|
||||
"legacy yolo switch still enables yolo approval mode",
|
||||
);
|
||||
eq(
|
||||
mockToolApprovalModeAfterModeChange("yolo", "plan"),
|
||||
"ask",
|
||||
"legacy non-yolo switch clears yolo back to ask",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -43,7 +43,7 @@ ok(
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
|
||||
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
|
||||
);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { mcpServerLifecycleActions } from "../lib/mcpServerLifecycle";
|
||||
import type { ServerView } from "../lib/types";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -21,16 +28,437 @@ function server(status: ServerView["status"]): ServerView {
|
||||
|
||||
const initializing = mcpServerLifecycleActions(server("initializing"));
|
||||
ok(initializing.enabled, "initializing server should still be treated as enabled");
|
||||
ok(initializing.showRetryInRow, "initializing server row should expose a retry/reset action");
|
||||
ok(initializing.canReconnect, "initializing server details should expose reconnect");
|
||||
ok(!initializing.showRetryInRow, "initializing server should not expose retry until it fails");
|
||||
ok(!initializing.canReconnect, "initializing server should not expose reconnect while already connecting");
|
||||
ok(!initializing.canConnectNow, "initializing server should not use the deferred connect-now action");
|
||||
|
||||
const connected = mcpServerLifecycleActions(server("connected"));
|
||||
ok(!connected.showRetryInRow, "connected server row should keep the toggle UI");
|
||||
ok(connected.canReconnect, "connected server details should expose reconnect");
|
||||
|
||||
const deferred = mcpServerLifecycleActions(server("deferred"));
|
||||
ok(deferred.canConnectNow, "deferred server should expose connect now");
|
||||
ok(!deferred.canReconnect, "deferred server should not show reconnect separately");
|
||||
const manuallyConnected = mcpServerLifecycleActions({ ...server("connected"), autoStart: false, startIntent: "off", runtimeState: "ready" });
|
||||
ok(manuallyConnected.enabled, "connected manual server should still render as enabled");
|
||||
ok(!manuallyConnected.canConnectNow, "connected manual server should not expose connect-now");
|
||||
ok(manuallyConnected.canReconnect, "connected manual server should expose reconnect");
|
||||
|
||||
const automaticIdle = mcpServerLifecycleActions({ ...server("deferred"), startIntent: "automatic" });
|
||||
ok(!automaticIdle.canConnectNow, "automatic idle server should not look like a manual connector");
|
||||
ok(!automaticIdle.canReconnect, "automatic idle server should wait for background connection or failure");
|
||||
|
||||
const failed = mcpServerLifecycleActions({ ...server("failed"), runtimeState: "issue" });
|
||||
ok(failed.showRetryInRow, "failed server row should expose retry");
|
||||
|
||||
ok(mcpServerRetryableFromAvailableList(server("initializing")), "connecting server should be included in available-list retry all");
|
||||
ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "automatic idle server should be included in available-list retry all");
|
||||
ok(!mcpServerRetryableFromAvailableList(server("connected")), "connected server should be excluded from available-list retry all");
|
||||
ok(!mcpServerRetryableFromAvailableList({ ...server("disabled"), startIntent: "off" }), "disabled server should be excluded from available-list retry all");
|
||||
ok(!mcpServerRetryableFromAvailableList({ ...server("failed"), runtimeState: "issue" }), "failed server is handled by the failure banner retry all");
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function findButton(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const win = input.ownerDocument.defaultView;
|
||||
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
const eventCtor = win?.Event ?? Event;
|
||||
const inputEventCtor = win?.InputEvent ?? eventCtor;
|
||||
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
|
||||
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
console.log("capabilities panel MCP actions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "test-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-1",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-1",
|
||||
topicTitle: "Test",
|
||||
label: "Test",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let trustCalls = 0;
|
||||
let bulkTrustCalls = 0;
|
||||
let untrustCalls = 0;
|
||||
let servers: ServerView[] = [{
|
||||
name: "github",
|
||||
transport: "stdio",
|
||||
status: "connected",
|
||||
configured: true,
|
||||
autoStart: true,
|
||||
tools: 2,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
toolList: [
|
||||
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
|
||||
{ name: "issue_write", description: "Write issues." },
|
||||
],
|
||||
trustedReadOnlyTools: [],
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
MCPServers: async () => servers.map((s) => ({
|
||||
...s,
|
||||
toolList: s.toolList?.map((tool) => ({ ...tool })),
|
||||
trustedReadOnlyTools: [...(s.trustedReadOnlyTools ?? [])],
|
||||
})),
|
||||
TrustMCPServerTool: async (name: string, toolName: string) => {
|
||||
trustCalls += 1;
|
||||
servers = servers.map((s) => s.name === name ? { ...s, trustedReadOnlyTools: [...(s.trustedReadOnlyTools ?? []), toolName] } : s);
|
||||
},
|
||||
TrustMCPServerTools: async (name: string, toolNames: string[]) => {
|
||||
bulkTrustCalls += 1;
|
||||
servers = servers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
const trusted = Array.from(new Set([...(s.trustedReadOnlyTools ?? []), ...toolNames]));
|
||||
return { ...s, trustedReadOnlyTools: trusted };
|
||||
});
|
||||
},
|
||||
UntrustMCPServerTool: async (name: string, toolName: string) => {
|
||||
untrustCalls += 1;
|
||||
servers = servers.map((s) => s.name === name ? { ...s, trustedReadOnlyTools: (s.trustedReadOnlyTools ?? []).filter((tool) => tool !== toolName) } : s);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("github server row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("github")));
|
||||
|
||||
const disclosure = document.querySelector<HTMLButtonElement>(".cap-disclosure");
|
||||
if (!disclosure) throw new Error("missing MCP disclosure button");
|
||||
await act(async () => {
|
||||
disclosure.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const trustReadOnly = findButton("Pre-trust read-only (1)");
|
||||
if (!trustReadOnly) throw new Error("missing bulk Pre-trust read-only button");
|
||||
await act(async () => {
|
||||
trustReadOnly.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("bulk trusted tool", () => servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false);
|
||||
|
||||
const viewTools = findButton("View tools");
|
||||
if (!viewTools) throw new Error("missing View tools button");
|
||||
await act(async () => {
|
||||
viewTools.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
await waitFor("trusted badge", () => Boolean(document.querySelector(".cap-tool-trust")?.textContent?.includes("Trusted")));
|
||||
const untrust = findButton("Untrust");
|
||||
if (!untrust) throw new Error("missing Untrust button");
|
||||
await act(async () => {
|
||||
untrust.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("untrusted tool", () => !(servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false));
|
||||
|
||||
await waitFor("Pre-trust button", () => Boolean(findButton("Pre-trust")));
|
||||
const trust = findButton("Pre-trust");
|
||||
if (!trust) throw new Error("missing Pre-trust button");
|
||||
await act(async () => {
|
||||
trust.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
await waitFor("trusted badge", () => Boolean(document.querySelector(".cap-tool-trust")?.textContent?.includes("Trusted")));
|
||||
ok(bulkTrustCalls === 1, "clicking Pre-trust read-only invokes the MCP bulk trust action once");
|
||||
ok(untrustCalls === 1, "clicking Untrust invokes the MCP untrust action once");
|
||||
ok(trustCalls === 1, "clicking Trust invokes the MCP trust action once");
|
||||
ok(servers[0]?.trustedReadOnlyTools?.includes("issue_read") ?? false, "trusted raw tool name is added to the server snapshot");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log("capabilities panel plugin actions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-plugin",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-plugin",
|
||||
topicTitle: "Plugins",
|
||||
label: "Plugins",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let planCalls = 0;
|
||||
let installCalls = 0;
|
||||
let toggleCalls = 0;
|
||||
let updateCalls = 0;
|
||||
let doctorCalls = 0;
|
||||
let removeCalls = 0;
|
||||
let pickFolderCalls = 0;
|
||||
const plannedSources: string[] = [];
|
||||
const installedSources: string[] = [];
|
||||
let plugins: PluginView[] = [{
|
||||
name: "superpowers",
|
||||
version: "0.1.0",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source: "git:github.com/obra/superpowers",
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
mcpServers: 0,
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
|
||||
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
|
||||
planCalls += 1;
|
||||
plannedSources.push(source);
|
||||
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
name: "superpowers",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
|
||||
installCalls += 1;
|
||||
installedSources.push(source);
|
||||
const next: PluginView = {
|
||||
name: "superpowers",
|
||||
version: "0.1.1",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source,
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 3,
|
||||
hooks: 1,
|
||||
mcpServers: 1,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
|
||||
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
|
||||
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
|
||||
};
|
||||
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
|
||||
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
|
||||
},
|
||||
SetPluginEnabled: async (name: string, enabled: boolean) => {
|
||||
toggleCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
|
||||
},
|
||||
UpdatePlugin: async (name: string) => {
|
||||
updateCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
|
||||
return JSON.stringify({ ok: true, status: "done", name });
|
||||
},
|
||||
PluginDoctor: async (name: string) => {
|
||||
doctorCalls += 1;
|
||||
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
|
||||
},
|
||||
RemovePlugin: async (name: string) => {
|
||||
removeCalls += 1;
|
||||
plugins = plugins.filter((plugin) => plugin.name !== name);
|
||||
},
|
||||
PickPluginFolder: async () => {
|
||||
pickFolderCalls += 1;
|
||||
return "/tmp/superpowers-plugin";
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
|
||||
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
|
||||
.map((option) => option.textContent ?? "");
|
||||
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
|
||||
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
|
||||
|
||||
const chooseFolder = findButton("Choose plugin folder");
|
||||
if (!chooseFolder) throw new Error("missing plugin folder picker button");
|
||||
await act(async () => {
|
||||
chooseFolder.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
|
||||
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
|
||||
|
||||
const gitMode = findButton("Git repository");
|
||||
if (!gitMode) throw new Error("missing Git repository install mode");
|
||||
await act(async () => {
|
||||
gitMode.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
|
||||
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
|
||||
if (!sourceInput) throw new Error("missing plugin git source input");
|
||||
await act(async () => {
|
||||
setInputValue(sourceInput, "git:github.com/obra/superpowers");
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
|
||||
|
||||
const preview = findButton("Preview");
|
||||
if (!preview) throw new Error("missing plugin preview button");
|
||||
await act(async () => {
|
||||
preview.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
|
||||
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
|
||||
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
|
||||
|
||||
const install = findButton("Install plugin");
|
||||
if (!install) throw new Error("missing plugin install button");
|
||||
await act(async () => {
|
||||
install.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
|
||||
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
|
||||
|
||||
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
|
||||
if (!disclosure) throw new Error("missing plugin disclosure");
|
||||
await act(async () => {
|
||||
disclosure.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update action", () => Boolean(findButton("Update")));
|
||||
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
|
||||
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
|
||||
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
|
||||
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
|
||||
|
||||
const update = findButton("Update");
|
||||
if (!update) throw new Error("missing plugin update button");
|
||||
await act(async () => {
|
||||
update.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
|
||||
|
||||
const doctor = findButton("Doctor");
|
||||
if (!doctor) throw new Error("missing plugin doctor button");
|
||||
await act(async () => {
|
||||
doctor.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
|
||||
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
|
||||
|
||||
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
|
||||
if (!toggle) throw new Error("missing plugin enable toggle");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
|
||||
|
||||
const remove = findButton("Remove plugin");
|
||||
if (!remove) throw new Error("missing plugin remove button");
|
||||
await act(async () => {
|
||||
remove.click();
|
||||
await flush();
|
||||
});
|
||||
const confirmRemove = findButton("Confirm remove");
|
||||
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
|
||||
await act(async () => {
|
||||
confirmRemove.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Run: tsx src/__tests__/code-block-copy-position.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import HljsCode from "../components/editors/HljsCode";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function notOk(value: unknown, label: string) {
|
||||
ok(!value, label);
|
||||
}
|
||||
|
||||
function renderCodeBlock() {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
LocaleProvider,
|
||||
null,
|
||||
createElement(HljsCode, {
|
||||
value: "const veryLongLine = 'x'.repeat(240);",
|
||||
language: "ts",
|
||||
maxHeight: 120,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function selectorPresent(css: string, selector: string): boolean {
|
||||
return css.includes(selector);
|
||||
}
|
||||
|
||||
console.log("\ncode block copy position");
|
||||
|
||||
const markup = renderCodeBlock();
|
||||
const dom = new JSDOM(markup);
|
||||
const document = dom.window.document;
|
||||
const wrap = document.querySelector(".code-block__wrap");
|
||||
const pre = document.querySelector("pre.code.hljs");
|
||||
const copy = document.querySelector(".code-block__copy");
|
||||
|
||||
ok(wrap, "HljsCode renders a positioned copy wrapper");
|
||||
ok(pre, "HljsCode renders the highlighted pre");
|
||||
ok(copy, "HljsCode renders the copy button");
|
||||
ok(pre?.parentElement === wrap, "scrollable pre is inside the wrapper");
|
||||
ok(copy?.parentElement === wrap, "copy button is a wrapper child");
|
||||
notOk(pre?.contains(copy ?? null), "copy button is outside the scrollable pre");
|
||||
ok(copy?.previousElementSibling === pre, "copy button follows pre as a sibling");
|
||||
|
||||
const stylesPath = fileURLToPath(new URL("../styles.css", import.meta.url));
|
||||
const css = readFileSync(stylesPath, "utf8");
|
||||
|
||||
ok(selectorPresent(css, ".code-block__wrap:hover .code-block__copy"), "hover reveal targets the wrapper");
|
||||
notOk(selectorPresent(css, ".code:hover .code-block__copy"), "hover reveal no longer depends on pre descendants");
|
||||
ok(
|
||||
selectorPresent(css, ".app--creation .msg--assistant .md .code:not([data-lang]) + .code-block__copy"),
|
||||
"creation unlabelled code button styles target sibling copy buttons",
|
||||
);
|
||||
notOk(
|
||||
selectorPresent(css, ".app--creation .msg--assistant .md .code:not([data-lang]) .code-block__copy"),
|
||||
"creation unlabelled code styles do not target descendants inside pre",
|
||||
);
|
||||
ok(
|
||||
selectorPresent(css, '.app--creation .msg--assistant .md .code[data-lang="bash"] + .code-block__copy'),
|
||||
"creation shell code button styles target sibling copy buttons",
|
||||
);
|
||||
notOk(
|
||||
selectorPresent(css, '.app--creation .msg--assistant .md .code[data-lang="bash"] .code-block__copy'),
|
||||
"creation shell code styles do not target descendants inside pre",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,138 @@
|
||||
// Run: tsx src/__tests__/command-palette-interactions.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { CommandPalette, type PaletteItem } from "../components/CommandPalette";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderPalette() {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { close: 0, run: 0 };
|
||||
const items: PaletteItem[] = [{
|
||||
id: "cmd-new",
|
||||
group: "Commands",
|
||||
title: "New session",
|
||||
run: () => {
|
||||
calls.run += 1;
|
||||
},
|
||||
}];
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<CommandPalette
|
||||
open
|
||||
onClose={() => {
|
||||
calls.close += 1;
|
||||
}}
|
||||
items={items}
|
||||
placeholder="Search"
|
||||
emptyText="No results"
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
return { root, calls };
|
||||
}
|
||||
|
||||
console.log("\ncommand palette interactions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderPalette();
|
||||
const esc = document.querySelector<HTMLButtonElement>(".palette__esc");
|
||||
ok(esc instanceof HTMLButtonElement, "header esc keycap is a real close button");
|
||||
|
||||
await act(async () => {
|
||||
esc?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(calls.close === 1, "clicking header esc closes the command palette");
|
||||
ok(calls.run === 0, "clicking header esc does not run the highlighted command");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderPalette();
|
||||
const esc = document.querySelector<HTMLButtonElement>(".palette__esc");
|
||||
if (!esc) throw new Error("missing header esc close button");
|
||||
|
||||
await act(async () => {
|
||||
esc.focus();
|
||||
esc.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
esc.click();
|
||||
await flush();
|
||||
});
|
||||
|
||||
ok(calls.close === 1, "Enter on focused header esc closes the command palette");
|
||||
ok(calls.run === 0, "Enter on focused header esc does not run the highlighted command");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -4,10 +4,11 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -56,6 +57,8 @@ function installDom() {
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
@@ -84,9 +87,15 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
const root = createRoot(rootEl);
|
||||
const calls: {
|
||||
send: string[];
|
||||
submit: (string | undefined)[];
|
||||
cancel: number;
|
||||
clearGoal: number;
|
||||
setCollaborationMode: CollaborationMode[];
|
||||
} = {
|
||||
send: [],
|
||||
submit: [],
|
||||
cancel: 0,
|
||||
clearGoal: 0,
|
||||
setCollaborationMode: [],
|
||||
};
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
@@ -97,14 +106,22 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
onSend: (displayText) => calls.send.push(displayText),
|
||||
onCancel: () => undefined,
|
||||
onSend: (displayText, submitText) => {
|
||||
calls.send.push(displayText);
|
||||
calls.submit.push(submitText);
|
||||
},
|
||||
onCancel: () => {
|
||||
calls.cancel += 1;
|
||||
return undefined;
|
||||
},
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: (mode) => calls.setCollaborationMode.push(mode),
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onClearGoal: () => {
|
||||
calls.clearGoal += 1;
|
||||
},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
@@ -128,6 +145,71 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
return { root, calls, rerender: paint };
|
||||
}
|
||||
|
||||
function mockApp(methods: Partial<AppBindings>) {
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: ["Files"],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function nativeFileDropEvent(): Event {
|
||||
const drop = new window.Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
configurable: true,
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: [{}],
|
||||
items: [
|
||||
{
|
||||
kind: "file",
|
||||
webkitGetAsEntry: () => ({ isFile: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return drop;
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
|
||||
|
||||
function fileEntry(name: string): DirEntry {
|
||||
return { name, isDir: false };
|
||||
}
|
||||
|
||||
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
|
||||
await rerender({ insertRequest: { id, text, mode: "replace" } });
|
||||
}
|
||||
|
||||
console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
@@ -166,5 +248,657 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({
|
||||
running: true,
|
||||
collaborationMode: "goal",
|
||||
goal: "finish the migration",
|
||||
turnStartAt: Date.now(),
|
||||
});
|
||||
|
||||
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
|
||||
if (!stopButton) throw new Error("composer stop button did not render");
|
||||
|
||||
await act(async () => {
|
||||
stopButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.cancel, 1, "goal-mode stop cancels the running turn");
|
||||
eq(calls.clearGoal, 1, "goal-mode stop clears the active goal");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFile: async () => {
|
||||
throw new Error("/Users/example/private.pdf: permission denied");
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
|
||||
await act(async () => {
|
||||
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error");
|
||||
eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text");
|
||||
ok(sendButton.disabled === false, "failed pasted file attach clears the pending state");
|
||||
ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFile: async () => ".reasonix/attachments/notes.txt",
|
||||
});
|
||||
const { root } = await renderComposer();
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await act(async () => {
|
||||
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("pasted file attachment", () => document.body.textContent?.includes("notes.txt") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("notes.txt") === true, "successful pasted file attach still renders the attachment");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
OnFileDrop: (cb) => {
|
||||
droppedCallback = cb;
|
||||
},
|
||||
OnFileDropOff: () => {},
|
||||
};
|
||||
mockApp({
|
||||
AttachDropped: async () => {
|
||||
throw new Error("/Users/example/secret.pdf: permission denied");
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
await rerender({ insertRequest: { id: 3, text: "drop draft", mode: "replace" } });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
if (!droppedCallback) throw new Error("native file drop handler did not register");
|
||||
|
||||
await act(async () => {
|
||||
droppedCallback?.(0, 0, ["/Users/example/secret.pdf"]);
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("dropped file failure toast", () => document.body.textContent?.includes("Dropped file attach failed") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("Dropped file attach failed") === true, "AttachDropped rejection shows a visible error");
|
||||
eq(textarea.value, "drop draft", "failed dropped file attach preserves composer text");
|
||||
ok(sendButton.disabled === false, "failed dropped file attach clears the pending state");
|
||||
ok(document.body.textContent?.includes("/Users/example") === false, "dropped file failure toast does not expose the local path");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
OnFileDrop: (cb) => {
|
||||
droppedCallback = cb;
|
||||
},
|
||||
OnFileDropOff: () => {},
|
||||
};
|
||||
mockApp({
|
||||
AttachDropped: async () => ({
|
||||
kind: "attachment",
|
||||
path: ".reasonix/attachments/report.pdf",
|
||||
}),
|
||||
});
|
||||
const { root } = await renderComposer();
|
||||
if (!droppedCallback) throw new Error("native file drop handler did not register");
|
||||
|
||||
await act(async () => {
|
||||
droppedCallback?.(0, 0, ["/Users/example/report.pdf"]);
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("dropped file attachment", () => document.body.textContent?.includes("report.pdf") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("report.pdf") === true, "successful dropped file attach still renders the attachment");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
OnFileDrop: (cb) => {
|
||||
droppedCallback = cb;
|
||||
},
|
||||
OnFileDropOff: () => {},
|
||||
};
|
||||
mockApp({
|
||||
AttachDropped: async () => ({
|
||||
kind: "workspace",
|
||||
path: "__reasonix_external_folder/mock/Folder-With-Spaces",
|
||||
isDir: true,
|
||||
displayPath: "/Users/example/Folder With Spaces",
|
||||
}),
|
||||
});
|
||||
const { root, calls, rerender } = await renderComposer();
|
||||
await rerender({ insertRequest: { id: 4, text: "inspect", mode: "replace" } });
|
||||
if (!droppedCallback) throw new Error("native file drop handler did not register");
|
||||
|
||||
await act(async () => {
|
||||
droppedCallback?.(0, 0, ["/Users/example/Folder With Spaces"]);
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("dropped external folder chip", () => document.body.textContent?.includes("Folder With Spaces/") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("Folder With Spaces/") === true, "dropped external folder renders as a folder context chip");
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "inspect @/Users/example/Folder With Spaces/", "external folder display text uses the real folder path");
|
||||
eq(calls.submit.join(","), "inspect @__reasonix_external_folder/mock/Folder-With-Spaces/", "external folder submit text uses the session ref token");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const externalToken = "__reasonix_external_folder/mock/Folder-With-Spaces/src/outside.txt";
|
||||
const externalDisplayPath = "/Users/example/Folder With Spaces/src/outside.txt";
|
||||
const picked = composerPickFileEntry("ask @outside", "outside", "", {
|
||||
name: "src/outside.txt",
|
||||
path: externalToken,
|
||||
isDir: false,
|
||||
displayName: "Folder With Spaces/src/outside.txt",
|
||||
displayPath: externalDisplayPath,
|
||||
});
|
||||
eq(picked.text, "ask ", "external search selection removes the token fragment from the draft");
|
||||
eq(picked.workspaceRef?.path, externalToken, "external search selection submits the session ref token");
|
||||
eq(picked.workspaceRef?.displayPath, externalDisplayPath, "external search selection keeps the real display path");
|
||||
|
||||
const localFile = composerPickFileEntry("ask @src/mai", "src/mai", "src/", { name: "main.go", isDir: false });
|
||||
eq(localFile.text, "ask @src/main.go ", "local file selection still completes inline text");
|
||||
|
||||
const localDir = composerPickFileEntry("ask @sr", "sr", "", { name: "src", isDir: true });
|
||||
eq(localDir.text, "ask @src/", "local dir selection still keeps the menu-open slash");
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root: dropNavRoot } = await renderComposer();
|
||||
const composer = document.querySelector(".composer") as HTMLElement | null;
|
||||
if (!composer) throw new Error("composer did not render");
|
||||
|
||||
const drop = nativeFileDropEvent();
|
||||
await act(async () => {
|
||||
composer.dispatchEvent(drop);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(drop.defaultPrevented, "native file drop prevents browser image navigation");
|
||||
|
||||
await act(async () => {
|
||||
dropNavRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root: dropWrapRoot } = await renderComposer();
|
||||
const wrap = document.querySelector(".composer-wrap") as HTMLElement | null;
|
||||
if (!wrap) throw new Error("composer wrap did not render");
|
||||
|
||||
const drop = nativeFileDropEvent();
|
||||
await act(async () => {
|
||||
wrap.dispatchEvent(drop);
|
||||
await flushTimers();
|
||||
});
|
||||
ok(drop.defaultPrevented, "outer native file drop target prevents browser image navigation");
|
||||
|
||||
await act(async () => {
|
||||
dropWrapRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let rejectSubmit: (err: Error) => void = () => {};
|
||||
const rejectedSubmit = new Promise<void>((_, reject) => {
|
||||
rejectSubmit = reject;
|
||||
});
|
||||
rejectedSubmit.catch(() => {});
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
onSend: (displayText) => {
|
||||
calls.send.push(displayText);
|
||||
return rejectedSubmit;
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
rejectSubmit(new Error("workspace is still starting"));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "keep this draft", "rejected submit attempts the send once");
|
||||
eq(textarea.value, "keep this draft", "rejected submit preserves the composer draft");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
onSend: (displayText) => {
|
||||
calls.send.push(displayText);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 3, text: "send this draft", mode: "replace" } });
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "send this draft", "successful submit attempts the send once");
|
||||
eq(textarea.value, "", "successful submit clears the composer draft");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({
|
||||
running: true,
|
||||
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
|
||||
});
|
||||
|
||||
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
|
||||
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
|
||||
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
|
||||
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
|
||||
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance overflow button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
|
||||
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
|
||||
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance collapse button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
|
||||
|
||||
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
|
||||
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
onSend: (displayText, submitText) => {
|
||||
calls.send.push(displayText);
|
||||
calls.submit.push(submitText);
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
|
||||
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
|
||||
ok(sendButton.disabled === true, "running steer button stays disabled without input");
|
||||
|
||||
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running steer button enables after text input");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
|
||||
eq(textarea.value, "", "queued running guidance clears the composer draft");
|
||||
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!guidanceItem) throw new Error("running guidance chip did not render");
|
||||
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
|
||||
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
|
||||
|
||||
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
|
||||
|
||||
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
|
||||
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
|
||||
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
|
||||
await act(async () => {
|
||||
dismissButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
|
||||
|
||||
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
|
||||
|
||||
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
submitDisabled: true,
|
||||
onSend: (displayText) => {
|
||||
calls.send.push(displayText);
|
||||
},
|
||||
});
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
|
||||
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
await replaceComposerDraft(rerender, 101, "@");
|
||||
await waitFor("initial @ directory load", () => listDirCalls === 1);
|
||||
|
||||
await replaceComposerDraft(rerender, 102, "");
|
||||
await replaceComposerDraft(rerender, 103, "@");
|
||||
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
await replaceComposerDraft(rerender, 201, "@");
|
||||
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const realDateNow = Date.now;
|
||||
let now = 1000;
|
||||
let searchCalls = 0;
|
||||
Date.now = () => now;
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => {
|
||||
searchCalls += 1;
|
||||
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
try {
|
||||
await replaceComposerDraft(rerender, 301, "@alpha");
|
||||
await waitFor("initial @ search request", () => searchCalls === 1);
|
||||
eq(searchCalls, 1, "@ search fetches the first query");
|
||||
|
||||
await replaceComposerDraft(rerender, 302, "");
|
||||
now = 2000;
|
||||
await replaceComposerDraft(rerender, 303, "@alpha");
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
|
||||
|
||||
await replaceComposerDraft(rerender, 304, "");
|
||||
now = 7001;
|
||||
await replaceComposerDraft(rerender, 305, "@alpha");
|
||||
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
|
||||
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
if (listDirCalls === 1) {
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
staleListDirResolve = resolve;
|
||||
});
|
||||
}
|
||||
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
thirdListDirResolve = resolve;
|
||||
});
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await replaceComposerDraft(rerender, 401, "@cache");
|
||||
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await replaceComposerDraft(rerender, 402, "");
|
||||
await replaceComposerDraft(rerender, 403, "@cache");
|
||||
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
|
||||
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,387 @@
|
||||
// Run: tsx src/__tests__/composer-image-capability.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (check()) return;
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installBridgeApp(methods: Record<string, unknown>) {
|
||||
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
|
||||
main: {
|
||||
App: {
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
...methods,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
imageInputEnabled: true,
|
||||
tabId: "single-surface-tab",
|
||||
sessionKey: "session:project:/repo:topic-a:session-a",
|
||||
onSend: () => {},
|
||||
onCancel: () => undefined,
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: (_mode: CollaborationMode) => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<div className="chat-pane">
|
||||
<Composer {...currentProps} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, rerender: paint };
|
||||
}
|
||||
|
||||
function textarea(): HTMLTextAreaElement {
|
||||
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!node) throw new Error("composer textarea did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function sendButton(): HTMLButtonElement {
|
||||
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!node) throw new Error("send button did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function contextItemCount(): number {
|
||||
return document.querySelectorAll(".composer-context__item").length;
|
||||
}
|
||||
|
||||
function toastText(): string {
|
||||
const items = Array.from(document.querySelectorAll(".toast__text"));
|
||||
return (items.at(-1)?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function imagePasteEvent(file: File): Event {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: [file.type],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function imageViewerOpen(): boolean {
|
||||
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
|
||||
}
|
||||
|
||||
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const paint = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<div className="chat-pane">
|
||||
<UserMessage text={text} {...props} />
|
||||
</div>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
return { root, paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer image capability");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let saveCalls = 0;
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => {
|
||||
saveCalls += 1;
|
||||
return ".reasonix/attachments/mock.png";
|
||||
},
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: false });
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => contextItemCount() === 1);
|
||||
|
||||
eq(saveCalls, 1, "text-only model stores pasted image attachments as tool-readable refs");
|
||||
eq(contextItemCount(), 1, "text-only model keeps the pasted image attachment in the draft");
|
||||
eq(toastText(), "", "text-only image attach does not warn before send");
|
||||
eq(document.querySelector(".composer__prompt") === null, true, "image attach warning does not render inside the composer layout");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const sent: Array<{ display: string; submit?: string }> = [];
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, rerender } = await renderComposer({
|
||||
imageInputEnabled: true,
|
||||
onSend: (display, submit) => sent.push({ display, submit }),
|
||||
});
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => contextItemCount() === 1);
|
||||
eq(contextItemCount(), 1, "vision-capable model keeps the pasted image attachment");
|
||||
|
||||
await rerender({ imageInputEnabled: false, insertRequest: { id: 1, text: "describe this image", mode: "insert" } });
|
||||
eq(toastText(), "", "model switch alone does not show a warning toast");
|
||||
await act(async () => {
|
||||
sendButton().click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
|
||||
ok(toastText().includes("will not receive images directly"), "text-only send warns about direct image input without blocking");
|
||||
eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
|
||||
ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing composer image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing message image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
|
||||
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
|
||||
|
||||
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
|
||||
if (!close) throw new Error("missing image viewer close button");
|
||||
await act(async () => {
|
||||
close.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
|
||||
turn: 1,
|
||||
onEdit: () => true,
|
||||
});
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
|
||||
if (!edit) throw new Error("missing message edit button");
|
||||
await act(async () => {
|
||||
edit.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing edit image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -7,10 +7,19 @@ import {
|
||||
hydrateComposerProfileFromMeta,
|
||||
hydrateComposerProfilesFromTabs,
|
||||
patchComposerProfile,
|
||||
pruneUserPlanModeIntents,
|
||||
resolvePlanRestoreTabId,
|
||||
shouldRestoreUserPlanMode,
|
||||
shouldRestoreUserPlanModeForProfile,
|
||||
updateUserPlanModeIntent,
|
||||
type ComposerProfilesByTab,
|
||||
type UserPlanModeIntents,
|
||||
} from "../lib/composerProfile";
|
||||
import type { Meta, TabMeta } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
type LooseMeta = Omit<Meta, "toolApprovalMode"> & { toolApprovalMode?: Meta["toolApprovalMode"] | "" };
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -24,7 +33,7 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function tab(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-1",
|
||||
scope: "project",
|
||||
@@ -44,10 +53,10 @@ function tab(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
...overrides,
|
||||
};
|
||||
} as TabMeta;
|
||||
}
|
||||
|
||||
function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
function meta(overrides: Partial<LooseMeta> = {}): Meta {
|
||||
return {
|
||||
label: "DeepSeek-R1",
|
||||
ready: true,
|
||||
@@ -61,7 +70,7 @@ function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
} as Meta;
|
||||
}
|
||||
|
||||
console.log("\ncomposer profile");
|
||||
@@ -138,5 +147,59 @@ console.log("\ncomposer profile");
|
||||
eq(Boolean(profiles["tab-2"]), false, "tab hydration removes profiles for closed tabs");
|
||||
}
|
||||
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "auto" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "" })]);
|
||||
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "blank tab payload does not demote explicit auto approval mode to ask");
|
||||
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "" }));
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "blank meta payload does not demote explicit auto approval mode to ask");
|
||||
}
|
||||
|
||||
{
|
||||
let intents: UserPlanModeIntents = {};
|
||||
intents = updateUserPlanModeIntent(intents, "tab-1", true);
|
||||
intents = updateUserPlanModeIntent(intents, "tab-2", false);
|
||||
|
||||
eq(shouldRestoreUserPlanMode(intents, "tab-1"), true, "manual plan intent restores only the tab that enabled it");
|
||||
eq(shouldRestoreUserPlanMode(intents, "tab-2"), false, "normal tabs do not inherit another tab's plan intent");
|
||||
|
||||
intents = updateUserPlanModeIntent(intents, "tab-1", false);
|
||||
eq(shouldRestoreUserPlanMode(intents, "tab-1"), false, "manual normal mode clears plan restore intent");
|
||||
}
|
||||
|
||||
{
|
||||
let intents: UserPlanModeIntents = {};
|
||||
intents = updateUserPlanModeIntent(intents, "tab-1", true);
|
||||
intents = updateUserPlanModeIntent(intents, "tab-2", true);
|
||||
intents = pruneUserPlanModeIntents(intents, ["tab-2"]);
|
||||
|
||||
eq(shouldRestoreUserPlanMode(intents, "tab-1"), false, "closed tabs lose plan restore intent");
|
||||
eq(shouldRestoreUserPlanMode(intents, "tab-2"), true, "open tabs keep plan restore intent");
|
||||
}
|
||||
|
||||
{
|
||||
eq(resolvePlanRestoreTabId("finished-tab", "active-tab"), "finished-tab", "turn_done plan restore uses the event tab when present");
|
||||
eq(resolvePlanRestoreTabId(undefined, "active-tab"), "active-tab", "turn_done plan restore falls back to the active tab for legacy events");
|
||||
}
|
||||
|
||||
{
|
||||
let intents: UserPlanModeIntents = {};
|
||||
intents = updateUserPlanModeIntent(intents, "tab-1", true);
|
||||
|
||||
eq(
|
||||
shouldRestoreUserPlanModeForProfile(intents, "tab-1", { goal: "research task" }),
|
||||
false,
|
||||
"active goals block remembered plan restoration",
|
||||
);
|
||||
eq(
|
||||
shouldRestoreUserPlanModeForProfile(intents, "tab-1", { goal: "" }),
|
||||
true,
|
||||
"empty goals still allow remembered plan restoration",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,311 @@
|
||||
// Run: tsx src/__tests__/composer-session-draft.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installBridgeApp(methods: Record<string, unknown>) {
|
||||
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
|
||||
main: {
|
||||
App: {
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
...methods,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
tabId: "single-surface-tab",
|
||||
sessionKey: "session:project:/repo:topic-a:session-a",
|
||||
onSend: () => {},
|
||||
onCancel: () => undefined,
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: (_mode: CollaborationMode) => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, rerender: paint };
|
||||
}
|
||||
|
||||
function textarea(): HTMLTextAreaElement {
|
||||
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!node) throw new Error("composer textarea did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function sendButton(): HTMLButtonElement {
|
||||
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!node) throw new Error("composer send button did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function contextItemCount(): number {
|
||||
return document.querySelectorAll(".composer-context__item").length;
|
||||
}
|
||||
|
||||
function textPasteEvent(text: string): Event {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [],
|
||||
items: [],
|
||||
types: ["text/plain"],
|
||||
getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""),
|
||||
},
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
console.log("\ncomposer session draft");
|
||||
|
||||
{
|
||||
const withoutPath = composerDraftKeyForTab({
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "",
|
||||
}, "tab-a");
|
||||
const withPath = composerDraftKeyForTab({
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/repo/.reasonix/sessions/topic-a.jsonl",
|
||||
}, "tab-a");
|
||||
eq(withPath, withoutPath, "topic draft key stays stable when session path appears");
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
await rerender({ insertRequest: { id: 1, text: "draft for A", mode: "replace" } });
|
||||
await rerender({ insertRequest: { id: 2, text: "@/repo/src/app.ts", mode: "insert" } });
|
||||
eq(textarea().value, "draft for A", "session A text is visible before switching");
|
||||
eq(contextItemCount(), 1, "session A workspace ref is visible before switching");
|
||||
|
||||
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
|
||||
eq(textarea().value, "", "session B does not inherit session A text");
|
||||
eq(contextItemCount(), 0, "session B does not inherit session A context refs");
|
||||
|
||||
await rerender({ insertRequest: { id: 3, text: "draft for B", mode: "replace" } });
|
||||
eq(textarea().value, "draft for B", "session B keeps its own text draft");
|
||||
|
||||
await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
|
||||
eq(textarea().value, "draft for A", "session A text is restored when switching back");
|
||||
eq(contextItemCount(), 1, "session A context refs are restored when switching back");
|
||||
|
||||
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
|
||||
eq(textarea().value, "draft for B", "session B text is restored independently");
|
||||
eq(contextItemCount(), 0, "session B context refs stay isolated");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const sent: Array<{ display: string; submit?: string }> = [];
|
||||
const { root } = await renderComposer({
|
||||
onSend: (display, submit) => {
|
||||
sent.push({ display, submit });
|
||||
},
|
||||
});
|
||||
const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
|
||||
const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
|
||||
const event = textPasteEvent(rawPaste);
|
||||
|
||||
await act(async () => {
|
||||
const input = textarea();
|
||||
input.selectionStart = input.selectionEnd = 0;
|
||||
input.dispatchEvent(event);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(event.defaultPrevented, true, "short text paste is handled by React state");
|
||||
eq(textarea().value, normalizedPaste, "short multiline paste is visible in the composer");
|
||||
|
||||
await act(async () => {
|
||||
sendButton().click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(sent.length, 1, "short multiline paste submits once");
|
||||
eq(sent[0]?.display, normalizedPaste, "short multiline paste is preserved in display text");
|
||||
eq(sent[0]?.submit, normalizedPaste, "short multiline paste is preserved in submit text");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const saveStarted = deferred<void>();
|
||||
const savePastedFile = deferred<string>();
|
||||
installBridgeApp({
|
||||
SavePastedFile: async () => {
|
||||
saveStarted.resolve();
|
||||
return savePastedFile.promise;
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
const file = new File(["draft attachment"], "draft.txt", { type: "text/plain", lastModified: 1 });
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: [],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(event);
|
||||
await saveStarted.promise;
|
||||
});
|
||||
await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
|
||||
await act(async () => {
|
||||
savePastedFile.resolve("/tmp/reasonix/draft.txt");
|
||||
await flushTimers();
|
||||
});
|
||||
eq(contextItemCount(), 0, "async attachment does not land in the switched-to session");
|
||||
|
||||
await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
|
||||
eq(contextItemCount(), 1, "async attachment returns to the source session draft");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { contextBreakdown, contextCostDisplay, formatCacheHitRate } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -49,7 +49,24 @@ eq(
|
||||
42_124,
|
||||
"legend values sum to used context tokens",
|
||||
);
|
||||
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
|
||||
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
|
||||
|
||||
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
|
||||
eq(
|
||||
{
|
||||
promptTokens: issue5283.promptTokens,
|
||||
completionTokens: issue5283.completionTokens,
|
||||
reasoningTokens: issue5283.reasoningTokens,
|
||||
otherTokens: issue5283.otherTokens,
|
||||
},
|
||||
{
|
||||
promptTokens: 6840,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 48,
|
||||
otherTokens: 0,
|
||||
},
|
||||
"prompt tokens are not scaled down when used context includes completion tokens",
|
||||
);
|
||||
|
||||
const oversized = contextBreakdown(61_000, 1_000_000, 1_622_277, 12_049, 3_217);
|
||||
eq(
|
||||
@@ -73,9 +90,16 @@ eq(
|
||||
reasoningPct: 0,
|
||||
otherPct: 0,
|
||||
},
|
||||
"unknown context window keeps donut segments empty",
|
||||
"unknown context window keeps usage segments empty",
|
||||
);
|
||||
|
||||
console.log("\ncontext window status");
|
||||
|
||||
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
|
||||
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
|
||||
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
|
||||
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
|
||||
|
||||
console.log("\ncontext panel cost");
|
||||
|
||||
const infoCost = contextCostDisplay({
|
||||
@@ -101,6 +125,143 @@ console.log("\ncontext panel cache rate");
|
||||
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
|
||||
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
|
||||
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
|
||||
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
|
||||
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
|
||||
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
|
||||
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
|
||||
|
||||
console.log("\ncontext panel usage refresh key");
|
||||
|
||||
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
|
||||
ok(
|
||||
contextUsageRefreshKey({
|
||||
totalTokens: 10,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}) !== contextUsageRefreshKey({
|
||||
totalTokens: 11,
|
||||
promptTokens: 10,
|
||||
completionTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}),
|
||||
"general token changes refresh even when cache counters stay unchanged",
|
||||
);
|
||||
|
||||
console.log("\ncontext panel source rows");
|
||||
|
||||
const sourceRows = contextSourceRows({
|
||||
usedTokens: 0,
|
||||
windowTokens: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
sessionCompletionTokens: 0,
|
||||
readFiles: [],
|
||||
changedFiles: [],
|
||||
sources: {
|
||||
planner: {
|
||||
promptTokens: 200,
|
||||
completionTokens: 20,
|
||||
totalTokens: 220,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
requestCount: 1,
|
||||
},
|
||||
executor: {
|
||||
promptTokens: 1000,
|
||||
completionTokens: 120,
|
||||
totalTokens: 1120,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 700,
|
||||
cacheMissTokens: 300,
|
||||
requestCount: 2,
|
||||
sessionCost: 0.42,
|
||||
sessionCurrency: "¥",
|
||||
},
|
||||
},
|
||||
}, "¥");
|
||||
|
||||
eq(sourceRows.map((row) => row.source), ["executor", "planner"], "source rows keep executor before planner");
|
||||
eq(
|
||||
{
|
||||
input: sourceRows[0].promptTokens,
|
||||
output: sourceRows[0].completionTokens,
|
||||
hit: sourceRows[0].cacheHitTokens,
|
||||
miss: sourceRows[0].cacheMissTokens,
|
||||
requests: sourceRows[0].requests,
|
||||
},
|
||||
{ input: 1000, output: 120, hit: 700, miss: 300, requests: 2 },
|
||||
"executor source row exposes input, output, cache hit, cache miss, and request count",
|
||||
);
|
||||
eq(sourceRows[1].requests, 1, "planner source row remains visible without cache metadata");
|
||||
eq(sourceRows[1].cacheHitTokens + sourceRows[1].cacheMissTokens, 0, "planner source preserves absent cache metadata as empty");
|
||||
|
||||
const executorOnlyRows = contextSourceRows({
|
||||
usedTokens: 0,
|
||||
windowTokens: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
sessionCompletionTokens: 0,
|
||||
readFiles: [],
|
||||
changedFiles: [],
|
||||
sources: {
|
||||
planner: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
requestCount: 0,
|
||||
},
|
||||
executor: {
|
||||
promptTokens: 4000,
|
||||
completionTokens: 800,
|
||||
totalTokens: 4800,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 2500,
|
||||
cacheMissTokens: 500,
|
||||
requestCount: 3,
|
||||
},
|
||||
subagent: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
requestCount: 0,
|
||||
},
|
||||
},
|
||||
}, "¥");
|
||||
eq(executorOnlyRows.map((row) => row.source), ["executor"], "source rows omit unused planner and subagent entries");
|
||||
|
||||
console.log("\ncontext panel metric token labels");
|
||||
|
||||
const exactMetric = formatMetricTokens(999_999, "en");
|
||||
eq(exactMetric.display, "999,999", "sub-million metric tokens keep exact comma formatting");
|
||||
eq(exactMetric.exact, "999,999", "sub-million exact metric title matches the display");
|
||||
|
||||
const largeMetric = formatMetricTokens(123_456_789, "en");
|
||||
eq(largeMetric.display, "123,456,789", "large metric tokens keep exact comma formatting");
|
||||
eq(largeMetric.exact, "123,456,789", "large metric exact title matches the display");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildCrashPayload,
|
||||
buildPerformancePayload,
|
||||
formatPerformanceContext,
|
||||
globalCrashReportReason,
|
||||
normalizeCrashError,
|
||||
parseReportedPerf,
|
||||
performanceLabelForReason,
|
||||
@@ -80,6 +81,25 @@ eq(
|
||||
false,
|
||||
"checks ErrorEvent.error when ErrorEvent.message is a wrapper",
|
||||
);
|
||||
eq(
|
||||
globalCrashReportReason({
|
||||
defaultPrevented: false,
|
||||
message: "Script error.",
|
||||
filename: "wails://wails/assets/index-abc123.js",
|
||||
lineno: 42,
|
||||
colno: 7,
|
||||
}),
|
||||
"Script error.\nfilename=wails://wails/assets/index-abc123.js lineno=42 colno=7",
|
||||
"adds script location to opaque window.error messages",
|
||||
);
|
||||
eq(
|
||||
globalCrashReportReason({
|
||||
defaultPrevented: false,
|
||||
message: "Script error.",
|
||||
}),
|
||||
"Script error.",
|
||||
"keeps opaque script errors bare when WebView provides no location",
|
||||
);
|
||||
|
||||
const perf: PerformanceSnapshot = {
|
||||
reason: "event loop lag 1300ms",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Run: tsx src/__tests__/heartbeat-next-run.test.ts
|
||||
|
||||
import { heartbeatBuildCycleInterval, heartbeatNextRunAt } from "../custom/features/heartbeat/HeartbeatPanel";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function localMs(year: number, month: number, day: number, hour: number, minute: number): number {
|
||||
return new Date(year, month - 1, day, hour, minute, 0, 0).getTime();
|
||||
}
|
||||
|
||||
console.log("\nheartbeat next run");
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30) },
|
||||
localMs(2026, 6, 18, 17, 20),
|
||||
),
|
||||
localMs(2026, 6, 18, 17, 0),
|
||||
"plain interval stays due after elapsed",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 30), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
|
||||
localMs(2026, 6, 18, 17, 20),
|
||||
),
|
||||
localMs(2026, 6, 19, 9, 0),
|
||||
"time window defers elapsed interval to next opening",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 16, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
|
||||
localMs(2026, 6, 18, 16, 10),
|
||||
),
|
||||
localMs(2026, 6, 18, 16, 30),
|
||||
"time window keeps next run inside the open window",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 21, 50), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
|
||||
localMs(2026, 6, 18, 22, 10),
|
||||
),
|
||||
localMs(2026, 6, 18, 22, 20),
|
||||
"cross-midnight window keeps due time in the open window",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "30m", lastRunAt: localMs(2026, 6, 18, 11, 30), timeWindowStart: "22:00", timeWindowEnd: "06:00" },
|
||||
localMs(2026, 6, 18, 12, 10),
|
||||
),
|
||||
localMs(2026, 6, 18, 22, 0),
|
||||
"cross-midnight window waits for today's opening from midday",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatNextRunAt(
|
||||
{ interval: "24h|daily@20:00", lastRunAt: localMs(2026, 6, 18, 20, 0), timeWindowStart: "09:00", timeWindowEnd: "17:00" },
|
||||
localMs(2026, 6, 19, 19, 0),
|
||||
),
|
||||
localMs(2026, 6, 19, 20, 0),
|
||||
"cycle next run ignores stale interval time windows",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatBuildCycleInterval("daily", [], "09:00"),
|
||||
"24h|weekly:mon@09:00",
|
||||
"empty daily day selection does not save as every day",
|
||||
);
|
||||
|
||||
eq(
|
||||
heartbeatBuildCycleInterval("weekly", [], "09:00"),
|
||||
"168h|weekly:mon@09:00",
|
||||
"weekly default uses one weekday",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -29,6 +29,25 @@ const userTimeItems = historyMessagesToItems([
|
||||
eq(userTimeItems[0]?.kind === "user" && userTimeItems[0].createdAt, undefined, "history users without createdAt keep no timestamp");
|
||||
eq(userTimeItems[1]?.kind === "user" && userTimeItems[1].createdAt, 1_718_000_000_000, "history users preserve createdAt when present");
|
||||
|
||||
const checkpointTurnItems = historyMessagesToItems([
|
||||
{ role: "user", content: "first", checkpointTurn: 0 },
|
||||
{ role: "assistant", content: "ok" },
|
||||
{ role: "user", content: "second", checkpointTurn: 3 },
|
||||
] as HistoryMessage[], "c").items.filter((item) => item.kind === "user");
|
||||
eq(checkpointTurnItems[0]?.kind === "user" && checkpointTurnItems[0].checkpointTurn, 0, "history users preserve checkpoint turn zero");
|
||||
eq(checkpointTurnItems[1]?.kind === "user" && checkpointTurnItems[1].checkpointTurn, 3, "history users preserve non-contiguous backend checkpoint turns");
|
||||
|
||||
const citedAssistant = historyMessagesToItems([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
memoryCitations: [{ id: "mem-1", source: "MEMORY.md", lineStart: 116, lineEnd: 123, note: "workflow" }],
|
||||
},
|
||||
] as HistoryMessage[], "m").items.filter((item) => item.kind === "assistant");
|
||||
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.length, 1, "assistant history preserves memory citations");
|
||||
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.[0]?.source, "MEMORY.md", "assistant memory citation source is preserved");
|
||||
eq(citedAssistant[0]?.kind === "assistant" && citedAssistant[0].memoryCitations?.[0]?.lineEnd, 123, "assistant memory citation line range is preserved");
|
||||
|
||||
const lowercaseError = toolItems([
|
||||
{
|
||||
role: "assistant",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Run: tsx src/__tests__/keyboard-shortcuts.test.ts
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
defaultShortcutCombo,
|
||||
formatShortcutCombo,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
shortcutConflict,
|
||||
type ShortcutPlatform,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { topicShortcutIndexFromEvent, topicShortcutLabel } from "../lib/topicShortcuts";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -23,13 +26,31 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function event(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean } = {}) {
|
||||
function event(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {}) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
defaultPrevented: modifiers.defaultPrevented ?? false,
|
||||
target: null,
|
||||
};
|
||||
}
|
||||
|
||||
function eventWithTarget(
|
||||
key: string,
|
||||
target: EventTarget | null,
|
||||
modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; defaultPrevented?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
altKey: modifiers.altKey ?? false,
|
||||
shiftKey: modifiers.shiftKey ?? false,
|
||||
defaultPrevented: modifiers.defaultPrevented ?? false,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +65,7 @@ eq(isCloseTabShortcut(event("w", { ctrlKey: true }), "linux"), true, "Ctrl+W clo
|
||||
|
||||
for (const platform of ["darwin", "windows", "linux"] satisfies ShortcutPlatform[]) {
|
||||
eq(isCloseTabShortcut(event("k", { ctrlKey: true, metaKey: true }), platform), false, `${platform} ignores non-W keys`);
|
||||
eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`);
|
||||
eq(isCloseTabShortcut(event("w"), platform), false, `${platform} requires the platform modifier`);
|
||||
}
|
||||
|
||||
eq(matchesShortcut(event("k", { metaKey: true }), "commandPalette.open", "darwin"), true, "Cmd+K opens the palette on macOS");
|
||||
@@ -56,6 +77,25 @@ eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open",
|
||||
eq(formatShortcutCombo(defaultShortcutCombo("settings.open", "windows"), "windows"), "Ctrl+,", "formats Windows settings shortcut");
|
||||
eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open", "windows"), "windows")), JSON.stringify(["Ctrl", ","]), "splits Windows settings shortcut for display");
|
||||
eq(shortcutConflict("settings.open", defaultShortcutCombo("commandPalette.open", "darwin"), "darwin")?.action, "commandPalette.open", "detects shortcut conflicts");
|
||||
eq(topicShortcutIndexFromEvent(event("1", { metaKey: true }), "darwin"), 0, "Cmd+1 maps to the first topic shortcut on macOS");
|
||||
eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true }), "darwin"), null, "Ctrl+1 is not a topic shortcut on macOS");
|
||||
eq(topicShortcutIndexFromEvent(event("9", { ctrlKey: true }), "windows"), 8, "Ctrl+9 maps to the ninth topic shortcut on Windows");
|
||||
eq(topicShortcutIndexFromEvent(event("9", { metaKey: true }), "windows"), null, "Meta+9 is not a topic shortcut on Windows");
|
||||
eq(topicShortcutIndexFromEvent(event("1", { ctrlKey: true, shiftKey: true }), "linux"), null, "topic shortcuts reject extra modifiers");
|
||||
eq(topicShortcutIndexFromEvent(event("0", { metaKey: true }), "darwin"), null, "Cmd+0 is not a topic shortcut");
|
||||
eq(topicShortcutIndexFromEvent(event("1", { metaKey: true, defaultPrevented: true }), "darwin"), null, "topic shortcuts yield to already-handled custom shortcuts");
|
||||
{
|
||||
const dom = new JSDOM("<!doctype html><html><body><input /><textarea></textarea></body></html>");
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
const input = dom.window.document.querySelector("input");
|
||||
const textarea = dom.window.document.querySelector("textarea");
|
||||
eq(topicShortcutIndexFromEvent(eventWithTarget("1", input, { metaKey: true }), "darwin"), 0, "Cmd+1 still works when an input has focus");
|
||||
eq(topicShortcutIndexFromEvent(eventWithTarget("9", textarea, { ctrlKey: true }), "windows"), 8, "Ctrl+9 still works when a textarea has focus");
|
||||
globalThis.HTMLElement = previousHTMLElement;
|
||||
}
|
||||
eq(topicShortcutLabel(1, "darwin"), "⌘1", "topic badge uses the macOS command glyph");
|
||||
eq(topicShortcutLabel(1, "windows"), "Ctrl+1", "topic badge uses the Windows control modifier");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -133,6 +133,17 @@ check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
|
||||
check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
|
||||
check("$x+1$", () => isLikelyInlineMath("x+1") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
|
||||
check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
|
||||
check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
|
||||
check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
|
||||
check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
|
||||
check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
|
||||
check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
|
||||
check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
|
||||
check("$+$", () => isLikelyInlineMath("+") === true);
|
||||
check("$=$", () => isLikelyInlineMath("=") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — currency/link (NOT math)");
|
||||
check("$5", () => isLikelyInlineMath("5") === false);
|
||||
check("$10", () => isLikelyInlineMath("10") === false);
|
||||
@@ -153,6 +164,15 @@ check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyI
|
||||
check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
|
||||
check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
|
||||
check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
|
||||
check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
|
||||
check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
|
||||
check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
|
||||
check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
|
||||
check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
|
||||
check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
|
||||
// LLMs frequently emit minimal LaTeX in math contexts that the older
|
||||
// classifier rejected as currency / word tokens. These tests pin down the
|
||||
@@ -170,6 +190,8 @@ check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath
|
||||
check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
|
||||
check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
|
||||
check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
|
||||
check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
|
||||
check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
|
||||
check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
|
||||
check("$S$ with surrounding prose (regression)", () => {
|
||||
return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
|
||||
@@ -217,7 +239,7 @@ check("\\|x\\| renders as double bars", () => {
|
||||
|
||||
console.log("\nnormalizeMath — LLM delimiter conversion");
|
||||
eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
|
||||
eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$");
|
||||
eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
|
||||
eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
|
||||
|
||||
console.log("\nnormalizeMath — \\slashed conversion (regression)");
|
||||
@@ -235,19 +257,17 @@ console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
|
||||
// line before any $$ preceded by a letter/closing bracket/etc.
|
||||
check("inline $$ after prose", () => {
|
||||
const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
|
||||
return /^decomposes as\n\n\$\$/.test(out) && out.includes("\\mathbf{6}");
|
||||
return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
|
||||
});
|
||||
check("inline $$ after closing bracket", () => {
|
||||
const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
|
||||
return out.startsWith("(octet)\n\n$$");
|
||||
return out.startsWith("(octet)\n$$");
|
||||
});
|
||||
check("inline $$ after closing brace (\\end{...}$$)", () => {
|
||||
// A model that writes `\end{array}$$` or `\frac{a}{b}$$` on one line
|
||||
// has the same micromark-fence problem as the comma case. The
|
||||
// closing brace is the most common end-of-content marker in LaTeX
|
||||
// math, so the repair-regex character class includes it.
|
||||
// A display equation ending with }$$ must be extracted as a unit.
|
||||
// The closing $$ must not be split off, or the equation body is emptied.
|
||||
const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
|
||||
return out.includes("\\end{pmatrix},\n\n$$") || out.includes("\\end{pmatrix}\n\n$$");
|
||||
return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
|
||||
});
|
||||
check("inline $$ after comma on same line as content", () => {
|
||||
// User-reported (2026-06-12, soft-pion chat): the model wrote the
|
||||
@@ -261,25 +281,31 @@ check("inline $$ after comma on same line as content", () => {
|
||||
// as math, which then fails to render with "Can't use function '$'
|
||||
// in math mode" on the stray $ inside the equation body.
|
||||
const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
|
||||
return out.includes("D(q^2),\n\n$$");
|
||||
return out.includes("D(q^2),\n$$");
|
||||
});
|
||||
check("well-formed $$ already on own line is normalised consistently", () => {
|
||||
// Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
|
||||
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce the same
|
||||
// remark-math-parseable form: opening $$ on its own line, body, blank
|
||||
// line, closing $$ on its own line.
|
||||
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
|
||||
// remark-math-parseable form: opening $$ on its own line, body, closing
|
||||
// $$ on its own line.
|
||||
const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
|
||||
const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
|
||||
const expected = "decomposes as\n\n$$\n\\mathbf{6}.\n\n$$";
|
||||
return inline === expected && block === expected;
|
||||
const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
|
||||
return valid(inline) && valid(block);
|
||||
});
|
||||
check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
|
||||
return normalizeMath("\\[E=mc^2\\]") === "$$E=mc^2$$";
|
||||
return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
|
||||
});
|
||||
check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
|
||||
const out = normalizeMath("c^2$$ x $$");
|
||||
return out === "c^2$$ x $$";
|
||||
});
|
||||
eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
|
||||
eq(
|
||||
normalizeMath("first$$a$$ middle $$b$$ end"),
|
||||
"first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
|
||||
"multiple display blocks on one line are all normalised",
|
||||
);
|
||||
|
||||
console.log("\nnormalizeMath — non-math dollar filtering");
|
||||
eq(normalizeMath("costs $1$ today"), "costs $1$ today", "$1$ not math");
|
||||
@@ -409,6 +435,10 @@ const e2e: Array<[string, string]> = [
|
||||
["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
|
||||
["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
|
||||
["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
|
||||
["$S'$", "primed letter S'"],
|
||||
["$f'(x)$", "primed function call"],
|
||||
["$[56]$", "bracketed irrep label"],
|
||||
["$[56,0^+]$", "bracketed irrep label with charge"],
|
||||
];
|
||||
for (const [src, label] of e2e) {
|
||||
check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
|
||||
@@ -458,6 +488,19 @@ check("real inline math $x^2$ still renders as KaTeX", () => {
|
||||
const html = renderHtml("the value $x^2$ here");
|
||||
return html.includes("katex");
|
||||
});
|
||||
check("blockquote display math does not swallow following inline math", () => {
|
||||
const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
|
||||
return html.includes("katex-display")
|
||||
&& !html.includes("katex-error")
|
||||
&& html.includes(">x</mi>");
|
||||
});
|
||||
check("multi-line blockquote display math strips quote markers from the formula", () => {
|
||||
const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
|
||||
return html.includes("katex-display")
|
||||
&& !html.includes("katex-error")
|
||||
&& !html.includes("> E")
|
||||
&& html.includes(">x</mi>");
|
||||
});
|
||||
|
||||
// ── Young diagram / tableau macros ─────────────────────────────────────────────
|
||||
// `\yng` (ytableau) and `\young` (youngtab) are common in physics —
|
||||
@@ -486,7 +529,7 @@ check("\\yng inside \\(...\\) does not get double-wrapped", () => {
|
||||
});
|
||||
check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
|
||||
const out = normalizeMath("\\[\\yng(2,1)\\]");
|
||||
return out.startsWith("$$\\begin{array}{l}")
|
||||
return out.startsWith("$$\n\\begin{array}{l}")
|
||||
&& out.endsWith("$$")
|
||||
&& !out.includes("$$$");
|
||||
});
|
||||
@@ -505,7 +548,7 @@ check("digit-starting inline math with \\young does not get nested wrappers", ()
|
||||
});
|
||||
check("display math ending in digit closes before following bare \\yng", () => {
|
||||
const out = normalizeMath("$$x^2$$ \\yng(1)");
|
||||
return out === "$$x^2$$ $\\begin{array}{l}\\square\\end{array}$";
|
||||
return out === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
|
||||
});
|
||||
check("bare \\yng after inline math is separated from adjacent dollars", () => {
|
||||
const out = normalizeMath("$x$\\yng(1)");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run: tsx src/__tests__/memory-citation-visibility.test.ts
|
||||
|
||||
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
|
||||
import type { MemoryCitation } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nmemory citation visibility");
|
||||
|
||||
const compilerCitations: MemoryCitation[] = [
|
||||
{ kind: "compiler_reference", source: "Memory v5", note: "evidence: bash succeeded" },
|
||||
{ kind: "constraint", source: "Memory v5", note: "must_use: verify source" },
|
||||
{ kind: "risk_note", source: "Memory v5", note: "risk: avoid failed strategy" },
|
||||
];
|
||||
eq(visibleTranscriptMemoryCitations(compilerCitations).length, 0, "compiler-only citations stay out of the transcript");
|
||||
|
||||
const regularCitation: MemoryCitation = { id: "mem-1", source: "MEMORY.md", lineStart: 12, note: "workflow" };
|
||||
eq(visibleTranscriptMemoryCitations([regularCitation])[0]?.source, "MEMORY.md", "regular memory citations remain visible");
|
||||
|
||||
const mixed = visibleTranscriptMemoryCitations([...compilerCitations, regularCitation]);
|
||||
eq(mixed.length, 1, "mixed citations keep only user-facing memory references");
|
||||
eq(mixed[0]?.id, "mem-1", "mixed citations preserve the regular memory citation");
|
||||
|
||||
const compilerWithOtherSource: MemoryCitation = { kind: "compiler_reference", source: "MEMORY.md", note: "explicit memory file reference" };
|
||||
eq(visibleTranscriptMemoryCitations([compilerWithOtherSource]).length, 1, "non-Memory-v5 compiler kind is not hidden by kind alone");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Run: tsx src/__tests__/memory-compiler-display.test.ts
|
||||
//
|
||||
// Display-boundary safety net for #5361: a corrupted/accreted Memory v5 contract
|
||||
// from the pre-fix goal loop (#5342) must never render as raw JSON "乱码".
|
||||
|
||||
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const contract = (sourceEvent: string) =>
|
||||
"<memory-compiler-execution>\n" +
|
||||
JSON.stringify({ type: "memory_v5_execution_contract", planner_ir: { version: 5, source_event: sourceEvent } }) +
|
||||
"\n</memory-compiler-execution>";
|
||||
|
||||
console.log("\nmemory compiler display strip");
|
||||
|
||||
ok(stripMemoryCompilerExecution("fix the login bug") === "fix the login bug", "leaves plain user text untouched");
|
||||
|
||||
const complete = stripMemoryCompilerExecution(contract("do the thing"));
|
||||
ok(!complete.includes("memory-compiler-execution"), "removes a complete contract block");
|
||||
|
||||
const withText = stripMemoryCompilerExecution("hello\n" + contract("hello"));
|
||||
ok(withText.includes("hello") && !withText.includes("<memory-compiler-execution>"), "removes a complete block after user text");
|
||||
|
||||
const partial = 'keep this\n<memory-compiler-execution>\n{"planner_ir":{"source_event":"keep this",' + "x".repeat(40);
|
||||
const cut = stripMemoryCompilerExecution(partial);
|
||||
ok(
|
||||
cut.includes("keep this") && !cut.includes("<memory-compiler-execution>") && !cut.includes("planner_ir"),
|
||||
"cuts a dangling/truncated block instead of leaking raw JSON",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,248 @@
|
||||
// Run: tsx src/__tests__/mermaid-rendering.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import MermaidDiagram from "../components/MermaidDiagram";
|
||||
import {
|
||||
__setMermaidPanZoomFactoryForTest,
|
||||
__setMermaidRenderAdapterForTest,
|
||||
isOpenableMermaidHref,
|
||||
isSafeMermaidHref,
|
||||
sanitizeMermaidSvg,
|
||||
} from "../components/MermaidDiagram";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
if (predicate()) return;
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.SVGElement = dom.window.SVGElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
globalThis.XMLSerializer = dom.window.XMLSerializer;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
globalThis.matchMedia = dom.window.matchMedia;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = styles;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return dom;
|
||||
}
|
||||
|
||||
function parseSvg(svg: string): Document {
|
||||
return new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
}
|
||||
|
||||
console.log("\nmermaid rendering");
|
||||
|
||||
{
|
||||
ok(
|
||||
markdownRendererSource.includes('lazy(() => import("./MermaidDiagram"))'),
|
||||
"MarkdownRenderer lazy-loads the Mermaid renderer",
|
||||
);
|
||||
ok(
|
||||
markdownRendererSource.includes('lang === "mermaid"'),
|
||||
"MarkdownRenderer routes mermaid fenced code blocks to the Mermaid renderer",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const dirtySvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="steal()">
|
||||
<script>alert(1)</script>
|
||||
<a id="safe" href="https://example.com/diagram"><text>safe</text></a>
|
||||
<a id="unsafe" href="javascript:alert(1)"><text>bad</text></a>
|
||||
<a id="unsafe-xlink" xlink:href="data:text/html,boom"><text>bad</text></a>
|
||||
<g onclick="steal()"><text>node</text></g>
|
||||
</svg>`;
|
||||
const sanitized = sanitizeMermaidSvg(dirtySvg);
|
||||
const doc = parseSvg(sanitized);
|
||||
|
||||
ok(!doc.documentElement.hasAttribute("onload"), "sanitizer strips event attributes from the root SVG");
|
||||
ok(!doc.querySelector("script"), "sanitizer removes script nodes");
|
||||
ok(doc.querySelector("#safe")?.getAttribute("href") === "https://example.com/diagram", "sanitizer keeps safe external links");
|
||||
ok(!doc.querySelector("#unsafe")?.hasAttribute("href"), "sanitizer removes javascript links");
|
||||
ok(!doc.querySelector("#unsafe-xlink")?.hasAttribute("xlink:href"), "sanitizer removes data xlink links");
|
||||
ok(!doc.querySelector("g")?.hasAttribute("onclick"), "sanitizer strips event attributes from child nodes");
|
||||
ok(isSafeMermaidHref("https://example.com/a"), "https Mermaid links are safe");
|
||||
ok(isSafeMermaidHref("mailto:[email protected]"), "mailto Mermaid links are safe");
|
||||
ok(!isSafeMermaidHref("file:///tmp/private"), "file Mermaid links are not safe");
|
||||
ok(!isOpenableMermaidHref("#internal"), "fragment Mermaid links are not opened externally");
|
||||
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const openedUrls: string[] = [];
|
||||
dom.window.open = ((url: string | URL | undefined) => {
|
||||
if (url) openedUrls.push(String(url));
|
||||
return null;
|
||||
}) as Window["open"];
|
||||
|
||||
const renders: Array<{ definition: string; theme: string }> = [];
|
||||
const panZoomCalls: string[] = [];
|
||||
|
||||
__setMermaidRenderAdapterForTest(async (_svgId, definition, theme, signal) => {
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
renders.push({ definition, theme });
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 80" onload="steal()">
|
||||
<script>alert(1)</script>
|
||||
<a id="safe-link" href="https://example.com/diagram"><text>Open</text></a>
|
||||
<a id="unsafe-link" href="vbscript:msgbox(1)"><text>Blocked</text></a>
|
||||
<g class="node"><text>Rendered Mermaid</text></g>
|
||||
</svg>`;
|
||||
});
|
||||
|
||||
__setMermaidPanZoomFactoryForTest(() => ({
|
||||
destroy: () => { panZoomCalls.push("destroy"); },
|
||||
resize: () => { panZoomCalls.push("resize"); },
|
||||
fit: () => { panZoomCalls.push("fit"); },
|
||||
center: () => { panZoomCalls.push("center"); },
|
||||
zoomIn: () => { panZoomCalls.push("zoomIn"); },
|
||||
zoomOut: () => { panZoomCalls.push("zoomOut"); },
|
||||
reset: () => { panZoomCalls.push("reset"); },
|
||||
}));
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<div className="chat-pane">
|
||||
<MermaidDiagram definition={"graph TD\nA-->B"} />
|
||||
</div>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await waitFor("Mermaid preview SVG rendered in DOM", () => Boolean(document.querySelector(".mermaid-diagram__preview svg")));
|
||||
ok(document.querySelector(".mermaid-diagram__toolbar"), "Mermaid renderer shows its toolbar");
|
||||
eq(renders.length, 1, "Mermaid renderer calls the render adapter once");
|
||||
eq(renders[0]?.definition, "graph TD\nA-->B", "Mermaid renderer passes the diagram definition to Mermaid");
|
||||
ok(document.querySelector("#safe-link"), "safe SVG link remains in the rendered DOM");
|
||||
ok(!document.querySelector("#unsafe-link")?.hasAttribute("href"), "unsafe SVG link href is stripped in the rendered DOM");
|
||||
ok(!document.querySelector(".mermaid-diagram__preview svg")?.hasAttribute("onload"), "rendered SVG root event handler is stripped");
|
||||
ok(!document.querySelector(".mermaid-diagram__preview script"), "rendered SVG script nodes are removed");
|
||||
|
||||
await waitFor("pan zoom instance initialized", () => panZoomCalls.includes("fit") && panZoomCalls.includes("center"));
|
||||
|
||||
const zoomIn = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]');
|
||||
const zoomOut = document.querySelector<HTMLButtonElement>('button[aria-label="Zoom out"]');
|
||||
const reset = document.querySelector<HTMLButtonElement>('button[aria-label="Reset zoom"]');
|
||||
zoomIn?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
zoomOut?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
reset?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
ok(panZoomCalls.includes("zoomIn"), "zoom in button calls the pan zoom instance");
|
||||
ok(panZoomCalls.includes("zoomOut"), "zoom out button calls the pan zoom instance");
|
||||
ok(panZoomCalls.includes("reset"), "reset zoom button calls the pan zoom instance");
|
||||
|
||||
document.querySelector("#safe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
eq(openedUrls[0], "https://example.com/diagram", "SVG links open through the external browser bridge");
|
||||
document.querySelector("#unsafe-link text")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
eq(openedUrls.length, 1, "unsafe SVG links do not open externally");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="Show diagram source"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".mermaid-diagram__code")?.textContent?.includes("graph TD"), "source tab shows the Mermaid definition");
|
||||
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="Open fullscreen"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "fullscreen diagram portals into the chat pane");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(!document.querySelector(".chat-pane > .mermaid-diagram--fullscreen"), "Escape closes the Mermaid fullscreen portal");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
__setMermaidRenderAdapterForTest(null);
|
||||
__setMermaidPanZoomFactoryForTest(undefined);
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
|
||||
|
||||
import { parsePastedBlocks } from "../components/Message";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapped(label: string, content: string): string {
|
||||
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
|
||||
}
|
||||
|
||||
console.log("\nmessage pasted blocks");
|
||||
|
||||
for (const [label, name] of [
|
||||
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
|
||||
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
|
||||
["[Pasted text #2 · 31 lines]", "English"],
|
||||
] as const) {
|
||||
eq(
|
||||
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
|
||||
[{ label, content: "line 1\nline 2\n" }],
|
||||
`${name} pasted text labels are parsed from submit text`,
|
||||
);
|
||||
}
|
||||
|
||||
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useController } from "../lib/useController";
|
||||
import { initialState, reducer, useController, type Item } from "../lib/useController";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
@@ -91,11 +91,35 @@ function meta(): Meta {
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nnew session load race");
|
||||
|
||||
const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }];
|
||||
const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }];
|
||||
const resetState = reducer(
|
||||
{
|
||||
...initialState,
|
||||
items: resetSourceItems,
|
||||
hydrating: true,
|
||||
hydrateReason: "open-topic",
|
||||
hydratePlaceholderItems: resetPlaceholderItems,
|
||||
},
|
||||
{ type: "reset" },
|
||||
);
|
||||
eq(resetState.items.length, 0, "reset clears real transcript items");
|
||||
eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately");
|
||||
|
||||
const emptyHistoryState = reducer(resetState, { type: "history", messages: [] });
|
||||
eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty");
|
||||
eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded");
|
||||
eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items");
|
||||
|
||||
const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" });
|
||||
eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -137,6 +161,11 @@ window.go = {
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => staleHistory.promise,
|
||||
HistoryPageForTab: async () => {
|
||||
const messages = await staleHistory.promise;
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
@@ -181,6 +210,77 @@ eq(controller?.state.items.length, 0, "stale history load cannot repopulate a ne
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
const staleProjectA = "/repo/project-a";
|
||||
const targetProjectB = "/repo/project-b";
|
||||
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => guardedStartupTabs.promise,
|
||||
MetaForTab: async (tabID: string) => tabID === "tab-new"
|
||||
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
|
||||
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
|
||||
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
|
||||
return tabMeta({
|
||||
id: "tab-new",
|
||||
topicId: "topic-new",
|
||||
topicTitle: "New session",
|
||||
workspaceRoot: targetProjectB,
|
||||
workspaceName: "project-b",
|
||||
workspacePath: targetProjectB,
|
||||
cwd: targetProjectB,
|
||||
});
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
controller = undefined;
|
||||
const guardRoot = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
guardRoot.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await controller?.ensureBlankSurface("project", targetProjectB);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
|
||||
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
|
||||
|
||||
await act(async () => {
|
||||
guardedStartupTabs.resolve([tabMeta({
|
||||
id: "tab-old",
|
||||
topicId: "topic-old",
|
||||
topicTitle: "Old session",
|
||||
workspaceRoot: staleProjectA,
|
||||
workspaceName: "project-a",
|
||||
workspacePath: staleProjectA,
|
||||
cwd: staleProjectA,
|
||||
})]);
|
||||
await guardedStartupTabs.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
|
||||
|
||||
await act(async () => {
|
||||
guardRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Run: tsx src/__tests__/open-topic-coalescing.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act, useCallback, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { enqueueNavigationRequest, enqueueOpenTopicRequest, type PendingOpenTopicRequest } from "../lib/openTopicCoalescing";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
console.log("\nopen topic coalescing");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const gates = new Map<string, ReturnType<typeof deferred<void>>>();
|
||||
const calls: string[] = [];
|
||||
const updates: string[] = [];
|
||||
const toasts: string[] = [];
|
||||
let refreshes = 0;
|
||||
let openTopic!: (topicId: string) => Promise<void>;
|
||||
|
||||
function gateFor(topicId: string) {
|
||||
let gate = gates.get(topicId);
|
||||
if (!gate) {
|
||||
gate = deferred<void>();
|
||||
gates.set(topicId, gate);
|
||||
}
|
||||
return gate;
|
||||
}
|
||||
|
||||
function resetRecords() {
|
||||
calls.length = 0;
|
||||
updates.length = 0;
|
||||
toasts.length = 0;
|
||||
refreshes = 0;
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const seqRef = useRef(0);
|
||||
const runningRef = useRef(false);
|
||||
const pendingRef = useRef<PendingOpenTopicRequest | null>(null);
|
||||
const run = useCallback(async (request: PendingOpenTopicRequest) => {
|
||||
calls.push(request.topicId);
|
||||
try {
|
||||
await gateFor(request.topicId).promise;
|
||||
if (request.topicId.includes("fail")) throw new Error(request.topicId);
|
||||
if (request.seq !== seqRef.current) return;
|
||||
updates.push(request.topicId);
|
||||
refreshes += 1;
|
||||
} catch {
|
||||
if (request.seq !== seqRef.current) return;
|
||||
toasts.push(request.topicId);
|
||||
refreshes += 1;
|
||||
}
|
||||
}, []);
|
||||
openTopic = (topicId: string) => enqueueOpenTopicRequest(
|
||||
{ seqRef, runningRef, pendingRef },
|
||||
{ scope: "global", workspaceRoot: "", topicId },
|
||||
run,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
let bResolved = false;
|
||||
let cResolved = false;
|
||||
let dResolved = false;
|
||||
const pA = openTopic("A");
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
const pB = openTopic("B").then(() => { bResolved = true; });
|
||||
const pC = openTopic("C").then(() => { cResolved = true; });
|
||||
const pD = openTopic("D").then(() => { dResolved = true; });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(calls.join(","), "A", "only the running request starts while newer requests coalesce");
|
||||
ok(bResolved && cResolved, "superseded pending requests resolve immediately");
|
||||
eq(dResolved, false, "latest pending request waits until it runs");
|
||||
|
||||
await act(async () => {
|
||||
gateFor("A").resolve();
|
||||
await pA;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(calls.join(","), "A,D", "after the running request finishes, only the latest pending request runs");
|
||||
eq(updates.join(","), "", "stale running request does not update UI");
|
||||
|
||||
await act(async () => {
|
||||
gateFor("D").resolve();
|
||||
await pD;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(updates.join(","), "D", "latest coalesced request updates UI");
|
||||
eq(refreshes, 1, "only the latest successful request refreshes metadata");
|
||||
|
||||
await pB;
|
||||
await pC;
|
||||
|
||||
resetRecords();
|
||||
let oldFailRejected = false;
|
||||
let newFailRejected = false;
|
||||
const pOldFail = openTopic("old-fail").catch(() => { oldFailRejected = true; });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
const pNewFail = openTopic("new-fail").catch(() => { newFailRejected = true; });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
gateFor("old-fail").resolve();
|
||||
await pOldFail;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(calls.join(","), "old-fail,new-fail", "latest pending request starts after an older failing request");
|
||||
eq(toasts.join(","), "", "stale failure does not show a toast");
|
||||
eq(oldFailRejected, false, "stale failure promise does not reject");
|
||||
|
||||
await act(async () => {
|
||||
gateFor("new-fail").resolve();
|
||||
await pNewFail;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(toasts.join(","), "new-fail", "latest failure shows a toast");
|
||||
eq(newFailRejected, false, "latest failure promise does not reject");
|
||||
eq(refreshes, 1, "latest failure refreshes metadata once");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
// Tab-bar switches (App.handleTabChange) route through the same scheduler so
|
||||
// rapidly clicking between two running sessions can't run switchTab()
|
||||
// concurrently — concurrent switches race the backend SetActiveTab ordering and
|
||||
// land events on the wrong session (#5352). This guards serialization (no two
|
||||
// run()s overlap) + last-click-wins.
|
||||
{
|
||||
const refs = { seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null as any } };
|
||||
let active = 0;
|
||||
let maxConcurrent = 0;
|
||||
const ran: string[] = [];
|
||||
const gates = new Map<string, ReturnType<typeof deferred<void>>>();
|
||||
const gate = (id: string) => {
|
||||
if (!gates.has(id)) gates.set(id, deferred<void>());
|
||||
return gates.get(id)!;
|
||||
};
|
||||
const switchTab = (req: { tabId: string }) =>
|
||||
enqueueNavigationRequest(refs, { tabId: req.tabId }, async (r) => {
|
||||
active += 1;
|
||||
maxConcurrent = Math.max(maxConcurrent, active);
|
||||
await gate(r.tabId).promise;
|
||||
ran.push(r.tabId);
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
const pA = switchTab({ tabId: "A" }); // starts running
|
||||
const pB = switchTab({ tabId: "B" }); // coalesced away (superseded while A runs)
|
||||
const pC = switchTab({ tabId: "C" }); // latest pending
|
||||
gate("A").resolve();
|
||||
await pA;
|
||||
await flushPromises();
|
||||
gate("C").resolve();
|
||||
await pC;
|
||||
await pB;
|
||||
await flushPromises();
|
||||
|
||||
eq(ran.join(","), "A,C", "tab switches serialize: only the running + latest run, middle coalesces");
|
||||
eq(maxConcurrent, 1, "no two tab switches run concurrently (no backend SetActiveTab race)");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
defaultExpandedProjectTreeKeys,
|
||||
activeSessionAncestorKeys,
|
||||
projectTreeTopicOpenRequest,
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -103,6 +107,85 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
const completedTopic: ProjectNode = {
|
||||
key: "topic_complete",
|
||||
kind: "topic",
|
||||
label: "Completed",
|
||||
root: "/repo",
|
||||
topicId: "topic-complete",
|
||||
lastActivityAt: 2000,
|
||||
};
|
||||
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
true,
|
||||
"completed inactive topic with newer activity shows unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"completed topic stops showing unread attention once opened at its latest activity",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
|
||||
false,
|
||||
"active topic does not show unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"running topic keeps runtime status instead of completed-unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, false),
|
||||
true,
|
||||
"read workbench topic renders hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, true),
|
||||
false,
|
||||
"unread workbench topic omits hover actions from the keyboard tab order",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(true, true, false),
|
||||
false,
|
||||
"runtime session rows do not render topic hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldSuppressOpenForRename(
|
||||
{ rowKey: "topic-a", canRename: true },
|
||||
{ rowKey: "topic-a", canRename: true },
|
||||
),
|
||||
true,
|
||||
"second click on the same renameable topic suppresses open for inline rename",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldSuppressOpenForRename(
|
||||
{ rowKey: "session-a", canRename: false },
|
||||
{ rowKey: "session-a", canRename: false },
|
||||
),
|
||||
false,
|
||||
"runtime session double-click still allows the session row to open",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldSuppressOpenForRename(
|
||||
{ rowKey: "topic-a", canRename: true },
|
||||
{ rowKey: "topic-b", canRename: true },
|
||||
),
|
||||
false,
|
||||
"quickly clicking a different topic still opens the new target",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeFolderDisclosure(false, true),
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Run: tsx src/__tests__/provider-model-refresh.test.ts
|
||||
|
||||
import {
|
||||
apiKeyEnvFromProviderName,
|
||||
inferredVisionModels,
|
||||
isLikelyChatModel,
|
||||
isLikelyVisionModel,
|
||||
@@ -46,14 +47,14 @@ eq(
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
["mimo-v2.5-pro"],
|
||||
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
"manual access refresh can show provider catalog as unsaved candidates",
|
||||
);
|
||||
|
||||
@@ -139,6 +140,21 @@ eq(
|
||||
"preserves an explicitly configured key env",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
apiKeyEnvFromProviderName("商汤"),
|
||||
apiKeyEnvFromProviderName("通义千问"),
|
||||
],
|
||||
["CUSTOM_d39b9067_API_KEY", "CUSTOM_e995c4c9_API_KEY"],
|
||||
"generates distinct stable key envs for non-ASCII provider names",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerApiKeyEnvForSave("商汤", "CUSTOM_API_KEY", "sk-test"),
|
||||
"CUSTOM_API_KEY",
|
||||
"preserves an explicitly configured legacy custom key env",
|
||||
);
|
||||
|
||||
eq(
|
||||
[
|
||||
providerRequiresKey({ apiKeyEnv: "" }),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Run: tsx src/__tests__/resize-drag.test.ts
|
||||
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nresizeDrag");
|
||||
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
}) as typeof requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = ((id: number) => {
|
||||
frames[id - 1] = () => undefined;
|
||||
}) as typeof cancelAnimationFrame;
|
||||
|
||||
try {
|
||||
const applied: Array<[string, string]> = [];
|
||||
const attrs: Record<string, string> = {};
|
||||
const updater = createRafResizeUpdater({
|
||||
target: {
|
||||
style: {
|
||||
setProperty(name: string, value: string) {
|
||||
applied.push([name, value]);
|
||||
},
|
||||
},
|
||||
},
|
||||
separator: {
|
||||
setAttribute(name: string, value: string) {
|
||||
attrs[name] = value;
|
||||
},
|
||||
},
|
||||
cssVar: "--pane-width",
|
||||
});
|
||||
|
||||
updater.schedule(101.2);
|
||||
updater.schedule(148.7);
|
||||
eq(applied.length, 0, "live resize waits for animation frame");
|
||||
eq(frames.length, 1, "multiple pointer moves share one animation frame");
|
||||
frames[0](0);
|
||||
eq(applied.length, 1, "one frame applies one live resize");
|
||||
eq(applied[0]?.[0], "--pane-width", "live resize writes the configured CSS variable");
|
||||
eq(applied[0]?.[1], "149px", "live resize applies the latest rounded pixel value");
|
||||
eq(attrs["aria-valuenow"], "149", "live resize keeps separator ARIA in sync");
|
||||
|
||||
frames.length = 0;
|
||||
const flushed: Array<[string, string]> = [];
|
||||
const flushUpdater = createRafResizeUpdater({
|
||||
target: {
|
||||
style: {
|
||||
setProperty(name: string, value: string) {
|
||||
flushed.push([name, value]);
|
||||
},
|
||||
},
|
||||
},
|
||||
cssVar: "--pane-width",
|
||||
});
|
||||
flushUpdater.schedule(600);
|
||||
frames[0](0);
|
||||
flushUpdater.schedule(420);
|
||||
eq(flushed[flushed.length - 1]?.[1], "600px", "pending final resize waits for animation frame before flush");
|
||||
flushUpdater.flush();
|
||||
eq(flushed[flushed.length - 1]?.[1], "420px", "flush applies the final resize before React state commits");
|
||||
|
||||
frames.length = 0;
|
||||
const appliedValues: number[] = [];
|
||||
const callbackUpdater = createRafResizeUpdater({
|
||||
target: {
|
||||
style: {
|
||||
setProperty() {
|
||||
// CSS write is covered above; this block checks the synchronized callback contract.
|
||||
},
|
||||
},
|
||||
},
|
||||
cssVar: "--pane-width",
|
||||
onApply(value) {
|
||||
appliedValues.push(value);
|
||||
},
|
||||
});
|
||||
callbackUpdater.schedule(279.7);
|
||||
callbackUpdater.schedule(300.2);
|
||||
eq(appliedValues.length, 0, "live resize callback waits for animation frame");
|
||||
frames[0](0);
|
||||
eq(appliedValues.join(","), "300", "live resize callback receives the applied rounded value");
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,5 +1,8 @@
|
||||
// Run: tsx src/__tests__/send-failed.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { initialState, reducer, replayPendingPromptsForActiveTab } from "../lib/useController";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
@@ -35,6 +38,41 @@ const confirmed = reducer(sent, { type: "event", e: { kind: "text", text: "hi" }
|
||||
eq(confirmed.items.filter((it) => it.kind === "user").length, 1, "first backend event confirms without duplicating");
|
||||
eq(confirmed.pendingUser, undefined, "confirmation clears the pending marker");
|
||||
|
||||
const memoryStatsEvent = {
|
||||
kind: "memory_compiler_stats",
|
||||
memoryCompiler: {
|
||||
injected: true,
|
||||
usefulIR: true,
|
||||
compiledTokens: 640,
|
||||
irOverheadTokens: 120,
|
||||
memoryReferences: 2,
|
||||
constraints: 1,
|
||||
riskNotes: 0,
|
||||
executionSteps: 3,
|
||||
totalNodes: 18,
|
||||
highSignalNodes: 4,
|
||||
toolResultNodes: 6,
|
||||
decisionNodes: 2,
|
||||
strategyCount: 5,
|
||||
learningCount: 3,
|
||||
},
|
||||
} as WireEvent;
|
||||
const statsOnly = reducer(sent, { type: "event", e: memoryStatsEvent });
|
||||
eq(statsOnly, sent, "memory compiler stats do not confirm or mutate the visible turn");
|
||||
const startedThenStats = reducer(reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }), { type: "event", e: memoryStatsEvent });
|
||||
eq(startedThenStats.items.length, 2, "memory compiler stats do not add transcript items after turn start");
|
||||
const compilerCitationMessage = {
|
||||
kind: "message",
|
||||
memoryCitations: [{ kind: "compiler_reference", source: "Memory v5", note: "evidence: bash succeeded" }],
|
||||
} as WireEvent;
|
||||
const citationOnlyFinal = reducer(reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent }), { type: "event", e: compilerCitationMessage });
|
||||
eq(citationOnlyFinal.items.length, 1, "memory compiler citations alone do not leave an empty assistant bubble");
|
||||
eq(citationOnlyFinal.items.some((it) => it.kind === "assistant"), false, "memory compiler citations alone stay hidden from the transcript");
|
||||
const textThenCitationFinal = reducer(reducer(startedThenStats, { type: "event", e: { kind: "text", text: "done" } as WireEvent }), { type: "event", e: compilerCitationMessage });
|
||||
const citedAssistant = textThenCitationFinal.items.find((it) => it.kind === "assistant");
|
||||
eq(citedAssistant?.kind === "assistant" && citedAssistant.text, "done", "memory compiler citations preserve existing assistant text");
|
||||
eq(citedAssistant?.kind === "assistant" && citedAssistant.memoryCitations?.length, 1, "memory compiler citations attach to real assistant content");
|
||||
|
||||
const failedState = reducer(sent, { type: "send_failed", error: "Send failed: bridge unavailable" });
|
||||
const failedBubble = failedState.items.find((it) => it.kind === "user");
|
||||
eq(failedBubble?.kind === "user" && failedBubble.failed, true, "send_failed marks the bubble failed");
|
||||
@@ -44,13 +82,67 @@ eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning");
|
||||
eq(failedState.running, false, "send_failed stops the running indicator");
|
||||
eq(failedState.pendingUser, undefined, "send_failed clears the pending marker");
|
||||
|
||||
const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 });
|
||||
const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" });
|
||||
const shellNotice = shellFailed.items[shellFailed.items.length - 1];
|
||||
eq(shellNotice.kind, "notice", "rejected shell command appends a visible notice");
|
||||
eq(shellNotice.kind === "notice" && shellNotice.text.includes("workspace is still starting"), true, "shell rejection notice includes the backend error");
|
||||
|
||||
const lateFailure = reducer(confirmed, { type: "send_failed", error: "Send failed: late" });
|
||||
eq(lateFailure, confirmed, "send_failed after backend confirmation is a no-op");
|
||||
|
||||
const beforeMcpReady = { ...initialState };
|
||||
const mcpReady = reducer(beforeMcpReady, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
|
||||
eq(mcpReady, beforeMcpReady, "mcp_surface_ready is accepted as a deliberate no-op");
|
||||
const pendingMcpReady = reducer(sent, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
|
||||
eq(pendingMcpReady, sent, "mcp_surface_ready does not confirm a pending submit");
|
||||
const failedAfterMcpReady = reducer(pendingMcpReady, { type: "send_failed", error: "Send failed: bridge unavailable" });
|
||||
const failedAfterMcpReadyBubble = failedAfterMcpReady.items.find((it) => it.kind === "user");
|
||||
eq(
|
||||
failedAfterMcpReadyBubble?.kind === "user" && failedAfterMcpReadyBubble.failed,
|
||||
true,
|
||||
"send_failed still marks a pending submit after mcp readiness",
|
||||
);
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8");
|
||||
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
|
||||
eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready");
|
||||
eq(controllerSource.includes('e.kind === "memory_compiler_stats" || e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation");
|
||||
eq(
|
||||
/state\.approval!\.tool === "exit_plan_mode" && allow\) await applyCollaborationMode\("normal"\);/.test(appSource),
|
||||
true,
|
||||
"plan approval clears the remembered plan restore intent before execution",
|
||||
);
|
||||
eq(
|
||||
!/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
|
||||
true,
|
||||
"plan approval must not preserve stale plan restore intent",
|
||||
);
|
||||
eq(
|
||||
!appSource.includes("rememberUserIntent"),
|
||||
true,
|
||||
"collaboration mode changes always reconcile the remembered plan restore intent",
|
||||
);
|
||||
|
||||
const unsent = reducer(sent, { type: "unsend" });
|
||||
eq(unsent.pendingUser, undefined, "unsend clears the pending marker");
|
||||
eq(unsent.discardTurn, true, "unsend discards the in-flight turn");
|
||||
|
||||
const planApprovalFirst = reducer(
|
||||
{ ...initialState },
|
||||
{ type: "event", e: { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent },
|
||||
);
|
||||
const planTurnDoneAfter = reducer(planApprovalFirst, { type: "event", e: { kind: "turn_done" } as WireEvent });
|
||||
eq(
|
||||
planTurnDoneAfter.approval?.id,
|
||||
"plan-1",
|
||||
"turn_done preserves out-of-order plan approval",
|
||||
);
|
||||
eq(planTurnDoneAfter.running, true, "preserved plan approval keeps the tab running");
|
||||
eq(planTurnDoneAfter.pendingPrompt, true, "preserved plan approval keeps the prompt gate active");
|
||||
|
||||
let replayCalls = 0;
|
||||
replayPendingPromptsForActiveTab(undefined, () => {
|
||||
replayCalls += 1;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Run: tsx src/__tests__/session-title-contract.test.ts
|
||||
|
||||
import {
|
||||
historySessionDisplayTitle,
|
||||
paletteSessionDisplayTitle,
|
||||
paletteSessionHint,
|
||||
paletteSessionKeywords,
|
||||
} from "../lib/session";
|
||||
import type { SessionMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq<T>(actual: T, expected: T, label: string) {
|
||||
if (Object.is(actual, expected)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n expected: ${String(expected)}\n actual: ${String(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function deepEq<T>(actual: T, expected: T, label: string) {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson === expectedJson) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n expected: ${expectedJson}\n actual: ${actualJson}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function session(overrides: Partial<SessionMeta> = {}): SessionMeta {
|
||||
return {
|
||||
path: "/sessions/topic-backed.jsonl",
|
||||
preview: "first saved prompt preview",
|
||||
title: "Renamed saved session",
|
||||
turns: 3,
|
||||
createdAt: 100,
|
||||
lastActivityAt: 200,
|
||||
modTime: 200,
|
||||
current: false,
|
||||
open: false,
|
||||
workspaceRoot: "/work/project-alpha",
|
||||
topicId: "topic-1",
|
||||
topicTitle: "Shared topic",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nsession title contracts");
|
||||
|
||||
{
|
||||
const item = session();
|
||||
eq(
|
||||
paletteSessionDisplayTitle(item, "Untitled"),
|
||||
"Shared topic",
|
||||
"Cmd+K displays the topic title for topic-backed sessions",
|
||||
);
|
||||
deepEq(
|
||||
paletteSessionKeywords(item),
|
||||
["Renamed saved session", "first saved prompt preview"],
|
||||
"Cmd+K still searches the session rename and preview text",
|
||||
);
|
||||
eq(
|
||||
paletteSessionHint(item),
|
||||
"Renamed saved session · /work/project-alpha",
|
||||
"Cmd+K shows the session rename in the hint so hidden search hits are visible",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const item = session();
|
||||
eq(
|
||||
historySessionDisplayTitle(item, "Untitled"),
|
||||
"Renamed saved session",
|
||||
"History displays an explicit session rename before the topic title",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const item = session({ title: " " });
|
||||
eq(
|
||||
historySessionDisplayTitle(item, "Untitled"),
|
||||
"Shared topic",
|
||||
"Unrenamed topic-backed history sessions fall back to topic title",
|
||||
);
|
||||
deepEq(
|
||||
paletteSessionKeywords(item),
|
||||
["first saved prompt preview"],
|
||||
"Blank session titles are not added as Cmd+K keywords",
|
||||
);
|
||||
eq(
|
||||
paletteSessionHint(item),
|
||||
"first saved prompt preview · /work/project-alpha",
|
||||
"Cmd+K falls back to preview text in the hint when there is no custom session title",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const item = session({ title: "Shared topic", preview: "first saved prompt preview" });
|
||||
eq(
|
||||
paletteSessionHint(item),
|
||||
"first saved prompt preview · /work/project-alpha",
|
||||
"Cmd+K skips duplicate title hints that repeat the visible topic title",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -4,7 +4,14 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SettingsPanel } from "../components/SettingsPanel";
|
||||
import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
} from "../components/SettingsPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { SettingsView } from "../lib/types";
|
||||
@@ -34,6 +41,16 @@ function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView {
|
||||
return {
|
||||
defaultModel: "",
|
||||
@@ -43,18 +60,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
autoPlan: "off",
|
||||
providers: [],
|
||||
officialProviders: [],
|
||||
providerPresets: [],
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
|
||||
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: { qq: [], feishu: [], weixin: [] },
|
||||
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
|
||||
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: false,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: [],
|
||||
weixinUsers: [],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: {
|
||||
enabled: false,
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
secretSet: false,
|
||||
sandbox: false,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
|
||||
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
|
||||
connections: [],
|
||||
@@ -67,9 +118,11 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
displayMode,
|
||||
statusBarStyle: "text",
|
||||
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
|
||||
defaultToolApprovalMode: "ask",
|
||||
checkUpdates: true,
|
||||
telemetry: true,
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "/tmp/reasonix/config.toml",
|
||||
providerKinds: [],
|
||||
autoApproveTools: false,
|
||||
@@ -79,6 +132,21 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
|
||||
console.log("\nsettings refresh snapshot");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
|
||||
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
|
||||
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
|
||||
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
|
||||
let extraBodyRejected = false;
|
||||
try {
|
||||
parseProviderExtraBody("[true]");
|
||||
} catch {
|
||||
extraBodyRejected = true;
|
||||
}
|
||||
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -148,6 +216,137 @@ ok(onChangedSettings?.displayMode === "compact", "onChanged receives the post-sa
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
const retryRootEl = document.createElement("div");
|
||||
document.body.appendChild(retryRootEl);
|
||||
const retryRoot = createRoot(retryRootEl);
|
||||
let failingSettingsCalls = 0;
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => {
|
||||
failingSettingsCalls += 1;
|
||||
if (failingSettingsCalls === 1) throw new Error("/Users/example/.reasonix/settings.toml: permission denied");
|
||||
return baseSettings("standard");
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
retryRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("settings load failure", () => Boolean(document.querySelector(".banner--error")));
|
||||
|
||||
ok(document.body.textContent?.includes("Settings could not be loaded.") === true, "failed initial settings load shows a visible error");
|
||||
ok(document.body.textContent?.includes("Loading…") === false, "failed initial settings load stops showing the loading state");
|
||||
|
||||
const retryButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry") as HTMLButtonElement | undefined;
|
||||
if (!retryButton) throw new Error("settings retry button did not render");
|
||||
|
||||
await act(async () => {
|
||||
retryButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("settings retry success", () => Boolean(Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact")));
|
||||
|
||||
eq(failingSettingsCalls, 2, "settings retry calls Settings again");
|
||||
ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error");
|
||||
|
||||
await act(async () => {
|
||||
retryRoot.unmount();
|
||||
});
|
||||
|
||||
// Bots tab: direct four-channel bot manager.
|
||||
const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
const botsRoot = createRoot(botsRootEl);
|
||||
const botsSettings = baseSettings("standard");
|
||||
botsSettings.bot.connections = [
|
||||
{
|
||||
id: "conn-feishu-1",
|
||||
provider: "feishu",
|
||||
domain: "feishu",
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
|
||||
sessionMappings: [],
|
||||
lastError: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => botsSettings,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
|
||||
|
||||
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
|
||||
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
|
||||
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
|
||||
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
|
||||
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
|
||||
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
|
||||
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
|
||||
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
|
||||
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
|
||||
|
||||
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
|
||||
if (!feishuTab) throw new Error("Feishu channel tab did not render");
|
||||
await act(async () => {
|
||||
feishuTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
|
||||
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
|
||||
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
|
||||
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
|
||||
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
|
||||
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
|
||||
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
|
||||
const modelIndex = selectedBotDetailText.indexOf("Model");
|
||||
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
|
||||
ok(
|
||||
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
|
||||
"selected bot detail places enable, approval, and model controls between summary and access control",
|
||||
);
|
||||
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
|
||||
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
|
||||
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -25,8 +25,6 @@ function renderStatusBar(props: Partial<Parameters<typeof StatusBar>[0]> = {}):
|
||||
<StatusBar
|
||||
context={{ used: 0, window: 0, sessionTokens: 0 }}
|
||||
running={false}
|
||||
collaborationMode="normal"
|
||||
toolApprovalMode="ask"
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
@@ -80,5 +78,16 @@ console.log("\nstatus bar workspace");
|
||||
ok(html.indexOf("feature/meta") < html.indexOf("workspace/repo"), "workspace items follow configured order");
|
||||
}
|
||||
|
||||
{
|
||||
const html = renderStatusBar({ items: ["model"] });
|
||||
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
|
||||
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
|
||||
}
|
||||
|
||||
{
|
||||
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
|
||||
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,518 @@
|
||||
// Run: tsx src/__tests__/tab-switch-hydration.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
const workspaceRoot = `/repo/${id}`;
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot,
|
||||
workspaceName: id,
|
||||
workspacePath: workspaceRoot,
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
|
||||
label: `model-${id}`,
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
cwd: workspaceRoot,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(content: string): HistoryMessage {
|
||||
return { role: "user", content };
|
||||
}
|
||||
|
||||
console.log("\ntab switch hydration");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabD = tabMeta("tab-d");
|
||||
const tabE = tabMeta("tab-e");
|
||||
const tabF = tabMeta("tab-f");
|
||||
const tabG = tabMeta("tab-g");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
const setActiveFGate = deferred<void>();
|
||||
const submitTabCGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let holdNextContextForD = false;
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
ContextUsageForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-d" && holdNextContextForD) {
|
||||
contextDCalls += 1;
|
||||
holdNextContextForD = false;
|
||||
return contextDGate.promise;
|
||||
}
|
||||
if (tabID === "tab-d") contextDCalls += 1;
|
||||
return context;
|
||||
},
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (tabID: string) => {
|
||||
historyCalls.push(tabID);
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
const messages = await window.go.main.App.HistoryForTab(tabID);
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
OpenProjectTab: async () => {
|
||||
backendActiveId = "tab-d";
|
||||
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
},
|
||||
Fork: async () => {
|
||||
tabsById.set("tab-e", tabE);
|
||||
backendActiveId = "tab-e";
|
||||
runningTabs.add("tab-e");
|
||||
return { ...tabE, active: true, running: true };
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls += 1;
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
if (tabID === "tab-e") await setActiveEGate.promise;
|
||||
if (tabID === "tab-f") await setActiveFGate.promise;
|
||||
if (tabID === failSetActiveFor) throw new Error("persist failed");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
cancelCalls.push(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
if (tabID === "tab-c") await submitTabCGate.promise;
|
||||
},
|
||||
SubmitDisplayToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("initial active tab", () => controller?.activeTabId === "tab-a" && controller.state.items.length === 1);
|
||||
|
||||
let switchToB: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToB = controller?.switchTab("tab-b", tabB);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(setActiveCalls, 1, "SetActiveTab is called for the selected tab");
|
||||
eq(controller?.activeTabId, "tab-b", "switchTab updates the active tab before backend activation resolves");
|
||||
eq(controller?.state.meta?.label, "model-tab-b", "switchTab applies optimistic tab metadata immediately");
|
||||
eq(controller?.state.items.length, 0, "uncached target tab does not keep the previous transcript visible");
|
||||
eq(controller?.state.hydrating, true, "target tab shows lightweight hydration state while backend activation is pending");
|
||||
eq(controller?.state.backendActivationPending, true, "target tab gates unscoped actions while backend activation is pending");
|
||||
ok(!historyCalls.includes("tab-b"), "HistoryForTab is not requested before SetActiveTab completes");
|
||||
|
||||
let newSessionWhileSwitching: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
newSessionWhileSwitching = controller?.newSession();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(newSessionCalls, 0, "newSession waits for backend activation before using the unscoped binding");
|
||||
|
||||
await act(async () => {
|
||||
setActiveBGate.resolve();
|
||||
await switchToB;
|
||||
await newSessionWhileSwitching;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(newSessionCalls, 1, "newSession runs after the selected tab is active in the backend");
|
||||
await waitFor("tab-b history request", () => historyCalls.includes("tab-b"));
|
||||
|
||||
const historyCallsBeforeReturnToA = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips history hydration when reselected");
|
||||
|
||||
await act(async () => {
|
||||
historyB.resolve([userMessage("late B")]);
|
||||
await historyB.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-a", "late history for another tab does not change the active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
|
||||
|
||||
const historyCallsBeforeFallbackSync = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
|
||||
await flushPromises();
|
||||
});
|
||||
backendActiveId = "tab-b";
|
||||
await act(async () => {
|
||||
await controller?.syncActiveTab(false);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
|
||||
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
runningTabs.add("tab-e");
|
||||
let switchToE: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
|
||||
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
|
||||
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
|
||||
await act(async () => {
|
||||
controller?.cancel();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
|
||||
await act(async () => {
|
||||
setActiveEGate.resolve();
|
||||
await switchToE;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
let switchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToF = controller?.switchTab("tab-f", tabF);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
|
||||
let switchToG: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToG = controller?.switchTab("tab-g", tabG);
|
||||
await switchToG;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
|
||||
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
|
||||
await act(async () => {
|
||||
setActiveFGate.resolve();
|
||||
await switchToF;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
|
||||
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
|
||||
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
failSetActiveFor = "tab-b";
|
||||
const historyCallsBeforeFailedSwitch = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-b", tabB);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
|
||||
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
|
||||
failSetActiveFor = "";
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
|
||||
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("cached planner transcript", () =>
|
||||
controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false
|
||||
);
|
||||
const historyCallsBeforeReady = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of readyHandlers) handler();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("ready hydration settled", () => controller?.state.hydrating === false);
|
||||
eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transcript skips executor-only history hydration");
|
||||
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
|
||||
|
||||
let tabCSendResolved = false;
|
||||
await act(async () => {
|
||||
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
|
||||
sendPromise?.then(() => {
|
||||
tabCSendResolved = true;
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-c", tabC);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
|
||||
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
|
||||
await act(async () => {
|
||||
submitTabCGate.resolve();
|
||||
await submitTabCGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
holdNextContextForD = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-d", "openProjectTab activates the opened tab");
|
||||
eq(controller?.state.items.length, 0, "open topic keeps the new tab transcript empty while hydrating");
|
||||
ok(controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "open topic stores previous transcript only as a hydration placeholder");
|
||||
|
||||
await act(async () => {
|
||||
historyD.resolve([userMessage("history D")]);
|
||||
await historyD.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.hydrating, false, "topic history clears visible hydration before ancillary phase 2 settles");
|
||||
await waitFor("open topic phase 2 started", () => contextDCalls === 1);
|
||||
const contextCallsBeforeReadyD = contextDCalls;
|
||||
const historyCallsBeforeReadyD = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of readyHandlers) {
|
||||
handler("tab-b");
|
||||
handler("tab-d");
|
||||
handler();
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
eq(contextDCalls, contextCallsBeforeReadyD, "agent ready reuses in-flight open-topic hydration for the active tab");
|
||||
eq(historyCalls.length, historyCallsBeforeReadyD, "background ready events do not hydrate the active tab");
|
||||
await act(async () => {
|
||||
contextDGate.resolve(context);
|
||||
await contextDGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "topic history replaces the hydration placeholder");
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
|
||||
|
||||
const historyCallsBeforeReopenD = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
|
||||
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.rewind(0, "fork");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
|
||||
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
|
||||
runningTabs.delete("tab-e");
|
||||
|
||||
const contextCallsBeforeInactiveD = contextDCalls;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(contextDCalls, contextCallsBeforeInactiveD, "inactive topic skips ancillary hydration after quick tab switch");
|
||||
|
||||
tabsById.set("tab-d", { ...tabD, sessionPath: `${tabD.workspaceRoot}/sessions/next-tab-d.jsonl` });
|
||||
const historyCallsBeforeReboundD = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -316,5 +316,25 @@ console.log("\ntool data archiving on tool_result");
|
||||
eq(failedTodo?.status, "error", "failed todo_write keeps error status");
|
||||
}
|
||||
|
||||
// ── Test 10: A successful empty todo_write becomes canonical without unarchiving older args ──
|
||||
{
|
||||
const oldArgs = todoArgs("old");
|
||||
const clearArgs = `{"todos":[]}`;
|
||||
let s = initialState;
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_started" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-old", name: "todo_write", args: oldArgs, readOnly: true } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-old", name: "todo_write", readOnly: true, output: "Todos updated" } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-clear", name: "todo_write", args: clearArgs, readOnly: true } } });
|
||||
s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-clear", name: "todo_write", readOnly: true, output: "Todos updated" } } });
|
||||
|
||||
const tools = toolItems(s);
|
||||
const oldTodo = tools.find((tool) => tool.id === "todo-old");
|
||||
const clearTodo = tools.find((tool) => tool.id === "todo-clear");
|
||||
ok((oldTodo?.args.length ?? 0) <= 205, "older todo_write args stay archived when latest todo_write clears the list");
|
||||
ok(oldTodo?.args !== oldArgs, "older todo_write does not keep full JSON after a clear");
|
||||
eq(clearTodo?.args, clearArgs, "empty todo_write clear keeps parseable canonical args");
|
||||
eq(JSON.parse(clearTodo?.args ?? "{}").todos.length, 0, "empty todo_write clear remains parseable as the latest canonical list");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,27 @@
|
||||
// Run: tsx src/__tests__/tool-subject.test.ts
|
||||
|
||||
import { subjectOf } from "../lib/tools";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ntool subject contract");
|
||||
|
||||
eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description");
|
||||
eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`\n${passed} passed\n`);
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { buildTurnGroups } from "../lib/transcriptGrouping";
|
||||
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -68,6 +68,45 @@ console.log("\ntranscript grouping contract");
|
||||
eq(groups[2].assistantPreview, "answer 2", "keeps latest assistant preview for each turn");
|
||||
}
|
||||
|
||||
{
|
||||
const groups = buildStepGroups([
|
||||
{ kind: "user", id: "u0", text: "fix this" },
|
||||
{ kind: "assistant", id: "a1", text: "visible answer before retry", reasoning: "", streaming: false },
|
||||
{ kind: "notice", id: "s1", level: "info", text: "↪ steer" },
|
||||
{ kind: "assistant", id: "a2", text: "", reasoning: "", streaming: true },
|
||||
] as Item[]);
|
||||
eq(groups[1]?.isFinal, true, "visible assistant text before a later assistant stays outside processed folds");
|
||||
}
|
||||
|
||||
{
|
||||
const groups = buildStepGroups([
|
||||
{ kind: "user", id: "u0", text: "use tools" },
|
||||
{ kind: "assistant", id: "a1", text: "", reasoning: "", streaming: false },
|
||||
{ kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "done" },
|
||||
{ kind: "assistant", id: "a2", text: "final answer", reasoning: "", streaming: false },
|
||||
] as Item[]);
|
||||
eq(groups[1]?.isFinal, false, "tool-only completed steps still fold in compact mode");
|
||||
eq(groups[2]?.isFinal, true, "later visible final answer renders directly");
|
||||
}
|
||||
|
||||
{
|
||||
const visibleTurns = questionTurnsById([
|
||||
{ id: "u0", text: "first", turn: 0 },
|
||||
{ id: "u1", text: "second", turn: 1 },
|
||||
]);
|
||||
eq(visibleTurns.get("u0"), 0, "falls back to visible ordinal when no checkpoint turns exist");
|
||||
eq(visibleTurns.get("u1"), 1, "visible ordinal fallback increments by question");
|
||||
|
||||
const backendTurns = questionTurnsById([
|
||||
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
|
||||
{ id: "u1", text: "live without server stamp yet", turn: 1 },
|
||||
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
|
||||
]);
|
||||
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
|
||||
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
|
||||
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
|
||||
}
|
||||
|
||||
{
|
||||
const items = syntheticTranscriptItems(10_000, 1);
|
||||
const start = performance.now();
|
||||
@@ -77,5 +116,50 @@ console.log("\ntranscript grouping contract");
|
||||
ok(elapsed < 50, `groups 10k turns in ${elapsed.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
{
|
||||
const firstPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 0 });
|
||||
eq(firstPage.warmStartTurn, 50, "long transcripts initially render only the latest warm page");
|
||||
eq(firstPage.warmEndTurn, 70, "warm page stops before the hot zone");
|
||||
eq(firstPage.coldTurnCount, 50, "older cold turns stay hidden behind the load-more button");
|
||||
|
||||
const secondPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 1 });
|
||||
eq(secondPage.warmStartTurn, 30, "loading earlier history adds one more warm page");
|
||||
eq(secondPage.warmEndTurn, 70, "loading earlier history keeps the hot-zone boundary stable");
|
||||
eq(secondPage.coldTurnCount, 30, "loading earlier history reduces the hidden cold count");
|
||||
|
||||
const shortTranscript = warmPagination({ turnCount: 25, hotTurns: 30, pageSize: 20, coldPage: 0 });
|
||||
eq(shortTranscript.warmStartTurn, 0, "short transcripts have no warm zone");
|
||||
eq(shortTranscript.warmEndTurn, 0, "short transcripts have no warm boundary");
|
||||
eq(shortTranscript.coldTurnCount, 0, "short transcripts have no cold turns");
|
||||
}
|
||||
|
||||
{
|
||||
eq(warmColdPageForTurn({ turn: 10, turnCount: 100, hotTurns: 30, pageSize: 20 }), 2, "jumping to cold-zone turn 10 loads enough warm pages");
|
||||
eq(warmColdPageForTurn({ turn: 0, turnCount: 100, hotTurns: 30, pageSize: 20 }), 3, "jumping to the first warm turn loads all warm pages");
|
||||
eq(warmColdPageForTurn({ turn: 65, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the initial warm page needs no extra cold page");
|
||||
eq(warmColdPageForTurn({ turn: 80, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the hot zone needs no warm pagination");
|
||||
}
|
||||
|
||||
{
|
||||
let state = createWarmLayerState("tab-a|0|a-u0");
|
||||
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
|
||||
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
|
||||
state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
|
||||
state = warmLayerWithExpandedTurn(state, "tab-a|0|a-u0", 10, true);
|
||||
eq(state.coldPage, 3, "loading earlier history advances the current session page");
|
||||
ok(state.expandedWarmTurns.has(10), "expanded warm turns stay scoped to the current session");
|
||||
|
||||
const switched = warmLayerForSession(state, "tab-b|1|b-u0");
|
||||
eq(switched.coldPage, 0, "switching sessions resets warm pagination before rendering");
|
||||
eq(switched.expandedWarmTurns.size, 0, "switching sessions clears expanded warm turns");
|
||||
|
||||
const switchedPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: switched.coldPage });
|
||||
eq(switchedPage.warmStartTurn, 50, "switched long transcripts render only the latest warm page first");
|
||||
|
||||
const paged = warmLayerWithColdPageAtLeast(switched, "tab-b|1|b-u0", 2);
|
||||
eq(paged.coldPage, 2, "jumping to a cold-zone question raises the session warm page");
|
||||
eq(warmLayerWithColdPageAtLeast(paged, "tab-b|1|b-u0", 1).coldPage, 2, "jumping never lowers the loaded warm page");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -113,6 +113,10 @@ eq(
|
||||
"var(--statusbar-dock-height)",
|
||||
"status bar min-height follows the scaled dock token",
|
||||
);
|
||||
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
|
||||
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
|
||||
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
|
||||
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
|
||||
|
||||
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
|
||||
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
|
||||
@@ -127,6 +131,8 @@ for (const selector of [
|
||||
".context-panel__section-head span",
|
||||
".context-panel__metric span",
|
||||
".context-panel__metric strong",
|
||||
".app--creation .context-panel__mini-stat span",
|
||||
".app--creation .context-panel__mini-stat strong",
|
||||
".topbar__model",
|
||||
".composer-modebar__item span",
|
||||
".composer-more-menu__item span",
|
||||
@@ -134,7 +140,44 @@ for (const selector of [
|
||||
clipsSingleLine(selector);
|
||||
}
|
||||
|
||||
eq(
|
||||
finalDeclaration(".app--creation .layout.layout--workspace-open", "transition"),
|
||||
"grid-template-columns 0s, min-width 0s",
|
||||
"creation dock skips zero-width grid interpolation on open",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".app--creation .context-panel__usage", "animation"),
|
||||
"none",
|
||||
"creation overview usage card disables inherited entrance animation",
|
||||
);
|
||||
ok(
|
||||
finalDeclaration(".app--creation .context-panel__mini-stat", "justify-content") !== "space-between",
|
||||
"creation overview rows avoid edge-pinned value alignment",
|
||||
);
|
||||
ok(
|
||||
finalDeclaration(".app--creation .context-panel__mini-stat", "grid-template-columns") !== "minmax(0, 1fr) auto",
|
||||
"creation overview rows avoid the spacer grid that pushes values to the edge",
|
||||
);
|
||||
ok(
|
||||
finalDeclaration(".app--creation .context-panel__mini-stat strong", "max-width") !== "14ch",
|
||||
"creation overview values are not capped to a fixed 14ch width",
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
|
||||
"0 !important",
|
||||
"collapsed creation tool bodies keep mounted content clipped",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
|
||||
"hidden",
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
);
|
||||
eq(finalDeclaration(".md table", "overflow-x"), "auto", "markdown tables scroll horizontally");
|
||||
eq(finalDeclaration(".code", "overflow"), "auto", "code blocks scroll instead of widening the layout");
|
||||
ok(
|
||||
@@ -149,6 +192,10 @@ ok(
|
||||
/@media\s*\(max-width:\s*760px\)[\s\S]*?\.settings-modal\s*\{[\s\S]*?width\s*:\s*100vw[\s\S]*?height\s*:\s*100vh/.test(styles),
|
||||
"settings modal only becomes fullscreen at the narrow breakpoint",
|
||||
);
|
||||
ok(
|
||||
/@media\s*\(max-width:\s*820px\)[\s\S]*?\.app\s+\.layout[\s\S]*?grid-template-columns\s*:\s*minmax\(0,\s*1fr\)\s*!important[\s\S]*?\.app\s+\.sidebar[\s\S]*?display\s*:\s*none\s*!important[\s\S]*?\.app\s+\.chat-pane[\s\S]*?grid-column\s*:\s*1\s*!important/.test(styles),
|
||||
"narrow workbench layout hides side panels and keeps chat single-column",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,187 @@
|
||||
// Run: tsx src/__tests__/use-controller-cancel-reconcile.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { ContextInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises(20);
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
topicId: "topic-a",
|
||||
topicTitle: "General",
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: false,
|
||||
cancellable: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nuse controller cancel reconcile");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
let backendRunning = false;
|
||||
let cancelCalls = 0;
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (payload: unknown) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => [tabMeta({ running: backendRunning, cancellable: backendRunning })],
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
HistoryForTab: async () => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
CancelTab: async () => {
|
||||
cancelCalls += 1;
|
||||
backendRunning = false;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("active tab", () => controller?.activeTabId === "tab-a");
|
||||
await act(async () => {
|
||||
await flushPromises(50);
|
||||
});
|
||||
|
||||
backendRunning = true;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_started", tabId: "tab-a" });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.running, true, "turn_started marks the tab running");
|
||||
|
||||
await act(async () => {
|
||||
controller?.cancel();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 20 && controller?.state.running; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises(50);
|
||||
});
|
||||
}
|
||||
|
||||
eq(controller?.state.running, false, "cancel reconciliation clears the running state");
|
||||
eq(cancelCalls, 1, "CancelTab is called once");
|
||||
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,7 +1,9 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { foregroundRunningFromRuntimeMeta, initialState, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, WireUsage } from "../lib/types";
|
||||
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -26,6 +28,7 @@ function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
imageInputEnabled: true,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
@@ -37,6 +40,31 @@ function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
};
|
||||
}
|
||||
|
||||
function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-1",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-1",
|
||||
topicTitle: "Topic",
|
||||
label: "DeepSeek-R1",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
...overrides,
|
||||
} as TabMeta;
|
||||
}
|
||||
|
||||
function usage(source: string): WireUsage {
|
||||
return {
|
||||
promptTokens: 100,
|
||||
@@ -59,6 +87,13 @@ console.log("\nuse controller meta");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
eq(sameMeta(meta({ workspacePath: "/repo" }), meta({ workspacePath: "/other" })), false, "workspace path changes invalidate meta equality");
|
||||
eq(sameMeta(meta({ gitBranch: "main" }), meta({ gitBranch: "feature" })), false, "git branch changes invalidate meta equality");
|
||||
eq(sameMeta(meta({ imageInputEnabled: true }), meta({ imageInputEnabled: false })), false, "image input capability changes invalidate meta equality");
|
||||
}
|
||||
|
||||
{
|
||||
const preserved = metaFromTab(tab({ toolApprovalMode: "" }), meta({ toolApprovalMode: "auto", autoApproveTools: false }));
|
||||
eq(preserved.toolApprovalMode, "auto", "blank tab snapshot preserves explicit auto approval mode");
|
||||
eq(preserved.autoApproveTools, false, "blank tab snapshot does not silently resurrect yolo approval");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -100,6 +135,31 @@ console.log("\nuse controller meta");
|
||||
eq(foregroundRunningFromRuntimeMeta({ running: true, backgroundJobs: 1 }), false, "background jobs without cancellable are background-only");
|
||||
}
|
||||
|
||||
{
|
||||
const restoredContext = reducer(initialState, {
|
||||
type: "context",
|
||||
context: {
|
||||
used: 42,
|
||||
window: 200,
|
||||
sessionTokens: 120,
|
||||
compactRatio: 0.5,
|
||||
sessionCost: 0.012,
|
||||
sessionCurrency: "$",
|
||||
cacheHitTokens: 80,
|
||||
cacheMissTokens: 20,
|
||||
},
|
||||
});
|
||||
const reset = reducer(restoredContext, { type: "reset" });
|
||||
eq(reset.context.used, 0, "reset clears context used tokens");
|
||||
eq(reset.context.window, 200, "reset preserves context window");
|
||||
eq(reset.context.sessionTokens, 0, "reset clears context session tokens");
|
||||
eq(reset.context.cacheHitTokens, undefined, "reset clears restored cache hit tokens");
|
||||
eq(reset.context.cacheMissTokens, undefined, "reset clears restored cache miss tokens");
|
||||
eq(reset.context.sessionCost, undefined, "reset clears restored context session cost");
|
||||
eq(reset.sessionCost, 0, "reset clears restored session cost state");
|
||||
eq(reset.sessionCurrency, "¥", "reset restores default session currency");
|
||||
}
|
||||
|
||||
{
|
||||
const idleExecutor = reducer(
|
||||
{ ...initialState, context: { used: 0, window: 200, sessionTokens: 0 } },
|
||||
@@ -124,6 +184,78 @@ console.log("\nuse controller meta");
|
||||
const activeHelper = reducer(active, { type: "event", e: { kind: "usage", usage: usage("subagent") } });
|
||||
eq(activeHelper.sessionTokens, 120, "helper usage inside a turn still counts toward session tokens");
|
||||
eq(activeHelper.sessionCost, 0.001, "helper usage inside a turn still counts toward session cost");
|
||||
eq(activeHelper.usage, undefined, "helper usage inside a turn does not become displayed latest usage");
|
||||
|
||||
const plannerFirst = reducer(active, { type: "event", e: { kind: "usage", usage: usage("planner") } });
|
||||
eq(plannerFirst.sessionTokens, 120, "planner usage inside a turn still counts toward session tokens");
|
||||
eq(plannerFirst.usage, undefined, "planner usage does not fill the single displayed usage slot");
|
||||
|
||||
const activeExecutor = reducer(active, { type: "event", e: { kind: "usage", usage: usage("executor") } });
|
||||
const afterCompaction = reducer(activeExecutor, { type: "event", e: { kind: "usage", usage: usage("compaction") } });
|
||||
eq(afterCompaction.usage?.source, "executor", "compaction usage does not overwrite displayed executor usage");
|
||||
eq(afterCompaction.sessionTokens, 240, "compaction usage still contributes to session token totals");
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, { type: "user", text: "first", seq: 0 });
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_started" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "info", text: "runtime notice" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
|
||||
const merged = reducer(s, {
|
||||
type: "history_checkpoint_turns",
|
||||
turns: [0],
|
||||
});
|
||||
const user = merged.items.find((item) => item.kind === "user");
|
||||
const notice = merged.items.find((item) => item.kind === "notice" && item.text === "runtime notice");
|
||||
eq(user?.kind === "user" && user.checkpointTurn, 0, "turn_done checkpoint merge stamps user turn zero");
|
||||
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "history_page",
|
||||
mode: "replace",
|
||||
page: {
|
||||
messages: [
|
||||
{ role: "user", content: "recent prompt" },
|
||||
{ role: "assistant", content: "recent answer" },
|
||||
],
|
||||
startTurn: 60,
|
||||
endTurn: 61,
|
||||
totalTurns: 61,
|
||||
hasOlder: true,
|
||||
},
|
||||
});
|
||||
eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
|
||||
eq(s.historyStartTurn, 60, "history page stores the older cursor");
|
||||
eq(s.historyHasOlder, true, "history page records older availability");
|
||||
const checkpointed = reducer(s, {
|
||||
type: "history_checkpoint_turns",
|
||||
turns: Array.from({ length: 61 }, (_, index) => index + 1000),
|
||||
});
|
||||
const recentUser = checkpointed.items.find((item) => item.kind === "user" && item.text === "recent prompt");
|
||||
eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged checkpoint merge uses the window start turn");
|
||||
s = reducer(s, { type: "history_older_start" });
|
||||
eq(s.historyOlderLoading, true, "older history request marks loading");
|
||||
s = reducer(s, {
|
||||
type: "history_page",
|
||||
mode: "prepend",
|
||||
page: {
|
||||
messages: [
|
||||
{ role: "user", content: "older prompt" },
|
||||
{ role: "assistant", content: "older answer" },
|
||||
],
|
||||
startTurn: 0,
|
||||
endTurn: 1,
|
||||
totalTurns: 61,
|
||||
hasOlder: false,
|
||||
},
|
||||
});
|
||||
const users = s.items.filter((item) => item.kind === "user");
|
||||
eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
|
||||
eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
|
||||
eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
|
||||
eq(s.historyOlderLoading, false, "older history clears loading");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-send",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo/send",
|
||||
workspaceName: "send",
|
||||
workspacePath: "/repo/send",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-send",
|
||||
topicTitle: "Send",
|
||||
label: "model-send",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo/send",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nuse controller send fallback");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const backendTab = tabMeta({ backgroundJobs: 2 });
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
let tabsAvailable = false;
|
||||
let submitCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
|
||||
MetaForTab: async () => metaFor(backendTab),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTab: async (tabId: string) => {
|
||||
submitCalls += tabId === "tab-send" ? 1 : 0;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
|
||||
|
||||
tabsAvailable = true;
|
||||
await act(async () => {
|
||||
await controller?.send("hello from fallback");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
|
||||
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
|
||||
eq(submitCalls, 1, "send fallback submits to the activated tab");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,219 @@
|
||||
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderWorkspace(changes: WorkspaceChangesView) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDir: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => changes,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel
|
||||
open
|
||||
tabId="tab-a"
|
||||
cwd="/repo"
|
||||
maximized={false}
|
||||
initialViewMode="changed"
|
||||
onClose={() => {}}
|
||||
onToggleMaximized={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
const { dom, root } = await renderWorkspace({ files: [], gitAvailable: false });
|
||||
await waitFor("git unavailable warning", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
|
||||
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitAvailable=false renders a warning");
|
||||
ok(document.body.textContent?.includes("No changed files") === false, "gitAvailable=false is not shown as a clean workspace");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { dom, root } = await renderWorkspace({
|
||||
files: [],
|
||||
gitAvailable: true,
|
||||
gitErr: "git status timed out",
|
||||
});
|
||||
await waitFor("git error warning without files", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
|
||||
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr without files renders a warning");
|
||||
ok(document.body.textContent?.includes("No changed files") === false, "empty files plus gitErr is not shown as a clean workspace");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { dom, root } = await renderWorkspace({
|
||||
files: [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
sources: ["session"],
|
||||
gitStatus: "modified",
|
||||
latestPrompt: "edit app",
|
||||
},
|
||||
],
|
||||
gitAvailable: true,
|
||||
gitErr: "git status timed out",
|
||||
});
|
||||
await waitFor("git error warning with files", () => document.body.textContent?.includes("app.ts") === true);
|
||||
ok(document.body.textContent?.includes("Git status is unavailable for this workspace.") === true, "gitErr renders a warning");
|
||||
ok(document.body.textContent?.includes("app.ts") === true, "files still render when gitErr is present");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const calls: string[] = [];
|
||||
const listDir = async (dir: string): Promise<DirEntry[]> => {
|
||||
calls.push(dir);
|
||||
return [];
|
||||
};
|
||||
const { dom, root, rerender } = await renderFilesWorkspace(
|
||||
{ ListDir: listDir },
|
||||
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
|
||||
);
|
||||
|
||||
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
|
||||
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
|
||||
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
|
||||
|
||||
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,13 +1,19 @@
|
||||
// Run: tsx src/__tests__/workspace-layout.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
availableWorkspacePanelWidth,
|
||||
resolveLiveWorkspacePanelWidth,
|
||||
resolveWorkspacePanelWidth,
|
||||
workspacePanelAriaMinWidth,
|
||||
} from "../lib/workspaceLayout";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
@@ -24,6 +30,7 @@ const SIDEBAR_WIDTH = 264;
|
||||
const RESIZER_WIDTH = 8;
|
||||
const PREVIEW_MIN_WIDTH = 420;
|
||||
const PREVIEW_DEFAULT_WIDTH = 660;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
|
||||
console.log("\nworkspace dock layout");
|
||||
|
||||
@@ -108,5 +115,42 @@ eq(
|
||||
"maximized panel preserves the saved preferred width",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveLiveWorkspacePanelWidth({
|
||||
viewportWidth: 1268,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 400,
|
||||
chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
open: true,
|
||||
maximized: false,
|
||||
preferredWidth: PREVIEW_MIN_WIDTH,
|
||||
minWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
300,
|
||||
"live dock drag clamps the hard minimum to the available dock width",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveLiveWorkspacePanelWidth({
|
||||
viewportWidth: 1280,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 500,
|
||||
chatMinWidth: CHAT_COMFORT_MIN_WIDTH,
|
||||
resizerWidth: RESIZER_WIDTH,
|
||||
open: true,
|
||||
maximized: false,
|
||||
preferredWidth: PREVIEW_DEFAULT_WIDTH,
|
||||
minWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
212,
|
||||
"live sidebar drag recomputes dock width from the dragged sidebar width",
|
||||
);
|
||||
eq(
|
||||
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);/.test(appSource),
|
||||
true,
|
||||
"closing the dock clears the transient render width before hiding the panel",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Run: tsx src/__tests__/workspace-preview-css.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function matchingBlocks(selector: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const rule = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = rule.exec(styles)) !== null) {
|
||||
const selectors = match[1].split(",").map((part) => part.trim());
|
||||
if (selectors.includes(selector)) blocks.push(match[2]);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function finalDeclaration(selector: string, property: string): string | undefined {
|
||||
let value: string | undefined;
|
||||
for (const block of matchingBlocks(selector)) {
|
||||
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = declaration.exec(block)) !== null) {
|
||||
value = match[1].trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function computedDeclaration(html: string, selector: string, property: string): string {
|
||||
const dom = new JSDOM(html);
|
||||
const style = dom.window.document.createElement("style");
|
||||
style.textContent = styles;
|
||||
dom.window.document.head.append(style);
|
||||
const element = dom.window.document.querySelector(selector);
|
||||
if (!element) throw new Error(`Missing selector in test DOM: ${selector}`);
|
||||
return dom.window.getComputedStyle(element).getPropertyValue(property).trim();
|
||||
}
|
||||
|
||||
console.log("\nworkspace preview css");
|
||||
|
||||
eq(finalDeclaration(".workspace-preview__body--code", "overflow"), "hidden", "code preview body does not create a nested scroller");
|
||||
eq(finalDeclaration(".workspace-preview__body--code", "display"), "flex", "code preview body hosts an editor-like viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code", "flex-direction"), "column", "truncated code notes stack above the code viewport");
|
||||
eq(
|
||||
computedDeclaration(
|
||||
`<html data-theme-style="default"><head></head><body><aside class="workspace-panel workspace-panel--embedded"><div class="workspace-preview__body workspace-preview__body--code"></div></aside></body></html>`,
|
||||
".workspace-preview__body--code",
|
||||
"padding",
|
||||
),
|
||||
"0px",
|
||||
"code preview body keeps zero padding under embedded and themed cascade",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
|
||||
"split mode keeps a narrow tree toggle rail beside the file tree",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
|
||||
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
|
||||
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
|
||||
"tree resizer accounts for the persistent rail",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
|
||||
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
|
||||
"preview-only mode keeps a narrow tree toggle rail",
|
||||
);
|
||||
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,257 @@
|
||||
// Run: tsx src/__tests__/workspace-split.test.ts
|
||||
|
||||
import {
|
||||
initialWorkspaceSplitTreeWidth,
|
||||
resolveWorkspaceSplitTreeWidth,
|
||||
shouldInitializeWorkspaceSplitOnFileSelect,
|
||||
workspaceSplitCanFit,
|
||||
workspaceSplitTreeWidthFromPointer,
|
||||
} from "../lib/workspaceSplit";
|
||||
import { resolveWorkspacePanelWidth } from "../lib/workspaceLayout";
|
||||
import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
|
||||
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
|
||||
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nworkspace file split");
|
||||
|
||||
const TREE_MIN_WIDTH = 140;
|
||||
const TREE_RAIL_WIDTH = 44;
|
||||
const PREVIEW_MIN_WIDTH = 140;
|
||||
|
||||
eq(
|
||||
initialWorkspaceSplitTreeWidth({
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: null,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
308,
|
||||
"first split divides the file area evenly after reserving the tree rail",
|
||||
);
|
||||
|
||||
eq(
|
||||
initialWorkspaceSplitTreeWidth({
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: 620,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
476,
|
||||
"tree width is clamped so the preview keeps its minimum width",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 400,
|
||||
panelLeft: 100,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
256,
|
||||
"tree resize pointer coordinates start after the tree rail",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitTreeWidthFromPointer({
|
||||
clientX: 700,
|
||||
panelLeft: 100,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
476,
|
||||
"tree resize pointer clamps against the preview minimum after reserving the rail",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveWorkspaceSplitTreeWidth({
|
||||
mode: "even",
|
||||
currentTreeWidth: 140,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
308,
|
||||
"default split recomputes evenly after the parent preview width applies",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveWorkspaceSplitTreeWidth({
|
||||
mode: "manual",
|
||||
currentTreeWidth: 256,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
256,
|
||||
"manual split width is preserved when the parent width changes",
|
||||
);
|
||||
|
||||
eq(
|
||||
resolveWorkspacePanelWidth({
|
||||
open: true,
|
||||
maximized: false,
|
||||
preferredWidth: 660,
|
||||
minWidth: 300,
|
||||
availableWidth: 228,
|
||||
}),
|
||||
228,
|
||||
"outer file area can still shrink below split target width",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: 323,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
false,
|
||||
"file tree collapses before rail tree and preview would overflow a narrow panel",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: 324,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
true,
|
||||
"file tree remains available at the exact split minimum width",
|
||||
);
|
||||
|
||||
eq(
|
||||
workspaceSplitCanFit({
|
||||
panelWidth: undefined,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
true,
|
||||
"file tree stays visible until the measured panel width is known",
|
||||
);
|
||||
|
||||
const closedRecentPreview = closeWorkspacePreviewTab(["a.ts", "b.ts", "c.ts"], "c.ts");
|
||||
eq(closedRecentPreview.selectedPath, "b.ts", "closing the current preview falls back to the previous recent file");
|
||||
eq(closedRecentPreview.openTabs.join(","), "a.ts,b.ts", "closing one preview preserves the other recent files");
|
||||
|
||||
eq(
|
||||
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: false, treeVisible: true }),
|
||||
true,
|
||||
"opening the first preview initializes an even file split",
|
||||
);
|
||||
|
||||
eq(
|
||||
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: true, treeVisible: true }),
|
||||
false,
|
||||
"switching files while preview and tree are visible preserves the manual split",
|
||||
);
|
||||
|
||||
eq(
|
||||
shouldInitializeWorkspaceSplitOnFileSelect({ previewVisible: true, treeVisible: false }),
|
||||
true,
|
||||
"revealing the hidden tree initializes an even file split",
|
||||
);
|
||||
|
||||
const effectiveEvenTreeWidth = resolveWorkspaceSplitTreeWidth({
|
||||
mode: "even",
|
||||
currentTreeWidth: TREE_MIN_WIDTH,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
});
|
||||
|
||||
eq(
|
||||
resolveWorkspaceSplitTreeWidth({
|
||||
mode: "manual",
|
||||
currentTreeWidth: effectiveEvenTreeWidth,
|
||||
panelWidth: 660,
|
||||
railWidth: TREE_RAIL_WIDTH,
|
||||
treeMinWidth: TREE_MIN_WIDTH,
|
||||
previewMinWidth: PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
effectiveEvenTreeWidth,
|
||||
"entering manual resize from even split keeps the currently rendered tree width",
|
||||
);
|
||||
|
||||
eq(
|
||||
shouldScrollWorkspaceTreeSelection({
|
||||
selectedPath: "src/App.tsx",
|
||||
pendingRevealPath: "src/App.tsx",
|
||||
actualTreeVisible: true,
|
||||
selectedIndex: 42,
|
||||
}),
|
||||
true,
|
||||
"pending file reveal scrolls once the selected row is present",
|
||||
);
|
||||
|
||||
eq(
|
||||
shouldScrollWorkspaceTreeSelection({
|
||||
selectedPath: "src/App.tsx",
|
||||
pendingRevealPath: null,
|
||||
actualTreeVisible: true,
|
||||
selectedIndex: 42,
|
||||
}),
|
||||
false,
|
||||
"manual folder changes do not re-scroll to the previous selected file",
|
||||
);
|
||||
|
||||
eq(
|
||||
shouldScrollWorkspaceTreeSelection({
|
||||
selectedPath: "src/App.tsx",
|
||||
pendingRevealPath: "src/App.tsx",
|
||||
actualTreeVisible: true,
|
||||
selectedIndex: -1,
|
||||
}),
|
||||
false,
|
||||
"pending file reveal waits until async directory loading exposes the row",
|
||||
);
|
||||
|
||||
eq(
|
||||
mergeWorkspaceSearchResults(
|
||||
[{ path: "README.md", entry: { name: "README.md", isDir: false } }],
|
||||
[
|
||||
{ name: "src/deep/README.md", isDir: false },
|
||||
{ name: "README.md", isDir: false },
|
||||
],
|
||||
).map((row) => `${row.path}:${row.entry.name}`).join("|"),
|
||||
"README.md:README.md|src/deep/README.md:README.md",
|
||||
"workspace filter merges backend deep search results without duplicating loaded rows",
|
||||
);
|
||||
|
||||
eq(
|
||||
mergeWorkspaceSearchResults(
|
||||
[{ path: "docs/assets/", entry: { name: "assets", isDir: true } }],
|
||||
[
|
||||
{ name: "docs/assets", isDir: true },
|
||||
{ name: "docs/guides", isDir: true },
|
||||
],
|
||||
).map((row) => `${row.path}:${row.entry.name}:${row.entry.isDir}`).join("|"),
|
||||
"docs/assets/:assets:true|docs/guides/:guides:true",
|
||||
"workspace filter normalizes directory search results to tree row paths",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
|
||||
import { PanelLeft, PanelRight, Search } from "lucide-react";
|
||||
import { TabBar } from "./TabBar";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -58,14 +58,13 @@ export function AppChrome({
|
||||
}: AppChromeProps) {
|
||||
const t = useT();
|
||||
const darwinChrome = platform === "darwin";
|
||||
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
|
||||
const titlebarDragRail = darwinChrome || platform === "windows";
|
||||
const chromeClassName = [
|
||||
"app-chrome",
|
||||
"app-chrome--tabs",
|
||||
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
|
||||
workbenchChrome ? "app-chrome--workbench" : "",
|
||||
!darwinChrome ? "app-chrome--identityless" : "",
|
||||
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
|
||||
`app-chrome--platform-${platform}`,
|
||||
].filter(Boolean).join(" ");
|
||||
const tabBar = (
|
||||
@@ -92,7 +91,7 @@ export function AppChrome({
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
<button
|
||||
className={[
|
||||
"app-chrome__panel-toggle",
|
||||
@@ -194,19 +193,6 @@ export function AppChrome({
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
{showWindowsPreviewControls && (
|
||||
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
|
||||
<span className="app-chrome__window-control app-chrome__window-control--minimize">
|
||||
<Minus size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--maximize">
|
||||
<Square size={10} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--close">
|
||||
<X size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,35 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { WireApproval } from "../lib/types";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
import { DUR_FAST } from "../lib/gsapAnimations";
|
||||
import {
|
||||
FileReferenceMenu,
|
||||
insertTextAtSelection,
|
||||
pickInlineFileReference,
|
||||
useFileReferenceMenu,
|
||||
} from "./FileReferenceMenu";
|
||||
|
||||
function animateShelfExit(
|
||||
el: HTMLDivElement,
|
||||
options: { opacity: number; y: number; duration: number; ease: string; onComplete: () => void },
|
||||
) {
|
||||
const animator = typeof gsap.to === "function"
|
||||
? gsap
|
||||
: (gsap as unknown as { default?: typeof gsap }).default;
|
||||
if (animator && typeof animator.to === "function") {
|
||||
animator.to(el, options);
|
||||
return;
|
||||
}
|
||||
options.onComplete();
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
approval,
|
||||
@@ -12,33 +37,49 @@ export function ApprovalModal({
|
||||
onRevisePlan,
|
||||
onExitPlan,
|
||||
onStop,
|
||||
cwd,
|
||||
insertRequest,
|
||||
onRevisionActiveChange,
|
||||
}: {
|
||||
approval: WireApproval;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
|
||||
onRevisePlan?: (text: string) => void;
|
||||
onExitPlan?: () => void;
|
||||
onStop: () => void;
|
||||
cwd?: string;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
onRevisionActiveChange?: (active: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(approval.reason || subject);
|
||||
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const consumedInsertIdRef = useRef(0);
|
||||
// When consecutive approvals arrive, animate the old card out before
|
||||
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
|
||||
// jarring pop when the API cycles through 4+ pending approvals.
|
||||
const closingRef = useRef(false);
|
||||
const subject = approval.subject.trim();
|
||||
const fileMenu = useFileReferenceMenu(revisionText, cwd);
|
||||
|
||||
const answerWithExit = (fn: () => void) => {
|
||||
if (closingRef.current) return;
|
||||
closingRef.current = true;
|
||||
const el = shelfRef.current;
|
||||
if (el) {
|
||||
gsap.to(el, {
|
||||
animateShelfExit(el, {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
duration: DUR_FAST,
|
||||
@@ -59,9 +100,10 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
@@ -69,11 +111,12 @@ export function ApprovalModal({
|
||||
cardRef.current?.focus();
|
||||
setRevisionOpen(false);
|
||||
setRevisionText("");
|
||||
setDetailsOpen(showToolDetailsByDefault);
|
||||
setSelectedIndex(isPlanApproval ? 1 : 0);
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval]);
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -94,6 +137,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
else chooseToolAction(key);
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -103,11 +147,78 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) inputRef.current?.focus();
|
||||
}, [revisionOpen]);
|
||||
if (revisionOpen) {
|
||||
onRevisionActiveChange?.(true);
|
||||
inputRef.current?.focus();
|
||||
return () => onRevisionActiveChange?.(false);
|
||||
}
|
||||
onRevisionActiveChange?.(false);
|
||||
}, [revisionOpen, onRevisionActiveChange]);
|
||||
|
||||
const focusRevisionInput = (caret = revisionText.length) => {
|
||||
requestAnimationFrame(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
const insertRevisionText = useCallback((text: string) => {
|
||||
const input = inputRef.current;
|
||||
const start = input?.selectionStart ?? revisionText.length;
|
||||
const end = input?.selectionEnd ?? start;
|
||||
const next = insertTextAtSelection(revisionText, text, start, end);
|
||||
setRevisionText(next.value);
|
||||
focusRevisionInput(next.caret);
|
||||
}, [revisionText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!insertRequest || insertRequest.id === consumedInsertIdRef.current) return;
|
||||
consumedInsertIdRef.current = insertRequest.id;
|
||||
insertRevisionText(insertRequest.text);
|
||||
}, [insertRequest, insertRevisionText]);
|
||||
|
||||
const pickRevisionFile = (entry: DirEntry) => {
|
||||
const next = pickInlineFileReference(revisionText, fileMenu.atRaw, fileMenu.atDir, entry);
|
||||
setRevisionText(next);
|
||||
focusRevisionInput(next.length);
|
||||
};
|
||||
|
||||
const onRevisionKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
submitRevision();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (fileMenu.open) {
|
||||
if (event.key === "ArrowDown" && fileMenu.count > 0) {
|
||||
event.preventDefault();
|
||||
fileMenu.setActive((index) => (index + 1) % fileMenu.count);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" && fileMenu.count > 0) {
|
||||
event.preventDefault();
|
||||
fileMenu.setActive((index) => (index - 1 + fileMenu.count) % fileMenu.count);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === "Tab") && fileMenu.count > 0) {
|
||||
event.preventDefault();
|
||||
const entry = fileMenu.items[fileMenu.active];
|
||||
if (entry) pickRevisionFile(entry);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
fileMenu.dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const submitRevision = () => {
|
||||
const text = revisionText.trim();
|
||||
@@ -123,6 +234,7 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--plan-approval"
|
||||
barRef={cardRef}
|
||||
titleId="plan-approval-title"
|
||||
title={t("approval.planReady")}
|
||||
@@ -155,11 +267,17 @@ export function ApprovalModal({
|
||||
rows={3}
|
||||
placeholder={t("approval.revisePlanPlaceholder")}
|
||||
onChange={(event) => setRevisionText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") submitRevision();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onFocus={() => onRevisionActiveChange?.(true)}
|
||||
onKeyDown={onRevisionKeyDown}
|
||||
/>
|
||||
{fileMenu.open && (
|
||||
<FileReferenceMenu
|
||||
items={fileMenu.items}
|
||||
activeIndex={fileMenu.active}
|
||||
onPick={pickRevisionFile}
|
||||
onHover={fileMenu.setActive}
|
||||
/>
|
||||
)}
|
||||
<div className="plan-revision__actions">
|
||||
<button className="btn" onClick={() => setRevisionOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
@@ -178,26 +296,46 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--tool-approval"
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
badges={<PromptBadge>{approval.tool}</PromptBadge>}
|
||||
badges={<PromptBadge>{toolLabel}</PromptBadge>}
|
||||
meta={toolMeta}
|
||||
headerActions={
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
<>
|
||||
{hasToolDetails && (
|
||||
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
|
||||
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
|
||||
</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,10 @@ export function AskCard({
|
||||
// Per-question state: selected option labels, and an optional typed answer.
|
||||
const [sel, setSel] = useState<Record<string, string[]>>({});
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
|
||||
const questions = ask.questions;
|
||||
@@ -36,6 +38,7 @@ export function AskCard({
|
||||
shelfRef.current?.focus();
|
||||
setSel({});
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
@@ -66,7 +69,15 @@ export function AskCard({
|
||||
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
|
||||
|
||||
const currentAnswered = q ? answered(q) : false;
|
||||
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
|
||||
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) customInputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
|
||||
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
|
||||
if (advanceTimer.current != null) {
|
||||
@@ -89,6 +100,7 @@ export function AskCard({
|
||||
|
||||
setCustom(nextCustom);
|
||||
setSel(nextSel);
|
||||
setCustomOpen(false);
|
||||
|
||||
if (!question.multi) {
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
@@ -148,6 +160,7 @@ export function AskCard({
|
||||
|
||||
return (
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--ask"
|
||||
barRef={shelfRef}
|
||||
titleId="ask-shelf-title"
|
||||
title={t("ask.title")}
|
||||
@@ -165,6 +178,9 @@ export function AskCard({
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
|
||||
{!customOpen && (
|
||||
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
@@ -213,8 +229,10 @@ export function AskCard({
|
||||
)
|
||||
}
|
||||
>
|
||||
{customOpen && (
|
||||
<div className="ask-shelf__custom-row">
|
||||
<input
|
||||
ref={customInputRef}
|
||||
className="ask-shelf__custom"
|
||||
placeholder={t("ask.customPlaceholder")}
|
||||
value={custom[q.id] ?? ""}
|
||||
@@ -225,6 +243,7 @@ export function AskCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Command, Search } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useMountTransition } from "../lib/useMountTransition";
|
||||
|
||||
// CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's
|
||||
@@ -57,9 +58,12 @@ export function CommandPalette({
|
||||
placeholder: string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const [active, setActive] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isOpenRef = useRef(false);
|
||||
isOpenRef.current = open;
|
||||
// Keep the palette mounted through its exit animation after `open` flips
|
||||
// false; `status` drives the enter/exit keyframes via data-state.
|
||||
const { mounted, status } = useMountTransition(open, 200);
|
||||
@@ -67,13 +71,27 @@ export function CommandPalette({
|
||||
// Re-init whenever the palette opens: clear the query, reset the
|
||||
// highlight, and steal focus. Doing it on the open edge (not on every
|
||||
// render) means a previously-typed query doesn't leak across opens.
|
||||
useEffect(() => {
|
||||
// useLayoutEffect fires synchronously after DOM mutations, before the
|
||||
// browser paints — ensures focus lands before any paint-time transitions
|
||||
// can interfere.
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
setActive(items.length > 0 ? 0 : -1);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, items.length]);
|
||||
|
||||
// Callback ref: when the input element mounts while the palette is open,
|
||||
// focus it immediately. This handles the case where the DOM element
|
||||
// becomes available after the useLayoutEffect already ran.
|
||||
const inputCallbackRef = useCallback(
|
||||
(el: HTMLInputElement | null) => {
|
||||
inputRef.current = el;
|
||||
if (el && isOpenRef.current) el.focus();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// score is the fuzzy match: every space-separated query token must
|
||||
// appear (case-insensitively) in the candidate's haystack, in the order
|
||||
@@ -130,11 +148,11 @@ export function CommandPalette({
|
||||
// Clamp the active index whenever the result set shrinks (e.g. user
|
||||
// typed something that filtered out the previously-highlighted item).
|
||||
useEffect(() => {
|
||||
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
|
||||
if (active >= 0 && active >= flat.length) setActive(Math.max(0, flat.length - 1));
|
||||
}, [flat.length, active]);
|
||||
|
||||
// Reset the highlight to 0 on every query change — the user just refined
|
||||
// their search, the old highlight is rarely still interesting.
|
||||
// Reset the highlight to the first match on every query change — the user
|
||||
// just refined their search, the old highlight is rarely still interesting.
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
@@ -145,6 +163,8 @@ export function CommandPalette({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const closeButtonHasFocus = e.target instanceof HTMLElement && Boolean(e.target.closest("[data-palette-close]"));
|
||||
if (closeButtonHasFocus && (e.key === "Enter" || e.key === " ")) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
@@ -152,12 +172,12 @@ export function CommandPalette({
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((i) => (flat.length === 0 ? 0 : (i + 1) % flat.length));
|
||||
setActive((i) => (flat.length === 0 ? -1 : i < 0 ? 0 : (i + 1) % flat.length));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((i) => (flat.length === 0 ? 0 : (i - 1 + flat.length) % flat.length));
|
||||
setActive((i) => (flat.length === 0 ? -1 : i <= 0 ? flat.length - 1 : i - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
@@ -189,7 +209,7 @@ export function CommandPalette({
|
||||
<div className="palette__inputrow">
|
||||
<Search className="palette__search-icon" size={18} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={inputCallbackRef}
|
||||
className="palette__input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -197,7 +217,16 @@ export function CommandPalette({
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd className="palette__esc">esc</kbd>
|
||||
<button
|
||||
className="palette__esc"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
title={t("common.close")}
|
||||
data-palette-close
|
||||
>
|
||||
esc
|
||||
</button>
|
||||
</div>
|
||||
<div className="palette__list" role="listbox">
|
||||
{flat.length === 0 ? (
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ export function ComposerContextCard({
|
||||
removeDisabled = false,
|
||||
removeIconSize,
|
||||
previewUrl,
|
||||
onImageClick,
|
||||
imageOnly = false,
|
||||
folder = false,
|
||||
name,
|
||||
@@ -26,6 +27,8 @@ export function ComposerContextCard({
|
||||
removeDisabled?: boolean;
|
||||
removeIconSize?: number;
|
||||
previewUrl?: string;
|
||||
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
|
||||
onImageClick?: () => void;
|
||||
imageOnly?: boolean;
|
||||
folder?: boolean;
|
||||
name?: string;
|
||||
@@ -46,7 +49,13 @@ export function ComposerContextCard({
|
||||
<Tooltip label={tooltipLabel}>
|
||||
<span className="composer-context__label">
|
||||
{previewUrl ? (
|
||||
<span className="composer-context__thumb">
|
||||
<span
|
||||
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
|
||||
onClick={onImageClick}
|
||||
role={onImageClick ? "button" : undefined}
|
||||
tabIndex={onImageClick ? 0 : undefined}
|
||||
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
|
||||
>
|
||||
<img src={previewUrl} alt="" draggable={false} />
|
||||
</span>
|
||||
) : variant === "attachment" ? (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ContextPanel shows the active tab's context gauge and token usage.
|
||||
// All visible text is routed through the i18n dictionary.
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useI18n, type Translator } from "../lib/i18n";
|
||||
import { useI18n, type Locale, type Translator } from "../lib/i18n";
|
||||
import { formatMoneyLocalized } from "../lib/money";
|
||||
import type { DictKey } from "../locales/en";
|
||||
import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types";
|
||||
@@ -23,9 +23,9 @@ interface ContextPanelProps {
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
function fmtFullTokens(n: number): string {
|
||||
if (n <= 0) return "0";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number, t: Translator): string {
|
||||
@@ -42,35 +42,77 @@ function fmtOptionalTokens(tokens?: number): string {
|
||||
return tokens.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtTurns(turns: number | undefined, t: Translator): string {
|
||||
if (typeof turns !== "number" || turns < 0) return "-";
|
||||
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
|
||||
interface MetricTokenDisplay {
|
||||
display: string;
|
||||
exact: string;
|
||||
}
|
||||
|
||||
function numberLocale(locale: Locale | string): string {
|
||||
if (locale === "zh") return "zh-CN";
|
||||
if (locale === "zh-TW") return "zh-TW";
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function formatMetricTokens(tokens: number | undefined, locale: Locale | string): MetricTokenDisplay {
|
||||
if (typeof tokens !== "number" || tokens <= 0) {
|
||||
return { display: "-", exact: "-" };
|
||||
}
|
||||
const tag = numberLocale(locale);
|
||||
const exact = tokens.toLocaleString(tag);
|
||||
return { display: exact, exact };
|
||||
}
|
||||
|
||||
function fmtUsageCacheRate(usage?: WireUsage): string {
|
||||
if (!usage) return "-";
|
||||
const denom = usage.cacheHitTokens + usage.cacheMissTokens || usage.promptTokens;
|
||||
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
|
||||
if (denom <= 0) return "-";
|
||||
return `${((usage.cacheHitTokens / denom) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function fmtSessionCacheRate(usage?: WireUsage): string {
|
||||
if (!usage) return "-";
|
||||
const denom = usage.sessionCacheHitTokens + usage.sessionCacheMissTokens;
|
||||
if (denom <= 0) return "-";
|
||||
return `${((usage.sessionCacheHitTokens / denom) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function formatCacheHitRate(hitTokens: number, missTokens: number): string {
|
||||
const denom = hitTokens + missTokens;
|
||||
if (denom <= 0) return "-";
|
||||
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
type MetricTone = "accent" | "good" | "notice" | "warn";
|
||||
type UsageAnalysisView = "source" | "type";
|
||||
type ContextUsageRefreshFields = Pick<
|
||||
WireUsage,
|
||||
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
|
||||
>;
|
||||
|
||||
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
|
||||
if (!usage) return "";
|
||||
return [
|
||||
usage.totalTokens ?? 0,
|
||||
usage.promptTokens ?? 0,
|
||||
usage.completionTokens ?? 0,
|
||||
usage.reasoningTokens ?? 0,
|
||||
usage.sessionCacheHitTokens ?? 0,
|
||||
usage.sessionCacheMissTokens ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
|
||||
const denom = hitTokens + missTokens;
|
||||
if (denom <= 0) return undefined;
|
||||
const pct = (hitTokens / denom) * 100;
|
||||
if (pct >= 80) return "good";
|
||||
if (pct >= 60) return "notice";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function formatSharePercent(value: number, total: number): string {
|
||||
if (total <= 0 || value <= 0) return "-";
|
||||
const pct = (value / total) * 100;
|
||||
if (pct > 0 && pct < 1) return "<1%";
|
||||
return `${Math.round(pct)}%`;
|
||||
}
|
||||
|
||||
interface ContextWindowStatus {
|
||||
tone: "good" | "notice" | "warn";
|
||||
shortKey: DictKey;
|
||||
vars: Record<string, string | number>;
|
||||
key: DictKey;
|
||||
}
|
||||
|
||||
export function contextCostDisplay({
|
||||
@@ -158,37 +200,27 @@ export function contextBreakdown(
|
||||
};
|
||||
}
|
||||
|
||||
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
|
||||
if (usagePct >= 85) {
|
||||
return {
|
||||
tone: "warn",
|
||||
shortKey: "context.healthNearLimitShort",
|
||||
vars: { pct: usagePct },
|
||||
};
|
||||
}
|
||||
if (readCount >= 8) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthManyFilesShort",
|
||||
vars: { count: readCount },
|
||||
};
|
||||
}
|
||||
if (cachePct > 0 && cachePct < 50) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthLowCacheShort",
|
||||
vars: { pct: cachePct },
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "good",
|
||||
shortKey: "context.healthGoodShort",
|
||||
vars: {},
|
||||
};
|
||||
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
|
||||
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
|
||||
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
|
||||
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
|
||||
return { tone: "good", key: "context.windowStatusHealthy" };
|
||||
}
|
||||
|
||||
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
|
||||
|
||||
function sourceTone(source: string): string {
|
||||
switch (source) {
|
||||
case "executor": return "teal";
|
||||
case "planner": return "blue";
|
||||
case "subagent": return "amber";
|
||||
case "compaction": return "slate";
|
||||
case "classifier": return "violet";
|
||||
case "title": return "rose";
|
||||
default: return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: string, t: Translator): string {
|
||||
switch (source) {
|
||||
case "executor": return t("context.sourceExecutor");
|
||||
@@ -205,11 +237,35 @@ function sourceCost(stats: UsageSourceStats): number {
|
||||
return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
|
||||
}
|
||||
|
||||
function sourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): Array<{ source: string; label: string; cost: number; currency?: string; requests: number }> {
|
||||
function sourceTokenTotal(row: Pick<ContextSourceRow, "promptTokens" | "completionTokens" | "totalTokens">): number {
|
||||
return row.totalTokens > 0 ? row.totalTokens : row.promptTokens + row.completionTokens;
|
||||
}
|
||||
|
||||
export interface ContextSourceRow {
|
||||
source: string;
|
||||
label: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
currency?: string;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): ContextSourceRow[] {
|
||||
const entries = Object.entries(info?.sources ?? {});
|
||||
if (entries.length === 0) return [];
|
||||
return entries
|
||||
.filter(([, stats]) => (stats.requestCount ?? 0) > 0 || sourceCost(stats) > 0)
|
||||
.filter(([, stats]) =>
|
||||
(stats.requestCount ?? 0) > 0 ||
|
||||
(stats.promptTokens ?? 0) > 0 ||
|
||||
(stats.completionTokens ?? 0) > 0 ||
|
||||
(stats.cacheHitTokens ?? 0) > 0 ||
|
||||
(stats.cacheMissTokens ?? 0) > 0 ||
|
||||
sourceCost(stats) > 0
|
||||
)
|
||||
.sort(([a], [b]) => {
|
||||
const ia = SOURCE_ORDER.indexOf(a);
|
||||
const ib = SOURCE_ORDER.indexOf(b);
|
||||
@@ -219,6 +275,11 @@ function sourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): Ar
|
||||
.map(([source, stats]) => ({
|
||||
source,
|
||||
label: source,
|
||||
promptTokens: stats.promptTokens ?? 0,
|
||||
completionTokens: stats.completionTokens ?? 0,
|
||||
cacheHitTokens: stats.cacheHitTokens ?? 0,
|
||||
cacheMissTokens: stats.cacheMissTokens ?? 0,
|
||||
totalTokens: stats.totalTokens ?? 0,
|
||||
cost: sourceCost(stats),
|
||||
currency: stats.sessionCurrency || sessionCurrency || info?.sessionCurrency,
|
||||
requests: stats.requestCount ?? 0,
|
||||
@@ -232,7 +293,6 @@ export function ContextPanel({
|
||||
sessionTokens,
|
||||
sessionCost,
|
||||
sessionCurrency,
|
||||
sessionTurns,
|
||||
turnTokens,
|
||||
turnCost,
|
||||
balance,
|
||||
@@ -241,24 +301,24 @@ export function ContextPanel({
|
||||
}: ContextPanelProps) {
|
||||
const { locale, t } = useI18n();
|
||||
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
|
||||
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
|
||||
const refreshSeq = useRef(0);
|
||||
const lastRefreshTime = useRef(0);
|
||||
const usageRefreshKey = contextUsageRefreshKey(usage);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tabId) return;
|
||||
const seq = ++refreshSeq.current;
|
||||
try {
|
||||
const next = await app.ContextPanel(tabId);
|
||||
if (refreshSeq.current === seq) setInfo(next);
|
||||
if (refreshSeq.current === seq) {
|
||||
setInfo(next);
|
||||
}
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
}, [tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => void refresh(), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSeq.current += 1;
|
||||
setInfo(null);
|
||||
@@ -269,19 +329,23 @@ export function ContextPanel({
|
||||
void refresh();
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
const hasPanelUsage = Boolean(
|
||||
(info?.requestCount ?? 0) > 0 ||
|
||||
(info?.promptTokens ?? 0) > 0 ||
|
||||
(info?.completionTokens ?? 0) > 0 ||
|
||||
(info?.totalTokens ?? 0) > 0 ||
|
||||
(info?.reasoningTokens ?? 0) > 0 ||
|
||||
(info?.cacheHitTokens ?? 0) > 0 ||
|
||||
(info?.cacheMissTokens ?? 0) > 0
|
||||
);
|
||||
// Refresh the panel snapshot while usage events stream. The key includes
|
||||
// general token fields so providers without cache telemetry still tick.
|
||||
useEffect(() => {
|
||||
if (!usageRefreshKey) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTime.current >= 1000) {
|
||||
lastRefreshTime.current = now;
|
||||
void refresh();
|
||||
}
|
||||
}, [usageRefreshKey, refresh]);
|
||||
|
||||
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
|
||||
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
|
||||
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
|
||||
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
|
||||
// Prefer live usage props (updated in real-time by the reducer during streaming)
|
||||
// over the async-fetched info snapshot (only refreshed on turn_done).
|
||||
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
|
||||
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
|
||||
const totalTokens = info?.totalTokens && info.totalTokens > 0
|
||||
? info.totalTokens
|
||||
: sessionTokens && sessionTokens > 0
|
||||
@@ -289,24 +353,28 @@ export function ContextPanel({
|
||||
: usage?.totalTokens && usage.totalTokens > 0
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
|
||||
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
|
||||
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
|
||||
// the Go agent (includes background tasks) and arrives with every usage event.
|
||||
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
|
||||
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
|
||||
const costSources = sourceRows(info, sessionCurrency);
|
||||
const showCostSources = costSources.some((row) => row.source !== "executor") || costSources.length > 1;
|
||||
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
|
||||
const showSourceUsageRows = sourceUsageRows.length > 0;
|
||||
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
|
||||
const visibleSourceRows = sourceUsageRows.slice(0, 3);
|
||||
const hiddenSourceRows = sourceUsageRows.slice(3);
|
||||
const readFiles = asArray(info?.readFiles);
|
||||
const changedFiles = asArray(info?.changedFiles);
|
||||
|
||||
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
|
||||
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
|
||||
const cacheDenom = cacheHitTokens + cacheMissTokens;
|
||||
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
|
||||
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
|
||||
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
|
||||
const compactPct = Math.round(compactRatio * 100);
|
||||
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
|
||||
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
|
||||
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
|
||||
const donutStyle = {
|
||||
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
|
||||
};
|
||||
const eventTimes = [
|
||||
...readFiles.map((file) => file.time),
|
||||
...changedFiles.map((file) => file.latestTime ?? 0),
|
||||
@@ -315,93 +383,236 @@ export function ContextPanel({
|
||||
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
|
||||
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
|
||||
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
|
||||
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
|
||||
const windowStatus = contextWindowStatus(usagePct, compactPct);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
|
||||
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
|
||||
const usedLabel = fmtFullTokens(usedTokens);
|
||||
const windowLabel = fmtFullTokens(windowTokens);
|
||||
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
|
||||
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
|
||||
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
|
||||
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
|
||||
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
|
||||
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
|
||||
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
|
||||
const tokenTypeRows = [
|
||||
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
|
||||
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
|
||||
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
|
||||
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
|
||||
];
|
||||
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
|
||||
const renderSourceRow = (row: ContextSourceRow) => {
|
||||
const inputMetric = formatMetricTokens(row.promptTokens, locale);
|
||||
const outputMetric = formatMetricTokens(row.completionTokens, locale);
|
||||
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
|
||||
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
<span>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)}
|
||||
</span>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
</div>
|
||||
<div className="context-panel__source-summary">
|
||||
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
|
||||
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
|
||||
</div>
|
||||
<details className="context-panel__source-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__source-details-body">
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
|
||||
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
|
||||
]}
|
||||
/>
|
||||
{cacheReported ? (
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
|
||||
]}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
|
||||
)}
|
||||
<div className="context-panel__source-metrics">
|
||||
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="context-panel__body">
|
||||
<section className="context-panel__overview">
|
||||
<section className="context-panel__usage">
|
||||
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
|
||||
<div className="context-panel__usage-visual">
|
||||
<div className="context-panel__donut" style={donutStyle}>
|
||||
<div className="context-panel__donut-core">
|
||||
<strong>{fmtTokens(usedTokens)}</strong>
|
||||
<span>/ {fmtTokens(windowTokens)} tokens</span>
|
||||
<SectionHeading title={t("context.windowTitle")} />
|
||||
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
|
||||
<div className="context-panel__capacity-top">
|
||||
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
|
||||
<strong>{usedLabel}/{windowLabel}</strong>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
|
||||
<div className="context-panel__capacity-scale" aria-hidden="true">
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
|
||||
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__percent">{usagePct}%</div>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
|
||||
<div className="context-panel__progress-head">
|
||||
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
|
||||
<span>{usagePct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
|
||||
<div className="context-panel__capacity-foot">
|
||||
<span>{t("context.windowUsedLabel")}</span>
|
||||
<span className="context-panel__capacity-remaining">
|
||||
<span>{t("context.windowCompactDistance")}</span>
|
||||
<strong>{compactRemainingLabel}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={fmtSessionCacheRate(usage)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
|
||||
</div>
|
||||
<div className="context-panel__breakdown">
|
||||
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
|
||||
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
|
||||
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
|
||||
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
|
||||
<div className="context-panel__total">
|
||||
<span>{t("context.total")}</span>
|
||||
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__session-section">
|
||||
<SectionHeading title={t("context.sessionMetrics")} />
|
||||
<div className="context-panel__session-metrics">
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
|
||||
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
|
||||
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
|
||||
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
|
||||
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.runtimeMetrics")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MetricCard label={t("context.sessionTokens")} value={totalTokens > 0 ? totalTokens.toLocaleString() : "-"} wide />
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.costMetrics")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
|
||||
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
|
||||
</div>
|
||||
{showCostSources && (
|
||||
<div className="context-panel__source-list" aria-label={t("context.costBreakdown")}>
|
||||
{costSources.map((row) => (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<span>{sourceLabel(row.label, t)}</span>
|
||||
<strong>{formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" })}</strong>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
<section className="context-panel__section context-panel__analysis">
|
||||
<SectionHeading title={t("context.usageAnalysis")}>
|
||||
{showSourceUsageRows && (
|
||||
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "source"}
|
||||
onClick={() => setAnalysisView("source")}
|
||||
>
|
||||
{t("context.usageAnalysisSource")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "type"}
|
||||
onClick={() => setAnalysisView("type")}
|
||||
>
|
||||
{t("context.usageAnalysisType")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SectionHeading>
|
||||
{activeAnalysisView === "source" ? (
|
||||
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__source-overview">
|
||||
<div className="context-panel__source-overview-head">
|
||||
<strong>{t("context.sourceShareTitle")}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className="context-panel__source-sharebar" aria-hidden="true">
|
||||
{sourceUsageRows.map((row) => {
|
||||
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
|
||||
if (sharePct <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
className={`context-panel__source-share context-panel__source-tone--${sourceTone(row.source)}`}
|
||||
key={row.source}
|
||||
style={{ width: `${sharePct}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="context-panel__source-legend">
|
||||
{sourceUsageRows.map((row) => {
|
||||
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
|
||||
return (
|
||||
<span key={row.source}>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)} {sharePct > 0 ? `${sharePct.toFixed(0)}%` : "-"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{visibleSourceRows.map(renderSourceRow)}
|
||||
{hiddenSourceRows.length > 0 && (
|
||||
<details className="context-panel__source-more">
|
||||
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
|
||||
<div className="context-panel__source-more-list">
|
||||
{hiddenSourceRows.map(renderSourceRow)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__type-overview">
|
||||
<div className="context-panel__type-overview-head">
|
||||
<strong>{t("context.tokenBreakdown")}</strong>
|
||||
</div>
|
||||
<div className="context-panel__type-sharebar" aria-hidden="true">
|
||||
{tokenTypeRows.map((row) => row.value > 0 ? (
|
||||
<span
|
||||
className={`context-panel__type-share context-panel__type-share--${row.key}`}
|
||||
key={row.key}
|
||||
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
|
||||
/>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="context-panel__type-legend">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<span key={row.key}>
|
||||
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
|
||||
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<details className="context-panel__breakdown-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__breakdown">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__section--status">
|
||||
<SectionHeading title={t("context.sessionStatus")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
|
||||
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -409,11 +620,12 @@ export function ContextPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, meta }: { title: string; meta?: string }) {
|
||||
function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<header className="context-panel__section-head">
|
||||
<h3>{title}</h3>
|
||||
{meta && <span>{meta}</span>}
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -428,22 +640,68 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: string }) {
|
||||
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
|
||||
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
|
||||
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__mini-stat">
|
||||
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone, wide }: { label: string; value: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
|
||||
function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; value: string; valueTitle?: string; tone?: "accent" | "good" | "notice" | "warn"; wide?: boolean }) {
|
||||
const toneClass = tone ? ` context-panel__metric--${tone}` : "";
|
||||
const wideClass = wide ? " context-panel__metric--wide" : "";
|
||||
const exactTitle = valueTitle && valueTitle !== value ? valueTitle : undefined;
|
||||
return (
|
||||
<div className={`context-panel__metric${toneClass}${wideClass}`}>
|
||||
<div className={`context-panel__metric${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__source-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSplitBar({ label, segments, compact }: { label: string; segments: Array<{ label: string; value: number; tone: string }>; compact?: boolean }) {
|
||||
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
|
||||
const visible = segments.filter((segment) => segment.value > 0);
|
||||
const compactClass = compact ? " context-panel__source-bar--compact" : "";
|
||||
if (total <= 0 || visible.length === 0) {
|
||||
return (
|
||||
<div className="context-panel__source-bar-row">
|
||||
<span>{label}</span>
|
||||
<div className={`context-panel__source-bar context-panel__source-bar--empty${compactClass}`} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="context-panel__source-bar-row">
|
||||
<span>{label}</span>
|
||||
<div className={`context-panel__source-bar${compactClass}`}>
|
||||
{visible.map((segment) => {
|
||||
const width = (segment.value / total) * 100;
|
||||
return (
|
||||
<span
|
||||
className={`context-panel__source-bar-segment context-panel__source-bar-segment--${segment.tone}`}
|
||||
key={segment.tone}
|
||||
style={{ width: `${width}%` }}
|
||||
title={`${segment.label}: ${segment.value.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FileText, Folder } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { app } from "../lib/bridge";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
|
||||
return entry.path || atDir + entry.name;
|
||||
}
|
||||
|
||||
export function dirEntryMenuLabel(entry: DirEntry): string {
|
||||
return entry.displayName || entry.name;
|
||||
}
|
||||
|
||||
export function activeFileReferenceToken(text: string): { raw: string; dir: string; frag: string } | null {
|
||||
const match = /(?:^|\s)@([^\s]*)$/.exec(text);
|
||||
if (!match) return null;
|
||||
const raw = match[1];
|
||||
const slash = raw.lastIndexOf("/");
|
||||
return {
|
||||
raw,
|
||||
dir: slash >= 0 ? raw.slice(0, slash + 1) : "",
|
||||
frag: (slash >= 0 ? raw.slice(slash + 1) : raw).toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function pickInlineFileReference(text: string, atRaw: string | null, atDir: string, entry: DirEntry): string {
|
||||
const atPos = text.length - (atRaw?.length ?? 0) - 1;
|
||||
const prefix = text.slice(0, Math.max(0, atPos));
|
||||
const refPath = dirEntrySubmitPath(entry, atDir);
|
||||
return prefix + "@" + refPath + (entry.isDir ? "/" : " ");
|
||||
}
|
||||
|
||||
export function insertTextAtSelection(
|
||||
value: string,
|
||||
insert: string,
|
||||
selectionStart = value.length,
|
||||
selectionEnd = selectionStart,
|
||||
): { value: string; caret: number } {
|
||||
const before = value.slice(0, selectionStart);
|
||||
const after = value.slice(selectionEnd);
|
||||
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) && !/^\s/.test(insert);
|
||||
const needsTrailingSpace = after.length > 0 && !/\s$/.test(insert) && !/^\s/.test(after);
|
||||
const text = `${needsLeadingSpace ? " " : ""}${insert}${needsTrailingSpace ? " " : ""}`;
|
||||
const next = before + text + after;
|
||||
return { value: next, caret: before.length + text.length };
|
||||
}
|
||||
|
||||
export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const token = useMemo(() => activeFileReferenceToken(text), [text]);
|
||||
const atRaw = token?.raw ?? null;
|
||||
const atDir = token?.dir ?? "";
|
||||
const atFrag = token?.frag ?? "";
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
const prevCwdRef = useRef(cwd);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return;
|
||||
prevCwdRef.current = cwd;
|
||||
dirCache.current = {};
|
||||
searchCache.current = {};
|
||||
setEntries([]);
|
||||
setSearchEntries([]);
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [atRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null) return;
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null || atDir !== "" || atFrag === "") {
|
||||
setSearchEntries([]);
|
||||
return;
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, atFrag, cwd]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (atRaw === null) return [];
|
||||
return filterAtMatches(entries, searchEntries, atFrag);
|
||||
}, [atRaw, atFrag, entries, searchEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxIdx = Math.max(0, items.length - 1);
|
||||
setActive((prev) => (prev > maxIdx ? 0 : prev));
|
||||
}, [items.length]);
|
||||
|
||||
return {
|
||||
atRaw,
|
||||
atDir,
|
||||
items,
|
||||
active,
|
||||
setActive,
|
||||
count: atRaw !== null && !dismissed ? items.length : 0,
|
||||
open: atRaw !== null && !dismissed,
|
||||
dismiss: () => setDismissed(true),
|
||||
};
|
||||
}
|
||||
|
||||
export function FileReferenceMenu({
|
||||
items,
|
||||
activeIndex,
|
||||
onPick,
|
||||
onHover,
|
||||
}: {
|
||||
items: DirEntry[];
|
||||
activeIndex: number;
|
||||
onPick: (entry: DirEntry) => void;
|
||||
onHover: (index: number) => void;
|
||||
}) {
|
||||
const renderEntry = (entry: DirEntry, index: number) => (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={`slashmenu__item ${index === activeIndex ? "slashmenu__item--active" : ""}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onPick(entry);
|
||||
}}
|
||||
onMouseMove={() => onHover(index)}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
<Folder size={13} className="filemenu__icon filemenu__icon--dir" />
|
||||
) : (
|
||||
<FileText size={13} className="filemenu__icon" />
|
||||
)}
|
||||
<span className="slashmenu__name slashmenu__name--file">
|
||||
{dirEntryMenuLabel(entry)}
|
||||
{entry.isDir ? "/" : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return (
|
||||
<div className="slashmenu" role="listbox">
|
||||
{items.map((entry, index) => (
|
||||
<div key={(entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}>
|
||||
{renderEntry(entry, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualMenu
|
||||
items={items}
|
||||
activeIndex={activeIndex}
|
||||
itemKey={(entry) => (entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}
|
||||
renderItem={renderEntry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
|
||||
import { t, useT } from "../lib/i18n";
|
||||
import { sessionActivityTime } from "../lib/session";
|
||||
import { historySessionDisplayTitle, sessionActivityTime } from "../lib/session";
|
||||
import type { HistoryMessage, SessionMeta } from "../lib/types";
|
||||
import { historyMessagesToItems, type Item } from "../lib/useController";
|
||||
import { Transcript } from "./Transcript";
|
||||
@@ -83,7 +83,7 @@ export function HistoryPanel({
|
||||
setEditing(null);
|
||||
setPreview({
|
||||
path: s.path,
|
||||
title: sessionDisplayTitle(s, tr("history.emptySession")),
|
||||
title: historySessionDisplayTitle(s, tr("history.emptySession")),
|
||||
meta: sessionMetaLine(s, tr, isTrash),
|
||||
messages: [],
|
||||
loading: true,
|
||||
@@ -464,7 +464,7 @@ export function HistoryPanel({
|
||||
if (!isTrash && !running) onResume(s);
|
||||
}}
|
||||
>
|
||||
<div className="hist-item__preview">{sessionDisplayTitle(s, tr("history.emptySession"))}</div>
|
||||
<div className="hist-item__preview">{historySessionDisplayTitle(s, tr("history.emptySession"))}</div>
|
||||
<div className="hist-item__meta">
|
||||
{!isTrash && isChannelSession(s) && <span className="hist-item__badge hist-item__badge--open">{tr("history.channel")}</span>}
|
||||
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
|
||||
@@ -607,7 +607,6 @@ function sessionLocation(s: SessionMeta, tr: ReturnType<typeof useT>): string {
|
||||
if (isChannelSession(s)) {
|
||||
return [s.channelLabel || s.channel || tr("history.channel"), s.remoteId].filter(Boolean).join(" · ");
|
||||
}
|
||||
if (s.topicTitle) return s.topicTitle;
|
||||
if (s.workspaceRoot) {
|
||||
const parts = s.workspaceRoot.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || s.workspaceRoot;
|
||||
@@ -615,10 +614,6 @@ function sessionLocation(s: SessionMeta, tr: ReturnType<typeof useT>): string {
|
||||
return sessionScope(s) === "project" ? tr("history.filterProject") : tr("history.filterGlobal");
|
||||
}
|
||||
|
||||
function sessionDisplayTitle(s: SessionMeta, fallback: string): string {
|
||||
return s.title || s.preview || fallback;
|
||||
}
|
||||
|
||||
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
|
||||
const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
|
||||
const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export interface ImageViewerProps {
|
||||
open: boolean;
|
||||
/** data-URL of the image to display */
|
||||
imageUrl: string;
|
||||
/** optional filename shown at the bottom */
|
||||
imageName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageViewer renders a full-size image preview in a portal overlay.
|
||||
*
|
||||
* It follows the same portal pattern as MermaidDiagram fullscreen:
|
||||
* the overlay is portaled into .chat-pane (fallback document.body) so it
|
||||
* covers the chat area without being clipped by overflow containers.
|
||||
*/
|
||||
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
|
||||
const t = useT();
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// Resolve portal target when opening.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setVisible(false);
|
||||
setPortalTarget(null);
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(".chat-pane") ?? document.body;
|
||||
setPortalTarget(target);
|
||||
// Trigger enter animation on the next frame.
|
||||
const raf = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Prevent body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open || !portalTarget || !imageUrl) return null;
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={imageName || t("imageViewer.title")}
|
||||
>
|
||||
<button
|
||||
className="image-viewer__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("imageViewer.closePreview")}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div className="image-viewer__content">
|
||||
<img
|
||||
className="image-viewer__image"
|
||||
src={imageUrl}
|
||||
alt={imageName || ""}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageName && <div className="image-viewer__name">{imageName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(overlay, portalTarget);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ const MarkdownRenderer = lazy(() => import("./MarkdownRenderer"));
|
||||
|
||||
export const Markdown = memo(function Markdown({
|
||||
text,
|
||||
plainStatusBlocks = false,
|
||||
}: {
|
||||
text: string;
|
||||
plainStatusBlocks?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<div className="md">{text}</div>}>
|
||||
<MarkdownRenderer text={text} />
|
||||
<MarkdownRenderer text={text} plainStatusBlocks={plainStatusBlocks} />
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef } from "react";
|
||||
import { lazy, memo, Suspense, useMemo, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -9,6 +9,8 @@ import { CodeViewer } from "./CodeViewer";
|
||||
import { normalizeMath } from "./mathNormalize";
|
||||
import { openExternal } from "../lib/bridge";
|
||||
|
||||
const MermaidDiagram = lazy(() => import("./MermaidDiagram"));
|
||||
|
||||
// Markdown rendering via react-markdown + remark-gfm (tables, task lists,
|
||||
// strike, autolinks) and remark-math + rehype-katex for $/$$ KaTeX math.
|
||||
// Fenced code blocks go through CodeViewer for syntax highlighting; inline
|
||||
@@ -19,44 +21,125 @@ import { openExternal } from "../lib/bridge";
|
||||
// pairs through a classifier to avoid false positives on $5, $PATH, etc.,
|
||||
// and runs KaTeX-specific normalisations (text-mode escapes, |→\vert).
|
||||
|
||||
const components: Components = {
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ className, children }) => {
|
||||
const text = String(children ?? "");
|
||||
const match = /language-([\w-]+)/.exec(className ?? "");
|
||||
const isBlock = match !== null || text.includes("\n");
|
||||
if (isBlock) {
|
||||
return <CodeViewer value={text.replace(/\n$/, "")} language={match?.[1]} maxHeight={360} />;
|
||||
const STATUS_MARKER_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/;
|
||||
const STATUS_MARKER_GLOBAL_RE = /(?:✅|☑|☒|✔️?|✓|\[[xX ]\])/g;
|
||||
const BULLET_RE = /^[-*•]\s+\S/;
|
||||
const DIVIDER_RE = /^[\s\-_=─━—]+$/;
|
||||
|
||||
function splitStatusLine(line: string): string[] {
|
||||
const parts = (line.match(STATUS_MARKER_GLOBAL_RE) ?? []).length > 1
|
||||
? line.split(/(?=(?:✅|☑|☒|✔️?|✓|\[[xX ]\]))/)
|
||||
: [line];
|
||||
return parts
|
||||
.map((part) => part.replace(/^(?:✅|☑|☒|✔️?|✓|\[[xX ]\]|[-*•])\s*/i, "").trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => part.replace(/\s{2,}/g, " · "));
|
||||
}
|
||||
|
||||
function looksLikeDiagram(text: string): boolean {
|
||||
return /[←→↔]|<{1,2}-{2,}|-{2,}>{1,2}|[-_=─━]{6,}/.test(text);
|
||||
}
|
||||
|
||||
function splitPlainBlock(text: string): { preText: string; statusItems: string[] } {
|
||||
const items: string[] = [];
|
||||
const preLines: string[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
const bulletLines = lines.filter((line) => BULLET_RE.test(line.trim())).length;
|
||||
const collectBulletLines = bulletLines >= 2 && !looksLikeDiagram(text);
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
const marked = STATUS_MARKER_RE.test(line) || (collectBulletLines && BULLET_RE.test(line));
|
||||
if (marked) {
|
||||
items.push(...splitStatusLine(line));
|
||||
} else if (DIVIDER_RE.test(line) && items.length > 0 && !looksLikeDiagram(text)) {
|
||||
continue;
|
||||
} else {
|
||||
preLines.push(rawLine);
|
||||
}
|
||||
return <code className="md-code">{children}</code>;
|
||||
},
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
}
|
||||
while (preLines.length > 0 && preLines[0].trim() === "") preLines.shift();
|
||||
while (preLines.length > 0 && preLines[preLines.length - 1].trim() === "") preLines.pop();
|
||||
return { preText: preLines.join("\n"), statusItems: items };
|
||||
}
|
||||
|
||||
function PlainMarkdownBlock({ text }: { text: string }) {
|
||||
const { preText, statusItems } = splitPlainBlock(text);
|
||||
const asList = statusItems.length >= 2;
|
||||
return (
|
||||
<div className={`md-plain-block${asList ? " md-plain-block--split" : " md-plain-block--pre"}`}>
|
||||
<CodeViewer value={text} maxHeight={360} />
|
||||
{asList && preText && (
|
||||
<div className="md-plain-block__diagram">
|
||||
<CodeViewer value={preText} maxHeight={360} />
|
||||
</div>
|
||||
)}
|
||||
{asList && (
|
||||
<div className="md-status-list">
|
||||
{statusItems.map((item, index) => (
|
||||
<div className="md-status-list__item" key={`${index}-${item}`}>
|
||||
<span className="md-status-list__dot" aria-hidden="true" />
|
||||
<span className="md-status-list__text">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function createComponents(plainStatusBlocks: boolean): Components {
|
||||
return {
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ className, children }) => {
|
||||
const text = String(children ?? "");
|
||||
const match = /language-([\w-]+)/.exec(className ?? "");
|
||||
const lang = match?.[1];
|
||||
const isBlock = match !== null || text.includes("\n");
|
||||
if (isBlock) {
|
||||
const value = text.replace(/\n$/, "");
|
||||
if (lang === "mermaid") {
|
||||
return (
|
||||
<Suspense fallback={<CodeViewer value={value} language="mermaid" maxHeight={360} />}>
|
||||
<MermaidDiagram definition={value} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
|
||||
return <CodeViewer value={value} language={lang} maxHeight={360} />;
|
||||
}
|
||||
return <code className="md-code">{children}</code>;
|
||||
},
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) openExternal(href);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
text,
|
||||
plainStatusBlocks = false,
|
||||
}: {
|
||||
text: string;
|
||||
plainStatusBlocks?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mathContent = useMemo(() => normalizeMath(text), [text]);
|
||||
const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]);
|
||||
return (
|
||||
<div className="md" ref={containerRef}>
|
||||
<ReactMarkdown
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import { memo, type RefObject, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertCircle, Code2, Maximize2, Minimize2, Play, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { openExternal } from "../lib/bridge";
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
definition: string;
|
||||
}
|
||||
|
||||
type DiagramState =
|
||||
| { status: "loading" }
|
||||
| { status: "rendered"; svg: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
type DiagramTab = "preview" | "code";
|
||||
type MermaidThemeName = "dark" | "light";
|
||||
type MermaidModule = typeof import("mermaid");
|
||||
type MermaidApi = MermaidModule["default"];
|
||||
type MermaidRenderAdapter = (
|
||||
svgId: string,
|
||||
definition: string,
|
||||
theme: MermaidThemeName,
|
||||
signal: AbortSignal,
|
||||
) => Promise<string>;
|
||||
|
||||
type PanZoomInstance = {
|
||||
destroy(): void;
|
||||
resize(): unknown;
|
||||
fit(): unknown;
|
||||
center(): unknown;
|
||||
zoomIn(): unknown;
|
||||
zoomOut(): unknown;
|
||||
reset(): unknown;
|
||||
};
|
||||
|
||||
type PanZoomFactory = (svg: SVGSVGElement, options?: Record<string, unknown>) => PanZoomInstance;
|
||||
|
||||
const MAX_TEXT_SIZE = 100000;
|
||||
const MIN_ZOOM = 0.3;
|
||||
const MAX_ZOOM = 8;
|
||||
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
||||
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
||||
|
||||
let mermaidApi: MermaidApi | null = null;
|
||||
let initPromise: Promise<MermaidApi> | null = null;
|
||||
let renderQueue: Promise<void> = Promise.resolve();
|
||||
let panZoomFactory: PanZoomFactory | null = null;
|
||||
let panZoomPromise: Promise<PanZoomFactory | null> | null = null;
|
||||
let renderAdapterForTest: MermaidRenderAdapter | null = null;
|
||||
let panZoomFactoryForTest: PanZoomFactory | null | undefined;
|
||||
|
||||
export function __setMermaidRenderAdapterForTest(adapter: MermaidRenderAdapter | null): void {
|
||||
renderAdapterForTest = adapter;
|
||||
renderQueue = Promise.resolve();
|
||||
}
|
||||
|
||||
export function __setMermaidPanZoomFactoryForTest(factory: PanZoomFactory | null | undefined): void {
|
||||
panZoomFactoryForTest = factory;
|
||||
}
|
||||
|
||||
function mermaidThemeVariables(theme: MermaidThemeName): Record<string, string | number | boolean> {
|
||||
if (theme === "dark") {
|
||||
return {
|
||||
darkMode: true,
|
||||
background: "#111319",
|
||||
fontSize: "13px",
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
primaryColor: "#1f2937",
|
||||
primaryTextColor: "#f4f5f7",
|
||||
primaryBorderColor: "#374151",
|
||||
mainBkg: "#1f2937",
|
||||
nodeBkg: "#1f2937",
|
||||
nodeBorder: "#374151",
|
||||
nodeTextColor: "#f4f5f7",
|
||||
stateBkg: "#1f2937",
|
||||
stateBorder: "#374151",
|
||||
stateLabelColor: "#f4f5f7",
|
||||
labelColor: "#d1d5db",
|
||||
lineColor: "#6b7280",
|
||||
textColor: "#d1d5db",
|
||||
defaultLinkColor: "#6b7280",
|
||||
edgeLabelBackground: "#111319",
|
||||
clusterBkg: "#0f172a",
|
||||
clusterBorder: "#374151",
|
||||
actorBkg: "#1f2937",
|
||||
actorBorder: "#374151",
|
||||
actorTextColor: "#f4f5f7",
|
||||
actorLineColor: "#4b5563",
|
||||
signalColor: "#d1d5db",
|
||||
signalTextColor: "#f4f5f7",
|
||||
labelBoxBkgColor: "#1f2937",
|
||||
labelBoxBorderColor: "#374151",
|
||||
labelTextColor: "#f4f5f7",
|
||||
loopTextColor: "#f4f5f7",
|
||||
noteBkgColor: "#0f172a",
|
||||
noteBorderColor: "#374151",
|
||||
noteTextColor: "#e5e7eb",
|
||||
classText: "#f4f5f7",
|
||||
classBorder: "#374151",
|
||||
classBkg: "#1f2937",
|
||||
secondaryColor: "#111319",
|
||||
tertiaryColor: "#1f2937",
|
||||
};
|
||||
}
|
||||
return {
|
||||
darkMode: false,
|
||||
background: "#ffffff",
|
||||
fontSize: "13px",
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
primaryColor: "#f8fafc",
|
||||
primaryTextColor: "#0f172a",
|
||||
primaryBorderColor: "#cbd5e1",
|
||||
mainBkg: "#f8fafc",
|
||||
nodeBkg: "#f8fafc",
|
||||
nodeBorder: "#cbd5e1",
|
||||
nodeTextColor: "#0f172a",
|
||||
stateBkg: "#f8fafc",
|
||||
stateBorder: "#cbd5e1",
|
||||
stateLabelColor: "#0f172a",
|
||||
labelColor: "#475569",
|
||||
lineColor: "#94a3b8",
|
||||
textColor: "#475569",
|
||||
defaultLinkColor: "#94a3b8",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
clusterBkg: "#f1f5f9",
|
||||
clusterBorder: "#e2e8f0",
|
||||
actorBkg: "#f8fafc",
|
||||
actorBorder: "#cbd5e1",
|
||||
actorTextColor: "#0f172a",
|
||||
actorLineColor: "#cbd5e1",
|
||||
signalColor: "#475569",
|
||||
signalTextColor: "#0f172a",
|
||||
labelBoxBkgColor: "#f8fafc",
|
||||
labelBoxBorderColor: "#cbd5e1",
|
||||
labelTextColor: "#0f172a",
|
||||
loopTextColor: "#0f172a",
|
||||
noteBkgColor: "#f1f5f9",
|
||||
noteBorderColor: "#e2e8f0",
|
||||
noteTextColor: "#334155",
|
||||
classText: "#0f172a",
|
||||
classBorder: "#cbd5e1",
|
||||
classBkg: "#f8fafc",
|
||||
secondaryColor: "#f1f5f9",
|
||||
tertiaryColor: "#f8fafc",
|
||||
};
|
||||
}
|
||||
|
||||
function mermaidConfigForTheme(theme: MermaidThemeName) {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
theme: "base" as const,
|
||||
securityLevel: "antiscript" as const,
|
||||
maxTextSize: MAX_TEXT_SIZE,
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
flowchart: { htmlLabels: false },
|
||||
themeVariables: mermaidThemeVariables(theme),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMermaid(theme: MermaidThemeName): Promise<MermaidApi> {
|
||||
if (mermaidApi) {
|
||||
mermaidApi.initialize(mermaidConfigForTheme(theme));
|
||||
return mermaidApi;
|
||||
}
|
||||
if (!initPromise) {
|
||||
initPromise = import("mermaid").then((mod) => {
|
||||
mermaidApi = mod.default;
|
||||
mermaidApi.initialize(mermaidConfigForTheme(theme));
|
||||
return mermaidApi;
|
||||
}).catch((err) => {
|
||||
initPromise = null;
|
||||
mermaidApi = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
const api = await initPromise;
|
||||
api.initialize(mermaidConfigForTheme(theme));
|
||||
return api;
|
||||
}
|
||||
|
||||
function queuedRender(
|
||||
svgId: string,
|
||||
definition: string,
|
||||
theme: MermaidThemeName,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const promise = renderQueue.then(async () => {
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const api = await ensureMermaid(theme);
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const { svg } = await api.render(svgId, definition);
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return svg;
|
||||
});
|
||||
renderQueue = promise.then(() => {}, () => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function renderMermaid(
|
||||
svgId: string,
|
||||
definition: string,
|
||||
theme: MermaidThemeName,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (renderAdapterForTest) return renderAdapterForTest(svgId, definition, theme, signal);
|
||||
return queuedRender(svgId, definition, theme, signal);
|
||||
}
|
||||
|
||||
async function ensurePanZoomFactory(): Promise<PanZoomFactory | null> {
|
||||
if (panZoomFactoryForTest !== undefined) return panZoomFactoryForTest;
|
||||
if (panZoomFactory) return panZoomFactory;
|
||||
if (!panZoomPromise) {
|
||||
panZoomPromise = import("svg-pan-zoom").then((mod) => {
|
||||
const factory = (("default" in mod ? mod.default : mod) as unknown) as PanZoomFactory;
|
||||
panZoomFactory = factory;
|
||||
return factory;
|
||||
}).catch(() => null);
|
||||
}
|
||||
return panZoomPromise;
|
||||
}
|
||||
|
||||
export function isSafeMermaidHref(href: string | null | undefined): boolean {
|
||||
const value = href?.trim();
|
||||
if (!value) return false;
|
||||
if (value.startsWith("#")) return true;
|
||||
try {
|
||||
return SAFE_LINK_PROTOCOLS.has(new URL(value).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenableMermaidHref(href: string | null | undefined): boolean {
|
||||
const value = href?.trim();
|
||||
return Boolean(value && !value.startsWith("#") && isSafeMermaidHref(value));
|
||||
}
|
||||
|
||||
export function getMermaidAnchorHref(anchor: Element): string | null {
|
||||
return anchor.getAttribute("href")
|
||||
?? anchor.getAttribute("xlink:href")
|
||||
?? anchor.getAttributeNS(XLINK_NS, "href");
|
||||
}
|
||||
|
||||
export function sanitizeMermaidSvg(svg: string): string {
|
||||
if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return svg;
|
||||
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
const svgEl = doc.documentElement;
|
||||
if (svgEl.localName.toLowerCase() !== "svg") return svg;
|
||||
|
||||
const elements = [svgEl, ...Array.from(svgEl.querySelectorAll("*"))];
|
||||
const toRemove: Element[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
const name = element.localName.toLowerCase();
|
||||
if (name === "script" || name === "iframe" || name === "object" || name === "embed") {
|
||||
toRemove.push(element);
|
||||
continue;
|
||||
}
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
const attrName = attr.name;
|
||||
const localName = attr.localName.toLowerCase();
|
||||
if (/^on/i.test(attrName)) {
|
||||
element.removeAttribute(attrName);
|
||||
continue;
|
||||
}
|
||||
if (localName === "href" && !isSafeMermaidHref(attr.value)) {
|
||||
element.removeAttribute(attrName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of toRemove) element.parentNode?.removeChild(element);
|
||||
return new XMLSerializer().serializeToString(svgEl);
|
||||
}
|
||||
|
||||
export function resolveMermaidTheme(): MermaidThemeName {
|
||||
if (typeof document === "undefined") return "dark";
|
||||
const forced = document.documentElement.getAttribute("data-theme");
|
||||
if (forced === "light" || forced === "dark") return forced;
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
function useMermaidTheme(): MermaidThemeName {
|
||||
const [theme, setTheme] = useState<MermaidThemeName>(resolveMermaidTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const updateTheme = () => setTheme(resolveMermaidTheme());
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(html, { attributeFilter: ["data-theme", "data-theme-mode"] });
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: light)");
|
||||
mq.addEventListener("change", updateTheme);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mq.removeEventListener("change", updateTheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function destroyPanZoom(instance: PanZoomInstance | null): void {
|
||||
if (!instance) return;
|
||||
try {
|
||||
instance.destroy();
|
||||
} catch {
|
||||
/* svg-pan-zoom cleanup is best-effort across browser and test DOMs. */
|
||||
}
|
||||
}
|
||||
|
||||
const MermaidDiagram = memo(function MermaidDiagram({ definition }: MermaidDiagramProps) {
|
||||
const [state, setState] = useState<DiagramState>({ status: "loading" });
|
||||
const [tab, setTab] = useState<DiagramTab>("preview");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
const theme = useMermaidTheme();
|
||||
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const svgId = `mermaid-${instanceId}`;
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const panZoomRef = useRef<PanZoomInstance | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const source = useMemo(() => definition.replace(/\n$/, ""), [definition]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const controller = new AbortController();
|
||||
setState({ status: "loading" });
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
setState({ status: "error", message: "Empty diagram definition" });
|
||||
return;
|
||||
}
|
||||
const rendered = await renderMermaid(svgId, trimmed, theme, controller.signal);
|
||||
if (controller.signal.aborted || !mountedRef.current) return;
|
||||
setState({ status: "rendered", svg: sanitizeMermaidSvg(rendered) });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (controller.signal.aborted || !mountedRef.current) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setState({ status: "error", message });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [source, svgId, theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (tab !== "preview" || state.status !== "rendered") return;
|
||||
const container = previewRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let cancelled = false;
|
||||
let raf = 0;
|
||||
let instance: PanZoomInstance | null = null;
|
||||
|
||||
void ensurePanZoomFactory().then((factory) => {
|
||||
if (cancelled || !factory) return;
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
const svg = container.querySelector("svg") as SVGSVGElement | null;
|
||||
if (cancelled || !svg) return;
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
svg.style.width = "100%";
|
||||
svg.style.height = "100%";
|
||||
svg.style.maxWidth = "none";
|
||||
|
||||
destroyPanZoom(panZoomRef.current);
|
||||
try {
|
||||
instance = factory(svg, {
|
||||
zoomEnabled: true,
|
||||
panEnabled: true,
|
||||
controlIconsEnabled: false,
|
||||
dblClickZoomEnabled: true,
|
||||
fit: true,
|
||||
center: true,
|
||||
minZoom: MIN_ZOOM,
|
||||
maxZoom: MAX_ZOOM,
|
||||
zoomScaleSensitivity: 0.3,
|
||||
});
|
||||
panZoomRef.current = instance;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (panZoomRef.current !== instance) return;
|
||||
instance?.resize();
|
||||
instance?.fit();
|
||||
instance?.center();
|
||||
});
|
||||
} catch {
|
||||
instance = null;
|
||||
panZoomRef.current = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
if (panZoomRef.current === instance) panZoomRef.current = null;
|
||||
destroyPanZoom(instance);
|
||||
};
|
||||
}, [state, tab, fullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setFullscreen(false);
|
||||
setPortalTarget(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [fullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen || !panZoomRef.current) return;
|
||||
panZoomRef.current.resize();
|
||||
panZoomRef.current.fit();
|
||||
panZoomRef.current.center();
|
||||
}, [fullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setFullscreen((current) => {
|
||||
const next = !current;
|
||||
setPortalTarget(next ? document.querySelector(".chat-pane") ?? document.body : null);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
panZoomRef.current?.zoomIn();
|
||||
}, []);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
panZoomRef.current?.zoomOut();
|
||||
}, []);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
const instance = panZoomRef.current;
|
||||
if (!instance) return;
|
||||
instance.reset();
|
||||
instance.fit();
|
||||
instance.center();
|
||||
}, []);
|
||||
|
||||
const body = (
|
||||
<MermaidBody
|
||||
state={state}
|
||||
source={source}
|
||||
tab={tab}
|
||||
previewRef={previewRef}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={[
|
||||
"mermaid-diagram",
|
||||
state.status === "error" ? "mermaid-diagram--error" : "",
|
||||
fullscreen ? "mermaid-diagram--fullscreen" : "",
|
||||
].filter(Boolean).join(" ")}>
|
||||
<MermaidToolbar
|
||||
tab={tab}
|
||||
source={source}
|
||||
fullscreen={fullscreen}
|
||||
onTabChange={setTab}
|
||||
onFullscreenToggle={toggleFullscreen}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomOut={zoomOut}
|
||||
onResetZoom={resetZoom}
|
||||
/>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullscreen && portalTarget) {
|
||||
return (
|
||||
<>
|
||||
<div className="mermaid-diagram mermaid-diagram--placeholder" aria-hidden="true" />
|
||||
{createPortal(content, portalTarget)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
function MermaidToolbar({
|
||||
tab,
|
||||
source,
|
||||
fullscreen,
|
||||
onTabChange,
|
||||
onFullscreenToggle,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetZoom,
|
||||
}: {
|
||||
tab: DiagramTab;
|
||||
source: string;
|
||||
fullscreen: boolean;
|
||||
onTabChange: (tab: DiagramTab) => void;
|
||||
onFullscreenToggle: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetZoom: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mermaid-diagram__toolbar">
|
||||
<div className="mermaid-diagram__title" aria-hidden="true">Mermaid</div>
|
||||
<div className="mermaid-diagram__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`mermaid-diagram__icon-btn${tab === "preview" ? " mermaid-diagram__icon-btn--active" : ""}`}
|
||||
onClick={() => onTabChange("preview")}
|
||||
aria-label="Preview diagram"
|
||||
title="Preview diagram"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mermaid-diagram__icon-btn${tab === "code" ? " mermaid-diagram__icon-btn--active" : ""}`}
|
||||
onClick={() => onTabChange("code")}
|
||||
aria-label="Show diagram source"
|
||||
title="Show diagram source"
|
||||
>
|
||||
<Code2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
|
||||
onClick={onZoomOut}
|
||||
aria-label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
|
||||
onClick={onZoomIn}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
|
||||
onClick={onResetZoom}
|
||||
aria-label="Reset zoom"
|
||||
title="Reset zoom"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<CopyButton text={source} className="mermaid-diagram__copy-btn" showInlineLabel={false} />
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-diagram__icon-btn"
|
||||
onClick={onFullscreenToggle}
|
||||
aria-label={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
|
||||
title={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidBody({
|
||||
state,
|
||||
source,
|
||||
tab,
|
||||
previewRef,
|
||||
}: {
|
||||
state: DiagramState;
|
||||
source: string;
|
||||
tab: DiagramTab;
|
||||
previewRef: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
if (tab === "code") {
|
||||
return (
|
||||
<pre className="code hljs mermaid-diagram__code" data-lang="mermaid">
|
||||
<code>{source}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<div className="mermaid-diagram__loading">
|
||||
<span className="mermaid-diagram__spinner" />
|
||||
<span>Rendering diagram...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<div className="mermaid-diagram__error">
|
||||
<div className="mermaid-diagram__error-bar">
|
||||
<AlertCircle size={14} className="mermaid-diagram__error-icon" />
|
||||
<span>Diagram syntax error</span>
|
||||
</div>
|
||||
<pre className="code hljs mermaid-diagram__error-source" data-lang="mermaid">
|
||||
<code>{source}</code>
|
||||
</pre>
|
||||
<details className="mermaid-diagram__error-details">
|
||||
<summary>Error details</summary>
|
||||
<pre className="mermaid-diagram__error-detail-text">{state.message}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SvgPreview refEl={previewRef} svg={state.svg} />;
|
||||
}
|
||||
|
||||
function SvgPreview({ refEl, svg }: { refEl: RefObject<HTMLDivElement | null>; svg: string }) {
|
||||
const openAnchor = useCallback((event: React.MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const anchor = target.closest("a");
|
||||
const href = anchor ? getMermaidAnchorHref(anchor) : null;
|
||||
if (!href) return;
|
||||
event.preventDefault();
|
||||
if (isOpenableMermaidHref(href)) openExternal(href);
|
||||
}, []);
|
||||
|
||||
const preventMiddleButtonNavigation = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 1) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("a")) event.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mermaid-diagram__preview-wrap">
|
||||
<div
|
||||
className="mermaid-diagram__preview"
|
||||
ref={refEl}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
onClick={openAnchor}
|
||||
onAuxClick={openAnchor}
|
||||
onMouseDown={preventMiddleButtonNavigation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MermaidDiagram;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { ProcessBrainIcon } from "./ProcessCard";
|
||||
@@ -10,10 +10,14 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
|
||||
import { app } from "../lib/bridge";
|
||||
import { replaySubmitText } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
|
||||
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
|
||||
import type { Item, MessageActionScope } from "../lib/useController";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
import type { CheckpointMeta, MemoryCitation } from "../lib/types";
|
||||
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
export type TurnActionMenu = "summary" | "rewind";
|
||||
@@ -78,6 +82,85 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
|
||||
return merged;
|
||||
}
|
||||
|
||||
type PastedBlockInfo = {
|
||||
label: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
|
||||
|
||||
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
|
||||
const labels = text.match(PASTE_LABEL_RE);
|
||||
if (!labels || labels.length === 0 || !submitText) return [];
|
||||
const unique = [...new Set(labels)];
|
||||
const blocks: PastedBlockInfo[] = [];
|
||||
for (const label of unique) {
|
||||
const beginMarker = `--- Begin ${label} ---`;
|
||||
const endMarker = `--- End ${label} ---`;
|
||||
const beginIdx = submitText.indexOf(beginMarker);
|
||||
const endIdx = submitText.indexOf(endMarker);
|
||||
if (beginIdx < 0 || endIdx <= beginIdx) continue;
|
||||
const contentStart = beginIdx + beginMarker.length;
|
||||
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
|
||||
blocks.push({ label, content });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const clean = visibleTranscriptMemoryCitations(citations)
|
||||
.filter((citation) => (citation.source ?? citation.id ?? citation.note ?? "").trim() !== "")
|
||||
.slice(0, 5);
|
||||
useGSAPCollapse(bodyRef, open);
|
||||
if (clean.length === 0) return null;
|
||||
return (
|
||||
<div className="msg-memory-citations">
|
||||
<button
|
||||
type="button"
|
||||
className="msg-memory-citations__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<ChevronRight className={`msg-memory-citations__chevron${open ? " msg-memory-citations__chevron--open" : ""}`} size={15} />
|
||||
<span>{t("msg.memoryCompilerCitationsCount", { n: clean.length })}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={bodyRef} className="msg-memory-citations__body">
|
||||
{clean.map((citation, index) => {
|
||||
const lines = memoryCitationLines(citation, t);
|
||||
return (
|
||||
<div key={`${citation.id ?? citation.source}-${index}`} className="msg-memory-citations__item">
|
||||
<div className="msg-memory-citations__source">
|
||||
<span>{memoryCitationSource(citation)}</span>
|
||||
{lines && <span className="msg-memory-citations__lines">{lines}</span>}
|
||||
</div>
|
||||
{citation.note && <div className="msg-memory-citations__note">{citation.note}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memoryCitationSource(citation: MemoryCitation): string {
|
||||
const source = (citation.source || citation.id || "Memory v5").trim();
|
||||
if (citation.kind === "compiler_reference" && source === "Memory v5") return "Memory v5 compiler";
|
||||
return source;
|
||||
}
|
||||
|
||||
function memoryCitationLines(citation: MemoryCitation, t: ReturnType<typeof useT>): string {
|
||||
const start = citation.lineStart ?? 0;
|
||||
const end = citation.lineEnd ?? 0;
|
||||
if (start <= 0) return "";
|
||||
if (end > 0 && end !== start) return t("msg.memoryCitationLineRange", { start, end });
|
||||
return t("msg.memoryCitationLine", { line: start });
|
||||
}
|
||||
|
||||
function messageDate(value?: number): Date {
|
||||
return new Date(typeof value === "number" && Number.isFinite(value) && value > 0 ? value : Date.now());
|
||||
}
|
||||
@@ -111,7 +194,8 @@ export function UserMessage({
|
||||
}) {
|
||||
const t = useT();
|
||||
const imSource = parseImSourceMessage(text);
|
||||
const actionText = imSource?.text ?? text;
|
||||
const actionText = stripMemoryCompilerExecution(imSource?.text ?? text);
|
||||
const hasMemoryCompiler = Boolean(submitText?.includes("<memory-compiler-execution>"));
|
||||
const { text: displayText, attachments } = parseAttachmentRefsForDisplay(actionText);
|
||||
const orderedAttachments = sortDisplayAttachments(attachments);
|
||||
const sourceLabel = imSource ? imSourceLabel(imSource, t) : "";
|
||||
@@ -123,6 +207,65 @@ export function UserMessage({
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openImageViewer = useCallback(async (path: string, name: string) => {
|
||||
let url = imagePreviews[path];
|
||||
if (!url) {
|
||||
try {
|
||||
url = await app.AttachmentDataURL(path);
|
||||
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, [imagePreviews]);
|
||||
|
||||
const closeImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
|
||||
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
|
||||
|
||||
type DisplaySegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "paste"; block: PastedBlockInfo };
|
||||
|
||||
const displaySegments = useMemo((): DisplaySegment[] => {
|
||||
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
|
||||
const segments: DisplaySegment[] = [];
|
||||
// Order blocks by their position in the text so cards appear inline.
|
||||
const ordered = pasteBlocks
|
||||
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
|
||||
.filter((x) => x.pos >= 0)
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
let remaining = displayText;
|
||||
for (const { block } of ordered) {
|
||||
const idx = remaining.indexOf(block.label);
|
||||
if (idx < 0) continue;
|
||||
// Text before the label: strip the trailing newline that separated the
|
||||
// label from the preceding line so the card sits tight against the text.
|
||||
if (idx > 0) {
|
||||
let before = remaining.slice(0, idx);
|
||||
before = before.replace(/\n$/, "");
|
||||
if (before) segments.push({ type: "text", content: before });
|
||||
}
|
||||
segments.push({ type: "paste", block });
|
||||
remaining = remaining.slice(idx + block.label.length);
|
||||
}
|
||||
// Strip the leading newline that followed the label.
|
||||
remaining = remaining.replace(/^\n/, "");
|
||||
if (remaining.trim()) segments.push({ type: "text", content: remaining });
|
||||
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
|
||||
}, [displayText, pasteBlocks]);
|
||||
|
||||
const togglePasteExpand = (label: string) => {
|
||||
setExpandedPasteLabels((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
};
|
||||
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
|
||||
const imagePreviewKey = orderedAttachments
|
||||
.concat(orderedDraftAttachments)
|
||||
@@ -247,11 +390,12 @@ export function UserMessage({
|
||||
<ComposerContextCard
|
||||
key={attachment.path}
|
||||
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
|
||||
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
|
||||
removeDisabled={editSubmitting}
|
||||
onRemove={() => removeDraftAttachment(attachment.path)}
|
||||
previewUrl={imagePreview}
|
||||
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
folder={attachment.kind === "folder"}
|
||||
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
|
||||
@@ -297,26 +441,78 @@ export function UserMessage({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayText && <div className="msg__text">{displayText}</div>
|
||||
<>
|
||||
{displaySegments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
|
||||
}
|
||||
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
|
||||
return (
|
||||
<div className="msg-pasted" key={seg.block.label}>
|
||||
<div className="msg-pasted-block">
|
||||
<div className="msg-pasted-head">
|
||||
<FileText size={15} />
|
||||
<span className="msg-pasted-label">{seg.block.label}</span>
|
||||
<div className="msg-pasted-actions">
|
||||
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
|
||||
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
|
||||
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="msg-pasted-expanded">{seg.block.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
|
||||
{orderedAttachments.length > 0 && (
|
||||
<div className="msg-attachments" aria-label={t("msg.attachments")}>
|
||||
{orderedAttachments.map((attachment, index) => (
|
||||
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
{orderedAttachments.map((attachment, index) => {
|
||||
const isImage = attachment.kind === "image";
|
||||
const el = (
|
||||
<div
|
||||
className={`msg-attachment msg-attachment--${attachment.kind}`}
|
||||
key={isImage ? undefined : `${attachment.path}:${index}`}
|
||||
title={isImage ? undefined : attachment.path}
|
||||
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
role={isImage ? "button" : undefined}
|
||||
tabIndex={isImage ? 0 : undefined}
|
||||
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
|
||||
>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (isImage) {
|
||||
return (
|
||||
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
|
||||
{el}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
})}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeImageViewer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,6 +523,11 @@ export function UserMessage({
|
||||
{formatMessageTime(sentAt)}
|
||||
</time>
|
||||
)}
|
||||
{hasMemoryCompiler && (
|
||||
<span className="msg-meta__indicator" title={t("msg.memoryCompilerApplied")} aria-hidden="true">
|
||||
<BrainCircuit size={14} />
|
||||
</span>
|
||||
)}
|
||||
<CopyButton text={actionText} label={t("msg.copy")} showInlineLabel={false} className="msg-meta__btn msg-meta__copy" />
|
||||
{onEdit && (
|
||||
<button
|
||||
@@ -420,11 +621,34 @@ export function TurnActions({
|
||||
}
|
||||
};
|
||||
const actionMeta = (scope: MessageActionScope): string => {
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
return t("rewind.filesChanged", { count: checkpoint.files.length });
|
||||
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const turnCount = checkpoint?.turnFileCount ?? 0;
|
||||
if (turnCount > 0 && turnCount < total) {
|
||||
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
|
||||
}
|
||||
return t("rewind.filesChanged", { count: total });
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const actionTooltipLabel = (scope: MessageActionScope) => {
|
||||
const reason = actionDisabledReason(scope);
|
||||
if (reason) return <span>{reason}</span>;
|
||||
const files = checkpoint?.files ?? [];
|
||||
const total = checkpoint?.fileCount ?? files.length;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const hidden = Math.max(0, total - files.length);
|
||||
return (
|
||||
<div className="rewind__files-tooltip">
|
||||
{files.map((file) => (
|
||||
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
|
||||
))}
|
||||
{hidden > 0 && <div>+{hidden}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const runAction = (scope: MessageActionScope) => {
|
||||
setConfirmScope(null);
|
||||
onOpenMenu?.(null);
|
||||
@@ -441,7 +665,8 @@ export function TurnActions({
|
||||
const renderAction = (scope: MessageActionScope, danger = false) => {
|
||||
const disabledReason = actionDisabledReason(scope);
|
||||
const meta = actionMeta(scope);
|
||||
return (
|
||||
const tipLabel = actionTooltipLabel(scope);
|
||||
const button = (
|
||||
<button
|
||||
className={[
|
||||
"rewind__menu-item",
|
||||
@@ -450,13 +675,14 @@ export function TurnActions({
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || undefined}
|
||||
{...(tipLabel ? {} : { title: disabledReason || undefined })}
|
||||
onClick={() => selectRewind(scope)}
|
||||
>
|
||||
<span>{actionLabel(scope)}</span>
|
||||
{meta && <span className="rewind__menu-meta">{meta}</span>}
|
||||
</button>
|
||||
);
|
||||
return tipLabel ? <Tooltip key={scope} label={tipLabel} side="top" block fill>{button}</Tooltip> : button;
|
||||
};
|
||||
const forkDisabledReason = canAct ? actionDisabledReason("fork") : "";
|
||||
const toggleMenu = (menu: TurnActionMenu) => {
|
||||
@@ -543,6 +769,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
defaultExpanded = false,
|
||||
expandWhileStreaming = true,
|
||||
truncateStreamingReasoning = false,
|
||||
creationMode = false,
|
||||
}: {
|
||||
item: AssistantItem;
|
||||
defaultExpanded?: boolean;
|
||||
@@ -550,6 +777,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
expandWhileStreaming?: boolean;
|
||||
/** Opt-in for compact mode to keep live DeepSeek reasoning from growing an unbounded DOM. */
|
||||
truncateStreamingReasoning?: boolean;
|
||||
creationMode?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const reasoningBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -628,9 +856,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
)}
|
||||
{hasText && (
|
||||
<div className="msg__body">
|
||||
<Markdown text={item.text} />
|
||||
<Markdown text={item.text} plainStatusBlocks={creationMode} />
|
||||
</div>
|
||||
)}
|
||||
<MemoryCitations citations={item.memoryCitations} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Loaded 100 of 710 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user