mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaaf863c11 | ||
|
|
a0ce934d1e | ||
|
|
284e65d912 | ||
|
|
2a9c3f5ede | ||
|
|
d7f49d1f54 | ||
|
|
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 | ||
|
|
bc9b7f2abb | ||
|
|
59dc039d33 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
917e036e65 | ||
|
|
11b6c52bf8 | ||
|
|
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,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
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
@@ -77,7 +77,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 +229,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:
|
||||
@@ -292,7 +292,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +55,8 @@
|
||||
|
||||
- **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.
|
||||
@@ -90,10 +90,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 +116,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)**.
|
||||
|
||||
|
||||
+7
-7
@@ -53,7 +53,7 @@
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
|
||||
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
|
||||
内置工具在编译期自注册。
|
||||
@@ -86,10 +86,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 +111,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)** 里。
|
||||
|
||||
## 文档
|
||||
|
||||
+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.
|
||||
+22
-5
@@ -183,11 +183,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 +247,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|on|status` in a session or
|
||||
`reasonix config memory-v5 off|on|status` from a shell.
|
||||
+1231
-229
File diff suppressed because it is too large.
Load diff
@@ -32,7 +32,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) {
|
||||
@@ -221,3 +221,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)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
@@ -67,6 +69,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 +87,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) {
|
||||
@@ -254,6 +341,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 +389,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)
|
||||
}
|
||||
}
|
||||
+945
-67
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
@@ -225,3 +228,57 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -242,3 +242,19 @@ 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
|
||||
}
|
||||
@@ -68,4 +68,13 @@ 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)
|
||||
}
|
||||
}
|
||||
+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,32 @@
|
||||
"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__/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__/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__/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__/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",
|
||||
"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",
|
||||
"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
+70
-381
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2([email protected])([email protected])
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.2
|
||||
version: 3.14.2([email protected]([email protected]))([email protected])
|
||||
specifier: ^3.14.3
|
||||
version: 3.14.3([email protected]([email protected]))([email protected])
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
@@ -27,8 +27,8 @@ importers:
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0
|
||||
lucide-react:
|
||||
specifier: ^1.18.0
|
||||
version: 1.18.0([email protected])
|
||||
specifier: ^1.21.0
|
||||
version: 1.21.0([email protected])
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0([email protected])
|
||||
@@ -50,13 +50,16 @@ importers:
|
||||
remark-math:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
zustand:
|
||||
specifier: ^5.0.14
|
||||
version: 5.0.14(@types/[email protected])([email protected])
|
||||
devDependencies:
|
||||
'@types/jsdom':
|
||||
specifier: ^28.0.3
|
||||
version: 28.0.3
|
||||
'@types/node':
|
||||
specifier: ^25.9.3
|
||||
version: 25.9.3
|
||||
specifier: ^26.0.0
|
||||
version: 26.0.0
|
||||
'@types/react':
|
||||
specifier: ^19.2.17
|
||||
version: 19.2.17
|
||||
@@ -64,8 +67,8 @@ importers:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0([email protected](@types/node@25.9.3)([email protected])([email protected])([email protected]))
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2([email protected](@types/node@26.0.0)([email protected])([email protected])([email protected]))
|
||||
jsdom:
|
||||
specifier: ^29.1.1
|
||||
version: 29.1.1
|
||||
@@ -80,7 +83,7 @@ importers:
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.0.16(@types/node@25.9.3)([email protected])([email protected])([email protected])
|
||||
version: 8.0.16(@types/node@26.0.0)([email protected])([email protected])([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
@@ -99,89 +102,6 @@ packages:
|
||||
'@asamuzakjp/[email protected]':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bramus/[email protected]':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
@@ -405,9 +325,6 @@ packages:
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -525,36 +442,21 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@tanstack/[email protected].2':
|
||||
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||
'@tanstack/[email protected].3':
|
||||
resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/[email protected].0':
|
||||
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||
'@tanstack/[email protected].1':
|
||||
resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==}
|
||||
|
||||
'@tybys/[email protected]':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -582,6 +484,9 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -602,11 +507,18 @@ packages:
|
||||
'@ungap/[email protected]':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
'@vitejs/plugin-react@6.0.2':
|
||||
resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
vite: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@rolldown/plugin-babel':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
||||
@@ -616,25 +528,12 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -660,9 +559,6 @@ packages:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
@@ -700,9 +596,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -716,10 +609,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -744,10 +633,6 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||
|
||||
@@ -814,9 +699,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
@@ -826,16 +708,6 @@ packages:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
@@ -925,11 +797,8 @@ packages:
|
||||
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
l[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==}
|
||||
l[email protected]:
|
||||
resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@@ -1082,10 +951,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -1129,10 +994,6 @@ packages:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1171,10 +1032,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1247,6 +1104,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
@@ -1275,12 +1135,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
|
||||
|
||||
@@ -1359,8 +1213,23 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
[email protected].14:
|
||||
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
@@ -1387,118 +1256,6 @@ snapshots:
|
||||
|
||||
'@asamuzakjp/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-compilation-targets': 7.29.7
|
||||
'@babel/helper-module-transforms': 7.29.7(@babel/[email protected])
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jsesc: 3.1.0
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.29.7
|
||||
'@babel/helper-validator-option': 7.29.7
|
||||
browserslist: 4.28.2
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/[email protected](@babel/[email protected])':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/[email protected](@babel/[email protected])':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/[email protected](@babel/[email protected])':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-plugin-utils': 7.29.7
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bramus/[email protected]':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
@@ -1633,11 +1390,6 @@ snapshots:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
@@ -1710,44 +1462,21 @@ snapshots:
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected].2([email protected]([email protected]))([email protected])':
|
||||
'@tanstack/[email protected].3([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.0
|
||||
'@tanstack/virtual-core': 3.17.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
|
||||
'@tanstack/[email protected].0': {}
|
||||
'@tanstack/[email protected].1': {}
|
||||
|
||||
'@tybys/[email protected]':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@types/babel__generator': 7.27.0
|
||||
'@types/babel__template': 7.4.4
|
||||
'@types/babel__traverse': 7.28.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
@@ -1781,6 +1510,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/[email protected](@types/[email protected])':
|
||||
dependencies:
|
||||
'@types/react': 19.2.17
|
||||
@@ -1797,40 +1530,21 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0([email protected](@types/node@25.9.3)([email protected])([email protected])([email protected]))':
|
||||
'@vitejs/plugin-react@6.0.2([email protected](@types/node@26.0.0)([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/[email protected])
|
||||
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/[email protected])
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.37
|
||||
caniuse-lite: 1.0.30001799
|
||||
electron-to-chromium: 1.5.372
|
||||
node-releases: 2.0.47
|
||||
update-browserslist-db: 1.2.3([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1847,8 +1561,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
@@ -1881,8 +1593,6 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1916,8 +1626,6 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1931,8 +1639,6 @@ snapshots:
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2042,8 +1748,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.1.11
|
||||
@@ -2070,10 +1774,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
@@ -2135,11 +1835,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
l[email protected]:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
l[email protected]([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
@@ -2517,8 +2213,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -2578,8 +2272,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2664,8 +2356,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2735,6 +2425,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2780,12 +2472,6 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -2801,7 +2487,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected](@types/node@25.9.3)([email protected])([email protected])([email protected]):
|
||||
[email protected](@types/node@26.0.0)([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -2809,7 +2495,7 @@ snapshots:
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 26.0.0
|
||||
esbuild: 0.28.1
|
||||
fsevents: 2.3.3
|
||||
terser: 5.48.0
|
||||
@@ -2837,6 +2523,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
react: 19.2.7
|
||||
|
||||
[email protected]: {}
|
||||
+584
-290
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,12 @@ ok(
|
||||
"AppChrome markup includes classic tab strip containers",
|
||||
);
|
||||
|
||||
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 +121,7 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
/\{!workbenchChromeHidden && \(/.test(appSource),
|
||||
/\{!appChromeHidden && \(/.test(appSource),
|
||||
"workbench skips rendering the top AppChrome row",
|
||||
);
|
||||
|
||||
@@ -119,6 +130,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",
|
||||
@@ -193,8 +302,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,200 @@
|
||||
// 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 { 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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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 } from "../components/CapabilitiesPanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { Meta, ServerView, TabMeta } from "../lib/types";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -21,16 +28,208 @@ 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.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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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,9 +4,10 @@ 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 { AppBindings } from "../lib/bridge";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 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,43 @@ 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);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
@@ -166,5 +220,329 @@ 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 = new window.Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
configurable: true,
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: [{}],
|
||||
items: [
|
||||
{
|
||||
kind: "file",
|
||||
webkitGetAsEntry: () => ({ isFile: true }),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
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();
|
||||
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();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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 { 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>
|
||||
<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("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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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,254 @@
|
||||
// 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 contextItemCount(): number {
|
||||
return document.querySelectorAll(".composer-context__item").length;
|
||||
}
|
||||
|
||||
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 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 { contextBreakdown, contextCostDisplay, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -51,6 +51,23 @@ eq(
|
||||
);
|
||||
eq(Math.round(mock.otherPct), 33, "donut 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(
|
||||
oversized.promptTokens + oversized.completionTokens + oversized.reasoningTokens + oversized.otherTokens,
|
||||
@@ -102,5 +119,15 @@ eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decim
|
||||
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");
|
||||
|
||||
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);
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -96,6 +96,29 @@ function meta(): Meta {
|
||||
|
||||
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 +160,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 +209,54 @@ eq(controller?.state.items.length, 0, "stale history load cannot repopulate a ne
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
let ensureBlankSurfaceCalls = 0;
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => guardedStartupTabs.promise,
|
||||
MetaForTab: async () => meta(),
|
||||
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 () => {
|
||||
ensureBlankSurfaceCalls += 1;
|
||||
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
|
||||
},
|
||||
} 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", "/repo");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
|
||||
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
|
||||
|
||||
await act(async () => {
|
||||
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
|
||||
await guardedStartupTabs.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
|
||||
|
||||
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,7 @@ import {
|
||||
defaultExpandedProjectTreeKeys,
|
||||
activeSessionAncestorKeys,
|
||||
projectTreeTopicOpenRequest,
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -103,6 +104,33 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -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);
|
||||
@@ -34,6 +34,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: "",
|
||||
@@ -46,7 +56,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], 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, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
@@ -67,9 +77,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,
|
||||
@@ -148,6 +160,55 @@ 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();
|
||||
});
|
||||
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,11 @@ 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");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,336 @@
|
||||
// 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");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<() => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
|
||||
}
|
||||
|
||||
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 () => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
ContextUsageForTab: async () => 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;
|
||||
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;
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls += 1;
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
},
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
await act(async () => {
|
||||
controller?.sendToTab("tab-c", "streaming C");
|
||||
await flushPromises();
|
||||
});
|
||||
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 () => {
|
||||
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();
|
||||
});
|
||||
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 () => {
|
||||
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");
|
||||
|
||||
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 { buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -68,6 +68,24 @@ console.log("\ntranscript grouping contract");
|
||||
eq(groups[2].assistantPreview, "answer 2", "keeps latest assistant preview for each turn");
|
||||
}
|
||||
|
||||
{
|
||||
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 +95,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);
|
||||
@@ -127,6 +127,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 +136,34 @@ 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");
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*1 1 160px[\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(
|
||||
|
||||
@@ -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 } },
|
||||
@@ -126,5 +186,67 @@ console.log("\nuse controller meta");
|
||||
eq(activeHelper.sessionCost, 0.001, "helper usage inside a turn still counts toward session cost");
|
||||
}
|
||||
|
||||
{
|
||||
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`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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 { 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 };
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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,96 @@
|
||||
// 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", "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,10 +1,31 @@
|
||||
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();
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
approval,
|
||||
@@ -12,12 +33,18 @@ 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";
|
||||
@@ -27,18 +54,20 @@ export function ApprovalModal({
|
||||
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,
|
||||
@@ -106,8 +135,75 @@ export function ApprovalModal({
|
||||
}, [isPlanApproval, 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();
|
||||
@@ -155,11 +251,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")}
|
||||
@@ -196,6 +298,7 @@ export function ApprovalModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions } from "../lib/mcpServerLifecycle";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
@@ -108,6 +109,7 @@ export function CapabilitiesPanel({
|
||||
active: servers.filter((s) => s.status !== "failed"),
|
||||
};
|
||||
}, [view]);
|
||||
const retryableActiveServerNames = useMemo(() => retryableAvailableServerNames(serverGroups.active), [serverGroups.active]);
|
||||
const toggleSkill = useCallback((name: string) => {
|
||||
setExpandedSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -213,7 +215,17 @@ export function CapabilitiesPanel({
|
||||
)}
|
||||
{serverGroups.active.length > 0 && (
|
||||
<div className="cap-server-section">
|
||||
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
|
||||
<div className="cap-server-section__head">
|
||||
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy || retryableActiveServerNames.length === 0}
|
||||
type="button"
|
||||
onClick={() => void mutate(() => Promise.allSettled(retryableActiveServerNames.map((name) => app.ReconnectMCPServer(name))))}
|
||||
>
|
||||
{t("caps.retryAll")}
|
||||
</button>
|
||||
</div>
|
||||
<ServerGroup
|
||||
busy={busy}
|
||||
servers={serverGroups.active}
|
||||
@@ -228,6 +240,9 @@ export function CapabilitiesPanel({
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onTrustTool={(name, toolName) => void mutate(() => app.TrustMCPServerTool(name, toolName))}
|
||||
onTrustTools={(name, toolNames) => void mutate(() => app.TrustMCPServerTools(name, toolNames))}
|
||||
onUntrustTool={(name, toolName) => void mutate(() => app.UntrustMCPServerTool(name, toolName))}
|
||||
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
|
||||
onUpdate={(name, input) =>
|
||||
void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => {
|
||||
@@ -309,7 +324,9 @@ function normalizeServerViews(servers: ServerView[] | null | undefined): ServerV
|
||||
...server,
|
||||
args: asArray(server.args),
|
||||
envKeys: asArray(server.envKeys),
|
||||
headerKeys: asArray(server.headerKeys),
|
||||
toolList: asArray(server.toolList),
|
||||
trustedReadOnlyTools: asArray(server.trustedReadOnlyTools),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -640,6 +657,9 @@ function ServerGroup({
|
||||
onRetry,
|
||||
onReconnect,
|
||||
onConfirmClearAuth,
|
||||
onTrustTool,
|
||||
onTrustTools,
|
||||
onUntrustTool,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onToggleDetails,
|
||||
@@ -656,6 +676,9 @@ function ServerGroup({
|
||||
onRetry: (name: string) => void;
|
||||
onReconnect: (name: string) => void;
|
||||
onConfirmClearAuth: (name: string) => void;
|
||||
onTrustTool: (name: string, toolName: string) => void;
|
||||
onTrustTools: (name: string, toolNames: string[]) => void;
|
||||
onUntrustTool: (name: string, toolName: string) => void;
|
||||
onToggle: (name: string, on: boolean) => void;
|
||||
onUpdate: (name: string, input: MCPServerInput) => void;
|
||||
onToggleDetails: (name: string) => void;
|
||||
@@ -678,6 +701,9 @@ function ServerGroup({
|
||||
onRetry={() => onRetry(s.name)}
|
||||
onReconnect={() => onReconnect(s.name)}
|
||||
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
|
||||
onTrustTool={(toolName) => onTrustTool(s.name, toolName)}
|
||||
onTrustTools={(toolNames) => onTrustTools(s.name, toolNames)}
|
||||
onUntrustTool={(toolName) => onUntrustTool(s.name, toolName)}
|
||||
onToggle={(on) => onToggle(s.name, on)}
|
||||
onUpdate={(input) => onUpdate(s.name, input)}
|
||||
onToggleDetails={() => onToggleDetails(s.name)}
|
||||
@@ -834,6 +860,9 @@ function ServerRow({
|
||||
onRetry,
|
||||
onReconnect,
|
||||
onConfirmClearAuth,
|
||||
onTrustTool,
|
||||
onTrustTools,
|
||||
onUntrustTool,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onToggleDetails,
|
||||
@@ -850,6 +879,9 @@ function ServerRow({
|
||||
onRetry: () => void;
|
||||
onReconnect: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
onTrustTool: (toolName: string) => void;
|
||||
onTrustTools: (toolNames: string[]) => void;
|
||||
onUntrustTool: (toolName: string) => void;
|
||||
onToggle: (on: boolean) => void;
|
||||
onUpdate: (input: MCPServerInput) => void;
|
||||
onToggleDetails: () => void;
|
||||
@@ -933,6 +965,9 @@ function ServerRow({
|
||||
onConnectNow={onRetry}
|
||||
onReconnect={onReconnect}
|
||||
onConfirmClearAuth={onConfirmClearAuth}
|
||||
onTrustTool={onTrustTool}
|
||||
onTrustTools={onTrustTools}
|
||||
onUntrustTool={onUntrustTool}
|
||||
toolsExpanded={toolsExpanded}
|
||||
editing={editing}
|
||||
onEdit={onEdit}
|
||||
@@ -953,6 +988,9 @@ function ServerDetails({
|
||||
onConnectNow,
|
||||
onReconnect,
|
||||
onConfirmClearAuth,
|
||||
onTrustTool,
|
||||
onTrustTools,
|
||||
onUntrustTool,
|
||||
toolsExpanded,
|
||||
editing,
|
||||
onEdit,
|
||||
@@ -967,6 +1005,9 @@ function ServerDetails({
|
||||
onConnectNow: () => void;
|
||||
onReconnect: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
onTrustTool: (toolName: string) => void;
|
||||
onTrustTools: (toolNames: string[]) => void;
|
||||
onUntrustTool: (toolName: string) => void;
|
||||
toolsExpanded: boolean;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
@@ -983,6 +1024,11 @@ function ServerDetails({
|
||||
const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0);
|
||||
const showClearAuth = canClearAuth(s);
|
||||
const authLabel = serverAuthLabel(s, t);
|
||||
const trustedReadOnlyTools = s.trustedReadOnlyTools ?? [];
|
||||
const trustedReadOnlyToolNames = new Set(trustedReadOnlyTools);
|
||||
const canTrustTool = s.configured && !s.builtIn;
|
||||
const reportedReadOnlyToolNames = (tools ?? []).filter((tool) => tool.readOnlyHint).map((tool) => tool.name);
|
||||
const bulkTrustToolNames = reportedReadOnlyToolNames.filter((name) => !trustedReadOnlyToolNames.has(name));
|
||||
if (editing && canEditConfig) {
|
||||
return (
|
||||
<div className="cap-server-details">
|
||||
@@ -1019,6 +1065,18 @@ function ServerDetails({
|
||||
<span className="cap-detail__value">{s.envKeys.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{s.headerKeys && s.headerKeys.length > 0 && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.headerKeys")}</span>
|
||||
<span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{trustedReadOnlyTools.length > 0 && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.trustedReadOnlyTools")}</span>
|
||||
<span className="cap-detail__code">{trustedReadOnlyTools.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-detail-actions">
|
||||
{canConnectNow && (
|
||||
@@ -1036,6 +1094,18 @@ function ServerDetails({
|
||||
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
|
||||
</button>
|
||||
)}
|
||||
{canTrustTool && bulkTrustToolNames.length > 0 && (
|
||||
<button
|
||||
className="btn btn--small cap-trust-bulk"
|
||||
disabled={busy}
|
||||
onClick={() => onTrustTools(bulkTrustToolNames)}
|
||||
title={t("caps.trustReportedReadOnlyTitle")}
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck aria-hidden size={13} strokeWidth={2.2} />
|
||||
{t("caps.trustReportedReadOnly", { count: bulkTrustToolNames.length })}
|
||||
</button>
|
||||
)}
|
||||
{showClearAuth && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.clearAuth")}
|
||||
@@ -1065,12 +1135,55 @@ function ServerDetails({
|
||||
tools && tools.length > 0 ? (
|
||||
<div className="cap-tool-list">
|
||||
<div className="cap-tool-list__title">{t("caps.tools")}</div>
|
||||
{tools.map((tool) => (
|
||||
<div className="cap-tool" key={tool.name}>
|
||||
<div className="cap-tool__name">{tool.name}</div>
|
||||
{tool.description && <div className="cap-tool__desc">{tool.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
{tools.map((tool) => {
|
||||
const trusted = trustedReadOnlyToolNames.has(tool.name);
|
||||
return (
|
||||
<div className="cap-tool" key={tool.name}>
|
||||
<div className="cap-tool__name">{tool.name}</div>
|
||||
<div className="cap-tool__desc">
|
||||
<span>{tool.description}</span>
|
||||
{tool.readOnlyHint && (
|
||||
<span className="cap-tool-hint" title={t("caps.reportedReadOnlyTitle")}>
|
||||
{t("caps.reportedReadOnly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-tool__action">
|
||||
{canTrustTool ? (
|
||||
trusted ? (
|
||||
<div className="cap-tool-trust-stack">
|
||||
<span className="cap-tool-trust cap-tool-trust--trusted" title={t("caps.trustedReadOnlyTitle")}>
|
||||
<ShieldCheck aria-hidden size={12} strokeWidth={2.2} />
|
||||
{t("caps.trustedReadOnly")}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn--small cap-tool-untrust-btn"
|
||||
disabled={busy}
|
||||
onClick={() => onUntrustTool(tool.name)}
|
||||
title={t("caps.untrustReadOnlyTitle")}
|
||||
type="button"
|
||||
>
|
||||
<ShieldOff aria-hidden size={12} strokeWidth={2.2} />
|
||||
{t("caps.untrustReadOnly")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn--small cap-tool-trust-btn"
|
||||
disabled={busy}
|
||||
onClick={() => onTrustTool(tool.name)}
|
||||
title={t("caps.trustReadOnlyTitle")}
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck aria-hidden size={12} strokeWidth={2.2} />
|
||||
{t("caps.trustReadOnly")}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-tool-empty">{t("caps.noToolDetails")}</div>
|
||||
@@ -1096,19 +1209,23 @@ function EditServerForm({
|
||||
const [transport, setTransport] = useState(initialTransport);
|
||||
const [command, setCommand] = useState(initialTransport === "stdio" ? serverCommand(s) : "");
|
||||
const [url, setUrl] = useState(initialTransport === "stdio" ? "" : s.url || serverCommand(s));
|
||||
const [headers, setHeaders] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
const isStdio = transport === "stdio";
|
||||
const ready = isStdio ? command.trim() !== "" : url.trim() !== "";
|
||||
|
||||
const submit = () => {
|
||||
const envText = env.trim();
|
||||
const headerText = headers.trim();
|
||||
onSave({
|
||||
name: s.name,
|
||||
transport,
|
||||
command: isStdio ? command.trim() : "",
|
||||
args: [],
|
||||
url: isStdio ? "" : url.trim(),
|
||||
env: envText === "" ? null : parseEnvText(envText),
|
||||
env: envText === "" ? null : parseKeyValueText(envText),
|
||||
headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
|
||||
trustedReadOnlyTools: s.trustedReadOnlyTools ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1138,6 +1255,19 @@ function EditServerForm({
|
||||
<input className="mem-input" value={url} disabled={busy} onChange={(e) => setUrl(e.target.value)} placeholder={t("caps.urlPlaceholder")} />
|
||||
</label>
|
||||
)}
|
||||
{!isStdio && (
|
||||
<label className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.headersLabel")}</span>
|
||||
<textarea className="mem-textarea cap-config-edit__env" value={headers} disabled={busy} onChange={(e) => setHeaders(e.target.value)} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
|
||||
</label>
|
||||
)}
|
||||
{!isStdio && s.headerKeys && s.headerKeys.length > 0 && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.headerKeys")}</span>
|
||||
<span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
|
||||
<span className="cap-edit-hint">{t("caps.headersPreserveHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
<label className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.envLabel")}</span>
|
||||
<textarea className="mem-textarea cap-config-edit__env" value={env} disabled={busy} onChange={(e) => setEnv(e.target.value)} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
|
||||
@@ -1171,13 +1301,15 @@ function normalizeTransportValue(transport: string): string {
|
||||
return transport === "http" || transport === "sse" ? transport : "stdio";
|
||||
}
|
||||
|
||||
function parseEnvText(env: string): Record<string, string> {
|
||||
const envMap: Record<string, string> = {};
|
||||
for (const line of env.split("\n")) {
|
||||
function parseKeyValueText(text: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) envMap[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
if (eq > 0) values[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
}
|
||||
return envMap;
|
||||
return values;
|
||||
}
|
||||
|
||||
function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
@@ -1272,6 +1404,10 @@ function canBulkRemoveFailure(server: ServerView): boolean {
|
||||
return kind === "missing-command" || kind === "command-unavailable";
|
||||
}
|
||||
|
||||
function retryableAvailableServerNames(servers: ServerView[]): string[] {
|
||||
return servers.filter(mcpServerRetryableFromAvailableList).map((s) => s.name);
|
||||
}
|
||||
|
||||
function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
const err = (s.error || "").toLowerCase();
|
||||
if (shouldOpenAuth(s)) return t("caps.reauthorize");
|
||||
@@ -1400,24 +1536,23 @@ function AddServerForm({
|
||||
const [transport, setTransport] = useState("stdio");
|
||||
const [command, setCommand] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headers, setHeaders] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
|
||||
const isStdio = transport === "stdio";
|
||||
const ready = name.trim() !== "" && (isStdio ? command.trim() !== "" : url.trim() !== "");
|
||||
|
||||
const submit = () => {
|
||||
const envMap: Record<string, string> = {};
|
||||
for (const line of env.split("\n")) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) envMap[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
}
|
||||
const envText = env.trim();
|
||||
const headerText = headers.trim();
|
||||
onAdd({
|
||||
name: name.trim(),
|
||||
transport,
|
||||
command: isStdio ? command.trim() : "",
|
||||
args: [],
|
||||
url: isStdio ? "" : url.trim(),
|
||||
env: envMap,
|
||||
env: envText === "" ? null : parseKeyValueText(envText),
|
||||
headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1435,6 +1570,12 @@ function AddServerForm({
|
||||
) : (
|
||||
<input className="mem-input" placeholder={t("caps.urlPlaceholder")} value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
)}
|
||||
{!isStdio && (
|
||||
<>
|
||||
<label className="set-label">{t("caps.headersLabel")}</label>
|
||||
<textarea className="mem-textarea" value={headers} onChange={(e) => setHeaders(e.target.value)} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
|
||||
</>
|
||||
)}
|
||||
<label className="set-label">{t("caps.envLabel")}</label>
|
||||
<textarea className="mem-textarea" value={env} onChange={(e) => setEnv(e.target.value)} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
|
||||
<div className="prov-card__actions">
|
||||
@@ -1509,6 +1650,7 @@ export function MCPServersSettingsPage() {
|
||||
active: sorted.filter((s) => s.status !== "failed"),
|
||||
};
|
||||
}, [servers]);
|
||||
const retryableActiveServerNames = useMemo(() => retryableAvailableServerNames(serverGroups.active), [serverGroups.active]);
|
||||
const toggleError = useCallback((name: string) => {
|
||||
setExpandedErrors((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; });
|
||||
}, []);
|
||||
@@ -1561,7 +1703,17 @@ export function MCPServersSettingsPage() {
|
||||
)}
|
||||
{serverGroups.active.length > 0 && (
|
||||
<div className="cap-server-section">
|
||||
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
|
||||
<div className="cap-server-section__head">
|
||||
<div className="cap-server-section__title">{t("caps.availableServers")}</div>
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={actionBusy || retryableActiveServerNames.length === 0}
|
||||
type="button"
|
||||
onClick={() => void mutate(() => Promise.allSettled(retryableActiveServerNames.map((name) => app.ReconnectMCPServer(name))))}
|
||||
>
|
||||
{t("caps.retryAll")}
|
||||
</button>
|
||||
</div>
|
||||
<ServerGroup
|
||||
busy={actionBusy}
|
||||
servers={serverGroups.active}
|
||||
@@ -1574,6 +1726,9 @@ export function MCPServersSettingsPage() {
|
||||
onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onTrustTool={(name, toolName) => void mutate(() => app.TrustMCPServerTool(name, toolName))}
|
||||
onTrustTools={(name, toolNames) => void mutate(() => app.TrustMCPServerTools(name, toolNames))}
|
||||
onUntrustTool={(name, toolName) => void mutate(() => app.UntrustMCPServerTool(name, toolName))}
|
||||
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
|
||||
onUpdate={(name, input) =>
|
||||
void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cacheGeneration, loadOlder } from "../lib/composerHistory";
|
||||
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
|
||||
import { detectShortcutPlatform, matchesShortcut } from "../lib/keyboardShortcuts";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type DirEntry, type EffortInfo, type HistoryMessage, type Mode, type PromptHistoryEntry, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type TokenMode, type ToolApprovalMode } from "../lib/types";
|
||||
import {
|
||||
@@ -20,12 +21,13 @@ import {
|
||||
} from "../lib/workspaceDrag";
|
||||
import { SlashMenu } from "./SlashMenu";
|
||||
import { ArgMenu } from "./ArgMenu";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { ANCHORED_POPOVER_CLOSE_MS, AnchoredPopover } from "./AnchoredPopover";
|
||||
import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
|
||||
|
||||
interface Attachment {
|
||||
path: string;
|
||||
@@ -38,9 +40,10 @@ interface AttachmentDedupKey {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface WorkspaceReference {
|
||||
export interface WorkspaceReference {
|
||||
path: string;
|
||||
isDir?: boolean;
|
||||
displayPath?: string;
|
||||
}
|
||||
|
||||
const LONG_PASTE_MIN_CHARS = 2000;
|
||||
@@ -60,10 +63,25 @@ type PastedBlock = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
workspaceRefs: WorkspaceReference[];
|
||||
pastedBlocks: PastedBlock[];
|
||||
openPastedLabels: string[];
|
||||
sessionRefs: SessionReference[];
|
||||
attachmentDedupKeys: Record<string, AttachmentDedupKey>;
|
||||
nextPasteId: number;
|
||||
historyIndex: number;
|
||||
savedText: string;
|
||||
};
|
||||
|
||||
type WebkitFileEntry = {
|
||||
isDirectory?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_COMPOSER_DRAFT_KEY = "__default_composer_draft__";
|
||||
|
||||
function lineCount(s: string): number {
|
||||
if (s === "") return 0;
|
||||
return s.split(/\r\n|\r|\n/).length;
|
||||
@@ -91,6 +109,10 @@ function attachmentExt(name: string): string {
|
||||
return dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
|
||||
}
|
||||
|
||||
function hasImageAttachments(items: Attachment[]): boolean {
|
||||
return items.some((attachment) => Boolean(attachment.previewUrl));
|
||||
}
|
||||
|
||||
function displayRefName(name: string): string {
|
||||
return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment";
|
||||
}
|
||||
@@ -111,6 +133,63 @@ function workspaceReferenceKey(ref: WorkspaceReference): string {
|
||||
return `${ref.isDir ? "dir" : "file"}:${ref.path}`;
|
||||
}
|
||||
|
||||
export function composerPickFileEntry(
|
||||
text: string,
|
||||
atRaw: string | null,
|
||||
atDir: string,
|
||||
entry: DirEntry,
|
||||
): { text: string; workspaceRef?: WorkspaceReference } {
|
||||
const atPos = text.length - (atRaw?.length ?? 0) - 1; // index of '@'
|
||||
const prefix = text.slice(0, Math.max(0, atPos));
|
||||
const refPath = dirEntrySubmitPath(entry, atDir);
|
||||
if (entry.path || entry.displayPath) {
|
||||
return { text: prefix, workspaceRef: { path: refPath, isDir: entry.isDir, displayPath: entry.displayPath } };
|
||||
}
|
||||
return { text: prefix + "@" + refPath + (entry.isDir ? "/" : " ") };
|
||||
}
|
||||
|
||||
function emptyComposerDraft(): ComposerDraft {
|
||||
return {
|
||||
text: "",
|
||||
attachments: [],
|
||||
workspaceRefs: [],
|
||||
pastedBlocks: [],
|
||||
openPastedLabels: [],
|
||||
sessionRefs: [],
|
||||
attachmentDedupKeys: {},
|
||||
nextPasteId: 1,
|
||||
historyIndex: -1,
|
||||
savedText: "",
|
||||
};
|
||||
}
|
||||
|
||||
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
|
||||
return {
|
||||
text: draft.text,
|
||||
attachments: [...draft.attachments],
|
||||
workspaceRefs: [...draft.workspaceRefs],
|
||||
pastedBlocks: [...draft.pastedBlocks],
|
||||
openPastedLabels: [...draft.openPastedLabels],
|
||||
sessionRefs: [...draft.sessionRefs],
|
||||
attachmentDedupKeys: { ...draft.attachmentDedupKeys },
|
||||
nextPasteId: draft.nextPasteId,
|
||||
historyIndex: draft.historyIndex,
|
||||
savedText: draft.savedText,
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentDedupFromKeys(keys: Record<string, AttachmentDedupKey>): DedupIndex {
|
||||
const index = new DedupIndex();
|
||||
for (const key of Object.values(keys)) {
|
||||
index.add(key.hash, key.source);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function draftHasAttachmentDedupKey(draft: ComposerDraft, key: AttachmentDedupKey): boolean {
|
||||
return Object.values(draft.attachmentDedupKeys).some((existing) => existing.hash === key.hash && existing.source === key.source);
|
||||
}
|
||||
|
||||
function fileKey(file: File): string {
|
||||
return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
@@ -327,6 +406,7 @@ export function Composer({
|
||||
goal,
|
||||
cwd,
|
||||
modelLabel,
|
||||
imageInputEnabled = true,
|
||||
tabId,
|
||||
effort,
|
||||
onSend,
|
||||
@@ -342,6 +422,7 @@ export function Composer({
|
||||
onSetTokenMode,
|
||||
insertRequest,
|
||||
disabled,
|
||||
submitDisabled = false,
|
||||
readOnly = false,
|
||||
decisionPending = false,
|
||||
ready,
|
||||
@@ -349,6 +430,7 @@ export function Composer({
|
||||
turnTokens,
|
||||
retry,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
}: {
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
@@ -357,9 +439,10 @@ export function Composer({
|
||||
goal?: string;
|
||||
cwd?: string;
|
||||
modelLabel: string;
|
||||
imageInputEnabled?: boolean;
|
||||
tabId?: string;
|
||||
effort?: EffortInfo;
|
||||
onSend: (displayText: string, submitText?: string) => void;
|
||||
onSend: (displayText: string, submitText?: string) => void | Promise<void>;
|
||||
// Returns the un-sent text when cancelling before the server replied (so it can
|
||||
// be restored to the input); undefined for a normal cancel.
|
||||
onCancel: () => string | undefined;
|
||||
@@ -374,6 +457,7 @@ export function Composer({
|
||||
onSetTokenMode: (mode: TokenMode) => void;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
disabled?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
decisionPending?: boolean;
|
||||
// ready/cwd/running re-trigger the command fetch: Commands() returns only
|
||||
@@ -385,6 +469,7 @@ export function Composer({
|
||||
turnTokens?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
@@ -447,6 +532,73 @@ export function Composer({
|
||||
cwdRef.current = cwd;
|
||||
const attachmentDedupRef = useRef(new DedupIndex());
|
||||
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
|
||||
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
|
||||
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
|
||||
const activeDraftKeyRef = useRef(draftKey);
|
||||
const textRef = useRef(text);
|
||||
const attachmentsRef = useRef(attachments);
|
||||
const workspaceRefsRef = useRef(workspaceRefs);
|
||||
const openPastedLabelsRef = useRef(openPastedLabels);
|
||||
const sessionRefsRef = useRef(sessionRefs);
|
||||
textRef.current = text;
|
||||
attachmentsRef.current = attachments;
|
||||
workspaceRefsRef.current = workspaceRefs;
|
||||
pastedBlocksRef.current = pastedBlocks;
|
||||
openPastedLabelsRef.current = openPastedLabels;
|
||||
sessionRefsRef.current = sessionRefs;
|
||||
|
||||
const snapshotComposerDraft = (): ComposerDraft => ({
|
||||
text: textRef.current,
|
||||
attachments: [...attachmentsRef.current],
|
||||
workspaceRefs: [...workspaceRefsRef.current],
|
||||
pastedBlocks: [...pastedBlocksRef.current],
|
||||
openPastedLabels: [...openPastedLabelsRef.current],
|
||||
sessionRefs: [...sessionRefsRef.current],
|
||||
attachmentDedupKeys: { ...attachmentDedupKeysRef.current },
|
||||
nextPasteId: nextPasteId.current,
|
||||
historyIndex: historyIndexRef.current,
|
||||
savedText: savedTextRef.current,
|
||||
});
|
||||
|
||||
const restoreComposerDraft = (draft: ComposerDraft) => {
|
||||
const next = cloneComposerDraft(draft);
|
||||
setText(next.text);
|
||||
setAttachments(next.attachments);
|
||||
setWorkspaceRefs(next.workspaceRefs);
|
||||
pastedBlocksRef.current = next.pastedBlocks;
|
||||
setPastedBlocks(next.pastedBlocks);
|
||||
setOpenPastedLabels(next.openPastedLabels);
|
||||
setSessionRefs(next.sessionRefs);
|
||||
attachmentDedupKeysRef.current = next.attachmentDedupKeys;
|
||||
attachmentDedupRef.current = attachmentDedupFromKeys(next.attachmentDedupKeys);
|
||||
nextPasteId.current = next.nextPasteId;
|
||||
historyIndexRef.current = next.historyIndex;
|
||||
savedTextRef.current = next.savedText;
|
||||
setHistoryIndex(next.historyIndex);
|
||||
lastSelectionRef.current = { start: next.text.length, end: next.text.length };
|
||||
setComposerPrompt(null);
|
||||
setShowPastChats(false);
|
||||
setPastChatQuery("");
|
||||
setActive(0);
|
||||
setIntentMenuOpen(false);
|
||||
setIntentMenuClosing(false);
|
||||
setMoreMenuOpen(false);
|
||||
setMoreMenuClosing(false);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousKey = activeDraftKeyRef.current;
|
||||
if (previousKey === draftKey) return;
|
||||
draftsBySessionRef.current[previousKey] = snapshotComposerDraft();
|
||||
activeDraftKeyRef.current = draftKey;
|
||||
restoreComposerDraft(draftsBySessionRef.current[draftKey] ?? emptyComposerDraft());
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
draftsBySessionRef.current[activeDraftKeyRef.current] = snapshotComposerDraft();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearNativeClipboardPasteTimer = () => {
|
||||
if (nativeClipboardPasteTimerRef.current === null) return;
|
||||
@@ -769,7 +921,9 @@ export function Composer({
|
||||
setWorkspaceRefs((prev) => {
|
||||
const key = workspaceReferenceKey(ref);
|
||||
if (prev.some((item) => workspaceReferenceKey(item) === key)) return prev;
|
||||
return [...prev, ref];
|
||||
const next = [...prev, ref];
|
||||
workspaceRefsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
};
|
||||
@@ -813,6 +967,7 @@ export function Composer({
|
||||
};
|
||||
|
||||
const clearAttachments = () => {
|
||||
attachmentsRef.current = [];
|
||||
setAttachments([]);
|
||||
attachmentDedupRef.current.clear();
|
||||
attachmentDedupKeysRef.current = {};
|
||||
@@ -820,10 +975,76 @@ export function Composer({
|
||||
|
||||
const removeAttachment = (path: string) => {
|
||||
forgetAttachment(path);
|
||||
setAttachments((prev) => prev.filter((x) => x.path !== path));
|
||||
setAttachments(attachmentsRef.current.filter((x) => x.path !== path));
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
};
|
||||
|
||||
const attachmentSeenInDraft = (targetDraftKey: string, key: AttachmentDedupKey): boolean => {
|
||||
if (targetDraftKey === activeDraftKeyRef.current) return attachmentDedupRef.current.seen(key.hash, key.source);
|
||||
const draft = draftsBySessionRef.current[targetDraftKey];
|
||||
return draft ? draftHasAttachmentDedupKey(draft, key) : false;
|
||||
};
|
||||
|
||||
const addAttachmentToDraft = (targetDraftKey: string, attachment: Attachment, key: AttachmentDedupKey): boolean => {
|
||||
if (targetDraftKey === activeDraftKeyRef.current) {
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) return false;
|
||||
rememberAttachment(attachment.path, key);
|
||||
const next = [...attachmentsRef.current, attachment];
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
return true;
|
||||
}
|
||||
const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
|
||||
if (draftHasAttachmentDedupKey(draft, key)) return false;
|
||||
draft.attachmentDedupKeys[attachment.path] = key;
|
||||
draft.attachments = [...draft.attachments, attachment];
|
||||
draftsBySessionRef.current[targetDraftKey] = draft;
|
||||
return true;
|
||||
};
|
||||
|
||||
const addWorkspaceReferenceToDraft = (targetDraftKey: string, ref: WorkspaceReference) => {
|
||||
if (targetDraftKey === activeDraftKeyRef.current) {
|
||||
addWorkspaceReference(ref);
|
||||
return;
|
||||
}
|
||||
const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
|
||||
const key = workspaceReferenceKey(ref);
|
||||
if (draft.workspaceRefs.some((item) => workspaceReferenceKey(item) === key)) return;
|
||||
draft.workspaceRefs = [...draft.workspaceRefs, ref];
|
||||
draftsBySessionRef.current[targetDraftKey] = draft;
|
||||
};
|
||||
|
||||
const clearSubmittedDraft = (targetDraftKey: string) => {
|
||||
if (targetDraftKey === activeDraftKeyRef.current) {
|
||||
textRef.current = "";
|
||||
setText("");
|
||||
historyIndexRef.current = -1;
|
||||
setHistoryIndex(-1);
|
||||
clearAttachments();
|
||||
workspaceRefsRef.current = [];
|
||||
setWorkspaceRefs([]);
|
||||
sessionRefsRef.current = [];
|
||||
setSessionRefs([]);
|
||||
pastedBlocksRef.current = [];
|
||||
setPastedBlocks([]);
|
||||
openPastedLabelsRef.current = [];
|
||||
setOpenPastedLabels([]);
|
||||
savedTextRef.current = "";
|
||||
return;
|
||||
}
|
||||
const draft = cloneComposerDraft(draftsBySessionRef.current[targetDraftKey] ?? emptyComposerDraft());
|
||||
draft.text = "";
|
||||
draft.attachments = [];
|
||||
draft.workspaceRefs = [];
|
||||
draft.pastedBlocks = [];
|
||||
draft.openPastedLabels = [];
|
||||
draft.sessionRefs = [];
|
||||
draft.attachmentDedupKeys = {};
|
||||
draft.historyIndex = -1;
|
||||
draft.savedText = "";
|
||||
draftsBySessionRef.current[targetDraftKey] = draft;
|
||||
};
|
||||
|
||||
const clearIntentCloseTimer = useCallback(() => {
|
||||
if (intentCloseTimerRef.current === null) return;
|
||||
window.clearTimeout(intentCloseTimerRef.current);
|
||||
@@ -885,11 +1106,18 @@ export function Composer({
|
||||
const activeGoal = (goal ?? "").trim();
|
||||
const goalModeOn = collaborationMode === "goal";
|
||||
const tokenModeOn = tokenMode === "economy";
|
||||
const warnImageInputFallback = useCallback((message = t("composer.imageInputUnsupported")) => {
|
||||
showToast(message, "warn");
|
||||
}, [showToast, t]);
|
||||
|
||||
const submit = async () => {
|
||||
if (disabled || readOnly || submittingRef.current) return;
|
||||
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
|
||||
const submitDraftKey = activeDraftKeyRef.current;
|
||||
const trimmedText = text.trim();
|
||||
if (pendingPaste > 0) return;
|
||||
if (!imageInputEnabled && hasImageAttachments(attachmentsRef.current)) {
|
||||
warnImageInputFallback();
|
||||
}
|
||||
if (!trimmedText && attachments.length === 0 && workspaceRefs.length === 0) {
|
||||
if (goalModeOn && !activeGoal) {
|
||||
setComposerPrompt(t("composer.goalInputRequired"));
|
||||
@@ -901,30 +1129,27 @@ export function Composer({
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const orderedAttachments = sortComposerAttachments(attachments);
|
||||
const refs = [
|
||||
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
...orderedAttachments.map((a) => `@${a.path}`),
|
||||
].join(" ");
|
||||
const displayRefs = [
|
||||
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
...orderedAttachments.map(formatAttachmentDisplayReference),
|
||||
].join(" ");
|
||||
const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
|
||||
// PR-B: when past:chats refs are attached, prepend their formatted transcript
|
||||
// to submitText only (displayText stays unchanged so the user still sees their
|
||||
// original prompt in the input preview). With no refs we keep the original
|
||||
// submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
|
||||
const sessionContext = sessionRefs.length === 0 ? "" : await buildSessionContext(sessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
onSend(displayText, submitText);
|
||||
setText("");
|
||||
historyIndexRef.current = -1;
|
||||
setHistoryIndex(-1);
|
||||
clearAttachments();
|
||||
setWorkspaceRefs([]);
|
||||
setSessionRefs([]);
|
||||
const orderedAttachments = sortComposerAttachments(attachments);
|
||||
const refs = [
|
||||
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
...orderedAttachments.map((a) => `@${a.path}`),
|
||||
].join(" ");
|
||||
const displayRefs = [
|
||||
...workspaceRefs.map((ref) => formatWorkspaceReference(ref.displayPath || ref.path, ref.isDir)),
|
||||
...orderedAttachments.map(formatAttachmentDisplayReference),
|
||||
].join(" ");
|
||||
const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
|
||||
// PR-B: when past:chats refs are attached, prepend their formatted transcript
|
||||
// to submitText only (displayText stays unchanged so the user still sees their
|
||||
// original prompt in the input preview). With no refs we keep the original
|
||||
// submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
|
||||
const sessionContext = sessionRefs.length === 0 ? "" : await buildSessionContext(sessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
await onSend(displayText, submitText);
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "warn");
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
@@ -939,19 +1164,18 @@ export function Composer({
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const attachImageFiles = async (files: File[]) => {
|
||||
const attachImageFiles = async (files: File[], sourceDraftKey: string) => {
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return;
|
||||
for (const file of images) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = await fileDedupKey(file);
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
const path = await app.SavePastedImage(dataUrl);
|
||||
const previewUrl = await app.AttachmentDataURL(path);
|
||||
rememberAttachment(path, key);
|
||||
setAttachments((prev) => [...prev, { path, previewUrl, displayName: file.name }]);
|
||||
addAttachmentToDraft(sourceDraftKey, { path, previewUrl, displayName: file.name }, key);
|
||||
} catch (error) {
|
||||
console.warn("[composer] failed to attach pasted image", error);
|
||||
showToast(t("composer.attachImageFailed"), "warn");
|
||||
@@ -964,19 +1188,20 @@ export function Composer({
|
||||
|
||||
// Non-image pastes (PDFs, docs): the clipboard hands us bytes, not a path, so
|
||||
// the kernel stores them and we reference the saved path — attached, not ignored.
|
||||
const attachOtherFiles = async (files: File[]) => {
|
||||
const attachOtherFiles = async (files: File[], sourceDraftKey: string) => {
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
if (others.length === 0) return;
|
||||
for (const file of others) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = await fileDedupKey(file);
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
const path = await app.SavePastedFile(file.name, dataUrl);
|
||||
rememberAttachment(path, key);
|
||||
setAttachments((prev) => [...prev, { path, displayName: file.name }]);
|
||||
addAttachmentToDraft(sourceDraftKey, { path, displayName: file.name }, key);
|
||||
} catch {
|
||||
console.warn("[composer] failed to attach pasted file");
|
||||
showToast(t("composer.attachFileFailed"), "warn");
|
||||
// non-fatal: a failed attach must not block normal text input
|
||||
} finally {
|
||||
setPendingPaste((n) => Math.max(0, n - 1));
|
||||
@@ -985,19 +1210,19 @@ export function Composer({
|
||||
};
|
||||
|
||||
const attachFiles = (files: File[]) => {
|
||||
void attachImageFiles(files);
|
||||
void attachOtherFiles(files);
|
||||
const sourceDraftKey = activeDraftKeyRef.current;
|
||||
void attachImageFiles(files, sourceDraftKey);
|
||||
void attachOtherFiles(files, sourceDraftKey);
|
||||
};
|
||||
|
||||
const attachNativeClipboardImage = async (notifyOnError: boolean) => {
|
||||
const attachNativeClipboardImage = async (notifyOnError: boolean, sourceDraftKey: string) => {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const path = await app.SaveClipboardImage();
|
||||
const previewUrl = await app.AttachmentDataURL(path);
|
||||
const key = { hash: await dataURLHash(previewUrl), source: `native-clipboard:${path}` };
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) return;
|
||||
rememberAttachment(path, key);
|
||||
setAttachments((prev) => [...prev, { path, previewUrl }]);
|
||||
if (attachmentSeenInDraft(sourceDraftKey, key)) return;
|
||||
addAttachmentToDraft(sourceDraftKey, { path, previewUrl }, key);
|
||||
} catch (error) {
|
||||
console.warn("[composer] failed to read native clipboard image", error);
|
||||
if (notifyOnError) showToast(t("composer.pasteImageFailed"), "warn");
|
||||
@@ -1009,21 +1234,22 @@ export function Composer({
|
||||
// OS file drops arrive as absolute paths through the native bridge (the webview
|
||||
// withholds them from the HTML drop event); the kernel resolves each into a
|
||||
// workspace @reference or a stored attachment.
|
||||
const attachDroppedPaths = async (paths: string[]) => {
|
||||
const attachDroppedPaths = async (paths: string[], sourceDraftKey = activeDraftKeyRef.current) => {
|
||||
setDragOver(false);
|
||||
for (const path of paths) {
|
||||
setPendingPaste((n) => n + 1);
|
||||
try {
|
||||
const key = { hash: "", source: `path:${path}` };
|
||||
if (attachmentDedupRef.current.seen(key.hash, key.source)) continue;
|
||||
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
|
||||
const item = await app.AttachDropped(path);
|
||||
if (item.kind === "workspace") {
|
||||
addWorkspaceReference({ path: item.path, isDir: item.isDir });
|
||||
addWorkspaceReferenceToDraft(sourceDraftKey, { path: item.path, isDir: item.isDir, displayPath: item.displayPath });
|
||||
} else {
|
||||
rememberAttachment(item.path, key);
|
||||
setAttachments((prev) => [...prev, { path: item.path, previewUrl: item.previewUrl, displayName: baseName(path) }]);
|
||||
addAttachmentToDraft(sourceDraftKey, { path: item.path, previewUrl: item.previewUrl, displayName: baseName(path) }, key);
|
||||
}
|
||||
} catch {
|
||||
console.warn("[composer] failed to attach dropped file");
|
||||
showToast(t("composer.attachDropFailed"), "warn");
|
||||
// non-fatal: a failed drop attach must not block normal text input
|
||||
} finally {
|
||||
setPendingPaste((n) => Math.max(0, n - 1));
|
||||
@@ -1032,7 +1258,7 @@ export function Composer({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return onFilesDropped((paths) => void attachDroppedPaths(paths));
|
||||
return onFilesDropped((paths) => void attachDroppedPaths(paths, activeDraftKeyRef.current));
|
||||
}, []);
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -1048,7 +1274,7 @@ export function Composer({
|
||||
const hasImageHint = clipboardHasImageHint(e.clipboardData);
|
||||
if (hasImageHint || pasted === "") {
|
||||
e.preventDefault();
|
||||
void attachNativeClipboardImage(hasImageHint);
|
||||
void attachNativeClipboardImage(hasImageHint, activeDraftKeyRef.current);
|
||||
return;
|
||||
}
|
||||
if (!shouldFoldPaste(pasted)) return;
|
||||
@@ -1125,8 +1351,11 @@ export function Composer({
|
||||
}
|
||||
|
||||
// OS file drops deliver no usable bytes/paths here; the native bridge
|
||||
// (onFilesDropped → AttachDropped) handles them. Just clear the hover state.
|
||||
if (hasFileDrag(e.dataTransfer)) setDragOver(false);
|
||||
// (onFilesDropped -> AttachDropped) handles them. Prevent webview navigation.
|
||||
if (hasFileDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -1142,6 +1371,7 @@ export function Composer({
|
||||
// replied, the just-sent text is handed back so we drop it back into the input.
|
||||
const handleCancel = () => {
|
||||
const restored = onCancel();
|
||||
if (goalModeOn && activeGoal) onClearGoal();
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
};
|
||||
|
||||
@@ -1246,18 +1476,29 @@ export function Composer({
|
||||
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
|
||||
let nextHeight = clampComposerHeight(startHeight);
|
||||
let moved = false;
|
||||
card.style.setProperty("--composer-height", `${nextHeight}px`);
|
||||
e.currentTarget.setAttribute("aria-valuenow", String(nextHeight));
|
||||
const liveResize = createRafResizeUpdater({
|
||||
target: card,
|
||||
separator: e.currentTarget,
|
||||
cssVar: "--composer-height",
|
||||
});
|
||||
setComposerResizing(true);
|
||||
document.body.classList.add("composer-resizing");
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
moved = true;
|
||||
nextHeight = clampComposerHeight(startHeight + startY - event.clientY);
|
||||
setComposerHeight(nextHeight);
|
||||
liveResize.schedule(nextHeight);
|
||||
};
|
||||
const onUp = () => {
|
||||
liveResize.flush();
|
||||
setComposerResizing(false);
|
||||
document.body.classList.remove("composer-resizing");
|
||||
if (moved) saveComposerHeight(nextHeight);
|
||||
if (moved) {
|
||||
setComposerHeight(nextHeight);
|
||||
saveComposerHeight(nextHeight);
|
||||
}
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onUp);
|
||||
document.removeEventListener("pointercancel", onUp);
|
||||
@@ -1285,10 +1526,14 @@ export function Composer({
|
||||
};
|
||||
|
||||
const pickEntry = (e: DirEntry) => {
|
||||
const atPos = text.length - (atRaw?.length ?? 0) - 1; // index of '@'
|
||||
const prefix = text.slice(0, atPos);
|
||||
const picked = composerPickFileEntry(text, atRaw, atDir, e);
|
||||
if (picked.workspaceRef) {
|
||||
setTextCaretEnd(picked.text);
|
||||
addWorkspaceReference(picked.workspaceRef);
|
||||
return;
|
||||
}
|
||||
// A directory keeps the menu open (trailing "/"); a file completes it (space).
|
||||
setTextCaretEnd(prefix + "@" + atDir + e.name + (e.isDir ? "/" : " "));
|
||||
setTextCaretEnd(picked.text);
|
||||
};
|
||||
|
||||
// --- past:chats session reference ---
|
||||
@@ -1447,9 +1692,10 @@ export function Composer({
|
||||
|
||||
if (isPasteShortcut(e) && !composing) {
|
||||
clearNativeClipboardPasteTimer();
|
||||
const sourceDraftKey = activeDraftKeyRef.current;
|
||||
nativeClipboardPasteTimerRef.current = window.setTimeout(() => {
|
||||
nativeClipboardPasteTimerRef.current = null;
|
||||
void attachNativeClipboardImage(false);
|
||||
void attachNativeClipboardImage(false, sourceDraftKey);
|
||||
}, 160);
|
||||
}
|
||||
|
||||
@@ -1601,6 +1847,7 @@ export function Composer({
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
|
||||
const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
|
||||
void onSetMode;
|
||||
const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
|
||||
onSetToolApprovalMode(nextMode);
|
||||
@@ -1853,7 +2100,7 @@ export function Composer({
|
||||
<VirtualMenu
|
||||
items={atMenuItems}
|
||||
activeIndex={active}
|
||||
itemKey={(it) => (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + it.entry.name)}
|
||||
itemKey={(it) => (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + (it.entry.path || it.entry.name))}
|
||||
renderItem={(it, i) =>
|
||||
it.kind === "pastChats" ? (
|
||||
<button
|
||||
@@ -1884,7 +2131,7 @@ export function Composer({
|
||||
<FileText size={13} className="filemenu__icon" />
|
||||
)}
|
||||
<span className="slashmenu__name slashmenu__name--file">
|
||||
{it.entry.name}
|
||||
{dirEntryMenuLabel(it.entry)}
|
||||
{it.entry.isDir ? "/" : ""}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1929,11 +2176,11 @@ export function Composer({
|
||||
<ComposerContextCard
|
||||
key={workspaceReferenceKey(ref)}
|
||||
variant="workspace"
|
||||
tooltipLabel={formatWorkspaceReference(ref.path, ref.isDir)}
|
||||
tooltipLabel={ref.displayPath ? formatWorkspaceReference(ref.displayPath, ref.isDir) : formatWorkspaceReference(ref.path, ref.isDir)}
|
||||
removeLabel={t("composer.removeReference")}
|
||||
onRemove={() => removeWorkspaceReference(ref)}
|
||||
folder={Boolean(ref.isDir)}
|
||||
label={ref.isDir ? `${baseName(ref.path)}/` : baseName(ref.path)}
|
||||
label={ref.isDir ? `${baseName(ref.displayPath || ref.path)}/` : baseName(ref.displayPath || ref.path)}
|
||||
/>
|
||||
))}
|
||||
{sessionRefs.map((ref) => (
|
||||
@@ -1996,14 +2243,19 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
<button
|
||||
className="composer-resize-handle"
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t("composer.resize")}
|
||||
aria-valuemin={COMPOSER_MIN_HEIGHT}
|
||||
aria-valuemax={composerMaxHeight()}
|
||||
aria-valuenow={composerResizeValue}
|
||||
title={t("composer.resize")}
|
||||
onPointerDown={onComposerResizeStart}
|
||||
onKeyDown={onComposerResizeKeyDown}
|
||||
@@ -2055,7 +2307,7 @@ export function Composer({
|
||||
<button
|
||||
className="composer__btn composer__btn--send"
|
||||
onClick={submit}
|
||||
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || readOnly}
|
||||
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } 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";
|
||||
@@ -42,6 +42,26 @@ function fmtOptionalTokens(tokens?: number): string {
|
||||
return tokens.toLocaleString();
|
||||
}
|
||||
|
||||
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 fmtTurns(turns: number | undefined, t: Translator): string {
|
||||
if (typeof turns !== "number" || turns < 0) return "-";
|
||||
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
|
||||
@@ -292,6 +312,14 @@ export function ContextPanel({
|
||||
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;
|
||||
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
|
||||
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
|
||||
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
|
||||
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
|
||||
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
|
||||
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;
|
||||
@@ -374,7 +402,10 @@ export function ContextPanel({
|
||||
<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 />
|
||||
<MetricCard label={t("context.inputCacheHit")} value={sessionCacheHitMetric.display} valueTitle={sessionCacheHitMetric.exact} wide />
|
||||
<MetricCard label={t("context.inputCacheMiss")} value={sessionCacheMissMetric.display} valueTitle={sessionCacheMissMetric.exact} wide />
|
||||
<MetricCard label={t("context.outputTokens")} value={sessionCompletionMetric.display} valueTitle={sessionCompletionMetric.exact} wide />
|
||||
<MetricCard label={t("context.sessionTokens")} value={totalTokensMetric.display} valueTitle={totalTokensMetric.exact} wide />
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
@@ -437,13 +468,14 @@ function MiniStat({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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";
|
||||
|
||||
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, DirEntry[]>>({});
|
||||
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);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) 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);
|
||||
return;
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((next) => {
|
||||
const list = next ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) 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")}` : "";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -19,44 +19,116 @@ 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 isBlock = match !== null || text.includes("\n");
|
||||
if (isBlock) {
|
||||
if (!match && plainStatusBlocks) return <PlainMarkdownBlock text={text.replace(/\n$/, "")} />;
|
||||
return <CodeViewer value={text.replace(/\n$/, "")} language={match?.[1]} 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useEffect, 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,13 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
|
||||
import { app } from "../lib/bridge";
|
||||
import { replaySubmitText } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
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 +81,60 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
|
||||
return merged;
|
||||
}
|
||||
|
||||
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 +168,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) : "";
|
||||
@@ -327,6 +385,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
|
||||
@@ -421,10 +484,29 @@ 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.files.length;
|
||||
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>;
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
return (
|
||||
<div className="rewind__files-tooltip">
|
||||
{checkpoint.files.map((file) => (
|
||||
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const runAction = (scope: MessageActionScope) => {
|
||||
setConfirmScope(null);
|
||||
onOpenMenu?.(null);
|
||||
@@ -441,7 +523,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 +533,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 +627,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
defaultExpanded = false,
|
||||
expandWhileStreaming = true,
|
||||
truncateStreamingReasoning = false,
|
||||
creationMode = false,
|
||||
}: {
|
||||
item: AssistantItem;
|
||||
defaultExpanded?: boolean;
|
||||
@@ -550,6 +635,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 +714,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>
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { ModelInfo } from "../lib/types";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
// ModelSwitcher opens an upward popover listing configured providers. Selecting
|
||||
// one switches the active model while the current conversation continues.
|
||||
@@ -79,6 +80,7 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?:
|
||||
const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
|
||||
return cur ? providerLabel(cur.provider, t) : null;
|
||||
}, [label, models, t]);
|
||||
const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label;
|
||||
|
||||
const pick = (name: string) => {
|
||||
setModels((prev) => prev.map((m) => ({ ...m, current: m.ref === name })));
|
||||
@@ -88,17 +90,20 @@ export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?:
|
||||
|
||||
return (
|
||||
<div className="modelsw">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="modelsw__trigger"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Brain size={13} className="modelsw__kind" />
|
||||
<span className="modelsw__label">{label}{currentProvider && <span className="modelsw__provider"> · {currentProvider}</span>}</span>
|
||||
<ChevronsUpDown size={11} />
|
||||
</button>
|
||||
<Tooltip label={triggerLabel} fill>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="modelsw__trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Brain size={13} className="modelsw__kind" />
|
||||
<span className="modelsw__label">{label}</span>
|
||||
<ChevronsUpDown size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AnchoredPopover
|
||||
open={open}
|
||||
anchorRef={triggerRef}
|
||||
@@ -156,14 +161,6 @@ function providerLabel(provider: string, t: ReturnType<typeof useT>): string {
|
||||
case "deepseek-flash":
|
||||
case "deepseek-pro":
|
||||
return t("settings.providerLabel.deepseek");
|
||||
case "mimo-api":
|
||||
case "mimo":
|
||||
case "xiaomi-mimo":
|
||||
return t("settings.providerLabel.mimoApi");
|
||||
case "mimo-token-plan":
|
||||
case "mimo-pro":
|
||||
case "mimo-flash":
|
||||
return t("settings.providerLabel.mimoTokenPlan");
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
|
||||
import { topicActivityTime } from "../lib/session";
|
||||
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
|
||||
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
|
||||
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
|
||||
import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -33,6 +35,9 @@ interface ProjectTreeProps {
|
||||
onTimeFilterChange: (filter: "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d") => void;
|
||||
searchExpanded?: boolean;
|
||||
searchFocusSignal?: number;
|
||||
showShortcutBadges?: boolean;
|
||||
shortcutPlatform?: ShortcutPlatform;
|
||||
onVisibleTopicsChange?: (topics: TopicShortcutEntry[]) => void;
|
||||
}
|
||||
|
||||
type ProjectTreeImTopicSource = {
|
||||
@@ -72,6 +77,22 @@ export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopic
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectTreeTopicClickTarget = {
|
||||
rowKey: string;
|
||||
canRename: boolean;
|
||||
};
|
||||
|
||||
type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export function projectTreeShouldSuppressOpenForRename(
|
||||
pending: ProjectTreeTopicClickTarget | null,
|
||||
next: ProjectTreeTopicClickTarget,
|
||||
): boolean {
|
||||
return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
|
||||
}
|
||||
|
||||
export type ProjectTreeFolderDisclosure = {
|
||||
canExpand: boolean;
|
||||
isOpen: boolean;
|
||||
@@ -446,6 +467,9 @@ export function ProjectTree({
|
||||
onTimeFilterChange,
|
||||
searchExpanded = true,
|
||||
searchFocusSignal = 0,
|
||||
showShortcutBadges = false,
|
||||
shortcutPlatform,
|
||||
onVisibleTopicsChange,
|
||||
}: ProjectTreeProps) {
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
@@ -476,8 +500,16 @@ export function ProjectTree({
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const filterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const topicIndexRef = useRef(0);
|
||||
const visibleTopicsCollectorRef = useRef<TopicShortcutEntry[]>([]);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
||||
const creatingRef = useRef(false);
|
||||
const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current.timer);
|
||||
};
|
||||
}, []);
|
||||
const manuallyCollapsedRef = useRef(manuallyCollapsed);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -954,7 +986,7 @@ export function ProjectTree({
|
||||
});
|
||||
}, [activeAncestorKeys, manuallyCollapsed]);
|
||||
|
||||
const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects") => {
|
||||
const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects", isVisible = true) => {
|
||||
if (!node) return null;
|
||||
const key = projectNodeKey(node, depth);
|
||||
const children = asArray(node.children);
|
||||
@@ -1029,7 +1061,7 @@ export function ProjectTree({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}`}
|
||||
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}${meta ? " project-tree__topic--has-meta" : ""}`}
|
||||
style={{ paddingLeft: 14 + depth * 16 }}
|
||||
>
|
||||
<input
|
||||
@@ -1037,6 +1069,7 @@ export function ProjectTree({
|
||||
className="project-tree__topic-input"
|
||||
value={topicDraft}
|
||||
onChange={(event) => setTopicDraft(event.target.value)}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitRenameTopic(topicId);
|
||||
if (event.key === "Escape") setEditingTopic(null);
|
||||
@@ -1046,9 +1079,20 @@ export function ProjectTree({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const shortcutIndex = showShortcutBadges && isVisible && topicIndexRef.current < 9 ? topicIndexRef.current + 1 : 0;
|
||||
if (shortcutIndex > 0) topicIndexRef.current++;
|
||||
// Collect visible topics in render order for shortcut navigation
|
||||
if (openRequest && isVisible) {
|
||||
visibleTopicsCollectorRef.current.push({
|
||||
scope: openRequest.scope,
|
||||
workspaceRoot: openRequest.workspaceRoot,
|
||||
topicId: openRequest.topicId,
|
||||
sessionPath: openRequest.sessionPath,
|
||||
});
|
||||
}
|
||||
const row = (
|
||||
<div
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}`}
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
style={accentStyle}
|
||||
onContextMenu={isSessionNode ? undefined : openTopicMenu}
|
||||
>
|
||||
@@ -1058,13 +1102,34 @@ export function ProjectTree({
|
||||
title={title}
|
||||
style={{ paddingLeft: 14 + depth * 16 }}
|
||||
onClick={() => {
|
||||
if (openRequest) onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
|
||||
if (!openRequest) return;
|
||||
const nextClick = { rowKey: key, canRename: !isSessionNode };
|
||||
const pending = clickTimerRef.current;
|
||||
if (pending !== null) {
|
||||
clearTimeout(pending.timer);
|
||||
clickTimerRef.current = null;
|
||||
if (projectTreeShouldSuppressOpenForRename(pending, nextClick)) return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
|
||||
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
|
||||
}, 200);
|
||||
clickTimerRef.current = { ...nextClick, timer };
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
||||
openTopicMenu(event);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
if (isSessionNode) return;
|
||||
event.stopPropagation();
|
||||
if (clickTimerRef.current !== null && clickTimerRef.current.rowKey === key) {
|
||||
clearTimeout(clickTimerRef.current.timer);
|
||||
clickTimerRef.current = null;
|
||||
}
|
||||
startRenameTopic(node, label);
|
||||
}}
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
@@ -1147,6 +1212,11 @@ export function ProjectTree({
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{shortcutIndex > 0 && (
|
||||
<span className="project-tree__topic-shortcut" aria-hidden="true">
|
||||
{topicShortcutLabel(shortcutIndex, shortcutPlatform)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -1155,7 +1225,7 @@ export function ProjectTree({
|
||||
{hasChildren && (
|
||||
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
|
||||
<div className="project-tree__children-inner">
|
||||
{children.map((child) => renderNode(child, depth + 1, section))}
|
||||
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1386,7 +1456,7 @@ export function ProjectTree({
|
||||
{hasChildren && (
|
||||
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
|
||||
<div className="project-tree__children-inner">
|
||||
{children.map((child) => renderNode(child, depth + 1, section))}
|
||||
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1474,7 +1544,7 @@ export function ProjectTree({
|
||||
{hasChildren && (
|
||||
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
|
||||
<div className="project-tree__children-inner">
|
||||
{children.map((child) => renderNode(child, depth + 1, section))}
|
||||
{children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1826,6 +1896,16 @@ export function ProjectTree({
|
||||
|
||||
const hasWorkbenchRows = workbenchTreeSections.pinned.length > 0 || workbenchTreeSections.projects.length > 0;
|
||||
|
||||
// Report visible topics to parent after render so shortcuts match sidebar order.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
onVisibleTopicsChange?.(visibleTopicsCollectorRef.current);
|
||||
});
|
||||
|
||||
// Reset topic index counter and visible topics collector before each render.
|
||||
topicIndexRef.current = 0;
|
||||
visibleTopicsCollectorRef.current = [];
|
||||
|
||||
return (
|
||||
<div className="project-tree">
|
||||
{searchVisible && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { loadLayoutSize, saveLayoutSize, type LayoutSizeKey } from "../lib/layoutPreferences";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
|
||||
const DRAWER_DEFAULT_WIDTH = 440;
|
||||
@@ -51,6 +52,7 @@ export function ResizableDrawer({
|
||||
}) {
|
||||
const t = useT();
|
||||
const config = drawerConfig(wide);
|
||||
const drawerRef = useRef<HTMLElement>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth));
|
||||
const [width, setWidth] = useState(() =>
|
||||
loadLayoutSize(config.key, config.defaultWidth, (value) => clampDrawerWidth(value, wide)),
|
||||
@@ -79,14 +81,22 @@ export function ResizableDrawer({
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const drawer = drawerRef.current;
|
||||
if (!drawer) return;
|
||||
event.preventDefault();
|
||||
setResizing(true);
|
||||
let nextWidth = effectiveWidth;
|
||||
const liveResize = createRafResizeUpdater({
|
||||
target: drawer,
|
||||
separator: event.currentTarget,
|
||||
cssVar: "--drawer-width",
|
||||
});
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = clampDrawerWidth(window.innerWidth - moveEvent.clientX, wide, window.innerWidth);
|
||||
setWidth(nextWidth);
|
||||
liveResize.schedule(nextWidth);
|
||||
};
|
||||
const onDone = () => {
|
||||
liveResize.flush();
|
||||
setWidth(nextWidth);
|
||||
saveLayoutSize(config.key, nextWidth);
|
||||
setResizing(false);
|
||||
@@ -124,6 +134,7 @@ export function ResizableDrawer({
|
||||
return (
|
||||
<div className={`drawer-backdrop${subtle ? " drawer-backdrop--subtle" : ""}`} data-state={status} onClick={requestClose}>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className={`drawer${wide ? " drawer--wide" : ""}${resizing ? " drawer--resizing" : ""}`}
|
||||
data-state={status}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { getAvailableFontFamilies, getAvailableMonoFontFamilies } from "../lib/fontAvailability";
|
||||
import { getDisplayMode, onDisplayModeChange, setDisplayMode as setLocalDisplayMode } from "../lib/displayMode";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
|
||||
import { normalizeToolApprovalMode } from "../lib/types";
|
||||
import {
|
||||
comboFromKeyboardEvent,
|
||||
detectShortcutPlatform,
|
||||
@@ -80,6 +81,8 @@ export function SettingsPanel({
|
||||
}) {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<SettingsView | null>(null);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [settingsLoadFailed, setSettingsLoadFailed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
@@ -95,9 +98,19 @@ export function SettingsPanel({
|
||||
const { status, requestClose } = useDeferredClose(onClose, 240);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const next = normalizeSettingsView(await app.Settings().catch(() => null));
|
||||
setS(next);
|
||||
return next;
|
||||
setLoadingSettings(true);
|
||||
setSettingsLoadFailed(false);
|
||||
try {
|
||||
const next = normalizeSettingsView(await app.Settings());
|
||||
setS(next);
|
||||
return next;
|
||||
} catch {
|
||||
setS(null);
|
||||
setSettingsLoadFailed(true);
|
||||
return null;
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -178,10 +191,16 @@ export function SettingsPanel({
|
||||
))}
|
||||
</nav>
|
||||
<main className="settings-center__content">
|
||||
{needsSettings && settingsLoadFailed && (
|
||||
<div className="banner banner--error settings-load-error" role="alert">
|
||||
<span>{t("settings.loadFailed")}</span>
|
||||
<button className="btn btn--small" type="button" onClick={() => void reload()}>{t("common.retry")}</button>
|
||||
</div>
|
||||
)}
|
||||
{needsSettings && err && <div className="banner banner--error">{err}</div>}
|
||||
{needsSettings && warning && <div className="banner banner--warning">{warning}</div>}
|
||||
{needsSettings && !s ? (
|
||||
<div className="empty">{t("settings.loading")}</div>
|
||||
loadingSettings ? <div className="empty">{t("settings.loading")}</div> : null
|
||||
) : (
|
||||
<>
|
||||
{tab === "general" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><GeneralSection s={s} busy={busy} apply={apply} agentRunning={agentRunning} /></SettingsPageShell>}
|
||||
@@ -534,8 +553,9 @@ function ShortcutsSection() {
|
||||
</div>
|
||||
<div className="shortcuts-settings__control">
|
||||
<button
|
||||
className={`shortcuts-settings__key${isRecording ? " shortcuts-settings__key--recording" : ""}`}
|
||||
className={`shortcuts-settings__key${isRecording ? " shortcuts-settings__key--recording" : ""}${definition.configurable === false ? " shortcuts-settings__key--locked" : ""}`}
|
||||
type="button"
|
||||
disabled={definition.configurable === false}
|
||||
aria-label={isRecording ? t("settings.shortcutsRecording") : display}
|
||||
aria-pressed={isRecording}
|
||||
onClick={() => {
|
||||
@@ -601,11 +621,11 @@ const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "openai", "none"
|
||||
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
|
||||
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
|
||||
const AUTO_PLAN_MODES = ["off", "on"] as const;
|
||||
const TOOL_APPROVAL_MODES = ["ask", "auto", "yolo"] as const;
|
||||
const BOT_TOOL_APPROVAL_MODES = ["", "ask", "auto", "yolo"] as const;
|
||||
|
||||
type ProxyMode = (typeof PROXY_MODES)[number];
|
||||
type AutoPlanMode = (typeof AUTO_PLAN_MODES)[number];
|
||||
type BotConnectionToolApprovalMode = (typeof BOT_TOOL_APPROVAL_MODES)[number];
|
||||
|
||||
function normalizeProxyMode(mode: string): ProxyMode {
|
||||
switch (mode) {
|
||||
@@ -785,8 +805,8 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
|
||||
};
|
||||
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" };
|
||||
agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 12;
|
||||
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" };
|
||||
agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 0;
|
||||
agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0;
|
||||
agent.reasoningLanguage = normalizeReasoningLanguage(agent.reasoningLanguage);
|
||||
return {
|
||||
@@ -811,6 +831,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
|
||||
agent,
|
||||
bot: normalizeBotSettings(view.bot),
|
||||
autoPlan: normalizeAutoPlan(view.autoPlan),
|
||||
defaultToolApprovalMode: normalizeToolApprovalMode(view.defaultToolApprovalMode),
|
||||
autoApproveTools: Boolean(view.autoApproveTools ?? view.bypass),
|
||||
bypass: Boolean(view.autoApproveTools ?? view.bypass),
|
||||
desktopLanguage: normalizeLangPref(view.desktopLanguage),
|
||||
@@ -822,6 +843,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
|
||||
statusBarStyle: normalizeStatusBarStyle(view.statusBarStyle),
|
||||
statusBarItems: normalizeStatusBarItems(view.statusBarItems),
|
||||
checkUpdates: view.checkUpdates !== false,
|
||||
memoryCompilerEnabled: view.memoryCompilerEnabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -938,6 +960,8 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
|
||||
useEffect(() => onDisplayModeChange((mode) => setDisplayMode(mode)), []);
|
||||
useEffect(() => () => mouseDragCleanupRef.current?.(), []);
|
||||
const autoPlan = normalizeAutoPlan(s.autoPlan);
|
||||
const defaultToolApprovalMode = normalizeToolApprovalMode(s.defaultToolApprovalMode);
|
||||
const memoryCompilerEnabled = s.memoryCompilerEnabled !== false;
|
||||
const languagePref = normalizeLangPref(s.desktopLanguage);
|
||||
const desktopLayoutStyle = normalizeDesktopLayoutStyle(s.desktopLayoutStyle);
|
||||
const [genMusicPreset, setGenMusicPreset] = useState<GenerativePreset>(getGenerativePreset());
|
||||
@@ -1155,6 +1179,20 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.defaultToolApprovalMode")} hint={t("settings.defaultToolApprovalModeHint")}>
|
||||
<div className="set-seg">
|
||||
{TOOL_APPROVAL_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`set-seg__btn${defaultToolApprovalMode === mode ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => void apply(() => app.SetDefaultToolApprovalMode(mode))}
|
||||
>
|
||||
{t(`settings.defaultToolApprovalMode.${mode}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.autoPlan")}>
|
||||
<div className="set-seg">
|
||||
{AUTO_PLAN_MODES.map((mode) => (
|
||||
@@ -1169,6 +1207,13 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
|
||||
))}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.memoryCompiler")} hint={t("settings.memoryCompilerHint")}>
|
||||
<ToggleSegment
|
||||
value={memoryCompilerEnabled}
|
||||
disabled={busy}
|
||||
onChange={(enabled) => void apply(() => app.SetMemoryCompilerEnabled(enabled))}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField label={t("settings.sound")} hint={t("settings.soundHint")} stacked>
|
||||
<div className={`settings-sound-editor${soundExpanded ? " settings-sound-editor--expanded" : ""}`}>
|
||||
<div className="settings-sound-editor__summary">
|
||||
@@ -1741,6 +1786,11 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
setConnections((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
const persistConnection = (id: string, patch: Partial<BotConnectionView>) =>
|
||||
persistConnections((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||||
const persistConnectionToolApprovalMode = (id: string, mode: string) => {
|
||||
const normalizedMode = normalizeBotToolApprovalMode(mode, true);
|
||||
setConnections((items) => items.map((item) => item.id === id ? { ...item, toolApprovalMode: normalizedMode } : item));
|
||||
void apply(() => app.SetBotConnectionToolApprovalMode(id, normalizedMode));
|
||||
};
|
||||
const updateConnectionCredential = (id: string, patch: Partial<BotConnectionView["credential"]>) =>
|
||||
setConnections((items) => items.map((item) => item.id === id ? { ...item, credential: { ...item.credential, ...patch } } : item));
|
||||
const persistConnectionCredential = (id: string, patch: Partial<BotConnectionView["credential"]>) =>
|
||||
@@ -2558,7 +2608,7 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
|
||||
type="button"
|
||||
className={selectedConnectionToolApprovalMode === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
|
||||
disabled={busy}
|
||||
onClick={() => void persistConnection(selectedConnection.id, { toolApprovalMode: mode as BotConnectionToolApprovalMode })}
|
||||
onClick={() => persistConnectionToolApprovalMode(selectedConnection.id, mode)}
|
||||
>
|
||||
{t(`settings.botToolApprovalMode.${mode || "inherit"}` as DictKey)}
|
||||
</button>
|
||||
@@ -2979,7 +3029,7 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
|
||||
: !providerIsConfigured(defaultProviderView)
|
||||
? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) })
|
||||
: "";
|
||||
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" };
|
||||
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" };
|
||||
const setAgentSteps = (maxSteps: number, plannerMaxSteps: number) => (
|
||||
app.SetAgentParams(agent.temperature, maxSteps, plannerMaxSteps, agent.systemPrompt)
|
||||
);
|
||||
@@ -3042,7 +3092,7 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
|
||||
{subtab === "usage" ? (
|
||||
<>
|
||||
<SettingsSection title={t("settings.modelUsage")}>
|
||||
<SettingsField label={t("settings.defaultModel")}>
|
||||
<SettingsField label={t("settings.defaultModel")} hint={t("settings.defaultModelHint")}>
|
||||
<ModelPicker
|
||||
s={s}
|
||||
refs={refs}
|
||||
@@ -3667,12 +3717,10 @@ type ProviderModelDraft = {
|
||||
};
|
||||
|
||||
type AddProviderMode = null | "official" | "custom";
|
||||
type OfficialProviderKind = "deepseek" | "mimo-api" | "mimo-token-plan";
|
||||
type OfficialProviderKind = "deepseek";
|
||||
|
||||
const OFFICIAL_PROVIDER_CHOICES: Array<{ kind: OfficialProviderKind; labelKey: DictKey; descKey: DictKey; keyEnv: string }> = [
|
||||
{ kind: "deepseek", labelKey: "settings.addProvider.official.deepseek", descKey: "settings.addProvider.official.deepseekDesc", keyEnv: "DEEPSEEK_API_KEY" },
|
||||
{ kind: "mimo-api", labelKey: "settings.addProvider.official.mimoApi", descKey: "settings.addProvider.official.mimoApiDesc", keyEnv: "MIMO_API_KEY" },
|
||||
{ kind: "mimo-token-plan", labelKey: "settings.addProvider.official.mimoTokenPlan", descKey: "settings.addProvider.official.mimoTokenPlanDesc", keyEnv: "MIMO_API_KEY" },
|
||||
];
|
||||
|
||||
function AddProviderPanel({
|
||||
@@ -4135,13 +4183,6 @@ function canonicalOfficialProviderName(name: string): string {
|
||||
case "deepseek-flash":
|
||||
case "deepseek-pro":
|
||||
return "deepseek";
|
||||
case "mimo":
|
||||
case "xiaomi-mimo":
|
||||
case "xiaomi_mimo":
|
||||
return "mimo-api";
|
||||
case "mimo-pro":
|
||||
case "mimo-flash":
|
||||
return "mimo-token-plan";
|
||||
default:
|
||||
return name.trim();
|
||||
}
|
||||
@@ -4152,8 +4193,6 @@ function officialProviderKind(p: ProviderView): string {
|
||||
const name = canonicalOfficialProviderName(p.name);
|
||||
const host = providerBaseHost(p.baseUrl);
|
||||
if (name === "deepseek" && host === "api.deepseek.com") return "deepseek";
|
||||
if (name === "mimo-token-plan" && host === "token-plan-cn.xiaomimimo.com") return "mimo-token-plan";
|
||||
if (name === "mimo-api" && host === "api.xiaomimimo.com") return "mimo-api";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -4166,16 +4205,12 @@ function providerGroupID(p: ProviderView): string {
|
||||
function providerGroupLabel(p: ProviderView, t?: ReturnType<typeof useT>): string {
|
||||
const id = providerGroupID(p);
|
||||
if (id === "builtin:deepseek") return t ? t("settings.providerLabel.deepseek") : "DeepSeek";
|
||||
if (id === "builtin:mimo-api") return t ? t("settings.providerLabel.mimoApi") : "Mimo API";
|
||||
if (id === "builtin:mimo-token-plan") return t ? t("settings.providerLabel.mimoTokenPlan") : "Mimo Token Plan";
|
||||
return p.name;
|
||||
}
|
||||
|
||||
function providerGroupDescription(p: ProviderView, t: ReturnType<typeof useT>): string {
|
||||
const id = providerGroupID(p);
|
||||
if (id === "builtin:deepseek") return t("settings.providerDesc.deepseek");
|
||||
if (id === "builtin:mimo-api") return t("settings.providerDesc.mimoApi");
|
||||
if (id === "builtin:mimo-token-plan") return t("settings.providerDesc.mimoTokenPlan");
|
||||
return p.baseUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,13 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
import { Activity, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Layers, Percent, RefreshCw, Wallet, Zap } from "lucide-react";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useI18n, type Translator } from "../lib/i18n";
|
||||
import { formatMoneyLocalized } from "../lib/money";
|
||||
import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
|
||||
import { type BalanceInfo, type CollaborationMode, type ContextInfo, type JobView, type ToolApprovalMode, type WireUsage } from "../lib/types";
|
||||
import { type BalanceInfo, type ContextInfo, type WireUsage } from "../lib/types";
|
||||
|
||||
type StatusBarLabelStyle = "icon" | "text";
|
||||
|
||||
// JobsChip is the status-bar background-jobs indicator: a count that opens an
|
||||
// upward popover listing the running jobs (id · label · status), mirroring the
|
||||
// ModelSwitcher's click-to-open pattern. With no jobs it stays hidden so the
|
||||
// high-priority status metrics keep the compact left-to-right scan.
|
||||
function JobsChip({ jobs }: { jobs: JobView[] }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (wrapRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("click", closeOnOutsideClick);
|
||||
return () => document.removeEventListener("click", closeOnOutsideClick);
|
||||
}, [open]);
|
||||
if (jobs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="statusbar__jobswrap" ref={wrapRef}>
|
||||
<Tooltip label={t("status.jobsTitle")}>
|
||||
<button className="stat stat--jobs statusbar__jobs" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="stat__label">{t("status.jobsLabel")}</span>
|
||||
<b>{jobs.length}</b>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<div className="modelsw__menu jobsmenu" role="listbox">
|
||||
<div className="jobsmenu__head">{t("status.jobsTitle")}</div>
|
||||
{jobs.map((j) => (
|
||||
<div className="jobsmenu__item" key={j.id} role="option">
|
||||
<span className="jobsmenu__id">{j.id}</span>
|
||||
<span className="jobsmenu__label">{j.label || j.kind}</span>
|
||||
<span className="jobsmenu__status">{j.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRate(hit: number, denom: number): string | null {
|
||||
if (denom <= 0) return null;
|
||||
return ((hit / denom) * 100).toFixed(2);
|
||||
@@ -78,6 +32,15 @@ function avgRate(u?: WireUsage): string | null {
|
||||
return formatRate(u.sessionCacheHitTokens, denom);
|
||||
}
|
||||
|
||||
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
|
||||
// cache tokens (loaded from persisted telemetry on session resume). Used as a
|
||||
// fallback when no live WireUsage is available yet.
|
||||
function contextAvgRate(ctx: ContextInfo): string | null {
|
||||
const hit = ctx.cacheHitTokens ?? 0;
|
||||
const miss = ctx.cacheMissTokens ?? 0;
|
||||
return formatRate(hit, hit + miss);
|
||||
}
|
||||
|
||||
function rateValueClass(rate: string | null): string {
|
||||
if (rate === null) return "stat__value--empty";
|
||||
const pct = Number.parseFloat(rate);
|
||||
@@ -134,10 +97,7 @@ export function StatusBar({
|
||||
context,
|
||||
usage,
|
||||
balance,
|
||||
jobs,
|
||||
running,
|
||||
collaborationMode,
|
||||
toolApprovalMode,
|
||||
sessionTurns,
|
||||
sessionTokens,
|
||||
turnTokens,
|
||||
@@ -154,10 +114,7 @@ export function StatusBar({
|
||||
context: ContextInfo;
|
||||
usage?: WireUsage;
|
||||
balance?: BalanceInfo;
|
||||
jobs?: JobView[];
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
sessionTurns?: number;
|
||||
sessionTokens?: number;
|
||||
turnTokens?: number;
|
||||
@@ -177,8 +134,7 @@ export function StatusBar({
|
||||
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
|
||||
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
|
||||
const nowPct = nowRate(usage);
|
||||
const avgPct = avgRate(usage);
|
||||
const jobsList = jobs ?? [];
|
||||
const avgPct = avgRate(usage) ?? contextAvgRate(context);
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
|
||||
const costLabel = formatMoneyLocalized(cost, currency, { locale });
|
||||
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
|
||||
@@ -189,8 +145,6 @@ export function StatusBar({
|
||||
const tokenLabel = formatTokenCount(sessionTokens);
|
||||
const turnTokenLabel = formatTokenCount(turnTokens);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const planMode = collaborationMode === "plan";
|
||||
const goalMode = collaborationMode === "goal";
|
||||
const metricLabelStyle = labelStyle === "text" ? "text" : "icon";
|
||||
const visibleItems = normalizeStatusBarItems(items);
|
||||
const itemRenderers: Record<StatusBarItemId, ReactNode> = {
|
||||
@@ -309,21 +263,6 @@ export function StatusBar({
|
||||
const renderedItems = visibleItems
|
||||
.map((id) => ({ id, node: itemRenderers[id] }))
|
||||
.filter(({ node }) => node !== null && node !== undefined && node !== false);
|
||||
const modeIndicators = [
|
||||
planMode ? <span className="statusbar__plan" key="plan">{t("status.plan")}</span> : null,
|
||||
goalMode ? <span className="statusbar__plan" key="goal">{t("composer.goalMode")}</span> : null,
|
||||
toolApprovalMode === "auto" ? (
|
||||
<Tooltip label={t("composer.accessAutoTitle")} key="auto">
|
||||
<span className="statusbar__yolo">{t("composer.accessAuto")}</span>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
toolApprovalMode === "yolo" ? (
|
||||
<Tooltip label={t("status.yoloTitle")} key="yolo">
|
||||
<span className="statusbar__yolo">{t("composer.accessYolo")}</span>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className={`statusbar statusbar--${metricLabelStyle}`}>
|
||||
<div className="statusbar__group statusbar__group--items">
|
||||
@@ -333,12 +272,6 @@ export function StatusBar({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{modeIndicators.length > 0 && <div className="statusbar__group statusbar__group--modes">{modeIndicators}</div>}
|
||||
{jobsList.length > 0 && (
|
||||
<div className="statusbar__group statusbar__group--jobs">
|
||||
<JobsChip jobs={jobsList} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,21 +3,38 @@ import { useT } from "../lib/i18n";
|
||||
import type { Todo } from "../lib/tools";
|
||||
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
|
||||
const STORAGE_KEY = "todoPanel:open";
|
||||
|
||||
function loadOpenState(): boolean {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === null ? true : saved === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function saveOpenState(open: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
}
|
||||
|
||||
// TodoPanel is the live task list pinned just above the composer — the kernel's
|
||||
// latest todo_write call drives it, and it updates in place as the agent flips
|
||||
// items to in_progress / completed. Completed lists collapse automatically so
|
||||
// the user still sees the final state without the footer staying tall forever.
|
||||
// items to in_progress / completed. The collapsed/expanded state is persisted
|
||||
// globally in localStorage so the user's choice sticks across sessions.
|
||||
export function TodoPanel({
|
||||
todoKey,
|
||||
todos,
|
||||
onDismiss,
|
||||
}: {
|
||||
todoKey: string;
|
||||
todos: Todo[];
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [open, setOpen] = useState(loadOpenState);
|
||||
const currentRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const done = todos.filter((t) => t.status === "completed").length;
|
||||
@@ -25,10 +42,6 @@ export function TodoPanel({
|
||||
const allDone = todos.length > 0 && done === todos.length;
|
||||
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(!allDone);
|
||||
}, [todoKey, allDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
currentRef.current?.scrollIntoView({ block: "nearest" });
|
||||
@@ -45,7 +58,7 @@ export function TodoPanel({
|
||||
role="region"
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={() => setOpen((value) => !value)}>
|
||||
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
|
||||
{open ? t("common.collapse") : t("common.expand")}
|
||||
</PromptHeaderAction>
|
||||
{allDone && (
|
||||
|
||||
@@ -41,7 +41,7 @@ function splitPreview(text: string, n: number): { preview: string; total: number
|
||||
// ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
|
||||
// `task` card (their ParentID points at this call); they render inline, live, so
|
||||
// the sub-agent's work is visible as it happens.
|
||||
export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string }) {
|
||||
export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayName }: { item: ToolItem; subcalls?: ToolItem[]; tabId?: string; displayName?: string }) {
|
||||
const t = useT();
|
||||
const nested = subcalls ?? [];
|
||||
const hasNested = nested.length > 0;
|
||||
@@ -131,7 +131,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item
|
||||
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
|
||||
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
|
||||
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
|
||||
<span className="tool__name">{item.name}</span>
|
||||
<span className="tool__name">{displayName ?? item.name}</span>
|
||||
{subject && <span className="tool__subject">{subject}</span>}
|
||||
</span>
|
||||
{profileText && <span className="tool__profile">{profileText}</span>}
|
||||
@@ -142,6 +142,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId }: { item
|
||||
<ChevronRight size={12} />
|
||||
</span>
|
||||
)}
|
||||
{item.status !== "running" && (
|
||||
<span
|
||||
className={`tool__dot${item.status === "done" ? " tool__dot--ok" : ""}${item.status === "error" ? " tool__dot--err" : ""}${item.status === "stopped" ? " tool__dot--stopped" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div ref={toolBodyRef} className="tool__body">
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import type { Item } from "../lib/useController";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
export type ToolGroupKind = "explore" | "modify" | "delegate" | "shell";
|
||||
|
||||
const SHELL_TOOLS = new Set(["bash", "bash_output", "wait", "waitJob", "kill_shell"]);
|
||||
const EXPLORE_TOOLS = new Set(["read_file", "ls", "grep", "glob", "web_fetch", "code_index", "read_skill", "connect_tool_source"]);
|
||||
const MODIFY_TOOLS = new Set(["write_file", "edit_file", "multi_edit", "move_file", "delete_range", "delete_symbol", "notebook_edit"]);
|
||||
const DELEGATE_TOOLS = new Set(["task", "run_skill", "explore", "research", "review", "security_review"]);
|
||||
|
||||
export function toolGroupKind(item: ToolItem): ToolGroupKind | null {
|
||||
if (item.parentId || item.name === "todo_write" || item.name === "exit_plan_mode") return null;
|
||||
if (SHELL_TOOLS.has(item.name)) return "shell";
|
||||
if (EXPLORE_TOOLS.has(item.name)) return "explore";
|
||||
if (MODIFY_TOOLS.has(item.name)) return "modify";
|
||||
if (DELEGATE_TOOLS.has(item.name)) return "delegate";
|
||||
return item.readOnly ? "explore" : "modify";
|
||||
}
|
||||
|
||||
export function isCreationGroupableTool(item: ToolItem): boolean {
|
||||
return item.status !== "running" && toolGroupKind(item) !== null;
|
||||
}
|
||||
|
||||
function count(items: ToolItem[], names: readonly string[]): number {
|
||||
return items.filter((item) => names.includes(item.name)).length;
|
||||
}
|
||||
|
||||
function titleFor(kind: ToolGroupKind, t: ReturnType<typeof useT>): string {
|
||||
switch (kind) {
|
||||
case "explore": return t("creation.toolGroup.explore");
|
||||
case "modify": return t("creation.toolGroup.modify");
|
||||
case "delegate": return t("creation.toolGroup.delegate");
|
||||
case "shell": return t("creation.toolGroup.shell");
|
||||
}
|
||||
}
|
||||
|
||||
function groupSummary(kind: ToolGroupKind, items: ToolItem[], t: ReturnType<typeof useT>): string {
|
||||
const parts: string[] = [];
|
||||
if (kind === "explore") {
|
||||
const readCount = count(items, ["read_file", "ls", "web_fetch", "read_skill"]);
|
||||
const searchCount = count(items, ["grep", "glob", "code_index"]);
|
||||
const otherCount = items.length - readCount - searchCount;
|
||||
if (readCount > 0) parts.push(t("creation.toolStat.read", { n: readCount }));
|
||||
if (searchCount > 0) parts.push(t("creation.toolStat.search", { n: searchCount }));
|
||||
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
|
||||
} else if (kind === "modify") {
|
||||
const writeCount = count(items, ["write_file"]);
|
||||
const editCount = count(items, ["edit_file", "multi_edit", "notebook_edit"]);
|
||||
const moveCount = count(items, ["move_file"]);
|
||||
const deleteCount = count(items, ["delete_range", "delete_symbol"]);
|
||||
const otherCount = items.length - writeCount - editCount - moveCount - deleteCount;
|
||||
if (writeCount > 0) parts.push(t("creation.toolStat.write", { n: writeCount }));
|
||||
if (editCount > 0) parts.push(t("creation.toolStat.edit", { n: editCount }));
|
||||
if (moveCount > 0) parts.push(t("creation.toolStat.move", { n: moveCount }));
|
||||
if (deleteCount > 0) parts.push(t("creation.toolStat.delete", { n: deleteCount }));
|
||||
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
|
||||
} else if (kind === "delegate") {
|
||||
const taskCount = count(items, ["task", "run_skill", "explore", "research", "review", "security_review"]);
|
||||
const otherCount = items.length - taskCount;
|
||||
if (taskCount > 0) parts.push(t("creation.toolStat.task", { n: taskCount }));
|
||||
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
|
||||
} else {
|
||||
const commandCount = count(items, ["bash"]);
|
||||
const checkCount = count(items, ["bash_output", "wait", "waitJob"]);
|
||||
const stopCount = count(items, ["kill_shell"]);
|
||||
const otherCount = items.length - commandCount - checkCount - stopCount;
|
||||
if (commandCount > 0) parts.push(t("creation.toolStat.command", { n: commandCount }));
|
||||
if (checkCount > 0) parts.push(t("creation.toolStat.check", { n: checkCount }));
|
||||
if (stopCount > 0) parts.push(t("creation.toolStat.stop", { n: stopCount }));
|
||||
if (otherCount > 0) parts.push(t("creation.toolStat.other", { n: otherCount }));
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function titleCaseName(name: string): string {
|
||||
return name
|
||||
.replace(/[-_]+/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function toolDisplayName(name: string): string {
|
||||
switch (name) {
|
||||
case "read_file": return "Read";
|
||||
case "ls": return "List";
|
||||
case "web_fetch": return "Web Fetch";
|
||||
case "code_index": return "Code Index";
|
||||
case "write_file": return "Write";
|
||||
case "edit_file": return "Edit";
|
||||
case "multi_edit": return "Multi Edit";
|
||||
case "move_file": return "Move";
|
||||
case "bash": return "Shell";
|
||||
case "bash_output": return "Shell Output";
|
||||
case "kill_shell": return "Kill Shell";
|
||||
case "wait":
|
||||
case "waitJob": return "Wait";
|
||||
default: return titleCaseName(name);
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolGroup = memo(function ToolGroup({
|
||||
kind,
|
||||
items,
|
||||
subcalls,
|
||||
tabId,
|
||||
}: {
|
||||
kind: ToolGroupKind;
|
||||
items: ToolItem[];
|
||||
subcalls: ReadonlyMap<string, ToolItem[]>;
|
||||
tabId?: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useGSAPCollapse(bodyRef, open);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`tool-group tool-group--${kind}${open ? " tool-group--open" : ""}`} data-kind={kind} data-entrance={items[0]?.id}>
|
||||
<button type="button" className="tool-group__head" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
|
||||
<span className="tool-group__title">{titleFor(kind, t)}</span>
|
||||
<span className="tool-group__summary">{groupSummary(kind, items, t)}</span>
|
||||
<ChevronRight className={`tool-group__chevron${open ? " tool-group__chevron--open" : ""}`} size={12} />
|
||||
</button>
|
||||
<div ref={bodyRef} className="tool-group__body">
|
||||
{items.map((item) => (
|
||||
<ToolCard key={item.id} item={item} subcalls={subcalls.get(item.id)} tabId={tabId} displayName={toolDisplayName(item.name)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -5,15 +5,16 @@ import { useT } from "../lib/i18n";
|
||||
import { AssistantMessage, TurnActions, UserMessage } from "./Message";
|
||||
import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ArrowDown, ChevronRight } from "lucide-react";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { ReadOnlyBatch } from "./ReadOnlyBatch";
|
||||
import { ToolGroup, isCreationGroupableTool, toolGroupKind, type ToolGroupKind } from "./ToolGroup";
|
||||
import { getDisplayMode, onDisplayModeChange, type DisplayMode } from "../lib/displayMode";
|
||||
import { isReadOnlyTool } from "../lib/useController";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useScrollManager } from "../lib/useScrollManager";
|
||||
import { buildTurnGroups, compactQuestionText, questionAnchorId, scrollVersion, warmUserPreview, type QuestionAnchor, type TurnGroup } from "../lib/transcriptGrouping";
|
||||
import { buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
@@ -27,11 +28,13 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
|
||||
defaultExpanded = false,
|
||||
expandWhileStreaming = true,
|
||||
truncateStreamingReasoning = false,
|
||||
creationMode = false,
|
||||
}: {
|
||||
item: AssistantItem;
|
||||
defaultExpanded?: boolean;
|
||||
expandWhileStreaming?: boolean;
|
||||
truncateStreamingReasoning?: boolean;
|
||||
creationMode?: boolean;
|
||||
}) {
|
||||
const live = useContext(LiveStreamContext);
|
||||
const shown = useMemo(
|
||||
@@ -47,6 +50,7 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
|
||||
defaultExpanded={defaultExpanded}
|
||||
expandWhileStreaming={expandWhileStreaming}
|
||||
truncateStreamingReasoning={truncateStreamingReasoning}
|
||||
creationMode={creationMode}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -86,8 +90,15 @@ export function Transcript({
|
||||
running = false,
|
||||
questionNavigator = true,
|
||||
welcomeVariant = "default",
|
||||
creationMode = false,
|
||||
actionHoverMenus = false,
|
||||
rewindSignal = 0,
|
||||
revealSignal = 0,
|
||||
hydrating = false,
|
||||
hasOlderHistory = false,
|
||||
olderHistoryCount = 0,
|
||||
loadingOlderHistory = false,
|
||||
onLoadOlderHistory,
|
||||
}: {
|
||||
items: Item[];
|
||||
live?: LiveStream;
|
||||
@@ -102,22 +113,35 @@ export function Transcript({
|
||||
running?: boolean;
|
||||
questionNavigator?: boolean;
|
||||
welcomeVariant?: "default" | "creation";
|
||||
creationMode?: boolean;
|
||||
actionHoverMenus?: boolean;
|
||||
rewindSignal?: number;
|
||||
revealSignal?: number;
|
||||
hydrating?: boolean;
|
||||
hasOlderHistory?: boolean;
|
||||
olderHistoryCount?: number;
|
||||
loadingOlderHistory?: boolean;
|
||||
onLoadOlderHistory?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const {
|
||||
scrollRef,
|
||||
stick,
|
||||
onScroll,
|
||||
isAtBottom,
|
||||
smoothScrollTo,
|
||||
scrollToBottomAfterLayout,
|
||||
trackQuestions,
|
||||
repinIfWasPinned,
|
||||
scheduleRepinIfWasPinned,
|
||||
resizeFrame,
|
||||
lastClientHeight,
|
||||
lastFooterHeight,
|
||||
} = useScrollManager();
|
||||
const autoScrollFrame = useRef<number | null>(null);
|
||||
const pendingRevealBottomScroll = useRef(false);
|
||||
const pendingQuestionJump = useRef<QuestionAnchor | null>(null);
|
||||
const sessionKey = useMemo(() => `${items[0]?.id ?? ""}|${items[items.length - 1]?.id ?? ""}`, [items]);
|
||||
const warmLayerSessionKey = useMemo(() => `${tabId ?? ""}|${revealSignal}|${items[0]?.id ?? ""}`, [items, revealSignal, tabId]);
|
||||
const entranceRef = useEntranceAnimation<HTMLDivElement>(sessionKey, items.length);
|
||||
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>(() => getDisplayMode());
|
||||
@@ -128,7 +152,7 @@ export function Transcript({
|
||||
let turn = 0;
|
||||
for (const it of items) {
|
||||
if (it.kind !== "user") continue;
|
||||
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
|
||||
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn, checkpointTurn: it.checkpointTurn });
|
||||
turn += 1;
|
||||
}
|
||||
return anchors;
|
||||
@@ -144,7 +168,17 @@ export function Transcript({
|
||||
// disables auto-scroll when the user had scrolled up in the old tab (#4584).
|
||||
useEffect(() => {
|
||||
stick.current = true;
|
||||
}, [tabId]);
|
||||
pendingRevealBottomScroll.current = true;
|
||||
}, [tabId, revealSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRevealBottomScroll.current || items.length === 0) return;
|
||||
pendingRevealBottomScroll.current = false;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
scrollToBottomAfterLayout(5);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [items.length, revealSignal, scrollToBottomAfterLayout, tabId]);
|
||||
|
||||
// Auto-scroll to bottom during streaming. Coalesce fast token/reasoning
|
||||
// updates into one layout read/write per animation frame.
|
||||
@@ -174,11 +208,12 @@ export function Transcript({
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
lastClientHeight.current = el.clientHeight;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const previous = lastClientHeight.current ?? el.clientHeight;
|
||||
lastClientHeight.current = el.clientHeight;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const height = entries[0]?.contentRect.height ?? el.clientHeight;
|
||||
const previous = lastClientHeight.current ?? height;
|
||||
lastClientHeight.current = height;
|
||||
if (items.length === 0) return;
|
||||
repinIfWasPinned(el.clientHeight - previous);
|
||||
scheduleRepinIfWasPinned(height - previous);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
@@ -188,7 +223,7 @@ export function Transcript({
|
||||
resizeFrame.current = null;
|
||||
}
|
||||
};
|
||||
}, [items.length]);
|
||||
}, [items.length, scheduleRepinIfWasPinned]);
|
||||
|
||||
// Footer height changes → smooth scroll repin with GSAP.
|
||||
useEffect(() => {
|
||||
@@ -197,8 +232,8 @@ export function Transcript({
|
||||
const previous = lastFooterHeight.current ?? footerHeight;
|
||||
lastFooterHeight.current = footerHeight;
|
||||
if (items.length === 0) return;
|
||||
repinIfWasPinned(previous - footerHeight);
|
||||
}, [footerHeight, items.length]);
|
||||
scheduleRepinIfWasPinned(previous - footerHeight);
|
||||
}, [footerHeight, items.length, scheduleRepinIfWasPinned]);
|
||||
|
||||
// After a non-fork rewind, scroll to the last user message (the
|
||||
// rewound-to point) so the user knows where they are.
|
||||
@@ -227,8 +262,12 @@ export function Transcript({
|
||||
}, [items]);
|
||||
|
||||
// ── Layer state ────────────────────────────────────────────────────────────
|
||||
const [expandedWarmTurns, setExpandedWarmTurns] = useState<Set<number>>(new Set());
|
||||
const [coldPage, setColdPage] = useState(0);
|
||||
const [warmLayerState, setWarmLayerState] = useState<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey));
|
||||
const defaultWarmLayerState = useMemo<WarmLayerState>(() => createWarmLayerState(warmLayerSessionKey), [warmLayerSessionKey]);
|
||||
const activeWarmLayerState = warmLayerState.sessionKey === warmLayerSessionKey
|
||||
? warmLayerState
|
||||
: defaultWarmLayerState;
|
||||
const { expandedWarmTurns, coldPage } = activeWarmLayerState;
|
||||
|
||||
// Compute turn groups from the structural item list. Streaming text updates
|
||||
// keep the same items[] reference, so this stays out of the token hot path.
|
||||
@@ -247,9 +286,20 @@ export function Transcript({
|
||||
}, [items]);
|
||||
|
||||
// How many turns are in the cold zone (not yet shown).
|
||||
const warmTurnCount = turnGroups.length - Math.min(turnGroups.length, HOT_TURNS);
|
||||
const shownWarmStart = Math.max(0, warmTurnCount - coldPage * WARM_PAGE_SIZE);
|
||||
const coldTurnCount = shownWarmStart;
|
||||
const { warmStartTurn, warmEndTurn, coldTurnCount } = useMemo(
|
||||
() => warmPagination({ turnCount: turnGroups.length, hotTurns: HOT_TURNS, pageSize: WARM_PAGE_SIZE, coldPage }),
|
||||
[coldPage, turnGroups.length],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const question = pendingQuestionJump.current;
|
||||
if (!question) return;
|
||||
const node = document.getElementById(questionAnchorId(question.id));
|
||||
if (!node) return;
|
||||
pendingQuestionJump.current = null;
|
||||
stick.current = false;
|
||||
smoothScrollTo(node, 12);
|
||||
}, [expandedWarmTurns, smoothScrollTo, stick, warmStartTurn]);
|
||||
|
||||
// ── The turn action menu ──────────────────────────────────────────────────
|
||||
const [openAction, setOpenAction] = useState<OpenTurnAction | null>(null);
|
||||
@@ -263,28 +313,36 @@ export function Transcript({
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [openAction]);
|
||||
|
||||
const userTurn = useMemo(() => new Map(questions.map((question) => [question.id, question.turn])), [questions]);
|
||||
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
|
||||
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
|
||||
|
||||
// ── JumpBar integration ───────────────────────────────────────────────────
|
||||
const jumpToQuestion = (question: QuestionAnchor) => {
|
||||
const node = document.getElementById(questionAnchorId(question.id));
|
||||
if (!node) return;
|
||||
pendingQuestionJump.current = null;
|
||||
stick.current = false;
|
||||
smoothScrollTo(node, 12);
|
||||
};
|
||||
|
||||
const handleJumpToQuestion = useCallback((question: QuestionAnchor) => {
|
||||
pendingQuestionJump.current = question;
|
||||
// Auto-expand the warm turn when jumping to an old question.
|
||||
const warmTurnStart = turnGroups.length - HOT_TURNS;
|
||||
if (question.turn < warmTurnStart) {
|
||||
setExpandedWarmTurns((prev) => {
|
||||
if (prev.has(question.turn)) return prev;
|
||||
return new Set([...prev, question.turn]);
|
||||
const neededColdPage = warmColdPageForTurn({
|
||||
turn: question.turn,
|
||||
turnCount: turnGroups.length,
|
||||
hotTurns: HOT_TURNS,
|
||||
pageSize: WARM_PAGE_SIZE,
|
||||
});
|
||||
setWarmLayerState((prev) => {
|
||||
const paged = warmLayerWithColdPageAtLeast(prev, warmLayerSessionKey, neededColdPage);
|
||||
return warmLayerWithExpandedTurn(paged, warmLayerSessionKey, question.turn, true);
|
||||
});
|
||||
}
|
||||
jumpToQuestion(question);
|
||||
}, [turnGroups.length]);
|
||||
}, [turnGroups.length, warmLayerSessionKey]);
|
||||
|
||||
// ── Hot zone: fully rendered from hotStartIdx to end ─────────────────────
|
||||
// Memoized separately from the assembly so streaming tokens don't rebuild
|
||||
@@ -389,6 +447,7 @@ export function Transcript({
|
||||
mode={displayMode}
|
||||
subcalls={subcallsByParent}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
/>,
|
||||
);
|
||||
collapseBatch = [];
|
||||
@@ -444,6 +503,7 @@ export function Transcript({
|
||||
mode={displayMode}
|
||||
subcalls={subcallsByParent}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
/>,
|
||||
);
|
||||
} else if (nonAssistantItems.length > 0) {
|
||||
@@ -466,6 +526,7 @@ export function Transcript({
|
||||
defaultExpanded={false}
|
||||
expandWhileStreaming={false}
|
||||
truncateStreamingReasoning={true}
|
||||
creationMode={creationMode}
|
||||
/>,
|
||||
);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
@@ -479,14 +540,32 @@ export function Transcript({
|
||||
} else {
|
||||
// Standard mode: flat rendering
|
||||
const roBatch: ToolItem[] = [];
|
||||
const toolBatch: ToolItem[] = [];
|
||||
let toolBatchKind: ToolGroupKind | null = null;
|
||||
const flushRO = () => {
|
||||
if (roBatch.length === 0) return;
|
||||
out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcallsByParent} tabId={tabId} />);
|
||||
roBatch.length = 0;
|
||||
};
|
||||
const flushToolBatch = () => {
|
||||
if (!toolBatchKind || toolBatch.length === 0) return;
|
||||
out.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcallsByParent} tabId={tabId} />);
|
||||
toolBatch.length = 0;
|
||||
toolBatchKind = null;
|
||||
};
|
||||
for (let i = hotStartIdx; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
|
||||
const kind = toolGroupKind(it as ToolItem);
|
||||
if (kind) {
|
||||
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
|
||||
toolBatchKind = kind;
|
||||
toolBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!creationMode &&
|
||||
it.kind === "tool" &&
|
||||
!it.parentId &&
|
||||
it.status !== "running" &&
|
||||
@@ -497,6 +576,7 @@ export function Transcript({
|
||||
roBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
switch (it.kind) {
|
||||
case "user": {
|
||||
@@ -521,7 +601,7 @@ export function Transcript({
|
||||
break;
|
||||
}
|
||||
case "assistant":
|
||||
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} />);
|
||||
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionReady = true;
|
||||
@@ -538,62 +618,85 @@ export function Transcript({
|
||||
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
if (!running) pushTurnActions();
|
||||
}
|
||||
return out;
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus]);
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
|
||||
|
||||
// ── Assemble rendered output ──────────────────────────────────────────────
|
||||
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
|
||||
// don't rebuild it. The hot zone uses LiveAssistantMessage (reads live from
|
||||
// LiveStreamContext) so streaming updates are captured immediately.
|
||||
return (
|
||||
<div
|
||||
className={`transcript${empty ? " transcript--empty" : ""}`}
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{empty && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
|
||||
<div className="transcript-shell">
|
||||
<div
|
||||
className={`transcript${empty ? " transcript--empty" : ""}`}
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{empty && !hydrating && <Welcome onPrompt={onPrompt} variant={welcomeVariant} />}
|
||||
|
||||
<LiveStreamContext.Provider value={live}>
|
||||
{hasOlderHistory && (
|
||||
<button
|
||||
type="button"
|
||||
className="warm-collapse"
|
||||
onClick={onLoadOlderHistory}
|
||||
disabled={loadingOlderHistory}
|
||||
>
|
||||
{loadingOlderHistory ? t("common.loading") : t("transcript.showEarlierHistory", { n: olderHistoryCount })}
|
||||
</button>
|
||||
)}
|
||||
{turnGroups.length > HOT_TURNS && (
|
||||
<WarmZone
|
||||
turnGroups={turnGroups}
|
||||
expandedWarmTurns={expandedWarmTurns}
|
||||
warmStartTurn={warmStartTurn}
|
||||
warmEndTurn={warmEndTurn}
|
||||
coldTurnCount={coldTurnCount}
|
||||
scrollRef={scrollRef}
|
||||
warmItems={items}
|
||||
warmSubcalls={subcallsByParent}
|
||||
warmUserTurn={userTurn}
|
||||
warmCheckpoints={checkpointsByTurn}
|
||||
warmOpenAction={openAction}
|
||||
warmActionPending={actionPending}
|
||||
warmRewindDisabled={rewindDisabled}
|
||||
warmActionHoverMenus={actionHoverMenus}
|
||||
warmOnRewind={onRewind}
|
||||
warmSetOpenAction={setOpenAction}
|
||||
warmOnEdit={onEditPrompt}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
onToggleColdPage={() => setWarmLayerState((prev) => warmLayerWithNextColdPage(prev, warmLayerSessionKey))}
|
||||
onToggleWarmTurn={(g, expand) => {
|
||||
setWarmLayerState((prev) => warmLayerWithExpandedTurn(prev, warmLayerSessionKey, g, expand));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={entranceRef}>
|
||||
{hotZoneNodes}
|
||||
</div>
|
||||
</LiveStreamContext.Provider>
|
||||
</div>
|
||||
|
||||
{!empty && showQuestionNav && (
|
||||
<QuestionJumpBar questions={questions} onJump={handleJumpToQuestion} />
|
||||
)}
|
||||
|
||||
<LiveStreamContext.Provider value={live}>
|
||||
{turnGroups.length > HOT_TURNS && (
|
||||
<WarmZone
|
||||
turnGroups={turnGroups}
|
||||
expandedWarmTurns={expandedWarmTurns}
|
||||
shownWarmStart={shownWarmStart}
|
||||
coldTurnCount={coldTurnCount}
|
||||
scrollRef={scrollRef}
|
||||
warmItems={items}
|
||||
warmSubcalls={subcallsByParent}
|
||||
warmUserTurn={userTurn}
|
||||
warmCheckpoints={checkpointsByTurn}
|
||||
warmOpenAction={openAction}
|
||||
warmActionPending={actionPending}
|
||||
warmRewindDisabled={rewindDisabled}
|
||||
warmActionHoverMenus={actionHoverMenus}
|
||||
warmOnRewind={onRewind}
|
||||
warmSetOpenAction={setOpenAction}
|
||||
warmOnEdit={onEditPrompt}
|
||||
tabId={tabId}
|
||||
onToggleColdPage={() => setColdPage((p) => p + 1)}
|
||||
onToggleWarmTurn={(g, expand) => {
|
||||
setExpandedWarmTurns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expand) next.add(g); else next.delete(g);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={entranceRef}>
|
||||
{hotZoneNodes}
|
||||
</div>
|
||||
</LiveStreamContext.Provider>
|
||||
{!empty && !isAtBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className="transcript__jump-bottom"
|
||||
onClick={() => scrollToBottomAfterLayout(2)}
|
||||
aria-label={t("transcript.jumpToBottom")}
|
||||
title={t("transcript.jumpToBottom")}
|
||||
>
|
||||
<ArrowDown size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -605,7 +708,8 @@ export function Transcript({
|
||||
const WarmZone = memo(function WarmZone({
|
||||
turnGroups,
|
||||
expandedWarmTurns,
|
||||
shownWarmStart,
|
||||
warmStartTurn,
|
||||
warmEndTurn,
|
||||
coldTurnCount,
|
||||
scrollRef,
|
||||
warmItems,
|
||||
@@ -620,12 +724,14 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSetOpenAction,
|
||||
warmOnEdit,
|
||||
tabId,
|
||||
creationMode,
|
||||
onToggleColdPage,
|
||||
onToggleWarmTurn,
|
||||
}: {
|
||||
turnGroups: TurnGroup[];
|
||||
expandedWarmTurns: ReadonlySet<number>;
|
||||
shownWarmStart: number;
|
||||
warmStartTurn: number;
|
||||
warmEndTurn: number;
|
||||
coldTurnCount: number;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
warmItems: readonly Item[];
|
||||
@@ -640,6 +746,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSetOpenAction: (action: OpenTurnAction | null) => void;
|
||||
warmOnEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
|
||||
tabId?: string;
|
||||
creationMode?: boolean;
|
||||
onToggleColdPage: () => void;
|
||||
onToggleWarmTurn: (g: number, expand: boolean) => void;
|
||||
}) {
|
||||
@@ -661,10 +768,8 @@ const WarmZone = memo(function WarmZone({
|
||||
}
|
||||
|
||||
// 2. Warm zone: collapsed/expanded warm turn cards.
|
||||
let warmStartTurn = 0;
|
||||
if (turnGroups.length > HOT_TURNS) {
|
||||
warmStartTurn = turnGroups.length - HOT_TURNS - shownWarmStart;
|
||||
for (let g = warmStartTurn; g < turnGroups.length - HOT_TURNS; g++) {
|
||||
for (let g = warmStartTurn; g < warmEndTurn; g++) {
|
||||
const group = turnGroups[g];
|
||||
if (!group) continue;
|
||||
const expanded = expandedWarmTurns.has(g);
|
||||
@@ -697,6 +802,7 @@ const WarmZone = memo(function WarmZone({
|
||||
setOpenAction={warmSetOpenAction}
|
||||
onEdit={warmOnEdit}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
/>
|
||||
</WarmTurnCard>,
|
||||
);
|
||||
@@ -743,6 +849,7 @@ function WarmTurnItems({
|
||||
setOpenAction,
|
||||
onEdit,
|
||||
tabId,
|
||||
creationMode = false,
|
||||
}: {
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
@@ -758,6 +865,7 @@ function WarmTurnItems({
|
||||
setOpenAction: (action: OpenTurnAction | null) => void;
|
||||
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
|
||||
tabId?: string;
|
||||
creationMode?: boolean;
|
||||
}) {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let actionText = "";
|
||||
@@ -790,20 +898,38 @@ function WarmTurnItems({
|
||||
|
||||
// Group consecutive completed read-only tools into ReadOnlyBatch
|
||||
const roBatch: ToolItem[] = [];
|
||||
const toolBatch: ToolItem[] = [];
|
||||
let toolBatchKind: ToolGroupKind | null = null;
|
||||
const flushRO = () => {
|
||||
if (roBatch.length === 0) return;
|
||||
nodes.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcalls} tabId={tabId} />);
|
||||
roBatch.length = 0;
|
||||
};
|
||||
const flushToolBatch = () => {
|
||||
if (!toolBatchKind || toolBatch.length === 0) return;
|
||||
nodes.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcalls} tabId={tabId} />);
|
||||
toolBatch.length = 0;
|
||||
toolBatchKind = null;
|
||||
};
|
||||
|
||||
for (let i = startIdx; i < endIdx && i < items.length; i++) {
|
||||
const it = items[i];
|
||||
|
||||
// Completed read-only tools → batch into ReadOnlyBatch
|
||||
if (it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && isReadOnlyTool(it.name)) {
|
||||
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
|
||||
const kind = toolGroupKind(it as ToolItem);
|
||||
if (kind) {
|
||||
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
|
||||
toolBatchKind = kind;
|
||||
toolBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && isReadOnlyTool(it.name)) {
|
||||
roBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
|
||||
switch (it.kind) {
|
||||
@@ -828,7 +954,7 @@ function WarmTurnItems({
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} />);
|
||||
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionReady = true;
|
||||
@@ -847,6 +973,7 @@ function WarmTurnItems({
|
||||
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
pushTurnActions();
|
||||
return nodes;
|
||||
@@ -918,9 +1045,10 @@ type TurnCollapseProps = {
|
||||
mode: DisplayMode;
|
||||
subcalls: Map<string, ToolItem[]>;
|
||||
tabId?: string;
|
||||
creationMode?: boolean;
|
||||
};
|
||||
|
||||
function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapseProps) {
|
||||
function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode = false }: TurnCollapseProps) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -960,16 +1088,34 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapse
|
||||
// Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch
|
||||
const body: ReactNode[] = [];
|
||||
const roBatch: ToolItem[] = [];
|
||||
const toolBatch: ToolItem[] = [];
|
||||
let toolBatchKind: ToolGroupKind | null = null;
|
||||
const flushRO = () => {
|
||||
if (roBatch.length === 0) return;
|
||||
body.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcalls} tabId={tabId} />);
|
||||
roBatch.length = 0;
|
||||
};
|
||||
const flushToolBatch = () => {
|
||||
if (!toolBatchKind || toolBatch.length === 0) return;
|
||||
body.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcalls} tabId={tabId} />);
|
||||
toolBatch.length = 0;
|
||||
toolBatchKind = null;
|
||||
};
|
||||
for (const it of displayItems) {
|
||||
if (it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) {
|
||||
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
|
||||
const kind = toolGroupKind(it as ToolItem);
|
||||
if (kind) {
|
||||
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
|
||||
toolBatchKind = kind;
|
||||
toolBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) {
|
||||
roBatch.push(it as ToolItem);
|
||||
continue;
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
switch (it.kind) {
|
||||
case "tool":
|
||||
@@ -981,11 +1127,12 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId }: TurnCollapse
|
||||
case "phase": body.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "assistant": {
|
||||
const displayItem = it;
|
||||
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} />);
|
||||
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} creationMode={creationMode} />);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
flushToolBatch();
|
||||
flushRO();
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,8 +26,20 @@ import {
|
||||
} from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangeView } from "../lib/types";
|
||||
import {
|
||||
clampWorkspaceSplitTreeWidth,
|
||||
initialWorkspaceSplitTreeWidth,
|
||||
resolveWorkspaceSplitTreeWidth,
|
||||
shouldInitializeWorkspaceSplitOnFileSelect,
|
||||
type WorkspaceSplitTreeWidthMode,
|
||||
workspaceSplitCanFit,
|
||||
workspaceSplitTreeWidthFromPointer,
|
||||
} from "../lib/workspaceSplit";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { closeWorkspacePreviewTab } from "../lib/workspacePreviewTabs";
|
||||
import { shouldScrollWorkspaceTreeSelection } from "../lib/workspaceTreeReveal";
|
||||
import { mergeWorkspaceSearchResults } from "../lib/workspaceTreeSearch";
|
||||
import type { DirEntry, FilePreview, GitCommitView, GitCommitDetailView, WorkspaceChangesView } from "../lib/types";
|
||||
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
|
||||
import { cleanGitDiff } from "../lib/diff";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
@@ -37,12 +49,11 @@ import { Markdown } from "./Markdown";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 300;
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 140;
|
||||
const WORKSPACE_TREE_DEFAULT_WIDTH = 300;
|
||||
const WORKSPACE_TREE_MAX_WIDTH = 340;
|
||||
const WORKSPACE_PREVIEW_MIN_WIDTH = 360;
|
||||
const WORKSPACE_TREE_RAIL_WIDTH = 44;
|
||||
const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
|
||||
const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
|
||||
const WORKSPACE_DUAL_PANEL_MIN_WIDTH = WORKSPACE_TREE_MIN_WIDTH + WORKSPACE_PREVIEW_MIN_WIDTH;
|
||||
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
|
||||
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
|
||||
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
|
||||
@@ -55,20 +66,13 @@ type WorkspaceChangeListEntry = { key: string; path: string; meta: string; time:
|
||||
type WorkspaceChangeListRequest = { id: number; changes: WorkspaceChangeListEntry[] };
|
||||
|
||||
function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
|
||||
const maxForPanel =
|
||||
typeof panelWidth === "number" && Number.isFinite(panelWidth)
|
||||
? Math.max(WORKSPACE_TREE_MIN_WIDTH, panelWidth - WORKSPACE_PREVIEW_MIN_WIDTH)
|
||||
: WORKSPACE_TREE_MAX_WIDTH;
|
||||
const max = Math.min(WORKSPACE_TREE_MAX_WIDTH, maxForPanel);
|
||||
return Math.min(max, Math.max(WORKSPACE_TREE_MIN_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
function loadWorkspaceTreeWidth(): number {
|
||||
return loadLayoutSize("workspaceTreeWidth", WORKSPACE_TREE_DEFAULT_WIDTH, clampWorkspaceTreeWidth);
|
||||
}
|
||||
|
||||
function saveWorkspaceTreeWidth(width: number): void {
|
||||
saveLayoutSize("workspaceTreeWidth", width);
|
||||
return clampWorkspaceSplitTreeWidth({
|
||||
width,
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
});
|
||||
}
|
||||
|
||||
function entryPath(dir: string, entry: DirEntry): string {
|
||||
@@ -241,7 +245,7 @@ export function WorkspacePanel({
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
|
||||
const [gitHistory, setGitHistory] = useState<GitCommitView[]>([]);
|
||||
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangeView[] | null>(null);
|
||||
const [workspaceChanges, setWorkspaceChanges] = useState<WorkspaceChangesView | null>(null);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<GitCommitDetailView | null>(null);
|
||||
@@ -250,10 +254,12 @@ export function WorkspacePanel({
|
||||
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
|
||||
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
|
||||
const [scopedFilePaths, setScopedFilePaths] = useState<string[] | null>(null);
|
||||
const [scopedChangeRows, setScopedChangeRows] = useState<WorkspaceChangeListEntry[] | null>(null);
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
|
||||
const [treeWidth, setTreeWidth] = useState(WORKSPACE_TREE_DEFAULT_WIDTH);
|
||||
const [treeWidthMode, setTreeWidthMode] = useState<WorkspaceSplitTreeWidthMode>("manual");
|
||||
const [treeResizing, setTreeResizing] = useState(false);
|
||||
const [recentOpen, setRecentOpen] = useState(false);
|
||||
const lastPreviewModeActiveRef = useRef<boolean | null>(null);
|
||||
@@ -271,6 +277,7 @@ export function WorkspacePanel({
|
||||
const commitDetailRequestIdRef = useRef(0);
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
const pendingTreeRevealPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
openDirsRef.current = openDirs;
|
||||
@@ -307,11 +314,16 @@ export function WorkspacePanel({
|
||||
try {
|
||||
const result = await app.WorkspaceChanges(requestTabId);
|
||||
if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) {
|
||||
setWorkspaceChanges(result.files && result.files.length > 0 ? result.files : null);
|
||||
setWorkspaceChanges({
|
||||
files: Array.isArray(result?.files) ? result.files : [],
|
||||
gitAvailable: result?.gitAvailable !== false,
|
||||
gitErr: result?.gitErr,
|
||||
gitBranch: result?.gitBranch,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (workspaceChangesRequestIdRef.current === requestId && lastWorkspaceTabIdRef.current === requestTabId) {
|
||||
setWorkspaceChanges(null);
|
||||
setWorkspaceChanges({ files: [], gitAvailable: false });
|
||||
}
|
||||
}
|
||||
}, [tabId]);
|
||||
@@ -359,7 +371,21 @@ export function WorkspacePanel({
|
||||
|
||||
const selectFile = useCallback(
|
||||
(path: string) => {
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
const initializeSplit = shouldInitializeWorkspaceSplitOnFileSelect({
|
||||
previewVisible: openTabs.length > 0 || selectedPath !== null,
|
||||
treeVisible,
|
||||
});
|
||||
if (initializeSplit) {
|
||||
setTreeWidth(initialWorkspaceSplitTreeWidth({
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: null,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
}));
|
||||
setTreeWidthMode("even");
|
||||
}
|
||||
pendingTreeRevealPathRef.current = path;
|
||||
setSelectedPath(path);
|
||||
setScopedFilePaths((current) => {
|
||||
if (current) dismissedFileListRequestIdRef.current = lastFileListRequestIdRef.current;
|
||||
@@ -377,7 +403,7 @@ export function WorkspacePanel({
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
},
|
||||
[entriesByDir, loadDir, onRequestPanelWidth],
|
||||
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -620,12 +646,13 @@ export function WorkspacePanel({
|
||||
setTreeMenu(null);
|
||||
if (viewMode === "changed") {
|
||||
void loadGitHistory();
|
||||
void loadWorkspaceChanges();
|
||||
return;
|
||||
}
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadGitHistory, loadDir, viewMode]);
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
@@ -679,36 +706,15 @@ export function WorkspacePanel({
|
||||
[entriesByDir, loadDir],
|
||||
);
|
||||
|
||||
const closeTab = (path: string) => {
|
||||
if (lastRevealRequestIdRef.current === revealPathRequest?.id && revealPathRequest.path === path) {
|
||||
dismissedRevealRequestIdRef.current = revealPathRequest.id;
|
||||
}
|
||||
if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id && changeRevealRequest.path === path) {
|
||||
dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
|
||||
}
|
||||
setOpenTabs((tabs) => {
|
||||
const next = tabs.filter((tab) => tab !== path);
|
||||
if (selectedPath === path) {
|
||||
const replacement = next[next.length - 1] ?? null;
|
||||
setSelectedPath(replacement);
|
||||
if (!replacement) {
|
||||
setPreview(null);
|
||||
setTreeVisible(true);
|
||||
}
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setRecentOpen(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
|
||||
const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
|
||||
const sessionChanges = useMemo(
|
||||
() => workspaceChanges?.filter((c) => c.sources.includes("session")) ?? null,
|
||||
() => workspaceChanges?.files.filter((c) => c.sources.includes("session")) ?? null,
|
||||
[workspaceChanges],
|
||||
);
|
||||
const workspaceGitWarning = workspaceChanges && (!workspaceChanges.gitAvailable || workspaceChanges.gitErr?.trim())
|
||||
? t("workspace.gitUnavailable")
|
||||
: null;
|
||||
|
||||
const changedMode = viewMode === "changed";
|
||||
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
|
||||
@@ -720,6 +726,22 @@ export function WorkspacePanel({
|
||||
? scopedChangeRows ? t("context.changedMeta", { count: scopedChangeRows.length }) : shortCwd(cwd) || t("workspace.title")
|
||||
: currentFileDir;
|
||||
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = filter.trim();
|
||||
if (!open || viewMode === "changed" || !q || scopedFilePaths) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
app.SearchFileRefs(q).then((results) => {
|
||||
if (!cancelled) setSearchResults(results);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSearchResults(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [filter, viewMode, scopedFilePaths, open]);
|
||||
|
||||
const flattened = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (scopedFilePaths) {
|
||||
@@ -735,10 +757,10 @@ export function WorkspacePanel({
|
||||
}
|
||||
}
|
||||
if (!q) return null;
|
||||
return rows
|
||||
return mergeWorkspaceSearchResults(rows, searchResults)
|
||||
.filter((row) => row.path.toLowerCase().includes(q))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [entriesByDir, filter, scopedFilePaths]);
|
||||
}, [entriesByDir, filter, scopedFilePaths, searchResults]);
|
||||
|
||||
const treeRows = useMemo<TreeRow[]>(() => {
|
||||
if (flattened) {
|
||||
@@ -784,34 +806,61 @@ export function WorkspacePanel({
|
||||
|
||||
const searchPlaceholder = t(scopedFilePaths ? "workspace.filterReferencedFiles" : changedMode ? "workspace.filterChanges" : "workspace.filter");
|
||||
|
||||
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
|
||||
const filePreviewActive = openTabs.length > 0 || selectedPath !== null;
|
||||
const changeDetailActive = changedMode && expandedCommit !== null;
|
||||
const previewVisible = changedMode || filePreviewActive;
|
||||
const selectedFileVisible = selectedPath !== null;
|
||||
const compactTreeRail =
|
||||
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
|
||||
const actualTreeVisible = changedMode ? false : treeVisible && !compactTreeRail;
|
||||
const showTreeRail = previewVisible && (!actualTreeVisible || compactTreeRail) && !changedMode;
|
||||
const showTreeRail = previewVisible && !changedMode;
|
||||
const splitPanesFit = useMemo(
|
||||
() =>
|
||||
workspaceSplitCanFit({
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
[panelWidth],
|
||||
);
|
||||
const actualTreeVisible = changedMode ? false : treeVisible && (!previewVisible || splitPanesFit);
|
||||
const previewModeActive = open && (filePreviewActive || changeDetailActive);
|
||||
const embeddedDockMode = !showViewTabs;
|
||||
const showFileTools = showViewTabs || filePreviewActive;
|
||||
const effectiveTreeWidth = useMemo(
|
||||
() =>
|
||||
resolveWorkspaceSplitTreeWidth({
|
||||
mode: treeWidthMode,
|
||||
currentTreeWidth: treeWidth,
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
}),
|
||||
[panelWidth, treeWidth, treeWidthMode],
|
||||
);
|
||||
const maxTreeWidthForPanel = useMemo(
|
||||
() => Math.max(WORKSPACE_TREE_MIN_WIDTH, (panelWidth ?? WORKSPACE_DUAL_PANEL_TARGET_WIDTH) - WORKSPACE_TREE_RAIL_WIDTH - WORKSPACE_PREVIEW_MIN_WIDTH),
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPath || !actualTreeVisible) return;
|
||||
const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
|
||||
if (selectedIndex !== -1) {
|
||||
virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
|
||||
const pendingRevealPath = pendingTreeRevealPathRef.current;
|
||||
if (!pendingRevealPath) return;
|
||||
if (!selectedPath || pendingRevealPath !== selectedPath) {
|
||||
pendingTreeRevealPathRef.current = null;
|
||||
return;
|
||||
}
|
||||
const selectedIndex = treeRows.findIndex((row) => row.path === selectedPath);
|
||||
if (!shouldScrollWorkspaceTreeSelection({ selectedPath, pendingRevealPath, actualTreeVisible, selectedIndex })) return;
|
||||
virtualizer.scrollToIndex(selectedIndex, { align: "auto" });
|
||||
pendingTreeRevealPathRef.current = null;
|
||||
}, [selectedPath, actualTreeVisible, treeRows, virtualizer]);
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
"--workspace-tree-width": `${effectiveTreeWidth}px`,
|
||||
"--workspace-preview-min-width": compactTreeRail ? "0px" : `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
|
||||
"--workspace-preview-min-width": `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
|
||||
}) as CSSProperties,
|
||||
[compactTreeRail, effectiveTreeWidth],
|
||||
[effectiveTreeWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -832,11 +881,48 @@ export function WorkspacePanel({
|
||||
}
|
||||
}, [onClose, previewVisible]);
|
||||
|
||||
const showTreeEvenSplit = useCallback(() => {
|
||||
setTreeWidth(initialWorkspaceSplitTreeWidth({
|
||||
panelWidth,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
savedTreeWidth: null,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
}));
|
||||
setTreeWidthMode("even");
|
||||
setTreeVisible(true);
|
||||
}, [panelWidth]);
|
||||
|
||||
const toggleTreeRail = useCallback(() => {
|
||||
if (actualTreeVisible) {
|
||||
setTreeVisible(false);
|
||||
return;
|
||||
}
|
||||
showTreeEvenSplit();
|
||||
}, [actualTreeVisible, showTreeEvenSplit]);
|
||||
|
||||
const closePreviewArea = useCallback(() => {
|
||||
if (lastRevealRequestIdRef.current === revealPathRequest?.id) {
|
||||
dismissedRevealRequestIdRef.current = revealPathRequest.id;
|
||||
}
|
||||
if (lastChangeRevealRequestIdRef.current === changeRevealRequest?.id) {
|
||||
dismissedChangeRevealRequestIdRef.current = changeRevealRequest.id;
|
||||
}
|
||||
const nextPreviewTabs = closeWorkspacePreviewTab(openTabs, selectedPath);
|
||||
setSelectedPath(nextPreviewTabs.selectedPath);
|
||||
setOpenTabs(nextPreviewTabs.openTabs);
|
||||
setPreview(null);
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setRecentOpen(false);
|
||||
setTreeVisible(true);
|
||||
}, [changeRevealRequest, openTabs, revealPathRequest, selectedPath]);
|
||||
|
||||
const setSavedTreeWidth = useCallback(
|
||||
(width: number) => {
|
||||
const next = clampWorkspaceTreeWidth(width, panelWidth);
|
||||
setTreeWidth(next);
|
||||
saveWorkspaceTreeWidth(next);
|
||||
setTreeWidthMode("manual");
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
@@ -844,18 +930,34 @@ export function WorkspacePanel({
|
||||
const startTreeResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (!treeVisible) return;
|
||||
const rect = panelRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const panel = panelRef.current;
|
||||
const rect = panel?.getBoundingClientRect();
|
||||
if (!panel || !rect) return;
|
||||
event.preventDefault();
|
||||
const committedTreeWidth = clampWorkspaceTreeWidth(effectiveTreeWidth, panelWidth);
|
||||
setTreeWidth(committedTreeWidth);
|
||||
setTreeWidthMode("manual");
|
||||
setTreeResizing(true);
|
||||
let nextWidth = effectiveTreeWidth;
|
||||
let nextWidth = committedTreeWidth;
|
||||
const liveResize = createRafResizeUpdater({
|
||||
target: panel,
|
||||
separator: event.currentTarget,
|
||||
cssVar: "--workspace-tree-width",
|
||||
});
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = clampWorkspaceTreeWidth(moveEvent.clientX - rect.left, rect.width);
|
||||
setTreeWidth(nextWidth);
|
||||
nextWidth = workspaceSplitTreeWidthFromPointer({
|
||||
clientX: moveEvent.clientX,
|
||||
panelLeft: rect.left,
|
||||
panelWidth: rect.width,
|
||||
railWidth: WORKSPACE_TREE_RAIL_WIDTH,
|
||||
treeMinWidth: WORKSPACE_TREE_MIN_WIDTH,
|
||||
previewMinWidth: WORKSPACE_PREVIEW_MIN_WIDTH,
|
||||
});
|
||||
liveResize.schedule(nextWidth);
|
||||
};
|
||||
const onDone = () => {
|
||||
liveResize.flush();
|
||||
setTreeWidth(nextWidth);
|
||||
saveWorkspaceTreeWidth(nextWidth);
|
||||
setTreeResizing(false);
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onDone);
|
||||
@@ -869,7 +971,7 @@ export function WorkspacePanel({
|
||||
window.addEventListener("pointerup", onDone);
|
||||
window.addEventListener("pointercancel", onDone);
|
||||
},
|
||||
[effectiveTreeWidth, treeVisible],
|
||||
[effectiveTreeWidth, panelWidth, treeVisible],
|
||||
);
|
||||
|
||||
const resizeTreeWithKeyboard = useCallback(
|
||||
@@ -882,10 +984,10 @@ export function WorkspacePanel({
|
||||
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setSavedTreeWidth(WORKSPACE_TREE_MAX_WIDTH);
|
||||
setSavedTreeWidth(maxTreeWidthForPanel);
|
||||
}
|
||||
},
|
||||
[effectiveTreeWidth, setSavedTreeWidth],
|
||||
[effectiveTreeWidth, maxTreeWidthForPanel, setSavedTreeWidth],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -1052,6 +1154,16 @@ export function WorkspacePanel({
|
||||
};
|
||||
|
||||
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
|
||||
const codePreviewActive = Boolean(
|
||||
selectedPath &&
|
||||
!changedMode &&
|
||||
preview &&
|
||||
!loadingPreview &&
|
||||
!preview.err &&
|
||||
!preview.kind &&
|
||||
!preview.binary &&
|
||||
!isMarkdown,
|
||||
);
|
||||
const treeBlankMenuItems: ContextMenuItem[] = [
|
||||
{
|
||||
key: "refresh-tree",
|
||||
@@ -1064,7 +1176,7 @@ export function WorkspacePanel({
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${compactTreeRail ? " workspace-panel--compact-rail" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
|
||||
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${showTreeRail ? " workspace-panel--with-tree-rail" : ""}${changedMode ? " workspace-panel--detail-only" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
|
||||
aria-label={t("workspace.title")}
|
||||
style={panelStyle}
|
||||
>
|
||||
@@ -1104,7 +1216,7 @@ export function WorkspacePanel({
|
||||
</Tooltip>
|
||||
{selectedPath && (
|
||||
<Tooltip label={t("workspace.closePreview")}>
|
||||
<button className="workspace-iconbtn" onClick={() => closeTab(selectedPath)}>
|
||||
<button className="workspace-iconbtn" onClick={closePreviewArea}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -1148,8 +1260,7 @@ export function WorkspacePanel({
|
||||
className="workspace-crumb"
|
||||
onClick={() => {
|
||||
setFilter("");
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
showTreeEvenSplit();
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
|
||||
}}
|
||||
>
|
||||
@@ -1167,8 +1278,7 @@ export function WorkspacePanel({
|
||||
className={`workspace-crumb${isLast ? " workspace-crumb--current" : ""}`}
|
||||
onClick={() => {
|
||||
if (isLast) return;
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
showTreeEvenSplit();
|
||||
setFilter("");
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
|
||||
void loadDir(dir);
|
||||
@@ -1183,7 +1293,11 @@ export function WorkspacePanel({
|
||||
{preview && preview.size > 0 && <span className="workspace-preview__size">{formatBytes(preview.size)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="workspace-preview__body" ref={previewBodyRef} onContextMenu={openSelectionMenu}>
|
||||
<div
|
||||
className={`workspace-preview__body${codePreviewActive ? " workspace-preview__body--code" : ""}`}
|
||||
ref={previewBodyRef}
|
||||
onContextMenu={openSelectionMenu}
|
||||
>
|
||||
{viewMode === "changed" && scopedChangeRows ? (
|
||||
<div className="workspace-change-scope">
|
||||
<div className="workspace-change-scope__head">
|
||||
@@ -1237,6 +1351,11 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
) : viewMode === "changed" && !selectedPath ? (
|
||||
<div className="workspace-git-history">
|
||||
{workspaceGitWarning && (
|
||||
<div className="workspace-note workspace-note--warning" role="status">
|
||||
{workspaceGitWarning}
|
||||
</div>
|
||||
)}
|
||||
{sessionChanges && sessionChanges.length > 0 && (
|
||||
<div className="workspace-change-scope">
|
||||
<div className="workspace-change-scope__head">
|
||||
@@ -1271,7 +1390,7 @@ export function WorkspacePanel({
|
||||
{loadingHistory ? (
|
||||
<div className="workspace-empty">{t("workspace.loading")}</div>
|
||||
) : gitHistory.length === 0 && !(sessionChanges && sessionChanges.length > 0) ? (
|
||||
<div className="workspace-empty">{t("workspace.noChanges")}</div>
|
||||
<div className="workspace-empty">{workspaceGitWarning ? t("workspace.gitChangesUnknown") : t("workspace.noChanges")}</div>
|
||||
) : (
|
||||
<div className="workspace-git-history__list">
|
||||
{gitHistory.map((commit) => (
|
||||
@@ -1397,16 +1516,14 @@ export function WorkspacePanel({
|
||||
</section>}
|
||||
|
||||
{showTreeRail && (
|
||||
<section className="workspace-tree-rail" aria-label={t("workspace.showTree")}>
|
||||
<Tooltip label={t("workspace.showTree")} side="right">
|
||||
<section className="workspace-tree-rail" aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
|
||||
<Tooltip label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")} side="right">
|
||||
<button
|
||||
className="workspace-tree-reveal workspace-iconbtn workspace-iconbtn--on"
|
||||
className={`workspace-tree-reveal workspace-iconbtn${actualTreeVisible ? " workspace-iconbtn--on" : ""}`}
|
||||
type="button"
|
||||
aria-label={t("workspace.showTree")}
|
||||
onClick={() => {
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
}}
|
||||
aria-label={actualTreeVisible ? t("workspace.hideTree") : t("workspace.showTree")}
|
||||
aria-pressed={actualTreeVisible}
|
||||
onClick={toggleTreeRail}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</button>
|
||||
@@ -1422,7 +1539,7 @@ export function WorkspacePanel({
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("workspace.resizeTree")}
|
||||
aria-valuemin={WORKSPACE_TREE_MIN_WIDTH}
|
||||
aria-valuemax={WORKSPACE_TREE_MAX_WIDTH}
|
||||
aria-valuemax={maxTreeWidthForPanel}
|
||||
aria-valuenow={effectiveTreeWidth}
|
||||
onPointerDown={startTreeResize}
|
||||
onKeyDown={resizeTreeWithKeyboard}
|
||||
@@ -1433,16 +1550,18 @@ export function WorkspacePanel({
|
||||
<section className="workspace-files">
|
||||
{showFileTools && (
|
||||
<div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
|
||||
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
|
||||
<button
|
||||
className="workspace-iconbtn workspace-iconbtn--on"
|
||||
type="button"
|
||||
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
|
||||
onClick={hideTreeOrClosePanel}
|
||||
>
|
||||
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showViewTabs && (
|
||||
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
|
||||
<button
|
||||
className="workspace-iconbtn workspace-iconbtn--on"
|
||||
type="button"
|
||||
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
|
||||
onClick={hideTreeOrClosePanel}
|
||||
>
|
||||
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showViewTabs && (
|
||||
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
|
||||
<button
|
||||
@@ -1465,7 +1584,7 @@ export function WorkspacePanel({
|
||||
)}
|
||||
{showViewTabs && (
|
||||
<Tooltip label={t("workspace.refreshChanges")}>
|
||||
<button className="workspace-iconbtn" onClick={() => void loadGitHistory()}>
|
||||
<button className="workspace-iconbtn" onClick={() => { void loadGitHistory(); void loadWorkspaceChanges(); }}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -10,10 +10,12 @@ import { CopyButton } from "../CopyButton";
|
||||
const HljsCode = memo(function HljsCode({ value, language, maxHeight }: EditorProps) {
|
||||
const html = useMemo(() => highlightToHtml(value, language), [value, language]);
|
||||
return (
|
||||
<pre className="code hljs" data-lang={language} style={maxHeight ? { maxHeight } : undefined}>
|
||||
<code dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="code-block__wrap">
|
||||
<pre className="code hljs" data-lang={language} style={maxHeight ? { maxHeight } : undefined}>
|
||||
<code dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</pre>
|
||||
<CopyButton text={value} className="code-block__copy" />
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Renders a list of tasks with add/edit/delete controls, plus a manual
|
||||
// "run now" button for each. The panel is opened from the sidebar nav item.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
@@ -30,6 +30,99 @@ import {
|
||||
import type { HeartbeatTask } from "./heartbeat.types";
|
||||
import type { WorkspaceView } from "../../../lib/types";
|
||||
|
||||
const INTERVAL_MS: Record<"s" | "m" | "h", number> = {
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
};
|
||||
|
||||
function heartbeatIntervalMs(interval?: string): number | null {
|
||||
const clean = (interval || "").replace(/\|.*$/, "");
|
||||
const m = clean.match(/^(\d+)([smh])$/);
|
||||
if (!m) return null;
|
||||
return parseInt(m[1], 10) * INTERVAL_MS[m[2] as "s" | "m" | "h"];
|
||||
}
|
||||
|
||||
function heartbeatClockMinutes(value?: string): number | null {
|
||||
const m = (value || "").match(/^(\d{2}):(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const hour = parseInt(m[1], 10);
|
||||
const minute = parseInt(m[2], 10);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function dateAtMinutes(base: Date, minutes: number): Date {
|
||||
const d = new Date(base);
|
||||
d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function heartbeatWithinWindow(date: Date, start: number | null, end: number | null): boolean {
|
||||
if (start === null && end === null) return true;
|
||||
const minutes = date.getHours() * 60 + date.getMinutes();
|
||||
if (start !== null && end === null) return minutes >= start;
|
||||
if (start === null && end !== null) return minutes < end;
|
||||
if (start === end) return true;
|
||||
if (start! < end!) return minutes >= start! && minutes < end!;
|
||||
return minutes >= start! || minutes < end!;
|
||||
}
|
||||
|
||||
function nextHeartbeatWindowTime(from: Date, start: number | null, end: number | null): Date {
|
||||
if (heartbeatWithinWindow(from, start, end)) return from;
|
||||
if (start !== null && end === null) return dateAtMinutes(from, start);
|
||||
if (start === null && end !== null) {
|
||||
const next = new Date(from);
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(0, 0, 0, 0);
|
||||
return next;
|
||||
}
|
||||
const minutes = from.getHours() * 60 + from.getMinutes();
|
||||
if (start! < end! && minutes < start!) return dateAtMinutes(from, start!);
|
||||
if (start! > end! && minutes < start! && minutes >= end!) return dateAtMinutes(from, start!);
|
||||
const next = dateAtMinutes(from, start!);
|
||||
next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function heartbeatNextRunAt(task: Pick<HeartbeatTask, "interval" | "lastRunAt" | "timeWindowStart" | "timeWindowEnd">, now = Date.now()): number | null {
|
||||
if (!task.lastRunAt) return null;
|
||||
const intervalMs = heartbeatIntervalMs(task.interval);
|
||||
if (intervalMs === null) return null;
|
||||
const rawNext = task.lastRunAt + intervalMs;
|
||||
if ((task.interval || "").includes("|")) return rawNext;
|
||||
const start = heartbeatClockMinutes(task.timeWindowStart);
|
||||
const end = heartbeatClockMinutes(task.timeWindowEnd);
|
||||
if (start === null && end === null) return rawNext;
|
||||
const candidate = new Date(Math.max(rawNext, now));
|
||||
return nextHeartbeatWindowTime(candidate, start, end).getTime();
|
||||
}
|
||||
|
||||
function heartbeatIntervalLabel(interval: string | undefined, t: ReturnType<typeof useT>): string {
|
||||
const cycleMatch = (interval || "").match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/);
|
||||
if (cycleMatch) {
|
||||
const [, , type, days, time] = cycleMatch;
|
||||
const timeStr = time ? ` ${time}` : "";
|
||||
if (type === "daily") return `${t("heartbeat.cycleDaily")}${timeStr}`;
|
||||
if (type === "weekly") return `${t("heartbeat.cycleWeekly")}${timeStr}`;
|
||||
if (type === "biweekly") return `${t("heartbeat.cycleBiweekly")}${timeStr}`;
|
||||
if (type === "monthly") return `${t("heartbeat.cycleMonthly")}${days ? ` ${days}` : ""}${timeStr}`;
|
||||
if (type === "yearly") {
|
||||
const parts = (days || "").split("-");
|
||||
return `${t("heartbeat.cycleYearly")} ${parts[0] || "1"}/${parts[1] || "1"}${timeStr}`;
|
||||
}
|
||||
}
|
||||
const clean = (interval || "").replace(/\|.*$/, "");
|
||||
const m = clean.match(/^(\d+)([smh])$/);
|
||||
if (!m) return clean;
|
||||
const unitLabels: Record<string, string> = {
|
||||
s: t("heartbeat.unitSec"),
|
||||
m: t("heartbeat.unitMin"),
|
||||
h: t("heartbeat.unitHour"),
|
||||
};
|
||||
return `${t("heartbeat.freqEvery")}${t("heartbeat.everyJoiner")}${m[1]}${unitLabels[m[2]] || m[2]}`;
|
||||
}
|
||||
|
||||
interface HeartbeatPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -95,6 +188,8 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
@@ -121,6 +216,8 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}, []);
|
||||
@@ -212,7 +309,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
</header>
|
||||
|
||||
{editing ? (
|
||||
<TaskEditor task={editing} onSave={handleSaveEdit} onCancel={() => setEditing(null)} onDelete={() => { handleDelete(editing.id); setEditing(null); }} />
|
||||
<TaskEditor key={editing.id} task={editing} onSave={handleSaveEdit} onCancel={() => setEditing(null)} onDelete={() => { handleDelete(editing.id); setEditing(null); }} />
|
||||
) : (
|
||||
<div className="heartbeat-modal__body">
|
||||
<div className="heartbeat-toolbar">
|
||||
@@ -394,10 +491,6 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="heartbeat-hint">
|
||||
<span>{t("heartbeat.configHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -426,24 +519,13 @@ function TaskCard({
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
// Parse interval for display and next-run calculation
|
||||
const intervalLabel = (() => {
|
||||
const clean = task.interval.replace(/\|.*$/, "");
|
||||
const m = clean.match(/^(\d+)([smh])$/);
|
||||
if (!m) return clean;
|
||||
const unitMap: Record<string, string> = { s: "s", m: "m", h: "h" };
|
||||
return `${m[1]}${unitMap[m[2]] || m[2]}`;
|
||||
})();
|
||||
const intervalLabel = heartbeatIntervalLabel(task.interval, t);
|
||||
|
||||
const nextRunLabel = (() => {
|
||||
if (!task.enabled) return t("heartbeat.disabled");
|
||||
const clean = task.interval.replace(/\|.*$/, "");
|
||||
const m = clean.match(/^(\d+)([smh])$/);
|
||||
if (!m) return "";
|
||||
const ms = parseInt(m[1]) * { s: 1000, m: 60000, h: 3600000 }[m[2] as "s" | "m" | "h"];
|
||||
if (!task.lastRunAt) return t("heartbeat.neverRun");
|
||||
const next = task.lastRunAt + ms;
|
||||
const now = Date.now();
|
||||
const next = heartbeatNextRunAt(task, now);
|
||||
if (next === null) return task.lastRunAt ? "" : t("heartbeat.neverRun");
|
||||
const diff = next - now;
|
||||
if (diff <= 0) return t("heartbeat.due" as any);
|
||||
if (diff < 60000) return t("heartbeat.soon" as any);
|
||||
@@ -532,6 +614,44 @@ const WEEKDAYS = [
|
||||
{ key: "sun", label: "周日" },
|
||||
] as const;
|
||||
|
||||
const ALL_WEEKDAYS = WEEKDAYS.map(w => w.key);
|
||||
const DEFAULT_WEEKLY_DAY = "mon";
|
||||
|
||||
function defaultHeartbeatCycleDays(cycleType: string): string[] {
|
||||
if (cycleType === "daily") return [...ALL_WEEKDAYS];
|
||||
if (cycleType === "weekly" || cycleType === "biweekly") return [DEFAULT_WEEKLY_DAY];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function heartbeatBuildCycleInterval(cycleType: string, days: string[], time: string): string {
|
||||
const base: Record<string, string> = {
|
||||
daily: "24h",
|
||||
weekly: "168h",
|
||||
biweekly: "336h",
|
||||
monthly: "720h",
|
||||
yearly: "8760h",
|
||||
};
|
||||
const selectedDays = days.filter(Boolean);
|
||||
const isDailyWithSelection = cycleType === "daily" && selectedDays.length > 0 && selectedDays.length < 7;
|
||||
const isDailyWithoutSelection = cycleType === "daily" && selectedDays.length === 0;
|
||||
const effectiveType = isDailyWithoutSelection || isDailyWithSelection ? "weekly" : cycleType;
|
||||
const scheduleDays =
|
||||
(effectiveType === "weekly" || effectiveType === "biweekly") && selectedDays.length === 0
|
||||
? defaultHeartbeatCycleDays(effectiveType)
|
||||
: selectedDays;
|
||||
|
||||
let suffix = `|${effectiveType}`;
|
||||
if (effectiveType === "weekly" || effectiveType === "biweekly") {
|
||||
suffix += `:${scheduleDays.join(",")}`;
|
||||
} else if (effectiveType === "monthly") {
|
||||
suffix += `:${scheduleDays[0] || "1"}`;
|
||||
} else if (effectiveType === "yearly") {
|
||||
suffix += `:${scheduleDays[0] || "1"}-${scheduleDays[1] || "1"}`;
|
||||
}
|
||||
suffix += `@${time}`;
|
||||
return (base[cycleType] || "24h") + suffix;
|
||||
}
|
||||
|
||||
function CycleEditor({
|
||||
draft,
|
||||
setDraft,
|
||||
@@ -540,45 +660,29 @@ function CycleEditor({
|
||||
setDraft: (field: keyof HeartbeatTask, value: string | boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const cycleMatch = draft.interval.match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/);
|
||||
const cycleMatch = (draft.interval || "").match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/);
|
||||
const [cycleType, setCycleType] = useState<string>(
|
||||
cycleMatch ? cycleMatch[2] : "daily"
|
||||
);
|
||||
const cycleDays = cycleMatch?.[3] || "";
|
||||
const cycleTime = cycleMatch?.[4] || "09:00";
|
||||
const [selectedDays, setSelectedDays] = useState<string[]>(
|
||||
cycleDays ? cycleDays.split(",") : []
|
||||
cycleDays ? cycleDays.split(",").filter(Boolean) :
|
||||
defaultHeartbeatCycleDays(cycleMatch ? cycleMatch[2] : "daily")
|
||||
);
|
||||
const [monthDay, setMonthDay] = useState(cycleDays || "1");
|
||||
const [yearMonth, setYearMonth] = useState(cycleDays.split("-")[0] || "1");
|
||||
const [yearDay, setYearDay] = useState(cycleDays.split("-")[1] || "1");
|
||||
const [timeVal, setTimeVal] = useState(cycleTime);
|
||||
|
||||
const hasWeekdays = cycleType === "daily" || cycleType === "weekly" || cycleType === "biweekly";
|
||||
|
||||
// Build interval string when config changes
|
||||
const buildInterval = useCallback((ct: string, days: string[], tm: string) => {
|
||||
const base: Record<string, string> = {
|
||||
daily: "24h",
|
||||
weekly: "168h",
|
||||
biweekly: "336h",
|
||||
monthly: "720h",
|
||||
yearly: "8760h",
|
||||
};
|
||||
let suffix = `|${ct}`;
|
||||
if (ct === "weekly" || ct === "biweekly") {
|
||||
suffix += `:${days.join(",")}`;
|
||||
} else if (ct === "monthly") {
|
||||
suffix += `:${days[0] || "1"}`;
|
||||
} else if (ct === "yearly") {
|
||||
// days[0] = month, days[1] = day — each is a plain number, no dash
|
||||
suffix += `:${days[0] || "1"}-${days[1] || "1"}`;
|
||||
}
|
||||
suffix += `@${tm}`;
|
||||
return (base[ct] || "24h") + suffix;
|
||||
}, []);
|
||||
const buildInterval = useCallback(heartbeatBuildCycleInterval, []);
|
||||
|
||||
const onCycleTypeChange = useCallback((ct: string) => {
|
||||
setCycleType(ct);
|
||||
const days: string[] = [];
|
||||
const days = defaultHeartbeatCycleDays(ct);
|
||||
setSelectedDays(days);
|
||||
setMonthDay("1");
|
||||
setYearMonth("1");
|
||||
@@ -588,6 +692,7 @@ function CycleEditor({
|
||||
|
||||
const onDayToggle = useCallback((day: string) => {
|
||||
setSelectedDays((prev) => {
|
||||
if (prev.includes(day) && prev.length <= 1) return prev;
|
||||
const next = prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day];
|
||||
setDraft("interval", buildInterval(cycleType, next, timeVal));
|
||||
return next;
|
||||
@@ -611,7 +716,7 @@ function CycleEditor({
|
||||
|
||||
const onTimeChange = useCallback((tm: string) => {
|
||||
setTimeVal(tm);
|
||||
const days = cycleType === "weekly" || cycleType === "biweekly" ? selectedDays
|
||||
const days = hasWeekdays ? selectedDays
|
||||
: cycleType === "monthly" ? [monthDay]
|
||||
: cycleType === "yearly" ? [yearMonth, yearDay]
|
||||
: [];
|
||||
@@ -642,22 +747,6 @@ function CycleEditor({
|
||||
<option value="yearly">{t("heartbeat.cycleYearly")}</option>
|
||||
</select>
|
||||
|
||||
{(cycleType === "weekly" || cycleType === "biweekly") && (
|
||||
<div className="heartbeat-editor__weekdays">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<button
|
||||
key={wd.key}
|
||||
type="button"
|
||||
className={`heartbeat-editor__weekday-btn${selectedDays.includes(wd.key) ? " heartbeat-editor__weekday-btn--on" : ""}`}
|
||||
onClick={() => onDayToggle(wd.key)}
|
||||
aria-pressed={selectedDays.includes(wd.key)}
|
||||
>
|
||||
{wd.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cycleType === "monthly" && (
|
||||
<select
|
||||
className="heartbeat-editor__freq-select"
|
||||
@@ -699,6 +788,22 @@ function CycleEditor({
|
||||
value={timeVal}
|
||||
onChange={(e) => onTimeChange(e.target.value)}
|
||||
/>
|
||||
|
||||
{hasWeekdays && (
|
||||
<div className="set-seg">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<button
|
||||
key={wd.key}
|
||||
type="button"
|
||||
className={`set-seg__btn${selectedDays.includes(wd.key) ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onDayToggle(wd.key)}
|
||||
aria-pressed={selectedDays.includes(wd.key)}
|
||||
>
|
||||
{wd.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -706,6 +811,11 @@ function CycleEditor({
|
||||
|
||||
// ── Editor ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeMode(mode: "ask" | "auto" | "yolo" | undefined): "ask" | "auto" | "yolo" {
|
||||
if (mode === "ask" || mode === "auto" || mode === "yolo") return mode;
|
||||
return "yolo"; // default
|
||||
}
|
||||
|
||||
function TaskEditor({
|
||||
task,
|
||||
onSave,
|
||||
@@ -741,13 +851,27 @@ function TaskEditor({
|
||||
}, [projectOpen]);
|
||||
|
||||
const [draft, setDraft] = useState(task);
|
||||
const intervalBeforeCycle = useRef<string | null>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-grow prompt textarea: shrink-to-fit then cap at 180px
|
||||
const autoGrowPrompt = useCallback(() => {
|
||||
const el = promptRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + "px";
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
autoGrowPrompt();
|
||||
}, [draft.prompt, autoGrowPrompt]);
|
||||
const set = useCallback((field: keyof HeartbeatTask, value: string | boolean) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// Detect frequency type from interval value
|
||||
const [freqType, setFreqType] = useState<"cycle" | "interval">(
|
||||
task.interval.includes("|") ? "cycle" : "interval"
|
||||
(task.interval && task.interval.includes("|")) ? "cycle" : "interval"
|
||||
);
|
||||
|
||||
const isNew = !task.createdAt;
|
||||
@@ -757,8 +881,9 @@ function TaskEditor({
|
||||
|
||||
return (
|
||||
<div className="heartbeat-editor">
|
||||
{/* Title */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<div className="heartbeat-editor__fields">
|
||||
{/* Title */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldTitle")}</label>
|
||||
<input
|
||||
ref={titleRef}
|
||||
@@ -816,14 +941,69 @@ function TaskEditor({
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldPrompt")}</label>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
className="heartbeat-editor__textarea"
|
||||
value={draft.prompt}
|
||||
onChange={(e) => set("prompt", e.target.value)}
|
||||
onChange={(e) => {
|
||||
set("prompt", e.target.value);
|
||||
// autoGrowPrompt is called via useEffect watching draft.prompt
|
||||
}}
|
||||
placeholder={t("heartbeat.promptPlaceholder")}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Approval Mode */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldApprovalMode")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
|
||||
title={t("heartbeat.approvalModeAskTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAsk")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
|
||||
title={t("heartbeat.approvalModeAutoTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAuto")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
|
||||
title={t("heartbeat.approvalModeYoloTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeYolo")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
|
||||
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
|
||||
t("heartbeat.approvalModeAskHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* New conversation per run */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldNewConversation")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${!draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: false }))}
|
||||
>
|
||||
{t("heartbeat.newConversationEachRunOff")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${draft.newConversationEachRun ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, newConversationEachRun: true }))}
|
||||
>
|
||||
{t("heartbeat.newConversationEachRunOn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldInterval")}</label>
|
||||
@@ -832,17 +1012,30 @@ function TaskEditor({
|
||||
className={`set-seg__btn${freqType === "cycle" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => {
|
||||
setFreqType("cycle");
|
||||
// Initialize interval to daily schedule when switching to cycle mode
|
||||
if (!draft.interval.includes("|")) {
|
||||
setDraft((prev) => ({ ...prev, interval: "24h|daily@09:00" }));
|
||||
// Save the original interval so switching back can restore it
|
||||
const cur = draft.interval || "";
|
||||
const nextInterval = cur.includes("|") ? cur : "24h|daily@09:00";
|
||||
if (!cur.includes("|")) {
|
||||
intervalBeforeCycle.current = cur;
|
||||
}
|
||||
setDraft((prev) => ({ ...prev, interval: nextInterval, timeWindowStart: undefined, timeWindowEnd: undefined }));
|
||||
}}
|
||||
>
|
||||
{t("heartbeat.freqCycle")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${freqType === "interval" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setFreqType("interval")}
|
||||
onClick={() => {
|
||||
setFreqType("interval");
|
||||
// Restore original interval if user toggled cycle and back without saving
|
||||
if (intervalBeforeCycle.current !== null) {
|
||||
setDraft((prev) => ({ ...prev, interval: intervalBeforeCycle.current! }));
|
||||
intervalBeforeCycle.current = null;
|
||||
} else if ((draft.interval || "").includes("|")) {
|
||||
// Fallback: strip cycle suffix
|
||||
setDraft((prev) => ({ ...prev, interval: (prev.interval || "").replace(/\|.*$/, "") }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("heartbeat.freqInterval")}
|
||||
</button>
|
||||
@@ -854,12 +1047,12 @@ function TaskEditor({
|
||||
<input
|
||||
className="heartbeat-editor__freq-input"
|
||||
value={(() => {
|
||||
const m = draft.interval.match(/^(\d+)/);
|
||||
const m = (draft.interval || "").match(/^(\d+)/);
|
||||
return m ? m[1] : "1";
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const num = e.target.value.replace(/\D/g, "");
|
||||
const mUnit = draft.interval.match(/^(\d+)([smh])/);
|
||||
const mUnit = (draft.interval || "").match(/^(\d+)([smh])/);
|
||||
const unit = mUnit ? mUnit[2] : "h";
|
||||
// Guard: never save a bare unit string like "h" or "m"
|
||||
setDraft((prev) => ({ ...prev, interval: num ? num + unit : "1" + unit }));
|
||||
@@ -869,21 +1062,60 @@ function TaskEditor({
|
||||
<select
|
||||
className="heartbeat-editor__freq-select"
|
||||
value={(() => {
|
||||
const m = draft.interval.match(/^(\d+)([smh])/);
|
||||
const m = (draft.interval || "").match(/^(\d+)([smh])/);
|
||||
return m ? m[2] : "h";
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const num = draft.interval.match(/^(\d+)/)?.[1] || "1";
|
||||
const num = (draft.interval || "").match(/^(\d+)/)?.[1] || "1";
|
||||
setDraft((prev) => ({ ...prev, interval: num + e.target.value }));
|
||||
}}
|
||||
>
|
||||
<option value="m">{t("heartbeat.unitMin")}</option>
|
||||
<option value="h">{t("heartbeat.unitHour")}</option>
|
||||
</select>
|
||||
<span className="heartbeat-editor__freq-label" style={{ marginLeft: "6px" }}>
|
||||
{draft.timeWindowStart || draft.timeWindowEnd ? (
|
||||
<>{t("heartbeat.timeWindow")}</>
|
||||
) : (
|
||||
<span className="heartbeat-editor__tw-add"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: "09:00", timeWindowEnd: "17:00" }))}
|
||||
>
|
||||
+ {t("heartbeat.timeWindow")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{(draft.timeWindowStart || draft.timeWindowEnd) && (
|
||||
<>
|
||||
<input
|
||||
className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
|
||||
type="time"
|
||||
value={draft.timeWindowStart || ""}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowStart: e.target.value || undefined }))}
|
||||
placeholder="09:00"
|
||||
/>
|
||||
<span className="heartbeat-editor__freq-label heartbeat-editor__tw-sep">—</span>
|
||||
<input
|
||||
className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time"
|
||||
type="time"
|
||||
value={draft.timeWindowEnd || ""}
|
||||
onChange={(e) => setDraft((prev) => ({ ...prev, timeWindowEnd: e.target.value || undefined }))}
|
||||
placeholder="17:00"
|
||||
/>
|
||||
<button
|
||||
className="heartbeat-card__open-btn heartbeat-editor__tw-clear"
|
||||
onClick={() => setDraft((prev) => ({ ...prev, timeWindowStart: undefined, timeWindowEnd: undefined }))}
|
||||
title={t("heartbeat.clearTimeWindow")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="heartbeat-editor__actions">
|
||||
{!isNew && !confirmingDelete && (
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(640px, 92vw);
|
||||
min-height: 520px;
|
||||
max-height: min(640px, 88vh);
|
||||
max-height: min(780px, 92vh);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
@@ -404,7 +403,7 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── card ─────────────────────────────────────────────────────── */
|
||||
@@ -659,11 +658,18 @@
|
||||
/* ── editor ───────────────────────────────────────────────────── */
|
||||
|
||||
.heartbeat-editor {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.heartbeat-editor__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.heartbeat-editor__field {
|
||||
@@ -714,8 +720,10 @@
|
||||
}
|
||||
|
||||
.heartbeat-editor__textarea {
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── editor rows ──────────────────────────────────────────────── */
|
||||
@@ -750,6 +758,31 @@
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.heartbeat-editor__mode-hint {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.heartbeat-editor__tw-add {
|
||||
cursor: pointer;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
|
||||
.heartbeat-editor__tw-sep {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.heartbeat-editor__tw-clear {
|
||||
padding: 0 4px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--fg-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── frequency: unified components ──────────────────────────────── */
|
||||
/* All freq-* components share height: 32px for visual consistency */
|
||||
|
||||
@@ -905,9 +938,9 @@
|
||||
|
||||
.heartbeat-editor__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding: 24px 0 16px;
|
||||
}
|
||||
|
||||
/* ── scope selector ───────────────────────────────────────────── */
|
||||
@@ -942,8 +975,8 @@
|
||||
|
||||
.heartbeat-scope-btn--active {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--border);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ── project dropdown ─────────────────────────────────────────── */
|
||||
@@ -1004,17 +1037,6 @@
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
|
||||
/* ── footer hint ──────────────────────────────────────────────── */
|
||||
|
||||
.heartbeat-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--fg-faint);
|
||||
text-align: center;
|
||||
padding: 8px 0 2px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ── Sidebar header automation button ── */
|
||||
|
||||
.sidebar__hb-header {
|
||||
|
||||
@@ -10,5 +10,9 @@ export interface HeartbeatTask {
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
lastRunAt?: number; // unix millis
|
||||
newConversationEachRun?: boolean; // true = create new topic each run
|
||||
createdAt?: number;
|
||||
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
|
||||
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
|
||||
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
|
||||
}
|
||||
@@ -11,24 +11,32 @@
|
||||
// "index.tsx".
|
||||
//
|
||||
// Entries from the local ListDir (`entries`) and the fuzzy Search
|
||||
// (`searchEntries`) are merged with stable de-duplication keyed on
|
||||
// `entry.name` so a result that appears in both lists is shown only
|
||||
// once. The local list takes precedence (it represents the user's
|
||||
// current directory and is more immediate).
|
||||
// (`searchEntries`) are merged with stable de-duplication keyed on the
|
||||
// submitted path (`entry.path` when present, otherwise `entry.name`) so a result
|
||||
// that appears in both lists is shown only once. The local list takes
|
||||
// precedence (it represents the user's current directory and is more immediate).
|
||||
|
||||
import type { DirEntry } from "./types";
|
||||
|
||||
function entryKey(entry: DirEntry): string {
|
||||
return entry.path || entry.name;
|
||||
}
|
||||
|
||||
function searchableText(entry: DirEntry): string {
|
||||
return [entry.name, entry.path, entry.displayName, entry.displayPath].filter(Boolean).join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
export function filterAtMatches(
|
||||
entries: readonly DirEntry[],
|
||||
searchEntries: readonly DirEntry[],
|
||||
atFrag: string,
|
||||
): DirEntry[] {
|
||||
const frag = atFrag.toLowerCase();
|
||||
const local = entries.filter((e) => e.name.toLowerCase().includes(frag));
|
||||
const seen = new Set(local.map((e) => e.name));
|
||||
const local = entries.filter((e) => searchableText(e).includes(frag));
|
||||
const seen = new Set(local.map(entryKey));
|
||||
const searched = searchEntries.filter((e) => {
|
||||
if (seen.has(e.name)) return false;
|
||||
return e.name.toLowerCase().includes(frag);
|
||||
if (seen.has(entryKey(e))) return false;
|
||||
return searchableText(e).includes(frag);
|
||||
});
|
||||
return [...local, ...searched];
|
||||
}
|
||||
@@ -5,13 +5,15 @@
|
||||
// that streams a canned turn through the same contract — letting the whole UI be
|
||||
// developed and laid out without rebuilding the Go side.
|
||||
|
||||
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
|
||||
// typecheck green by falling back to a disabled drift check below.
|
||||
import type * as GeneratedApp from "../../wailsjs/go/main/App";
|
||||
|
||||
import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { t } from "./i18n";
|
||||
import { providerRequiresKey } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
import { modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
@@ -31,6 +33,7 @@ import type {
|
||||
EffortInfo,
|
||||
FilePreview,
|
||||
HistoryMessage,
|
||||
HistoryPage,
|
||||
HookConfigView,
|
||||
HooksSettingsView,
|
||||
JobView,
|
||||
@@ -39,6 +42,7 @@ import type {
|
||||
MemorySuggestionsView,
|
||||
MemoryView,
|
||||
Meta,
|
||||
Mode,
|
||||
ModelInfo,
|
||||
NetworkView,
|
||||
ProjectNode,
|
||||
@@ -56,6 +60,7 @@ import type {
|
||||
SlashArgsResult,
|
||||
TabMeta,
|
||||
TopicMeta,
|
||||
ToolApprovalMode,
|
||||
UpdateDownloadResult,
|
||||
UpdateInfo,
|
||||
UpdateProgress,
|
||||
@@ -148,6 +153,9 @@ export interface AppBindings {
|
||||
ClearSession(): Promise<void>;
|
||||
History(): Promise<HistoryMessage[]>;
|
||||
HistoryForTab(tabID: string): Promise<HistoryMessage[]>;
|
||||
HistoryPage(beforeTurn: number, limit: number): Promise<HistoryPage>;
|
||||
HistoryPageForTab(tabID: string, beforeTurn: number, limit: number): Promise<HistoryPage>;
|
||||
HistoryCheckpointTurnsForTab(tabID: string): Promise<number[]>;
|
||||
Checkpoints(): Promise<CheckpointMeta[]>;
|
||||
CheckpointsForTab(tabID: string): Promise<CheckpointMeta[]>;
|
||||
Rewind(turn: number, scope: string): Promise<void>;
|
||||
@@ -158,7 +166,10 @@ export interface AppBindings {
|
||||
ListTrashedSessions(): Promise<SessionMeta[]>;
|
||||
ResumeSession(path: string): Promise<HistoryMessage[]>;
|
||||
ResumeSessionForTab(tabID: string, path: string): Promise<HistoryMessage[]>;
|
||||
ResumeSessionPage(path: string, limit: number): Promise<HistoryPage>;
|
||||
ResumeSessionPageForTab(tabID: string, path: string, limit: number): Promise<HistoryPage>;
|
||||
OpenChannelSessionForTab(tabID: string, path: string): Promise<HistoryMessage[]>;
|
||||
OpenChannelSessionPageForTab(tabID: string, path: string, limit: number): Promise<HistoryPage>;
|
||||
PreviewSession(path: string): Promise<HistoryMessage[]>;
|
||||
DeleteSession(path: string): Promise<void>;
|
||||
RestoreSession(path: string): Promise<void>;
|
||||
@@ -187,10 +198,14 @@ export interface AppBindings {
|
||||
RemoveMCPServer(name: string): Promise<void>;
|
||||
ReconnectMCPServer(name: string): Promise<void>;
|
||||
ClearMCPServerAuthentication(name: string): Promise<void>;
|
||||
TrustMCPServerTool(name: string, toolName: string): Promise<void>;
|
||||
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
|
||||
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
|
||||
PickSkillFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
RemoveSkillPath(path: string): Promise<void>;
|
||||
RefreshSkills(): Promise<void>;
|
||||
ReloadCommands(): Promise<void>;
|
||||
SetSkillEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
SetMCPServerEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
SetMCPServerTier(name: string, tier: string): Promise<void>;
|
||||
@@ -249,6 +264,7 @@ export interface AppBindings {
|
||||
SetSubagentModel(ref: string): Promise<void>;
|
||||
SetSubagentEffort(level: string): Promise<void>;
|
||||
SetAutoPlan(mode: string): Promise<void>;
|
||||
SetDefaultToolApprovalMode(mode: string): Promise<void>;
|
||||
SaveProvider(p: ProviderView): Promise<void>;
|
||||
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
|
||||
FetchProviderModels(p: ProviderView): Promise<string[]>;
|
||||
@@ -262,6 +278,7 @@ export interface AppBindings {
|
||||
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
|
||||
SetNetwork(n: NetworkView): Promise<void>;
|
||||
SetBotSettings(b: BotSettingsView): Promise<void>;
|
||||
SetBotConnectionToolApprovalMode(connID: string, mode: string): Promise<void>;
|
||||
SetBotSecret(envName: string, value: string): Promise<void>;
|
||||
ClearBotSecret(envName: string): Promise<void>;
|
||||
StartBotConnectionInstall(provider: string, domain: string): Promise<BotInstallStartResult>;
|
||||
@@ -279,6 +296,7 @@ export interface AppBindings {
|
||||
SetDesktopCheckUpdates(enabled: boolean): Promise<void>;
|
||||
SetDesktopTelemetry(enabled: boolean): Promise<void>;
|
||||
SetDesktopMetrics(enabled: boolean): Promise<void>;
|
||||
SetMemoryCompilerEnabled(enabled: boolean): Promise<void>;
|
||||
SetExpandThinking(on: boolean): Promise<void>;
|
||||
MigrateDesktopPreferences(language: string, theme: string, style: string): Promise<void>;
|
||||
SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise<void>;
|
||||
@@ -305,6 +323,8 @@ export interface AppBindings {
|
||||
OpenGlobalTab(topicID: string): Promise<TabMeta>;
|
||||
OpenTopicSession(scope: string, workspaceRoot: string, topicID: string, sessionPath: string): Promise<TabMeta>;
|
||||
EnsureBlankTab(scope: string, workspaceRoot: string): Promise<TabMeta>;
|
||||
ActivateTopic(scope: string, workspaceRoot: string, topicID: string, sessionPath: string): Promise<TabMeta>;
|
||||
EnsureBlankSurface(scope: string, workspaceRoot: string): Promise<TabMeta>;
|
||||
SetActiveTab(tabID: string): Promise<void>;
|
||||
ReorderTabs(tabIDs: string[]): Promise<void>;
|
||||
CloseTab(tabID: string): Promise<void>;
|
||||
@@ -334,7 +354,15 @@ export interface AppBindings {
|
||||
// mismatch would produce false positives. Method-arity and parameter-order drift
|
||||
// are caught at the call sites by tsc when components invoke app.<method>(...).
|
||||
type AssertNever<T extends never> = T;
|
||||
export type _CheckGenToApp = AssertNever<Exclude<keyof typeof GeneratedApp, keyof AppBindings>>;
|
||||
type GeneratedAppKeys = keyof typeof GeneratedApp;
|
||||
type GeneratedAppMissing =
|
||||
string extends GeneratedAppKeys ? true :
|
||||
number extends GeneratedAppKeys ? true :
|
||||
symbol extends GeneratedAppKeys ? true :
|
||||
false;
|
||||
export type _CheckGenToApp = AssertNever<
|
||||
GeneratedAppMissing extends true ? never : Exclude<GeneratedAppKeys, keyof AppBindings>
|
||||
>;
|
||||
|
||||
interface WailsRuntime {
|
||||
EventsOn(name: string, cb: (...data: unknown[]) => void): () => void;
|
||||
@@ -527,16 +555,16 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return `turn ${method}`;
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetReasoningLanguage)/.test(method))
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method))
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
|
||||
return `mcp ${method}`;
|
||||
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
|
||||
return `skill ${method}`;
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|EnsureBlankTab|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
|
||||
return `nav ${method}`;
|
||||
return "";
|
||||
}
|
||||
@@ -595,6 +623,12 @@ function emit(e: WireEvent) {
|
||||
listeners.forEach((l) => l(event));
|
||||
}
|
||||
|
||||
export function mockToolApprovalModeAfterModeChange(current: string | undefined, nextMode: Mode): ToolApprovalMode {
|
||||
if (modeHasAutoApproveTools(nextMode)) return "yolo";
|
||||
const currentMode = normalizeToolApprovalMode(current);
|
||||
return currentMode === "yolo" ? "ask" : currentMode;
|
||||
}
|
||||
|
||||
async function withMockTabScope<T>(tabId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = mockScopedTabId;
|
||||
mockScopedTabId = tabId || previous;
|
||||
@@ -650,7 +684,26 @@ function makeMockApp(): AppBindings {
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
let capServers: ServerView[] = [
|
||||
{ name: "github", transport: "stdio", status: "connected", configured: true, autoStart: true, tier: "background", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], tools: 12, prompts: 2, resources: 0 },
|
||||
{
|
||||
name: "github",
|
||||
transport: "stdio",
|
||||
status: "connected",
|
||||
configured: true,
|
||||
autoStart: true,
|
||||
tier: "background",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
tools: 4,
|
||||
prompts: 2,
|
||||
resources: 0,
|
||||
trustedReadOnlyTools: ["pull_request_read"],
|
||||
toolList: [
|
||||
{ name: "issue_read", description: "Read GitHub issue details and comments.", readOnlyHint: true },
|
||||
{ name: "pull_request_read", description: "Read pull request metadata, files, and review threads.", readOnlyHint: true },
|
||||
{ name: "search_issues", description: "Search issues and pull requests.", readOnlyHint: true },
|
||||
{ name: "issue_write", description: "Create or update GitHub issues." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
transport: "http",
|
||||
@@ -791,12 +844,9 @@ function makeMockApp(): AppBindings {
|
||||
autoPlan: "off",
|
||||
providers: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "mimo-token-plan", builtIn: true, added: false, kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro", "mimo-v2.5"], visionModels: ["mimo-v2.5"], visionModelsConfigured: true, default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
officialProviders: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "mimo-api", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"], visionModels: ["mimo-v2.5", "mimo-v2-omni"], visionModelsConfigured: true, default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "mimo-token-plan", builtIn: true, added: false, kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro", "mimo-v2.5"], visionModels: ["mimo-v2.5"], visionModelsConfigured: true, default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
@@ -806,7 +856,7 @@ function makeMockApp(): AppBindings {
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
|
||||
},
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: !freshMock,
|
||||
model: "",
|
||||
@@ -921,9 +971,11 @@ function makeMockApp(): AppBindings {
|
||||
displayMode: "compact",
|
||||
statusBarStyle: "text",
|
||||
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
|
||||
defaultToolApprovalMode: "ask",
|
||||
checkUpdates: true,
|
||||
telemetry: true,
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "~/projects/reasonix/reasonix.toml",
|
||||
providerKinds: ["openai"],
|
||||
autoApproveTools: false,
|
||||
@@ -1065,8 +1117,60 @@ function makeMockApp(): AppBindings {
|
||||
return Boolean(topic && topic.label === t("mock.newSession") && !topic.turns && !topic.lastActivityAt && !topic.status);
|
||||
};
|
||||
const mockTopicRunsInScenario = (topicId: string) => runningMock && mockTopicIsRunning(topicId);
|
||||
const mockTopicHistory = (topicId: string): HistoryMessage[] => {
|
||||
switch (topicId) {
|
||||
const mockLongTranscriptHistory = (): HistoryMessage[] => {
|
||||
const out: HistoryMessage[] = [];
|
||||
for (let i = 1; i <= 18; i++) {
|
||||
out.push({
|
||||
role: "user",
|
||||
content: `第 ${i} 轮:检查聊天滚动定位,切换会话后应该自动停在最新消息底部。`,
|
||||
});
|
||||
if (i === 4) {
|
||||
out.push({ role: "phase", content: "复现切换会话后的滚动位置" });
|
||||
}
|
||||
if (i === 8) {
|
||||
const toolID = "mock-scroll-layout-check";
|
||||
out.push({
|
||||
role: "assistant",
|
||||
content: "我会先读取滚动容器尺寸,再确认是否存在动态高度变化导致的底部偏移。",
|
||||
reasoning: "旧实现只重置 stick 标志,没有主动等待布局稳定;AskCard、Approval、Todo 这类卡片可能在下一帧改变高度。",
|
||||
toolCalls: [{ id: toolID, name: "bash", arguments: JSON.stringify({ command: "npm run check:css && pnpm typecheck" }) }],
|
||||
});
|
||||
out.push({
|
||||
role: "tool",
|
||||
toolCallId: toolID,
|
||||
toolName: "bash",
|
||||
content: "CSS syntax check passed\nz-index token check passed\ntsc --noEmit passed\n",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (i === 13) {
|
||||
out.push({ role: "notice", level: "info", content: "模拟提示:用户向上查看历史后,右下角应出现跳到底部按钮。" });
|
||||
}
|
||||
out.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
`第 ${i} 轮结果:当前滚动契约会在切换会话或 reveal 信号到达后执行强制贴底。`,
|
||||
"它会先立即设置 scrollTop 到 scrollHeight,再连续几个 animation frame 复查,避免动态内容把底部再次推走。",
|
||||
"如果用户主动向上滚动,普通 streaming 不会强行拉回;只有点击跳到底部按钮或显式切换会话才会重新贴底。",
|
||||
].join("\n\n"),
|
||||
});
|
||||
}
|
||||
out.push({
|
||||
role: "compaction",
|
||||
content: "",
|
||||
trigger: "manual",
|
||||
messages: 36,
|
||||
summary: "Mock 长会话用于验证桌面端 Transcript 自动贴底、多帧布局修正和跳到底部按钮。",
|
||||
archive: "mock-scroll-preview",
|
||||
});
|
||||
out.push({
|
||||
role: "assistant",
|
||||
content: "最终状态:这条消息应该位于真实底部。向上滚动后,右下角会显示跳到底部按钮;点击按钮后应回到这里。",
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const mockTopicHistory = (topicId: string): HistoryMessage[] => {
|
||||
switch (topicId) {
|
||||
case "topic_product":
|
||||
return [
|
||||
{
|
||||
@@ -1106,24 +1210,7 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
];
|
||||
case "topic_dev_standard":
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
"[[reasonix-im]]",
|
||||
"provider=lark",
|
||||
"label=Feishu / Lark",
|
||||
"sender=ou_mock_user_001",
|
||||
"chat=p2p 会话",
|
||||
"[[/reasonix-im]]",
|
||||
"你可以做什么",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "我可以在桌面端帮你处理代码编写、文件操作、项目分析和问题定位。来自 IM 的请求会进入同一条聊天时间线,桌面端继续承载模型调用、工具执行和上下文管理。",
|
||||
},
|
||||
];
|
||||
return mockLongTranscriptHistory();
|
||||
case "topic_p3b_pd":
|
||||
return [
|
||||
{ role: "user", content: "把 p3b P&D 的范围和风险重新整理成可执行计划。" },
|
||||
@@ -1157,9 +1244,22 @@ function makeMockApp(): AppBindings {
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const mockRuntimeInjected = new Set<string>();
|
||||
}
|
||||
};
|
||||
const mockHistoryPage = (messages: HistoryMessage[], beforeTurn = 0, limit = 60): HistoryPage => {
|
||||
const totalTurns = messages.reduce((count, message) => count + (message.role === "user" ? 1 : 0), 0);
|
||||
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit || 60)));
|
||||
const endTurn = beforeTurn > 0 && beforeTurn <= totalTurns ? beforeTurn : totalTurns;
|
||||
const startTurn = Math.max(0, endTurn - safeLimit);
|
||||
let turn = -1;
|
||||
const pageMessages = messages.filter((message) => {
|
||||
if (message.role === "user") turn += 1;
|
||||
if (turn < 0) return startTurn === 0;
|
||||
return turn >= startTurn && turn < endTurn;
|
||||
});
|
||||
return { messages: pageMessages, startTurn, endTurn, totalTurns, hasOlder: startTurn > 0 };
|
||||
};
|
||||
const mockRuntimeInjected = new Set<string>();
|
||||
const queueMockTopicRuntime = (tab: TabMeta) => {
|
||||
if (!runningMock) return;
|
||||
const status = mockTopicStatus(tab.topicId);
|
||||
@@ -1632,7 +1732,7 @@ function makeMockApp(): AppBindings {
|
||||
...tab,
|
||||
mode: nextMode,
|
||||
collaborationMode: normalizeCollaborationMode(undefined, tab.goal, nextMode),
|
||||
toolApprovalMode: normalizeToolApprovalMode(undefined, nextMode),
|
||||
toolApprovalMode: mockToolApprovalModeAfterModeChange(tab.toolApprovalMode, nextMode),
|
||||
}
|
||||
: tab,
|
||||
);
|
||||
@@ -1701,7 +1801,7 @@ function makeMockApp(): AppBindings {
|
||||
async ClearSession() {},
|
||||
async Checkpoints() {
|
||||
return [
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
];
|
||||
},
|
||||
async CheckpointsForTab() {
|
||||
@@ -1734,6 +1834,20 @@ function makeMockApp(): AppBindings {
|
||||
}
|
||||
return this.History();
|
||||
},
|
||||
async HistoryPage(beforeTurn = 0, limit = 60) {
|
||||
return mockHistoryPage(await this.History(), beforeTurn, limit);
|
||||
},
|
||||
async HistoryPageForTab(tabID: string, beforeTurn = 0, limit = 60) {
|
||||
return mockHistoryPage(await this.HistoryForTab(tabID), beforeTurn, limit);
|
||||
},
|
||||
async HistoryCheckpointTurnsForTab(tabID: string) {
|
||||
const turns: number[] = [];
|
||||
for (const message of await this.HistoryForTab(tabID)) {
|
||||
if (message.role !== "user") continue;
|
||||
turns.push(message.checkpointTurn ?? turns.length);
|
||||
}
|
||||
return turns;
|
||||
},
|
||||
async ListSessions() {
|
||||
return sessions.map((s) => ({ ...s }));
|
||||
},
|
||||
@@ -1750,14 +1864,23 @@ function makeMockApp(): AppBindings {
|
||||
{ role: "assistant", content: "This is a mock resumed transcript — the real one comes from the kernel." },
|
||||
];
|
||||
},
|
||||
async ResumeSessionForTab(_tabID: string, path: string) {
|
||||
return this.ResumeSession(path);
|
||||
},
|
||||
async OpenChannelSessionForTab(tabID: string, path: string) {
|
||||
mockTabs = mockTabs.map((tab) => tab.id === tabID ? { ...tab, sessionPath: path, readOnly: true } : tab);
|
||||
return this.ResumeSession(path);
|
||||
},
|
||||
async PreviewSession(path: string) {
|
||||
async ResumeSessionForTab(_tabID: string, path: string) {
|
||||
return this.ResumeSession(path);
|
||||
},
|
||||
async ResumeSessionPage(path: string, limit = 60) {
|
||||
return mockHistoryPage(await this.ResumeSession(path), 0, limit);
|
||||
},
|
||||
async ResumeSessionPageForTab(_tabID: string, path: string, limit = 60) {
|
||||
return this.ResumeSessionPage(path, limit);
|
||||
},
|
||||
async OpenChannelSessionForTab(tabID: string, path: string) {
|
||||
mockTabs = mockTabs.map((tab) => tab.id === tabID ? { ...tab, sessionPath: path, readOnly: true } : tab);
|
||||
return this.ResumeSession(path);
|
||||
},
|
||||
async OpenChannelSessionPageForTab(tabID: string, path: string, limit = 60) {
|
||||
return mockHistoryPage(await this.OpenChannelSessionForTab(tabID, path), 0, limit);
|
||||
},
|
||||
async PreviewSession(path: string) {
|
||||
const s = sessions.find((x) => x.path === path) ?? trashedSessions.find((x) => x.path === path);
|
||||
return [
|
||||
{ role: "user", content: s?.preview || `(mock) preview ${path}` },
|
||||
@@ -1874,6 +1997,7 @@ function makeMockApp(): AppBindings {
|
||||
workspacePath,
|
||||
sandboxPath: settings.sandbox.workspaceRoot,
|
||||
gitBranch: active?.gitBranch || (active?.scope === "project" ? "main" : ""),
|
||||
imageInputEnabled: true,
|
||||
autoApproveTools,
|
||||
bypass: autoApproveTools,
|
||||
collaborationMode,
|
||||
@@ -1948,6 +2072,8 @@ function makeMockApp(): AppBindings {
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
url: input.url,
|
||||
envKeys: input.env ? Object.keys(input.env).sort() : undefined,
|
||||
headerKeys: input.headers ? Object.keys(input.headers).sort() : undefined,
|
||||
tools,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
@@ -1961,7 +2087,7 @@ function makeMockApp(): AppBindings {
|
||||
async UpdateMCPServer(name: string, input: MCPServerInput) {
|
||||
capServers = capServers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
const connected = s.status === "connected" || s.status === "failed" || s.tier !== "lazy";
|
||||
const connected = s.status === "connected" || s.status === "failed" || s.autoStart !== false;
|
||||
const nextStatus = s.status === "disabled" ? "disabled" : connected ? "connected" : "deferred";
|
||||
const nextTools = nextStatus === "connected" ? s.tools || (input.transport === "stdio" ? 3 : 5) : 0;
|
||||
return {
|
||||
@@ -1972,6 +2098,8 @@ function makeMockApp(): AppBindings {
|
||||
args: input.transport === "stdio" ? input.args : [],
|
||||
url: input.transport === "stdio" ? "" : input.url,
|
||||
envKeys: input.env ? Object.keys(input.env).sort() : s.envKeys,
|
||||
headerKeys: input.headers ? Object.keys(input.headers).sort() : s.headerKeys,
|
||||
trustedReadOnlyTools: input.trustedReadOnlyTools ?? s.trustedReadOnlyTools,
|
||||
tools: nextTools,
|
||||
error: undefined,
|
||||
authStatus: nextStatus !== "connected" && input.transport !== "stdio" ? "possible" : undefined,
|
||||
@@ -1998,7 +2126,7 @@ function makeMockApp(): AppBindings {
|
||||
s.name === name
|
||||
? {
|
||||
...s,
|
||||
status: s.tier === "background" || s.tier === "eager" ? "initializing" : "deferred",
|
||||
status: s.autoStart === false ? "disabled" : "initializing",
|
||||
tools: 0,
|
||||
error: undefined,
|
||||
authStatus: s.transport !== "stdio" ? "possible" : undefined,
|
||||
@@ -2008,6 +2136,33 @@ function makeMockApp(): AppBindings {
|
||||
: s,
|
||||
);
|
||||
},
|
||||
async TrustMCPServerTool(name: string, toolName: string) {
|
||||
const normalizedTool = toolName.trim();
|
||||
if (!normalizedTool) return;
|
||||
capServers = capServers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
const trusted = Array.from(new Set([...(s.trustedReadOnlyTools ?? []), normalizedTool]));
|
||||
return { ...s, trustedReadOnlyTools: trusted };
|
||||
});
|
||||
},
|
||||
async TrustMCPServerTools(name: string, toolNames: string[]) {
|
||||
const normalizedTools = toolNames.map((tool) => tool.trim()).filter(Boolean);
|
||||
if (normalizedTools.length === 0) return;
|
||||
capServers = capServers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
const trusted = Array.from(new Set([...(s.trustedReadOnlyTools ?? []), ...normalizedTools]));
|
||||
return { ...s, trustedReadOnlyTools: trusted };
|
||||
});
|
||||
},
|
||||
async UntrustMCPServerTool(name: string, toolName: string) {
|
||||
const normalizedTool = toolName.trim();
|
||||
if (!normalizedTool) return;
|
||||
capServers = capServers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
const trusted = (s.trustedReadOnlyTools ?? []).filter((tool) => tool !== normalizedTool);
|
||||
return { ...s, trustedReadOnlyTools: trusted };
|
||||
});
|
||||
},
|
||||
async PickSkillFolder() {
|
||||
return "~/my-skills";
|
||||
},
|
||||
@@ -2037,6 +2192,7 @@ function makeMockApp(): AppBindings {
|
||||
}
|
||||
},
|
||||
async RefreshSkills() {},
|
||||
async ReloadCommands() {},
|
||||
async SetSkillEnabled(name: string, enabled: boolean) {
|
||||
const skill = capSkills.find((s) => s.name === name);
|
||||
if (skill) skill.enabled = enabled;
|
||||
@@ -2059,7 +2215,6 @@ function makeMockApp(): AppBindings {
|
||||
async SetMCPServerTier(name: string, tier: string) {
|
||||
capServers = capServers.map((s) => {
|
||||
if (s.name !== name) return s;
|
||||
if (tier === "lazy") return { ...s, tier, autoStart: true };
|
||||
const tools = s.tools || (s.transport === "stdio" ? 3 : 5);
|
||||
return { ...s, tier, autoStart: true, status: "connected", tools, error: undefined, authStatus: undefined, authUrl: undefined };
|
||||
});
|
||||
@@ -2210,6 +2365,11 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
async AttachDropped(path: string) {
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() ?? path;
|
||||
const hasExt = /\.\w{1,6}$/i.test(name);
|
||||
if (!hasExt) {
|
||||
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
|
||||
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
|
||||
}
|
||||
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
|
||||
},
|
||||
async AttachmentDataURL(_path: string) {
|
||||
@@ -2415,6 +2575,9 @@ function makeMockApp(): AppBindings {
|
||||
async SetAutoPlan(mode: string) {
|
||||
settings.autoPlan = mode;
|
||||
},
|
||||
async SetDefaultToolApprovalMode(mode: string) {
|
||||
settings.defaultToolApprovalMode = normalizeToolApprovalMode(mode);
|
||||
},
|
||||
async SaveProvider(p: ProviderView) {
|
||||
p.added = true;
|
||||
const i = settings.providers.findIndex((x) => x.name === p.name);
|
||||
@@ -2424,10 +2587,9 @@ function makeMockApp(): AppBindings {
|
||||
async AddOfficialProviderAccess(kind: string, key: string) {
|
||||
const templates: Record<string, ProviderView> = {
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
"mimo-api": { name: "mimo-api", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"], visionModels: ["mimo-v2.5", "mimo-v2-omni"], visionModelsConfigured: true, default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: !!key.trim(), balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
"mimo-token-plan": { name: "mimo-token-plan", builtIn: true, added: true, kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", modelsUrl: "", models: ["mimo-v2.5-pro", "mimo-v2.5"], visionModels: ["mimo-v2.5"], visionModelsConfigured: true, default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: !!key.trim(), balanceUrl: "", contextWindow: 1_048_576, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
};
|
||||
const next = templates[kind] ?? templates.deepseek;
|
||||
const next = templates[kind];
|
||||
if (!next) throw new Error(`unknown official provider template ${kind}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
|
||||
else settings.providers.push(next);
|
||||
@@ -2480,6 +2642,10 @@ function makeMockApp(): AppBindings {
|
||||
async SetBotSettings(b: BotSettingsView) {
|
||||
settings.bot = JSON.parse(JSON.stringify(b)) as BotSettingsView;
|
||||
},
|
||||
async SetBotConnectionToolApprovalMode(connID, mode) {
|
||||
const conn = settings.bot.connections.find((c) => c.id === connID);
|
||||
if (conn) conn.toolApprovalMode = mode as any;
|
||||
},
|
||||
async SetBotSecret(envName: string, _value: string) {
|
||||
const name = envName.trim();
|
||||
if (settings.bot.qq.appSecretEnv === name) settings.bot.qq.secretSet = true;
|
||||
@@ -2603,6 +2769,9 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopMetrics(enabled: boolean) {
|
||||
settings.metrics = enabled;
|
||||
},
|
||||
async SetMemoryCompilerEnabled(enabled: boolean) {
|
||||
settings.memoryCompilerEnabled = enabled;
|
||||
},
|
||||
async SetExpandThinking(_on: boolean) {},
|
||||
async MigrateDesktopPreferences(language: string, theme: string, style: string) {
|
||||
if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" ? language : "";
|
||||
@@ -2708,6 +2877,7 @@ function makeMockApp(): AppBindings {
|
||||
mockTabs = mockTabs.map((tab) => (tab.id === existing.id ? active : { ...tab, active: false }));
|
||||
return { ...active };
|
||||
}
|
||||
const defaultToolApprovalMode = normalizeToolApprovalMode(settings.defaultToolApprovalMode);
|
||||
const tab: TabMeta = {
|
||||
id: "tab_" + Date.now(),
|
||||
scope: "project",
|
||||
@@ -2719,12 +2889,12 @@ function makeMockApp(): AppBindings {
|
||||
topicTitle: topicLabel(_topicID, t("mock.newSession")),
|
||||
sessionPath: `/mock/sessions/${_topicID}.jsonl`,
|
||||
projectColor: mockProjectTree.find((node) => node.root === workspaceRoot)?.projectColor,
|
||||
label: "deepseek-v4-flash",
|
||||
label: mockModelLabel(settings.defaultModel),
|
||||
ready: true,
|
||||
running: mockTopicRunsInScenario(_topicID),
|
||||
mode: "normal",
|
||||
mode: modeWithAutoApproveTools("normal", defaultToolApprovalMode === "yolo"),
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
toolApprovalMode: defaultToolApprovalMode,
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: workspaceRoot,
|
||||
@@ -2738,6 +2908,7 @@ function makeMockApp(): AppBindings {
|
||||
setMockActiveTab(existing.id);
|
||||
return { ...existing, active: true };
|
||||
}
|
||||
const defaultToolApprovalMode = normalizeToolApprovalMode(settings.defaultToolApprovalMode);
|
||||
const tab: TabMeta = {
|
||||
id: "tab_" + Date.now(),
|
||||
scope: "global",
|
||||
@@ -2747,12 +2918,12 @@ function makeMockApp(): AppBindings {
|
||||
topicId: _topicID,
|
||||
topicTitle: topicLabel(_topicID, "Global"),
|
||||
sessionPath: `/mock/sessions/${_topicID}.jsonl`,
|
||||
label: "deepseek-v4-flash",
|
||||
label: mockModelLabel(settings.defaultModel),
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
mode: modeWithAutoApproveTools("normal", defaultToolApprovalMode === "yolo"),
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
toolApprovalMode: defaultToolApprovalMode,
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "",
|
||||
@@ -2784,6 +2955,20 @@ function makeMockApp(): AppBindings {
|
||||
const topic = await this.CreateTopic(targetScope, targetRoot, "");
|
||||
return targetScope === "global" ? this.OpenGlobalTab(topic.id) : this.OpenProjectTab(targetRoot, topic.id);
|
||||
},
|
||||
async ActivateTopic(scope: string, workspaceRoot: string, topicID: string, sessionPath: string) {
|
||||
const tab = sessionPath
|
||||
? await this.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
|
||||
: scope === "project"
|
||||
? await this.OpenProjectTab(workspaceRoot, topicID)
|
||||
: await this.OpenGlobalTab(topicID);
|
||||
mockTabs = mockTabs.filter((item) => item.id === tab.id).map((item) => ({ ...item, active: true }));
|
||||
return { ...mockTabs[0] };
|
||||
},
|
||||
async EnsureBlankSurface(scope: string, workspaceRoot: string) {
|
||||
const tab = await this.EnsureBlankTab(scope, workspaceRoot);
|
||||
mockTabs = mockTabs.filter((item) => item.id === tab.id).map((item) => ({ ...item, active: true }));
|
||||
return { ...mockTabs[0] };
|
||||
},
|
||||
async SetActiveTab(_tabID: string) {
|
||||
setMockActiveTab(_tabID);
|
||||
const tab = mockTabs.find((item) => item.id === _tabID);
|
||||
@@ -2911,6 +3096,9 @@ function makeMockApp(): AppBindings {
|
||||
reasoningTokens: 7521,
|
||||
cacheHitTokens: 87000,
|
||||
cacheMissTokens: 13000,
|
||||
sessionCacheHitTokens: 87000,
|
||||
sessionCacheMissTokens: 13000,
|
||||
sessionCompletionTokens: 12345,
|
||||
requestCount: 10,
|
||||
elapsedMs: 33 * 60 * 1000,
|
||||
sessionCost: cost(0.018),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
type ComposerDraftKeyTab = {
|
||||
id?: string;
|
||||
scope?: string;
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
sessionPath?: string;
|
||||
};
|
||||
|
||||
export function composerDraftKeyForTab(activeTab: ComposerDraftKeyTab | undefined, activeTabId?: string | null): string {
|
||||
if (!activeTab) return activeTabId ?? "";
|
||||
const scope = activeTab.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? activeTab.workspaceRoot || "" : "";
|
||||
const topicId = activeTab.topicId || "";
|
||||
const sessionPath = activeTab.sessionPath || "";
|
||||
if (topicId) {
|
||||
return ["session-topic", scope, workspaceRoot, topicId].join("\u0000");
|
||||
}
|
||||
if (sessionPath) {
|
||||
return ["session-path", scope, workspaceRoot, sessionPath].join("\u0000");
|
||||
}
|
||||
return ["tab", activeTab.id || activeTabId || ""].join("\u0000");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface ComposerProfile {
|
||||
}
|
||||
|
||||
export type ComposerProfilesByTab = Record<string, ComposerProfile>;
|
||||
export type UserPlanModeIntents = Record<string, true>;
|
||||
|
||||
const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "tokenMode", "goal"];
|
||||
|
||||
@@ -52,24 +53,39 @@ function profileWithPending(profile: Omit<ComposerProfile, "pending">, pending:
|
||||
return { ...profile, pending };
|
||||
}
|
||||
|
||||
export function composerProfileFromTab(tab?: TabMeta | null): ComposerProfile {
|
||||
function fallbackToolApprovalMode(rawMode: string | undefined, fallback?: ToolApprovalMode | null): ToolApprovalMode | undefined {
|
||||
if ((rawMode ?? "").trim() !== "") return undefined;
|
||||
return fallback === "auto" ? "auto" : undefined;
|
||||
}
|
||||
|
||||
export function composerProfileFromTab(tab?: TabMeta | null, fallback?: ToolApprovalMode | null): ComposerProfile {
|
||||
if (!tab) return { ...defaultComposerProfile, pending: {} };
|
||||
const legacyMode = normalizeMode(tab.mode);
|
||||
const goal = activeGoal(tab.goal, tab.goalStatus);
|
||||
return profileWithPending({
|
||||
collaborationMode: normalizeCollaborationMode(tab.collaborationMode, goal, legacyMode),
|
||||
goalDraftMode: false,
|
||||
toolApprovalMode: normalizeToolApprovalMode(tab.toolApprovalMode, legacyMode, tab.toolApprovalMode === "yolo"),
|
||||
toolApprovalMode: normalizeToolApprovalMode(
|
||||
tab.toolApprovalMode,
|
||||
legacyMode,
|
||||
tab.toolApprovalMode === "yolo",
|
||||
fallbackToolApprovalMode(tab.toolApprovalMode, fallback),
|
||||
),
|
||||
tokenMode: normalizeTokenMode(tab.tokenMode),
|
||||
goal,
|
||||
});
|
||||
}
|
||||
|
||||
export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode): ComposerProfile {
|
||||
export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode, fallback?: ToolApprovalMode | null): ComposerProfile {
|
||||
if (!meta) return { ...defaultComposerProfile, pending: {} };
|
||||
const fallbackMode = normalizeMode(legacyMode);
|
||||
const goal = activeGoal(meta.goal, meta.goalStatus);
|
||||
const toolApprovalMode = normalizeToolApprovalMode(meta.toolApprovalMode, fallbackMode, meta.autoApproveTools ?? meta.bypass);
|
||||
const toolApprovalMode = normalizeToolApprovalMode(
|
||||
meta.toolApprovalMode,
|
||||
fallbackMode,
|
||||
meta.autoApproveTools ?? meta.bypass,
|
||||
fallbackToolApprovalMode(meta.toolApprovalMode, fallback),
|
||||
);
|
||||
return profileWithPending({
|
||||
collaborationMode: normalizeCollaborationMode(meta.collaborationMode, goal, fallbackMode),
|
||||
goalDraftMode: false,
|
||||
@@ -138,7 +154,7 @@ export function hydrateComposerProfilesFromTabs(current: ComposerProfilesByTab,
|
||||
let changed = false;
|
||||
|
||||
for (const tab of tabs) {
|
||||
const profile = reconcileComposerProfile(current[tab.id], composerProfileFromTab(tab));
|
||||
const profile = reconcileComposerProfile(current[tab.id], composerProfileFromTab(tab, current[tab.id]?.toolApprovalMode));
|
||||
next[tab.id] = profile;
|
||||
if (!profilesEqual(current[tab.id], profile)) changed = true;
|
||||
}
|
||||
@@ -152,7 +168,11 @@ export function hydrateComposerProfilesFromTabs(current: ComposerProfilesByTab,
|
||||
|
||||
export function hydrateComposerProfileFromMeta(current: ComposerProfilesByTab, tabId: string, meta: Meta): ComposerProfilesByTab {
|
||||
const previous = current[tabId];
|
||||
const backend = composerProfileFromMeta(meta, previous ? composerProfileMode(previous) : undefined);
|
||||
const backend = composerProfileFromMeta(
|
||||
meta,
|
||||
previous ? composerProfileMode(previous) : undefined,
|
||||
previous?.toolApprovalMode,
|
||||
);
|
||||
const profile = reconcileComposerProfile(previous, backend);
|
||||
if (profilesEqual(previous, profile)) return current;
|
||||
return { ...current, [tabId]: profile };
|
||||
@@ -202,3 +222,48 @@ export function composerProfileWithMode(mode: Mode): Partial<Omit<ComposerProfil
|
||||
goal: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUserPlanModeIntent(
|
||||
current: UserPlanModeIntents,
|
||||
tabId: string | null | undefined,
|
||||
enabled: boolean,
|
||||
): UserPlanModeIntents {
|
||||
if (!tabId) return current;
|
||||
if (enabled) {
|
||||
return current[tabId] ? current : { ...current, [tabId]: true };
|
||||
}
|
||||
if (!current[tabId]) return current;
|
||||
const next = { ...current };
|
||||
delete next[tabId];
|
||||
return next;
|
||||
}
|
||||
|
||||
export function pruneUserPlanModeIntents(current: UserPlanModeIntents, tabIds: Iterable<string>): UserPlanModeIntents {
|
||||
const live = new Set(tabIds);
|
||||
let changed = false;
|
||||
const next: UserPlanModeIntents = {};
|
||||
for (const tabId of Object.keys(current)) {
|
||||
if (live.has(tabId)) {
|
||||
next[tabId] = true;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
}
|
||||
|
||||
export function shouldRestoreUserPlanMode(current: UserPlanModeIntents, tabId: string | null | undefined): boolean {
|
||||
return Boolean(tabId && current[tabId]);
|
||||
}
|
||||
|
||||
export function resolvePlanRestoreTabId(eventTabId: string | null | undefined, activeTabId: string | null | undefined): string | null {
|
||||
return eventTabId || activeTabId || null;
|
||||
}
|
||||
|
||||
export function shouldRestoreUserPlanModeForProfile(
|
||||
current: UserPlanModeIntents,
|
||||
tabId: string | null | undefined,
|
||||
profile?: Pick<ComposerProfile, "goal"> | null,
|
||||
): boolean {
|
||||
return shouldRestoreUserPlanMode(current, tabId) && !profile?.goal.trim();
|
||||
}
|
||||
@@ -535,10 +535,14 @@ export function reportCrash(label: string, err: unknown, extra?: string) {
|
||||
type GlobalCrashEventLike = Pick<Event, "defaultPrevented"> & {
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
filename?: unknown;
|
||||
lineno?: unknown;
|
||||
colno?: unknown;
|
||||
};
|
||||
|
||||
const RESIZE_OBSERVER_LOOP_MESSAGE_RE =
|
||||
/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications\.?)$/;
|
||||
const OPAQUE_SCRIPT_ERROR_MESSAGE = "Script error.";
|
||||
|
||||
function globalCrashEventMessages(e: GlobalCrashEventLike): string[] {
|
||||
const messages: string[] = [];
|
||||
@@ -562,6 +566,24 @@ export function shouldReportGlobalCrashEvent(e: GlobalCrashEventLike): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function globalScriptErrorLocation(e: GlobalCrashEventLike): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof e.filename === "string" && e.filename.trim()) parts.push(`filename=${e.filename.trim()}`);
|
||||
if (typeof e.lineno === "number" && Number.isFinite(e.lineno) && e.lineno > 0) parts.push(`lineno=${e.lineno}`);
|
||||
if (typeof e.colno === "number" && Number.isFinite(e.colno) && e.colno > 0) parts.push(`colno=${e.colno}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function globalCrashReportReason(e: GlobalCrashEventLike): unknown {
|
||||
if (e.error !== undefined && e.error !== null) return e.error;
|
||||
const message = typeof e.message === "string" ? e.message.trim() : e.message;
|
||||
if (message === OPAQUE_SCRIPT_ERROR_MESSAGE) {
|
||||
const location = globalScriptErrorLocation(e);
|
||||
if (location) return `${OPAQUE_SCRIPT_ERROR_MESSAGE}\n${location}`;
|
||||
}
|
||||
return e.message;
|
||||
}
|
||||
|
||||
export function shouldPromptForPerformanceLabel(
|
||||
alreadyHandled: boolean,
|
||||
msSinceLastPrompt: number,
|
||||
@@ -664,7 +686,7 @@ export function installPerformancePressureMonitor() {
|
||||
|
||||
export function installGlobalCrashHandlers() {
|
||||
window.addEventListener("error", (e) => {
|
||||
if (shouldReportGlobalCrashEvent(e)) reportCrash("window.error", e.error ?? e.message);
|
||||
if (shouldReportGlobalCrashEvent(e)) reportCrash("window.error", globalCrashReportReason(e));
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
if (shouldReportGlobalCrashEvent(e)) reportCrash("unhandledrejection", e.reason);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { WireGuardian } from "./types";
|
||||
|
||||
export function formatGuardianAssessmentNotice(guardian: WireGuardian): string {
|
||||
const outcome = guardian.outcome || "unknown";
|
||||
const parts = [`Guardian ${outcome}`];
|
||||
if (guardian.tool) parts.push(guardian.tool);
|
||||
if (guardian.subject) parts.push(guardian.subject);
|
||||
if (guardian.risk_level) parts.push(`risk=${guardian.risk_level}`);
|
||||
if (guardian.user_authorization) parts.push(`authorization=${guardian.user_authorization}`);
|
||||
if (guardian.rationale) parts.push(guardian.rationale);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -9,11 +9,21 @@ export type ShortcutAction =
|
||||
| "settings.open"
|
||||
| "tab.close"
|
||||
| "shell.toggle"
|
||||
| "sidebar.toggle"
|
||||
| "textSize.increase"
|
||||
| "textSize.decrease"
|
||||
| "textSize.reset"
|
||||
| "toolApproval.yolo"
|
||||
| "shortcuts.show";
|
||||
| "shortcuts.show"
|
||||
| "topic.goto.1"
|
||||
| "topic.goto.2"
|
||||
| "topic.goto.3"
|
||||
| "topic.goto.4"
|
||||
| "topic.goto.5"
|
||||
| "topic.goto.6"
|
||||
| "topic.goto.7"
|
||||
| "topic.goto.8"
|
||||
| "topic.goto.9";
|
||||
|
||||
type KeyboardShortcutEvent = Pick<globalThis.KeyboardEvent, "key"> &
|
||||
Partial<Pick<globalThis.KeyboardEvent, "ctrlKey" | "metaKey" | "altKey" | "shiftKey" | "target">>;
|
||||
@@ -82,6 +92,18 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
|
||||
section: "view",
|
||||
labelKey: "shortcuts.action.shellToggle",
|
||||
descriptionKey: "shortcuts.desc.shellToggle",
|
||||
defaults: {
|
||||
darwin: { key: "b", meta: true, shift: true },
|
||||
windows: { key: "b", ctrl: true, shift: true },
|
||||
linux: { key: "b", ctrl: true, shift: true },
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
action: "sidebar.toggle",
|
||||
section: "view",
|
||||
labelKey: "shortcuts.action.sidebarToggle",
|
||||
descriptionKey: "shortcuts.desc.sidebarToggle",
|
||||
defaults: modCombo("b"),
|
||||
preventDefault: true,
|
||||
},
|
||||
@@ -131,6 +153,87 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
|
||||
defaults: allPlatforms({ key: "?", shift: true }),
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.1",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto1",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("1"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.2",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto2",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("2"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.3",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto3",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("3"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.4",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto4",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("4"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.5",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto5",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("5"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.6",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto6",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("6"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.7",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto7",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("7"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.8",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto8",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("8"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
{
|
||||
action: "topic.goto.9",
|
||||
section: "session",
|
||||
labelKey: "shortcuts.action.topicGoto9",
|
||||
descriptionKey: "shortcuts.desc.topicGoto",
|
||||
defaults: modCombo("9"),
|
||||
preventDefault: true,
|
||||
configurable: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
let cachedCustomShortcuts: Partial<Record<ShortcutAction, ShortcutCombo>> | null = null;
|
||||
@@ -382,13 +485,15 @@ function isModifierKey(key: string): boolean {
|
||||
return key === "Meta" || key === "Control" || key === "Alt" || key === "Shift";
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (typeof HTMLElement === "undefined") return false;
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
}
|
||||
|
||||
function isShortcutRecorderTarget(target: EventTarget | null): boolean {
|
||||
export function isShortcutRecorderTarget(target: EventTarget | null): boolean {
|
||||
if (typeof HTMLElement === "undefined") return false;
|
||||
return target instanceof HTMLElement && Boolean(target.closest(".shortcuts-settings__key--recording"));
|
||||
}
|
||||
@@ -1,15 +1,36 @@
|
||||
import type { ServerView } from "./types";
|
||||
|
||||
function startIntent(s: ServerView): "off" | "automatic" {
|
||||
if (s.startIntent === "off" || s.startIntent === "automatic") return s.startIntent;
|
||||
if (s.status === "disabled" || (s.configured && !s.autoStart)) return "off";
|
||||
return "automatic";
|
||||
}
|
||||
|
||||
function runtimeState(s: ServerView): "idle" | "connecting" | "ready" | "issue" {
|
||||
if (s.runtimeState === "idle" || s.runtimeState === "connecting" || s.runtimeState === "ready" || s.runtimeState === "issue") return s.runtimeState;
|
||||
if (s.status === "connected") return "ready";
|
||||
if (s.status === "initializing") return "connecting";
|
||||
if (s.status === "failed") return "issue";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function mcpServerLifecycleActions(s: ServerView): {
|
||||
enabled: boolean;
|
||||
showRetryInRow: boolean;
|
||||
canConnectNow: boolean;
|
||||
canReconnect: boolean;
|
||||
} {
|
||||
const intent = startIntent(s);
|
||||
const state = runtimeState(s);
|
||||
return {
|
||||
enabled: s.status === "connected" || s.status === "deferred" || s.status === "initializing",
|
||||
showRetryInRow: s.status === "failed" || s.status === "initializing",
|
||||
canConnectNow: s.status === "deferred" || s.status === "disabled",
|
||||
canReconnect: s.status === "connected" || s.status === "initializing",
|
||||
enabled: state === "ready" || intent !== "off",
|
||||
showRetryInRow: state === "issue",
|
||||
canConnectNow: intent === "off" && state !== "ready",
|
||||
canReconnect: state === "ready" || state === "issue",
|
||||
};
|
||||
}
|
||||
|
||||
export function mcpServerRetryableFromAvailableList(s: ServerView): boolean {
|
||||
if (s.status === "connected" || s.status === "disabled" || s.status === "failed") return false;
|
||||
return startIntent(s) !== "off";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MemoryCitation } from "./types";
|
||||
|
||||
const compilerCitationKinds = new Set(["compiler_reference", "constraint", "risk_note"]);
|
||||
|
||||
export function visibleTranscriptMemoryCitations(citations?: MemoryCitation[]): MemoryCitation[] {
|
||||
return (citations ?? []).filter((citation) => !isMemoryCompilerCitation(citation));
|
||||
}
|
||||
|
||||
function isMemoryCompilerCitation(citation: MemoryCitation): boolean {
|
||||
const kind = (citation.kind ?? "").trim();
|
||||
if (!compilerCitationKinds.has(kind)) return false;
|
||||
const source = (citation.source || citation.id || "").trim().toLowerCase();
|
||||
return source === "" || source === "memory v5";
|
||||
}
|
||||
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user