Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd20bb50be | ||
|
|
a6b7f9d8c5 | ||
|
|
75f5604001 | ||
|
|
bb5888a50d | ||
|
|
9eace52719 | ||
|
|
c8a7d7ef9a | ||
|
|
21ffa334b7 | ||
|
|
ccaefa0822 | ||
|
|
72070dde3e | ||
|
|
76fdbabd6e | ||
|
|
e3ef61b9aa | ||
|
|
0a17f77749 | ||
|
|
803583e4a2 | ||
|
|
a717dcd344 | ||
|
|
3763768436 | ||
|
|
7c15d9c619 | ||
|
|
1ea3f097f4 | ||
|
|
f4b815ecda | ||
|
|
7f4c9dc589 | ||
|
|
8aa4ea7a87 | ||
|
|
a0af95ffab | ||
|
|
2cc5ad25f4 | ||
|
|
07e6a61344 | ||
|
|
3807e411d7 | ||
|
|
21f37476dc | ||
|
|
5cd38fde8f |
No files matched your search
|
Before Width: | Height: | Size: 138 KiB |
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ffffff;
|
||||
stroke: #ffffff;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #111111;
|
||||
stroke: #111111;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 138 KiB |
@@ -2,6 +2,9 @@
|
||||
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> **Reasonix is no longer maintained as of 2026-11-12.** The final release is v2.61.0; there will be no further releases, fixes or issue triage. The project is MIT-licensed and forks are welcome. See the [announcement](https://github.com/esengine/DeepSeek-Reasonix/issues/15109).
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong>
|
||||
·
|
||||
@@ -16,10 +19,6 @@
|
||||
<a href="./docs/SPEC.md">Spec</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,9 +30,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -48,19 +44,8 @@
|
||||
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
|
||||
|
||||
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
|
||||
|
||||
<br/>
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
|
||||
@@ -312,19 +297,3 @@ Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for design
|
||||
<br/>
|
||||
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub><strong>Support this project</strong></sub></p>
|
||||
|
||||
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
|
||||
coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付 / 支付宝(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
|
||||
</p>
|
||||
@@ -2,6 +2,9 @@
|
||||
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> **Reasonix 已于 2026-11-12 起停止维护。** 最后一个版本为 v2.61.0,此后不再发布新版本、修复缺陷或处理 Issue。项目采用 MIT 协议,欢迎 fork。详见[公告](https://github.com/esengine/DeepSeek-Reasonix/issues/15109)。
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a>
|
||||
·
|
||||
@@ -16,10 +19,6 @@
|
||||
<a href="./docs/SPEC.md">规格</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,9 +30,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
|
||||
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -48,17 +44,6 @@
|
||||
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
|
||||
@@ -293,18 +278,3 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
<br/>
|
||||
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub><strong>支持本项目</strong></sub></p>
|
||||
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付 / 支付宝(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
|
||||
</p>
|
||||
@@ -77,5 +77,10 @@
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"redos-detector": "file:./vendor/redos-detector-6.1.4.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class BenchPort extends MockPort {
|
||||
// ?peerset=1 starts with session messages on and two projects already allowed through.
|
||||
if (query.has("peerset")) {
|
||||
this.peerSettings = {
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, applies: "next_turn", pending: query.has("pending"), available: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
|
||||
};
|
||||
|
||||
@@ -27,17 +27,16 @@ try {
|
||||
check(`${scheme}: the shortcut buttons share one size`, spread("w") <= TOLERANCE && spread("h") <= TOLERANCE, JSON.stringify(strip.map((s) => [s.w, s.h])));
|
||||
check(`${scheme}: the shortcut rows align and center their icons`, spread("left") <= TOLERANCE && strip.every((s) => Math.abs(s.cy - s.iconCy) <= TOLERANCE), JSON.stringify(strip.map((s) => [s.left, s.cy - s.iconCy])));
|
||||
check(`${scheme}: the shortcut icons share one size`, new Set(strip.map((s) => s.icon.join("x"))).size === 1, strip.map((s) => s.icon.join("x")).join(" "));
|
||||
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row", ".studio-feedback"].map((s) => {
|
||||
const rows = await page.evaluate(() => [".studio-new-task", ".studio-search", ".studio-tools-row"].map((s) => {
|
||||
const row = document.querySelector(s);
|
||||
if (!row) return null;
|
||||
const r = row.getBoundingClientRect();
|
||||
const svg = row.querySelector("svg, .studio-icon").getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, icon: svg.left - r.left, visible: r.width > 0 && r.height > 0 };
|
||||
}));
|
||||
check(`${scheme}: new session, search and feedback are drawn`, rows.every((r) => r?.visible), JSON.stringify(rows));
|
||||
check(`${scheme}: new session, search and tools are drawn`, rows.every((r) => r?.visible), JSON.stringify(rows));
|
||||
check(`${scheme}: those rows share the sidebar's left and right edges`, rows.every((r) => r && Math.abs(r.left - rows[0].left) <= TOLERANCE && Math.abs(r.right - rows[0].right) <= TOLERANCE), JSON.stringify(rows.map((r) => [r.left, r.right])));
|
||||
const fill = await page.evaluate(() => getComputedStyle(document.querySelector(".studio-feedback")).backgroundColor);
|
||||
check(`${scheme}: the feedback row has no fill at rest`, /rgba\(0, 0, 0, 0\)|transparent/.test(fill), fill);
|
||||
check(`${scheme}: the retired feedback row is not drawn`, !(await page.locator(".studio-feedback").count()));
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -21,12 +21,7 @@ try {
|
||||
const root = page.locator(".studio-tools");
|
||||
const settings = root.locator('[data-action="chrome.settings"]');
|
||||
assert.equal(await settings.innerText(), "设置");
|
||||
const alignment = await settings.evaluate((button) => {
|
||||
const icon = button.querySelector('svg').getBoundingClientRect();
|
||||
const feedback = document.querySelector('.studio-feedback svg').getBoundingClientRect();
|
||||
return Math.abs(icon.x - feedback.x);
|
||||
});
|
||||
assert.ok(alignment <= 4, `settings and feedback icons align: ${alignment}`);
|
||||
assert.equal(await page.locator('.studio-feedback').count(), 0, 'the retired feedback row is not drawn');
|
||||
if (hidden) {
|
||||
assert.equal(await root.locator('.studio-tools-toggle').count(), 0);
|
||||
await root.hover();
|
||||
|
||||
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
redos-detector: file:./vendor/redos-detector-6.1.4.tgz
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -2475,8 +2478,9 @@ packages:
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==}
|
||||
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
|
||||
resolution: {integrity: sha512-0a7pybN7BYeVkQnDiL0GuELrvrvRYd4cDqV0T7G0+UsRAdg7SfMy60Vf0V4SPeftu6hD60FiDi5VANJsP9+ISg==, tarball: file:vendor/redos-detector-6.1.4.tgz}
|
||||
version: 6.1.4
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3049,7 +3053,7 @@ snapshots:
|
||||
'@lit/context': 1.1.6
|
||||
'@preact/signals-core': 1.14.4
|
||||
lit: 3.3.3
|
||||
redos-detector: 6.1.4
|
||||
redos-detector: file:vendor/redos-detector-6.1.4.tgz
|
||||
validate-color: 2.2.4
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2([email protected])
|
||||
@@ -5599,7 +5603,7 @@ snapshots:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
[email protected]:
|
||||
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
|
||||
dependencies:
|
||||
regjsparser: 0.13.0
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "phone-device.retry", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "phone-device.restart", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "workbench.tree-resize", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -682,6 +683,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "reply.run-detail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.processes", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.jump", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -744,6 +746,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "peers.approve-project", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.open-nested", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -162,6 +162,8 @@
|
||||
}
|
||||
.workbench-explorer .workbench-explorer-head small, .workbench-history > summary small { margin-left: auto; color: var(--faint); font: var(--w-med) var(--fs-meta)/1 var(--ui); }
|
||||
.workbench-explorer .workbench-explorer-head small + .btn, .workbench-explorer .workbench-explorer-head small ~ .btn { flex: none; }
|
||||
.workbench-explorer .workbench-changes > .workbench-explorer-head { height: auto; min-height: 34px; padding-block: 5px; flex-wrap: wrap; row-gap: 4px; }
|
||||
.workbench-explorer .workbench-changes > .workbench-explorer-head > :is(span, small, .workbench-section-title) { flex: none; white-space: nowrap; }
|
||||
.workbench-section-fold { width: 18px; height: 18px; padding: 0; display: grid; place-items: center; flex: none; border: 0; background: none; color: var(--faint); cursor: pointer; }
|
||||
.workbench-section-fold .studio-icon, .gh-fold { width: 12px; height: 12px; flex: none; color: var(--faint); transition: transform var(--t-line) ease; }
|
||||
.workbench-history:not([open]) > summary .gh-fold { transform: rotate(-90deg); }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The project is no longer maintained, so nothing in the window offers to send
|
||||
* a report or suggestion. The kernel answers `feedback.unavailable` on its own;
|
||||
* this keeps the entry points from being drawn at all. */
|
||||
export const FEEDBACK_RETIRED = true;
|
||||
@@ -120,6 +120,7 @@ const SAID: Record<string, string> = {
|
||||
"terminal.not_found": "kernel.that_terminal_is_not_open",
|
||||
"terminal.shell_missing": "kernel.that_shell_is_not_on",
|
||||
"terminal.start_failed": "kernel.the_shell_could_not_start",
|
||||
"terminal.unsupported": "kernel.this_windows_has_no_built_in_terminal",
|
||||
"terminal.exited": "kernel.the_shell_has_ended",
|
||||
"terminal.limit": "kernel.too_many_terminals_are_open",
|
||||
"workspace.file_missing": "kernel.the_file_was_not_found",
|
||||
@@ -518,6 +519,7 @@ const SAID: Record<string, string> = {
|
||||
"subagent.not_found": "kernel.no_sub_agent_run_was",
|
||||
"subagent.not_owned": "kernel.that_sub_agent_belongs_to",
|
||||
"subagent.read_failed": "kernel.could_not_read_the_sub",
|
||||
"process.read_failed": "kernel.could_not_read_the_processes",
|
||||
"subagent.not_running": "kernel.that_sub_agent_is_no",
|
||||
"subagent.steer_empty": "kernel.guidance_cannot_be_empty",
|
||||
"subagent.steer_too_large": "kernel.that_guidance_is_longer_than",
|
||||
|
||||
@@ -111,6 +111,7 @@ export const KERNEL: Messages = {
|
||||
"kernel.could_not_reach_host_detail": { zh: "连接 {host} 失败:{detail}", en: "Could not reach {host}: {detail}" },
|
||||
"kernel.could_not_reach_the_account": { zh: "连不上账号服务,请检查网络后重试", en: "Could not reach the account service; check the network and try again" },
|
||||
"kernel.could_not_read_the_file": { zh: "文件预览读取失败,请重试", en: "Could not read the file preview. Try again." },
|
||||
"kernel.could_not_read_the_processes": { zh: "读取进程列表失败:{detail}", en: "Could not read the process list: {detail}" },
|
||||
"kernel.could_not_read_the_sub": { zh: "读取子代理记录失败:{detail}", en: "Could not read the sub-agent record: {detail}" },
|
||||
"kernel.could_not_read_this_commit": { zh: "无法读取这个提交", en: "Could not read this commit" },
|
||||
"kernel.could_not_read_this_file": { zh: "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。", en: "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage." },
|
||||
@@ -596,6 +597,7 @@ export const KERNEL: Messages = {
|
||||
"kernel.the_session_workspace_or_runtime": { zh: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发", en: "The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again" },
|
||||
"kernel.the_settings_were_written_but": { zh: "设置已写入,但运行时未能按新设置重建:{detail}", en: "The settings were written, but the runtime could not be rebuilt on them: {detail}" },
|
||||
"kernel.the_sharing_listener_is_not": { zh: "共享入口配置不完整,无法接受连接。", en: "The sharing listener is not fully configured and cannot accept connections." },
|
||||
"kernel.this_windows_has_no_built_in_terminal": { zh: "这个 Windows 版本不支持内置终端(需要 Windows 10 1809 或更高版本)", en: "The built-in terminal needs Windows 10 version 1809 or later" },
|
||||
"kernel.the_shell_could_not_start": { zh: "终端没能启动", en: "The terminal could not start" },
|
||||
"kernel.the_shell_has_ended": { zh: "终端里的程序已经结束,重新启动后再输入", en: "The shell has ended; restart it before typing" },
|
||||
"kernel.the_shell_settings_were_not": { zh: "shell 设置未能保存:{detail}", en: "The shell settings were not saved: {detail}" },
|
||||
|
||||
@@ -79,6 +79,8 @@ export const REMOTE: Messages = {
|
||||
"remote.last_used_at": { zh: "最近使用 {at}", en: "Last used {at}" },
|
||||
"remote.leave_empty_to_let_the": { zh: "留空则每次开启时由系统随机选择,范围 1024–65535。更改端口后,手机需要重新扫码配对。", en: "Leave empty to let the system pick a port each time, or choose one from 1024 to 65535. After changing the port, phones need to scan a new code to pair again." },
|
||||
"remote.left_blank_it_tries_in": { zh: "留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。", en: "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively." },
|
||||
"remote.open_nested_folders_without_asking": { zh: "不询问直接打开本项目内的文件夹", en: "Open folders inside this project without asking" },
|
||||
"remote.open_nested_folders_without_asking_ds": { zh: "模型用 open_projects 把本项目目录下的子文件夹加为项目时,直接添加并标为可信,不再弹确认卡;项目之外的文件夹仍然会询问。被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。", en: "When the model adds a subfolder of this project as a project with open_projects, it is added and trusted without a card; folders outside the project still ask. A trusted folder's configuration can run programs, so turn this on only for a repository you trust." },
|
||||
"remote.let_the_model_hand_a": { zh: "允许模型把任务交给别的会话", en: "Let the model hand a task to another session" },
|
||||
"remote.local_pairing_valid_until_time": { zh: "局域网配对 · {time} 前有效", en: "Local pairing · valid until {time}" },
|
||||
"remote.lost_the_connection_to_machine": { zh: "与 {machine} 的连接已中断", en: "Lost the connection to {machine}" },
|
||||
|
||||
@@ -10,6 +10,26 @@ export const SUBAGENT: Messages = {
|
||||
"subagent.deck.hint": { zh: "点一行在工作台打开", en: "Select a row to open it in the workbench" },
|
||||
"subagent.deck.open_full": { zh: "查看完整记录:{name}", en: "Open full transcript: {name}" },
|
||||
"subagent.deck.title": { zh: "本会话的子代理", en: "This session's subagents" },
|
||||
"subagent.process.cause.adopted": { zh: "沿用已有结果", en: "Reused an earlier result" },
|
||||
"subagent.process.cause.budget": { zh: "预算用尽", en: "Out of budget" },
|
||||
"subagent.process.cause.claim": { zh: "等被占用的写入路径", en: "Waiting for a write path someone holds" },
|
||||
"subagent.process.cause.held": { zh: "被搁置,尚未开始", en: "Held back, not started" },
|
||||
"subagent.process.cause.parked": { zh: "停在无人回答的提问上", en: "Stopped on a question nobody could answer" },
|
||||
"subagent.process.cause.slots": { zh: "等并发名额", en: "Waiting for a free slot" },
|
||||
"subagent.process.cause.writers": { zh: "等写入名额", en: "Waiting for a writer slot" },
|
||||
"subagent.process.chip": { zh: "进程", en: "Processes" },
|
||||
"subagent.process.empty": { zh: "现在没有在运行的东西", en: "Nothing is running" },
|
||||
"subagent.process.kind.job": { zh: "后台作业", en: "Background job" },
|
||||
"subagent.process.kind.subagent": { zh: "子代理", en: "Sub-agent" },
|
||||
"subagent.process.state.completed": { zh: "已完成", en: "Completed" },
|
||||
"subagent.process.state.failed": { zh: "失败", en: "Failed" },
|
||||
"subagent.process.state.interrupted": { zh: "已中断", en: "Interrupted" },
|
||||
"subagent.process.state.pending": { zh: "等待上游", en: "Waiting on upstream" },
|
||||
"subagent.process.state.queued": { zh: "排队中", en: "Queued" },
|
||||
"subagent.process.state.running": { zh: "运行中", en: "Running" },
|
||||
"subagent.process.state.skipped": { zh: "已跳过", en: "Skipped" },
|
||||
"subagent.process.state.stopped": { zh: "已停止", en: "Stopped" },
|
||||
"subagent.process.state.waiting": { zh: "等待中", en: "Waiting" },
|
||||
"subagent.doing": { zh: "正在{what}", en: "Now: {what}" },
|
||||
"subagent.full_transcript_of_subagent_name": { zh: "子代理完整记录:{name}", en: "Full transcript of subagent: {name}" },
|
||||
"subagent.group.all_delivered": { zh: "{n} 个都已交付", en: "All {n} delivered" },
|
||||
|
||||
@@ -115,6 +115,8 @@ export const WORKBENCH: Messages = {
|
||||
"workbench.reveal_in_finder": { zh: "在访达中显示", en: "Reveal in Finder" },
|
||||
"workbench.run_interactively": { zh: "交互运行", en: "Run interactively" },
|
||||
"workbench.search_files": { zh: "搜索文件", en: "Search files" },
|
||||
"workbench.pick_a_file": { zh: "从左侧选一个文件", en: "Pick a file on the left" },
|
||||
"workbench.pick_a_file_detail": { zh: "要开浏览器或终端,点上方标签栏的 +。", en: "For a browser or a terminal, use + in the tab bar above." },
|
||||
"workbench.select_all": { zh: "全选", en: "Select all" },
|
||||
"workbench.show_file_tree": { zh: "展开文件树", en: "Show file tree" },
|
||||
"workbench.show_hidden_files": { zh: "显示隐藏文件", en: "Show hidden files" },
|
||||
@@ -183,6 +185,7 @@ export const WORKBENCH: Messages = {
|
||||
"workbench.changes_page_hint": { zh: "勾选=暂存;悬停文件头出「打开文件」「丢弃」;点差异里的行号打开这个文件。", en: "Tick to stage; hover a file header for Open file and Discard; click a line number to open the file." },
|
||||
"workbench.diff_cut_short": { zh: "差异太长,只显示了前一部分。", en: "The diff is long; only its first part is shown." },
|
||||
"workbench.expand_section": { zh: "展开{name}", en: "Expand {name}" },
|
||||
"workbench.resize_the_file_tree": { zh: "调整文件树宽度", en: "Resize the file tree" },
|
||||
"workbench.collapse_section": { zh: "收起{name}", en: "Collapse {name}" },
|
||||
"workbench.name_is_not_saved": { zh: "{name} 还没保存", en: "{name} is not saved" },
|
||||
"workbench.dont_save": { zh: "不保存", en: "Don't save" },
|
||||
|
||||
@@ -204,6 +204,9 @@ export interface PeersSettings {
|
||||
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
|
||||
max_dispatch_depth: number;
|
||||
max_dispatched_sessions: number;
|
||||
// The user's standing yes for open_projects to add folders inside the session's
|
||||
// own workspace without a card; it applies at the next call.
|
||||
open_nested: boolean;
|
||||
path: string;
|
||||
applies: "next_turn";
|
||||
pending: boolean;
|
||||
@@ -218,6 +221,7 @@ export interface PeersSettingsEdit {
|
||||
release_projects?: string[];
|
||||
max_dispatch_depth?: number;
|
||||
max_dispatched_sessions?: number;
|
||||
open_nested?: boolean;
|
||||
}
|
||||
|
||||
export type PeerRemember = "project" | "none";
|
||||
|
||||
@@ -31,8 +31,8 @@ export type DeliveryFault = "kernel_busy" | "unreachable" | "ui_stalled";
|
||||
// may not have landed, so the identity is the fault: the kernel was slow, could
|
||||
// not be reached, or this window's own event loop was too starved to say which.
|
||||
export class DeliveryError extends Error {
|
||||
constructor(readonly fault: DeliveryFault) {
|
||||
super("request unconfirmed: " + fault);
|
||||
constructor(readonly fault: DeliveryFault, readonly path?: string) {
|
||||
super("request unconfirmed: " + fault + (path ? " " + path : ""));
|
||||
this.name = "DeliveryError";
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export class MockBoundary extends MockShell {
|
||||
return mockMods(this.modConfig.enabled);
|
||||
}
|
||||
|
||||
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
|
||||
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
|
||||
|
||||
async peersSettings(): Promise<PeersSettings> {
|
||||
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
|
||||
@@ -338,6 +338,7 @@ export class MockBoundary extends MockShell {
|
||||
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
|
||||
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
|
||||
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
|
||||
open_nested: edit.open_nested ?? this.peerSettings.open_nested,
|
||||
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockMcp } from "./mock_mcp";
|
||||
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { ProcessEntry } from "./process";
|
||||
|
||||
// Hooks, and what a dry run of one reports back.
|
||||
export class MockHook extends MockMcp {
|
||||
@@ -17,6 +18,10 @@ export class MockHook extends MockMcp {
|
||||
return [];
|
||||
}
|
||||
|
||||
async processes(): Promise<ProcessEntry[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
|
||||
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { RunningWorkPort } from "./running";
|
||||
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
|
||||
import type { SessionTailPort } from "./session_tail";
|
||||
@@ -181,7 +181,7 @@ export interface TrayPrefs {
|
||||
closeToTray: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
|
||||
export interface AgentPort extends RunningWorkPort, MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
|
||||
providerSetup(): Promise<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
// The models that answer the job asked about; "chat" when none is named.
|
||||
@@ -708,11 +708,6 @@ export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, Session
|
||||
// Ends one running tool call and lets the turn go on; a call that already
|
||||
// finished is refused (tool_call.not_running).
|
||||
stopToolCall(callId: string): Promise<void>;
|
||||
// The sub-agent runs this conversation spawned, running or finished, newest
|
||||
// first, and one run's conversation a page at a time: a running run is read
|
||||
// live. A run of another conversation is refused (subagent.not_owned).
|
||||
subagents(): Promise<SubagentRun[]>;
|
||||
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
|
||||
// The person's guidance to a sub-agent that is still running, read at its next
|
||||
// tool call. Refused with a code when it is not running (subagent.not_running),
|
||||
// is empty, over the size or rate limit, or is another conversation's.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Where a unit of work stands, in terms every kind of work can be mapped onto. */
|
||||
export type ProcessState = "pending" | "queued" | "running" | "waiting" | "completed" | "failed" | "stopped" | "interrupted" | "skipped";
|
||||
|
||||
/** What kind of work a process is: the owner that produced it. */
|
||||
export type ProcessKind = "subagent" | "job";
|
||||
|
||||
/** One unit of work this session has running now: GET /processes. */
|
||||
export interface ProcessEntry {
|
||||
/** "<kind>:<owner id>". */
|
||||
id: string;
|
||||
kind: ProcessKind;
|
||||
/** The process that started this one; absent for a root. */
|
||||
parent?: string;
|
||||
name: string;
|
||||
state: ProcessState;
|
||||
/** Why it is in this state, when the owner records one. */
|
||||
cause?: string;
|
||||
startedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ProcessEntry } from "./process";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
|
||||
/** What this conversation has handed off or has running. */
|
||||
export interface RunningWorkPort {
|
||||
// The sub-agent runs this conversation spawned, running or finished, newest
|
||||
// first, and one run's conversation a page at a time: a running run is read
|
||||
// live. A run of another conversation is refused (subagent.not_owned).
|
||||
subagents(): Promise<SubagentRun[]>;
|
||||
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
|
||||
// What this session has running now across owners, read when asked and never
|
||||
// pushed: a window that does not open the list pays nothing for it.
|
||||
processes(): Promise<ProcessEntry[]>;
|
||||
}
|
||||
@@ -89,9 +89,19 @@ describe("idempotent reads", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const probe = new Probe();
|
||||
await expect(probe.read("/status")).rejects.toBeInstanceOf(TypeError);
|
||||
await expect(probe.read("/status")).rejects.toBeInstanceOf(DeliveryError);
|
||||
expect(await probe.read("/status")).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it("names an unreachable kernel and the read, not the browser's bare fetch failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const err = (await new Probe().read("/status").catch((e) => e)) as DeliveryError;
|
||||
expect(err).toBeInstanceOf(DeliveryError);
|
||||
expect(err.fault).toBe("unreachable");
|
||||
expect(err.path).toBe("/status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a decision submit", () => {
|
||||
|
||||
@@ -142,11 +142,22 @@ export class SseHttp {
|
||||
// A read the caller can abandon: it is not shared with another caller's, so
|
||||
// abandoning it frees its connection instead of leaving it to run out.
|
||||
protected async getUntil<T>(path: string, signal: AbortSignal): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin", signal });
|
||||
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin", signal }), signal);
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// A transport failure on a read is the kernel being unreachable, and the
|
||||
// browser's own "Failed to fetch" names neither that nor which read it was.
|
||||
private static async reach(path: string, send: () => Promise<Response>, signal?: AbortSignal): Promise<Response> {
|
||||
try {
|
||||
return await send();
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && !signal?.aborted) throw new DeliveryError("unreachable", path);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private startRead<T>(path: string): Promise<T> {
|
||||
const flight = this.fetchRead<T>(path);
|
||||
const slot: ReadSlot = { flight };
|
||||
@@ -159,7 +170,7 @@ export class SseHttp {
|
||||
}
|
||||
|
||||
private async fetchRead<T>(path: string): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin" });
|
||||
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin" }));
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, Wor
|
||||
import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace";
|
||||
import type { JobOutput, ToolOutputPage } from "./session";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { ProcessEntry } from "./process";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import { host } from "./host";
|
||||
import { HttpError } from "./http_error";
|
||||
@@ -82,6 +83,9 @@ export class SseWorkspace extends SseFeedback {
|
||||
subagents() {
|
||||
return this.get<SubagentRun[]>("/subagents");
|
||||
}
|
||||
processes() {
|
||||
return this.get<ProcessEntry[]>("/processes");
|
||||
}
|
||||
subagentTranscript(ref: string, after = 0, limit = 0) {
|
||||
const query = [after ? `after=${after}` : "", limit ? `limit=${limit}` : ""].filter(Boolean).join("&");
|
||||
return this.get<SubagentTranscript>("/subagents/" + encodeURIComponent(ref) + "/transcript" + (query ? "?" + query : ""));
|
||||
|
||||
@@ -1750,3 +1750,13 @@ A sub-agent row, by the list's own width:
|
||||
- Hover waits briefly before opening and closing so the pointer can cross to
|
||||
the card. Click and keyboard activation also work; Escape restores focus.
|
||||
The portaled card follows its anchor and stays inside the viewport.
|
||||
|
||||
## Display math
|
||||
|
||||
- A display formula scrolls sideways when it is wider than the column, which
|
||||
makes its vertical overflow hidden as well. KaTeX draws raised exponents, tall
|
||||
delimiters and big-operator limits past the line box, so the block carries
|
||||
`.6em` of padding above and below and only `.2em` of margin: the clip edge
|
||||
sits outside everything a formula draws, and the gap between blocks is
|
||||
unchanged to the eye. Padding on one side only leaves the other flush with
|
||||
the clip.
|
||||
@@ -3550,7 +3550,7 @@
|
||||
}
|
||||
|
||||
.md .katex { font-size: 1.02em }
|
||||
.md .katex-display { margin: .6em 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px }
|
||||
.md .katex-display { margin: .2em 0; overflow-x: auto; overflow-y: hidden; padding: .6em 0 }
|
||||
|
||||
.md-tw { margin: 0 0 .7em; overflow-x: auto }
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.workbench-tree-resize { position: absolute; top: 0; bottom: 0; right: 0; width: 6px; z-index: 3; cursor: col-resize; touch-action: none; }
|
||||
.workbench-tree-resize:hover, .workbench-tree-resize[data-on] { background: linear-gradient(to right, transparent 2px, var(--accent) 2px, var(--accent) 4px, transparent 4px); }
|
||||
.workbench-tree-resize:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import app from "./app.css?raw";
|
||||
|
||||
const rule = (selector: string): string => {
|
||||
const at = app.indexOf(selector + " {");
|
||||
return at < 0 ? "" : app.slice(at, app.indexOf("}", at));
|
||||
};
|
||||
|
||||
const em = (value: string): number => (value.endsWith("em") ? parseFloat(value) : NaN);
|
||||
|
||||
describe("a display formula in a message", () => {
|
||||
const block = rule(".md .katex-display");
|
||||
|
||||
it("scrolls sideways when it is too wide", () => {
|
||||
expect(block).toMatch(/overflow-x:\s*auto/);
|
||||
});
|
||||
|
||||
it("leaves room above and below for what a raised exponent or a tall delimiter draws past the line", () => {
|
||||
expect(block).toMatch(/overflow-y:\s*hidden/);
|
||||
const pad = block.match(/padding:\s*([\d.]+em)\s+0/);
|
||||
expect(pad, "vertical padding in em on both sides").not.toBeNull();
|
||||
expect(em(pad![1])).toBeGreaterThanOrEqual(0.55);
|
||||
});
|
||||
|
||||
it("is not given a bottom-only allowance that leaves the top flush with the clip edge", () => {
|
||||
expect(block).not.toMatch(/padding-bottom/);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@
|
||||
@import "./sessionpin.css" layer(legacy);
|
||||
@import "./treerows.css" layer(legacy);
|
||||
@import "./workbench-changes.css" layer(legacy);
|
||||
@import "./explorer-resize.css" layer(legacy);
|
||||
@import "./agentrun.css" layer(legacy);
|
||||
@import "./gitgraph.css" layer(legacy);
|
||||
@import "./modelmenu.css" layer(legacy);
|
||||
|
||||
@@ -1188,11 +1188,11 @@ body { background: var(--page) !important; }
|
||||
.workbench-tab:hover .workbench-tab-close, .workbench-tab[aria-selected="true"] .workbench-tab-close { opacity: 1; }
|
||||
.workbench-tab-close:hover { color: var(--text); }
|
||||
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: clamp(190px, 36%, 260px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: min(var(--wb-tree-w, clamp(190px, 36%, 260px)), 70%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-canvas { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
|
||||
.workbench-canvas .bbar > .vtabs { display: none; }
|
||||
.workbench-explorer { grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer { position: relative; grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); text-transform: uppercase; }
|
||||
.workbench-explorer-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench-explorer-head small { color: var(--faint); font-size: var(--fs-micro); }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import css from "../design/workbench.css?raw";
|
||||
|
||||
const rule = (selector: string): string => {
|
||||
const at = css.indexOf(selector + " {");
|
||||
return at < 0 ? "" : css.slice(at, css.indexOf("}", at));
|
||||
};
|
||||
|
||||
describe("the changes section's head in a narrow explorer", () => {
|
||||
const head = rule(".workbench-explorer .workbench-changes > .workbench-explorer-head");
|
||||
|
||||
it("wraps its buttons onto a second line instead of overflowing sideways", () => {
|
||||
expect(head).toMatch(/flex-wrap:\s*wrap/);
|
||||
expect(head).toMatch(/height:\s*auto/);
|
||||
});
|
||||
|
||||
it("keeps the title and the file count whole rather than breaking them a character at a time", () => {
|
||||
const labels = rule(".workbench-explorer .workbench-changes > .workbench-explorer-head > :is(span, small, .workbench-section-title)");
|
||||
expect(labels).toMatch(/flex:\s*none/);
|
||||
expect(labels).toMatch(/white-space:\s*nowrap/);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,7 @@ import { keepSame } from "./stable";
|
||||
import { usePaneCallbacks } from "./usePaneCallbacks";
|
||||
import { watchTreeWake } from "./treewake";
|
||||
import { usePinnedSessions } from "./usePinnedSessions";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { LevelUpNotice } from "./LevelUpNotice";
|
||||
import { useWindowNotices } from "./useWindowNotices";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
@@ -20,7 +19,7 @@ import { Nav } from "./Nav";
|
||||
import { useTreeWindow, type TreePaging } from "./treewindow";
|
||||
import { useNavRail } from "./navrail";
|
||||
import { feedbackEntryTab } from "./feedbackentry";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { noteFault } from "./fault";
|
||||
import { ToastStack } from "./ToastStack";
|
||||
import { useLinkRouting } from "./links";
|
||||
@@ -292,7 +291,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// Asked on open, on focus (at most every 30 s) and every 10 min while a report
|
||||
// is unsettled, never otherwise; a failed refresh stays silent and backs off.
|
||||
useEffect(() => startUpdateWatch(machinePort), [machinePort]);
|
||||
useFeedbackUnread(machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
|
||||
useFeedbackUnread(!FEEDBACK_RETIRED && machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
|
||||
|
||||
useFirstRunRead(activePort, setSetup, setWelcomed);
|
||||
|
||||
@@ -481,11 +480,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// A failed request floats above the composer of the pane in front, where the
|
||||
// eye already is; with no pane on screen it takes the main area's corner.
|
||||
const activePaneShown = !!activeRuntime && panePorts.has(activeRuntime.id);
|
||||
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={() => setFeedback("send")} /> : null), [error]);
|
||||
const bar = useMemo(
|
||||
() => errorBar ?? <LevelUpNotice port={machineRt ? machinePort : null} tick={feedback === null ? 0 : 1} fallback={<AutoArchiveNotice tree={tree} />} />,
|
||||
[errorBar, tree, machineRt, machinePort, feedback],
|
||||
);
|
||||
const reportError = useMemo(() => (FEEDBACK_RETIRED ? undefined : () => setFeedback("send")), []);
|
||||
const { errorBar, note, bar } = useWindowNotices({ error, setError, onReport: reportError, tree, port: machineRt ? machinePort : null, feedbackOpen: feedback !== null });
|
||||
const activeWorkspace = tree.find((ws) => !activeRuntime?.host && workspaceContains(ws, activeRuntime?.root)) ?? tree[0];
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
const startConversation = useNewConversation(active, runtimes, newSessionRoot, openPane, openRemotePane);
|
||||
@@ -704,13 +700,14 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
terminalPulse={terminalRequests[rt.id] ?? 0}
|
||||
viewRequest={viewRequests[rt.id]}
|
||||
sessionRef={sessionRefs[rt.id]}
|
||||
alert={rt.id === active ? bar : undefined}
|
||||
alert={rt.id === active && !errorBar ? note : undefined}
|
||||
failure={rt.id === active ? errorBar : undefined}
|
||||
connect={first.connect}
|
||||
needsProject={needsProject}
|
||||
onOpenProject={openProject}
|
||||
onKeepHere={keepHere}
|
||||
onSettings={openSettings}
|
||||
onFeedback={openFeedback}
|
||||
onFeedback={FEEDBACK_RETIRED ? undefined : openFeedback}
|
||||
onOpenSession={openSessionFor(rt.id)}
|
||||
theme={scheme}
|
||||
dockW={shownDockW}
|
||||
@@ -754,7 +751,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
<ToastStack onFeedback={() => setFeedback("send")} port={machinePort} idle={!running} look={look} onLook={onLook} />
|
||||
|
||||
{feedback && activePort && (
|
||||
{!FEEDBACK_RETIRED && feedback && activePort && (
|
||||
<Feedback port={machinePort} tab={feedback} onClose={() => setFeedback(null)} onError={setError} onUnread={setFeedbackUnread} unread={feedbackUnread} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { ChangedFilesSetting } from "./ChangedFilesSetting";
|
||||
@@ -28,7 +29,7 @@ export function ChatDisplaySettings({ port }: { port: AgentPort }) {
|
||||
</SectionCard>
|
||||
<SectionCard title={t("settings.notices_card")}>
|
||||
<NewAnswerSection />
|
||||
<FeedbackLevelNoticeSetting port={port} />
|
||||
{!FEEDBACK_RETIRED && <FeedbackLevelNoticeSetting port={port} />}
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JobEntry, JobOutput } from "../port/port";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { agentsIn } from "./panels/derive";
|
||||
import { AgentDeck } from "./AgentDeck";
|
||||
import { AgentTranscript } from "./panels/AgentTranscript";
|
||||
import { JobDeck } from "./JobDeck";
|
||||
import { ProcessDeck } from "./ProcessDeck";
|
||||
import { JobOutputView } from "./panels/JobOutputView";
|
||||
import type { Task } from "./panels/Agents";
|
||||
import { useDismiss } from "./dismiss";
|
||||
|
||||
export type Deck = "" | "agents" | "jobs";
|
||||
export type Deck = "" | "agents" | "jobs" | "processes";
|
||||
|
||||
/** What is running on this session's behalf but is not in the transcript: work
|
||||
* handed to a sub-agent, and processes left running in the background. The
|
||||
* readings sit at the right end of the run rail, beside the other numbers
|
||||
* about this turn, and each opens on hover the way the context reading does —
|
||||
* one gesture for every summary on this row. */
|
||||
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void }) {
|
||||
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, onReadProcesses }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void; onReadProcesses?: () => Promise<ProcessEntry[]> }) {
|
||||
const agentsBox = useRef<HTMLDivElement>(null);
|
||||
const jobsBox = useRef<HTMLDivElement>(null);
|
||||
const processBox = useRef<HTMLDivElement>(null);
|
||||
const [processes, setProcesses] = useState<ProcessEntry[]>([]);
|
||||
const [processError, setProcessError] = useState("");
|
||||
const [viewing, setViewing] = useState("");
|
||||
const [viewingJob, setViewingJob] = useState("");
|
||||
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
|
||||
@@ -38,6 +44,16 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
|
||||
};
|
||||
useDismiss(open === "agents", agentsBox, shut);
|
||||
useDismiss(open === "jobs", jobsBox, shut);
|
||||
useDismiss(open === "processes", processBox, shut);
|
||||
useEffect(() => {
|
||||
if (open !== "processes" || !onReadProcesses) return;
|
||||
let current = true;
|
||||
onReadProcesses().then(
|
||||
(rows) => { if (current) { setProcesses(rows); setProcessError(""); } },
|
||||
(e: unknown) => { if (current) { setProcesses([]); setProcessError(reason(e)); } },
|
||||
);
|
||||
return () => { current = false; };
|
||||
}, [open, onReadProcesses, tasks, jobs]);
|
||||
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
|
||||
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
|
||||
// A failure stays loud until the list has been opened on it.
|
||||
@@ -79,6 +95,27 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
|
||||
{viewingJob && onReadJob && jobs.some((j) => j.id === viewingJob) && (
|
||||
<JobOutputView job={jobs.find((j) => j.id === viewingJob)!} read={onReadJob} onClose={() => setViewingJob("")} onCancel={onCancelJob} />
|
||||
)}
|
||||
{onReadProcesses && (
|
||||
<div ref={processBox} className="studio-deck-anchor" data-open={open === "processes" ? "" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-deckchip"
|
||||
data-action="deck.processes"
|
||||
aria-label={t("subagent.process.chip")}
|
||||
title={t("subagent.process.chip")}
|
||||
data-live={liveAgents || liveJobs ? "" : undefined}
|
||||
aria-expanded={open === "processes"}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => onOpen((was) => (was === "processes" ? "" : "processes"))}
|
||||
>
|
||||
<StudioIcon name="play" />
|
||||
<span>{t("subagent.process.chip")}</span>
|
||||
</button>
|
||||
<div className="studio-deck-pop" role="dialog" aria-label={t("subagent.process.chip")}>
|
||||
<ProcessDeck rows={processes} error={processError} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const KEY = "rx-wb-tree-w";
|
||||
const MIN = 150;
|
||||
const MAX = 560;
|
||||
const VAR = "--wb-tree-w";
|
||||
|
||||
const clamp = (v: number) => Math.min(MAX, Math.max(MIN, Math.round(v)));
|
||||
|
||||
function saved(): number | null {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(KEY));
|
||||
return Number.isFinite(n) && n > 0 ? clamp(n) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keep(w: number | null) {
|
||||
try {
|
||||
if (w === null) localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, String(w));
|
||||
} catch {
|
||||
/* the width only fails to be remembered */
|
||||
}
|
||||
}
|
||||
|
||||
/** The explorer's width is the reader's call about their own screen, so it is
|
||||
* kept in this browser and absent until they drag; without it the stylesheet's
|
||||
* clamp decides. */
|
||||
export function ExplorerResize() {
|
||||
const bar = useRef<HTMLDivElement>(null);
|
||||
const host = () => bar.current?.closest<HTMLElement>(".workbench-body") ?? null;
|
||||
const explorer = () => bar.current?.closest<HTMLElement>(".workbench-explorer") ?? null;
|
||||
|
||||
const apply = (w: number | null) => {
|
||||
const body = host();
|
||||
if (!body) return;
|
||||
if (w === null) body.style.removeProperty(VAR);
|
||||
else body.style.setProperty(VAR, `${w}px`);
|
||||
bar.current?.setAttribute("aria-valuenow", String(Math.round(explorer()?.getBoundingClientRect().width ?? 0)));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const w = saved();
|
||||
if (w !== null) apply(w);
|
||||
}, []);
|
||||
|
||||
const drag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const from = explorer()?.getBoundingClientRect().width ?? 0;
|
||||
const x0 = e.clientX;
|
||||
let now = from;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.dataset.on = "";
|
||||
const move = (ev: PointerEvent) => {
|
||||
now = clamp(from + ev.clientX - x0);
|
||||
apply(now);
|
||||
};
|
||||
const up = () => {
|
||||
el.removeEventListener("pointermove", move);
|
||||
el.removeEventListener("pointerup", up);
|
||||
el.removeEventListener("pointercancel", up);
|
||||
delete el.dataset.on;
|
||||
keep(clamp(now));
|
||||
};
|
||||
el.addEventListener("pointermove", move);
|
||||
el.addEventListener("pointerup", up);
|
||||
el.addEventListener("pointercancel", up);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
apply(null);
|
||||
keep(null);
|
||||
};
|
||||
|
||||
const key = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
|
||||
if (!dir) return;
|
||||
e.preventDefault();
|
||||
const next = clamp((explorer()?.getBoundingClientRect().width ?? 0) + dir * (e.shiftKey ? 48 : 12));
|
||||
apply(next);
|
||||
keep(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bar}
|
||||
className="workbench-tree-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("workbench.resize_the_file_tree")}
|
||||
aria-valuemin={MIN}
|
||||
aria-valuemax={MAX}
|
||||
tabIndex={0}
|
||||
onPointerDown={drag}
|
||||
onDoubleClick={reset}
|
||||
onKeyDown={key}
|
||||
data-action-pointerdown="workbench.tree-resize"
|
||||
data-action-dblclick="workbench.tree-resize"
|
||||
data-action-keydown="workbench.tree-resize"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import "./testkit";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { AgentPort } from "../port/port";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import { money } from "../i18n/format";
|
||||
import type { ContextBreakdown, JobEntry, JobOutput, McpEntry, SessionUsage } from "../port/port";
|
||||
import type { CostQuote } from "../port/wire";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { cacheRate as rateOf } from "../state/overview";
|
||||
import { SessionOverviewCard } from "./SessionOverviewCard";
|
||||
import type { CurrencyAmount, Metrics } from "../state/session_types";
|
||||
@@ -20,7 +21,7 @@ import { MASK, type Wallet } from "./wallet";
|
||||
import type { SpeedSummary } from "./speed";
|
||||
|
||||
/** The numbers about this turn, in one row under the composer. */
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onReadProcesses, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
|
||||
tps: number;
|
||||
trail: number[];
|
||||
running: boolean;
|
||||
@@ -40,6 +41,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
onSettings: (section?: string) => void;
|
||||
onCancelJob: (id: string) => Promise<void>;
|
||||
onReadJob?: (id: string) => Promise<JobOutput>;
|
||||
onReadProcesses?: () => Promise<ProcessEntry[]>;
|
||||
onOpenRun?: (call: string) => Promise<boolean>;
|
||||
onStopCall?: (callId: string) => void;
|
||||
onClearJobs?: (ids: string[]) => void;
|
||||
@@ -115,7 +117,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
{cost.length > 0 && turnCost.some((m) => m.amount > 0) && <span className="studio-meter-cost"><span>{t("composer.this_turn")}</span>{hideAmounts ? <b>{MASK}</b> : turnCost.map((m) => <b key={m.currency}>{money(m.amount, m.currency)}</b>)}</span>}
|
||||
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} title={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
|
||||
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("common.insufficient_balance")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("composer.view_wallet_balance") : `${t("composer.view_wallet_balance")}, ${t("common.insufficient_balance")}`} onClick={() => onSettings("spend")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
|
||||
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
|
||||
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onReadProcesses={onReadProcesses} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { t } from "../i18n";
|
||||
import { FeedbackBadge } from "./feedbackentry";
|
||||
import { useDestinations, type Mark } from "./navdest";
|
||||
@@ -59,11 +60,13 @@ export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, onNew, feed
|
||||
{[HOME, ...destinations].map(btn)}
|
||||
</div>
|
||||
<div className="navgrp foot">
|
||||
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
|
||||
<StudioIcon name="feedback" aria-hidden="true" />
|
||||
<span className="navhint" role="tooltip">{t("common.send_feedback")}</span>
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
{!FEEDBACK_RETIRED && (
|
||||
<button className="navbtn" data-action="feedback.open" onClick={onFeedback}>
|
||||
<StudioIcon name="feedback" aria-hidden="true" />
|
||||
<span className="navhint" role="tooltip">{t("common.send_feedback")}</span>
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
)}
|
||||
{FOOT.map(btn)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -103,7 +103,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert, failure }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -200,6 +200,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
}, [pacer]);
|
||||
|
||||
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
|
||||
const readProcesses = useCallback(() => port.processes(), [port]);
|
||||
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
|
||||
|
||||
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
|
||||
@@ -672,6 +673,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
onCancelJob={(id) => cancelJob(port, id, notify, refreshStatus).then(() => {})}
|
||||
onReadJob={readJob}
|
||||
onReadProcesses={readProcesses}
|
||||
onOpenRun={openRun} onStopCall={(id) => void port.stopToolCall(id).catch(notify)}
|
||||
onClearJobs={(ids) => setClearedJobs((was) => new Set([...was, ...ids]))}
|
||||
onOpenJob={jobsLive.open}
|
||||
@@ -682,6 +684,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
both are dismissed one at a time, so nothing else bounds how many can
|
||||
be on screen at once. */}
|
||||
<div className="composenotes">
|
||||
{failure}
|
||||
{s.error && (
|
||||
<ErrorBar text={s.error} onDismiss={() => dispatch({ kind: "__error", text: "" } as never)} onReport={onFeedback} />
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,13 @@ export function PeerSettings({ port, settings, onSaved }: { port: AgentPort; set
|
||||
</span>
|
||||
<Switch data-action="peers.dispatch" on={state.dispatch} busy={off || !state.enabled} label={t("remote.let_the_model_hand_a")} onClick={() => void save({ dispatch: !state.dispatch })} />
|
||||
</div>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("remote.open_nested_folders_without_asking")}</span>
|
||||
<span className="ds">{t("remote.open_nested_folders_without_asking_ds")}</span>
|
||||
</span>
|
||||
<Switch data-action="peers.open-nested" on={state.open_nested} busy={off || !state.enabled || !state.dispatch} label={t("remote.open_nested_folders_without_asking")} onClick={() => void save({ open_nested: !state.open_nested })} />
|
||||
</div>
|
||||
{!state.available && <div className="peernote" role="status">{t("remote.not_supported_in_this_session")}</div>}
|
||||
{state.pending && <div className="peernote" role="status">{t("remote.saved_takes_effect_from_the")}</div>}
|
||||
<div className="peernote">{t("remote.these_switches_are_kept_in")}<code>{state.path}</code></div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { t } from "../i18n";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { jobElapsed } from "./panels/jobElapsed";
|
||||
|
||||
function stateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case "pending": return t("subagent.process.state.pending");
|
||||
case "queued": return t("subagent.process.state.queued");
|
||||
case "running": return t("subagent.process.state.running");
|
||||
case "waiting": return t("subagent.process.state.waiting");
|
||||
case "completed": return t("subagent.process.state.completed");
|
||||
case "failed": return t("subagent.process.state.failed");
|
||||
case "stopped": return t("subagent.process.state.stopped");
|
||||
case "interrupted": return t("subagent.process.state.interrupted");
|
||||
case "skipped": return t("subagent.process.state.skipped");
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
function causeLabel(cause: string): string {
|
||||
switch (cause) {
|
||||
case "slots": return t("subagent.process.cause.slots");
|
||||
case "writers": return t("subagent.process.cause.writers");
|
||||
case "claim": return t("subagent.process.cause.claim");
|
||||
case "held": return t("subagent.process.cause.held");
|
||||
case "adopted": return t("subagent.process.cause.adopted");
|
||||
case "budget": return t("subagent.process.cause.budget");
|
||||
case "parked": return t("subagent.process.cause.parked");
|
||||
default: return cause;
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "subagent": return t("subagent.process.kind.subagent");
|
||||
case "job": return t("subagent.process.kind.job");
|
||||
default: return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** What this session has running now, one row per process. State and cause are
|
||||
* the kernel's words for it; a value this page has no wording for is shown as
|
||||
* it came rather than guessed at. */
|
||||
export function ProcessDeck({ rows, error }: { rows: ProcessEntry[]; error: string }) {
|
||||
if (error) return <div className="empty" role="alert">{error}</div>;
|
||||
if (rows.length === 0) return <div className="empty">{t("subagent.process.empty")}</div>;
|
||||
return (
|
||||
<div className="job-deck">
|
||||
{rows.map((p) => (
|
||||
<div className="job-deck-row" key={p.id} data-status={p.state}>
|
||||
<div className="job-deck-line">
|
||||
<b className="job-deck-name">{p.name}</b>
|
||||
<span className="job-deck-time">{[kindLabel(p.kind), stateLabel(p.state), jobElapsed(Date.now() - p.startedAt)].join(" · ")}</span>
|
||||
</div>
|
||||
{p.cause && <span className="job-deck-tail">{causeLabel(p.cause)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { SidebarTools } from "./SidebarTools";
|
||||
import { FeedbackBadge, feedbackEntryTab } from "./feedbackentry";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -216,10 +217,12 @@ export function Sidebar({
|
||||
run: () => onSettings("providers") },
|
||||
{ id: "usage", label: t("session.wallet_and_usage"), icon: "wallet", keywords: "usage cost token 用量 费用",
|
||||
run: () => onSettings("spend") },
|
||||
{ id: "feedback", label: t("common.send_feedback"), icon: "feedback", keywords: "feedback report bug idea suggestion 反馈 报告 问题 建议 意见",
|
||||
run: () => onFeedback("send") },
|
||||
{ id: "feedback-mine", label: t("session.my_feedback"), icon: "feedback", keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复",
|
||||
status: feedbackUnread > 0 ? t("common.n_to_review", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") },
|
||||
...(FEEDBACK_RETIRED ? [] : [
|
||||
{ id: "feedback", label: t("common.send_feedback"), icon: "feedback" as const, keywords: "feedback report bug idea suggestion 反馈 报告 问题 建议 意见",
|
||||
run: () => onFeedback("send") },
|
||||
{ id: "feedback-mine", label: t("session.my_feedback"), icon: "feedback" as const, keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复",
|
||||
status: feedbackUnread > 0 ? t("common.n_to_review", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") },
|
||||
]),
|
||||
{ id: "community", label: t("common.community_and_contributors"), icon: "globe", keywords: "community discord qq group contributors 社区 交流群 加群 贡献者",
|
||||
run: () => onSettings("versions:community") },
|
||||
{ id: "settings", label: t("common.settings"), icon: "settings", keywords: "settings preferences 设置 偏好",
|
||||
@@ -382,11 +385,13 @@ export function Sidebar({
|
||||
{!navShown && <div className="railfoot">
|
||||
<UpdateNotice onOpen={() => onSettings("versions")} />
|
||||
<SidebarTools wallet={wallet} approval={approval} online={online} onSettings={onSettings} onFiles={onFiles} />
|
||||
<button className="studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
|
||||
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
|
||||
<b>{t("common.send_feedback")}</b>
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
{!FEEDBACK_RETIRED && (
|
||||
<button className="studio-feedback" data-action="feedback.open" onClick={() => onFeedback(feedbackEntryTab(feedbackUnread))}>
|
||||
<span aria-hidden="true"><StudioIcon name="feedback" /></span>
|
||||
<b>{t("common.send_feedback")}</b>
|
||||
<FeedbackBadge unread={feedbackUnread} />
|
||||
</button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { clearToasts, pushNote, pushOffer, pushToast } from "../state/toasts";
|
||||
import { ToastStack } from "./ToastStack";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort, Appearance } from "../port/port";
|
||||
@@ -84,17 +85,19 @@ export function ToastStack({
|
||||
key={toast.id}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.report"
|
||||
onClick={() => {
|
||||
reportProblem(toast.text);
|
||||
onFeedback();
|
||||
dismissToast(toast.id);
|
||||
}}
|
||||
>
|
||||
{t("app.report_this_problem")}
|
||||
</button>
|
||||
{!FEEDBACK_RETIRED && (
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.report"
|
||||
onClick={() => {
|
||||
reportProblem(toast.text);
|
||||
onFeedback();
|
||||
dismissToast(toast.id);
|
||||
}}
|
||||
>
|
||||
{t("app.report_this_problem")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.dismiss"
|
||||
|
||||
@@ -45,14 +45,14 @@ export function WorkbenchGit({ port, workspaceRoot, changes, repos, failure = ""
|
||||
{failure}
|
||||
{onRetry && <button type="button" className="btn sm" data-action="workbench.repos-reload" onClick={onRetry}>{t("common.read_again")}</button>}
|
||||
</p>
|
||||
<RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} />
|
||||
<RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} wrote={wrote} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (repos.length === 0) return null;
|
||||
const several = repos.length > 1 || repos[0].dir !== "";
|
||||
if (!several) {
|
||||
return <RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} />;
|
||||
return <RepoSection port={port} workspaceRoot={workspaceRoot} repo="" changes={changes} current={current} onOpen={onOpen} onChanged={onChanged} remote={remote} onOpenPage={onOpenPage} branch={repos.find((r) => r.dir === "")?.branch} wrote={wrote} />;
|
||||
}
|
||||
return (
|
||||
<div className="workbench-git" data-repos={repos.length}>
|
||||
@@ -75,14 +75,14 @@ function Folder({ port, workspaceRoot, row, wrote, current, onOpen, remote }: {
|
||||
<span>{row.branch}</span>
|
||||
<small>{changes.length}</small>
|
||||
</button>
|
||||
{open && <RepoSection port={port} workspaceRoot={workspaceRoot} repo={row.dir} changes={changes} current={current} onOpen={onOpen} onChanged={reload} remote={remote} />}
|
||||
{open && <RepoSection port={port} workspaceRoot={workspaceRoot} repo={row.dir} changes={changes} current={current} onOpen={onOpen} onChanged={reload} remote={remote} wrote={wrote} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onChanged, remote, onOpenPage, branch }: {
|
||||
function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onChanged, remote, onOpenPage, branch, wrote }: {
|
||||
port: AgentPort; workspaceRoot?: string; repo: string; changes: WorkspaceChange[]; current: string; onOpen: (path: string) => void; onChanged?: () => void; remote: boolean;
|
||||
onOpenPage?: () => void; branch?: string;
|
||||
onOpenPage?: () => void; branch?: string; wrote: number;
|
||||
}) {
|
||||
const refresh = () => onChanged?.();
|
||||
const commit = useCommitCard(port, refresh, repo || undefined);
|
||||
@@ -103,7 +103,7 @@ function RepoSection({ port, workspaceRoot, repo, changes, current, onOpen, onCh
|
||||
below={remote ? null : push.card}
|
||||
onOpenPage={onOpenPage}
|
||||
/>
|
||||
{!remote && <WorkbenchHistory port={port} repo={repo || undefined} branch={branch} />}
|
||||
{!remote && <WorkbenchHistory port={port} repo={repo || undefined} branch={branch} stamp={`${wrote}:${changes.length}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,26 +25,37 @@ const x = (lane: number) => PAD + lane * COL;
|
||||
type Read = { k: "idle" } | { k: "loading" } | { k: "ready"; history: GitHistory } | { k: "failed"; why: string };
|
||||
|
||||
// The commit graph beside the changes: what the project's history looks like,
|
||||
// read from git on request and never changed from here. It reads when opened and
|
||||
// again on demand, so a closed section costs nothing.
|
||||
export function WorkbenchHistory({ port, repo, branch }: { port: AgentPort; repo?: string; branch?: string }) {
|
||||
// read from git and never changed from here. It reads when opened and again when
|
||||
// stamp moves (the agent wrote, the changes or the branch moved), so a closed
|
||||
// section costs nothing and an open one does not go on showing a past tip.
|
||||
export function WorkbenchHistory({ port, repo, branch, stamp = "" }: { port: AgentPort; repo?: string; branch?: string; stamp?: string }) {
|
||||
const [openId, setOpenId] = useState("");
|
||||
const [shown, setShown] = useState(true);
|
||||
const [read, setRead] = useState<Read>({ k: "idle" });
|
||||
const latest = useRef(0);
|
||||
const load = useCallback(async () => {
|
||||
const seen = useRef<string | null>(null);
|
||||
const load = useCallback(async (quiet = false) => {
|
||||
const id = ++latest.current;
|
||||
setRead({ k: "loading" });
|
||||
if (!quiet) setRead({ k: "loading" });
|
||||
try {
|
||||
const history = await port.workspaceGitLog(100, repo);
|
||||
if (id === latest.current) setRead({ k: "ready", history });
|
||||
} catch (e) {
|
||||
if (id === latest.current) setRead({ k: "failed", why: reason(e) });
|
||||
if (id === latest.current && !quiet) setRead({ k: "failed", why: reason(e) });
|
||||
}
|
||||
}, [port, repo]);
|
||||
const key = `${branch ?? ""}
|
||||
${stamp}`;
|
||||
useEffect(() => {
|
||||
if (shown && read.k === "idle") void load();
|
||||
}, [shown, read.k, load]);
|
||||
if (!shown) return;
|
||||
if (read.k === "idle") {
|
||||
seen.current = key;
|
||||
void load();
|
||||
} else if (seen.current !== key) {
|
||||
seen.current = key;
|
||||
void load(true);
|
||||
}
|
||||
}, [shown, read.k, load, key]);
|
||||
useEffect(() => () => { latest.current++; }, []);
|
||||
|
||||
const history = read.k === "ready" ? read.history : null;
|
||||
|
||||
@@ -55,6 +55,7 @@ const DocumentPreview = lazy(() => import("./document/DocumentPreview"));
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
import { treeRows } from "./workbench_tree";
|
||||
import { ExplorerResize } from "./ExplorerResize";
|
||||
import { AgentRunView } from "./AgentRunView";
|
||||
import { JobRunView } from "./JobRunView";
|
||||
import { useAgentTabs, type AgentRequest } from "./useAgentTabs";
|
||||
@@ -610,7 +611,10 @@ export function WorkbenchPanel({
|
||||
{failed}
|
||||
</div>
|
||||
)}
|
||||
{(launcher || !active) && (
|
||||
{!launcher && !active && showFiles && (
|
||||
<WorkbenchState icon="file" title={t("workbench.pick_a_file")} detail={t("workbench.pick_a_file_detail")} />
|
||||
)}
|
||||
{(launcher || (!active && !showFiles)) && (
|
||||
<WorkbenchLauncher
|
||||
key={String(launcher)} focus={launcher}
|
||||
changes={changes.length}
|
||||
@@ -713,6 +717,7 @@ export function WorkbenchPanel({
|
||||
</div>
|
||||
</main>
|
||||
<aside className="workbench-explorer">
|
||||
<ExplorerResize />
|
||||
<WorkbenchExplorerHead
|
||||
port={port}
|
||||
count={files.length + directories.length}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ExplorerResize } from "./ExplorerResize";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); });
|
||||
|
||||
function mount() {
|
||||
const view = render(
|
||||
<div className="workbench-body">
|
||||
<aside className="workbench-explorer">
|
||||
<ExplorerResize />
|
||||
</aside>
|
||||
</div>,
|
||||
);
|
||||
const body = view.container.querySelector<HTMLElement>(".workbench-body")!;
|
||||
const aside = view.container.querySelector<HTMLElement>(".workbench-explorer")!;
|
||||
aside.getBoundingClientRect = () => ({ width: Number.parseFloat(body.style.getPropertyValue("--wb-tree-w")) || 220 }) as DOMRect;
|
||||
return { body, bar: screen.getByRole("separator", { name: "调整文件树宽度" }) };
|
||||
}
|
||||
|
||||
describe("the file tree's width handle", () => {
|
||||
it("leaves the stylesheet's width alone until it is used", () => {
|
||||
const { body } = mount();
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
|
||||
it("widens with the arrow key, remembers it, and clamps at the ends", () => {
|
||||
const { body, bar } = mount();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("268px");
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBe("268");
|
||||
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("560px");
|
||||
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowLeft", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("150px");
|
||||
});
|
||||
|
||||
it("restores the remembered width on the next mount", () => {
|
||||
localStorage.setItem("rx-wb-tree-w", "333");
|
||||
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("333px");
|
||||
});
|
||||
|
||||
it("returns to the default on Home and on a double click", () => {
|
||||
const { body, bar } = mount();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(bar, { key: "Home" });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBeNull();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight" });
|
||||
fireEvent.doubleClick(bar);
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
|
||||
it("follows a pointer drag and keeps the width where it was released", () => {
|
||||
const { body, bar } = mount();
|
||||
bar.setPointerCapture = () => {};
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 220 });
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 300 });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
|
||||
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 300 });
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBe("300");
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 500 });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
|
||||
});
|
||||
|
||||
it("ignores a stored width that is not a number", () => {
|
||||
localStorage.setItem("rx-wb-tree-w", "wide");
|
||||
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { Nav } from "./Nav";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the retired feedback channel", () => {
|
||||
it("is retired in the shipped build", () => {
|
||||
expect(FEEDBACK_RETIRED).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves no feedback entry on the nav rail", () => {
|
||||
render(<Nav at="" onGo={vi.fn()} onFiles={vi.fn()} onHome={vi.fn()} onRail={vi.fn()} onFeedback={vi.fn()} onNew={vi.fn()} feedbackUnread={3} shown />);
|
||||
expect(document.querySelector('[data-action="feedback.open"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no way to report an error from its bar", () => {
|
||||
render(<ErrorBar text="boom" onDismiss={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /报告|report/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { Pane } from "./Pane";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function mount(extra: Record<string, unknown>) {
|
||||
return render(<Pane port={new MockPort()} rt={{ id: "p1", root: "/w", name: "w" } as RuntimeView}
|
||||
title="w" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}}
|
||||
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
|
||||
onOpenProject={() => {}} onKeepHere={() => {}} theme="dark" dockW={560} dockMax={880} onDockW={() => {}} {...extra} />);
|
||||
}
|
||||
|
||||
describe("where a refused action's reason sits in the pane", () => {
|
||||
it("is in the stack above the composer, not in the corner layer the composer paints over", async () => {
|
||||
const view = mount({ failure: <div className="errbar" role="alert" data-testid="refusal">这个文件夹里有正在运行的对话</div> });
|
||||
const refusal = await view.findByTestId("refusal");
|
||||
expect(refusal.parentElement?.classList.contains("composenotes")).toBe(true);
|
||||
expect(refusal.closest(".cmpalert")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a note, which has no business over the composer, in the corner layer", async () => {
|
||||
const view = mount({ alert: <div className="errbar" data-kind="note" role="status" data-testid="note">升级了</div> });
|
||||
const note = await view.findByTestId("note");
|
||||
expect(note.closest(".cmpalert")).not.toBeNull();
|
||||
expect(note.closest(".composenotes")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,9 @@ export interface PaneProps {
|
||||
onDockW: (w: number) => void;
|
||||
manualBrowser?: boolean;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
// The window's failed-request notice, handed only to the pane in front.
|
||||
// The window's passing notes, handed only to the pane in front.
|
||||
alert?: ReactNode;
|
||||
// The window's refused-action reason, handed only to the pane in front; it joins
|
||||
// the stack above the composer rather than a corner the composer paints over.
|
||||
failure?: ReactNode;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ function Projects({ port, hub }: { port: AgentPort; hub: Parameters<typeof PeerP
|
||||
}
|
||||
|
||||
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
|
||||
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
});
|
||||
const ws = (root: string, name: string, remembered = true): TreeWorkspace => ({ root, name, remembered, sessions: [] });
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const base: PeersSettings = {
|
||||
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8,
|
||||
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false,
|
||||
path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
|
||||
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
});
|
||||
|
||||
function portWith(initial: PeersSettings) {
|
||||
@@ -37,6 +37,7 @@ function portWith(initial: PeersSettings) {
|
||||
...current,
|
||||
enabled: edit.enabled ?? current.enabled,
|
||||
dispatch: edit.dispatch ?? current.dispatch,
|
||||
open_nested: edit.open_nested ?? current.open_nested,
|
||||
release_projects: edit.release_projects ? current.release_projects.filter((p) => edit.release_projects!.includes(p.path)) : current.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
@@ -82,6 +83,27 @@ describe("the session messages switches", () => {
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ dispatch: true });
|
||||
});
|
||||
|
||||
it("offers opening folders inside the project without asking, off until the user turns it on", async () => {
|
||||
const { port, save } = portWith(settings({ enabled: true, dispatch: true }));
|
||||
render(<Messages port={port} />);
|
||||
const name = "不询问直接打开本项目内的文件夹";
|
||||
const sw = await screen.findByRole("switch", { name });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
expect(screen.getByText(/项目之外的文件夹仍然会询问/)).toBeTruthy();
|
||||
await userEvent.click(sw);
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ open_nested: true });
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name }).getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
|
||||
it("holds the open-folders switch inert until the model may hand off tasks", async () => {
|
||||
const { port, save } = portWith(settings({ enabled: true, dispatch: false }));
|
||||
render(<Messages port={port} />);
|
||||
const sw = await screen.findByRole("switch", { name: "不询问直接打开本项目内的文件夹" });
|
||||
expect((sw as HTMLButtonElement).disabled).toBe(true);
|
||||
await userEvent.click(sw);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file the switches live in, and shows a refusal beside them", async () => {
|
||||
const { port } = portWith(settings({ enabled: true }));
|
||||
vi.spyOn(port, "savePeersSettings").mockRejectedValue(new HttpError(500, "x", { code: "peer.settings_unwritable", params: { detail: "/home/me/.reasonix/config.toml" } }));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import type { ReactNode } from "react";
|
||||
import { MODS_SHOWN } from "./modsgate";
|
||||
@@ -154,7 +155,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
{ section: "ext", anchor: "mods", title: "Claude Code mod", scope: "machine", apply: "on-open", hidden: !MODS_SHOWN, keywords: ["mod", "钩子", "hook", "Claude Code", "完全信任", "插件", "扩展"] },
|
||||
{ section: "session", anchor: "progress-watch", title: "panels.long_stretches_without_progress", scope: "machine", apply: "immediate", keywords: ["暂停", "无进展", "空转", "卡住", "循环", "轮次", "token"] },
|
||||
{ section: "display", anchor: "changed-files", title: "settings.file_changes", scope: "machine", apply: "immediate", keywords: ["改动", "变更", "文件", "已更改", "药丸", "输入框"] },
|
||||
{ section: "display", anchor: "feedback-level-notices", title: "feedback.level_notices", scope: "machine", apply: "immediate", keywords: ["等级", "晋级", "升级", "反馈", "提示"] },
|
||||
{ section: "display", anchor: "feedback-level-notices", title: "feedback.level_notices", scope: "machine", apply: "immediate", hidden: FEEDBACK_RETIRED, keywords: ["等级", "晋级", "升级", "反馈", "提示"] },
|
||||
{ section: "session", anchor: "turn-narration", title: "settings.narration_between_tool_calls", scope: "machine", apply: "immediate", keywords: ["叙述", "总结", "小轮", "工具之间", "详细", "简短", "思路", "监控", "narration"] },
|
||||
{ section: "display", anchor: "fold-work", title: "settings.fold_the_work_in_between", scope: "machine", apply: "immediate", keywords: ["折叠", "中间过程", "工具调用", "思考", "最后一句", "收起", "摘要"] },
|
||||
// Written to the user file only; the kernel's background sweep reads it on each pass.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import "./testkit";
|
||||
import { DeckChips, type Deck } from "./DeckChips";
|
||||
import type { JobEntry } from "../port/port";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { HttpError } from "../port/http_error";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const JOBS: JobEntry[] = [{ id: "j1", kind: "bash", label: "npm run build", status: "running", startedAt: Date.now() - 5000 }];
|
||||
|
||||
const ROWS: ProcessEntry[] = [
|
||||
{ id: "job:j1", kind: "job", name: "npm run build", state: "running", startedAt: Date.now() - 5000 },
|
||||
{ id: "subagent:sa_1", kind: "subagent", parent: "turn:t3", name: "audit", state: "queued", cause: "writers", startedAt: Date.now() - 2000 },
|
||||
];
|
||||
|
||||
function Host({ read, jobs = JOBS }: { read?: () => Promise<ProcessEntry[]>; jobs?: JobEntry[] }) {
|
||||
const [open, setOpen] = useState<Deck>("");
|
||||
return <DeckChips tasks={[]} jobs={jobs} open={open} onOpen={(next) => setOpen(next)} onReadProcesses={read} />;
|
||||
}
|
||||
|
||||
const chip = () => document.querySelector<HTMLButtonElement>('[data-action="deck.processes"]')!;
|
||||
|
||||
it("reads nothing until the list is opened", async () => {
|
||||
const read = vi.fn().mockResolvedValue(ROWS);
|
||||
render(<Host read={read} />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("shows each process with its state and the cause the kernel gave", async () => {
|
||||
render(<Host read={vi.fn().mockResolvedValue(ROWS)} />);
|
||||
await userEvent.click(chip());
|
||||
const dialog = await screen.findByRole("dialog", { name: "进程" });
|
||||
expect(dialog.textContent).toContain("npm run build");
|
||||
expect(dialog.textContent).toContain("运行中");
|
||||
expect(dialog.textContent).toContain("audit");
|
||||
expect(dialog.textContent).toContain("排队中");
|
||||
expect(dialog.textContent).toContain("等写入名额");
|
||||
});
|
||||
|
||||
it("does not poll while open, and reads again when the jobs it was built from change", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const read = vi.fn().mockResolvedValue(ROWS);
|
||||
const { rerender } = render(<Host read={read} />);
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
await act(async () => { vi.advanceTimersByTime(120_000); });
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
rerender(<Host read={read} jobs={[...JOBS, { id: "j2", kind: "bash", label: "go test", status: "running", startedAt: Date.now() }]} />);
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("says so when the read fails and keeps no stale rows", async () => {
|
||||
const read = vi.fn().mockResolvedValueOnce(ROWS).mockRejectedValueOnce(new HttpError(500, "disk gone", { code: "process.read_failed", params: { detail: "disk gone" } }));
|
||||
render(<Host read={read} />);
|
||||
await userEvent.click(chip());
|
||||
await screen.findByText("audit");
|
||||
await userEvent.click(chip());
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(screen.queryByText("audit")).toBeNull());
|
||||
expect(screen.getByRole("alert").textContent).toContain("读取进程列表失败:disk gone");
|
||||
});
|
||||
|
||||
it("offers no process chip where the window cannot read processes", () => {
|
||||
render(<Host />);
|
||||
expect(chip()).toBeNull();
|
||||
});
|
||||
@@ -2,6 +2,9 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import "./testkit";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
import { ToastStack } from "./ToastStack";
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SETTING_SCREEN: Record<string, string> = {
|
||||
"agent.persona.include_runtime_context": "persona:persona",
|
||||
"peers.enabled": "peers:peers",
|
||||
"peers.dispatch": "peers:peers",
|
||||
"peers.open_nested": "peers:peers",
|
||||
"peers.release_projects": "peers:peer-projects",
|
||||
"desktop.default_tool_approval_mode": "tools:approval",
|
||||
"agent.plan_approval": "session:plan-approval",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// These tests describe the feedback channel while it exists; the shipped build has it retired.
|
||||
vi.mock("../feedbackchannel", () => ({ FEEDBACK_RETIRED: false }));
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FEEDBACK_RETIRED } from "../feedbackchannel";
|
||||
import { useMemo, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { TreeWorkspace } from "../port/hub";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
import { LevelUpNotice } from "./LevelUpNotice";
|
||||
|
||||
/** What the window says about itself. A refused action's reason is the error bar
|
||||
* and takes the place of the passing notes, which fill it when there is none. */
|
||||
export function useWindowNotices({ error, setError, onReport, tree, port, feedbackOpen }: {
|
||||
error: string;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
onReport?: () => void;
|
||||
tree: TreeWorkspace[];
|
||||
port: AgentPort | null;
|
||||
feedbackOpen: boolean;
|
||||
}): { errorBar: ReactNode; note: ReactNode; bar: ReactNode } {
|
||||
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={onReport} /> : null), [error, setError, onReport]);
|
||||
const note = useMemo(
|
||||
() => <LevelUpNotice port={FEEDBACK_RETIRED ? null : port} tick={feedbackOpen ? 1 : 0} fallback={<AutoArchiveNotice tree={tree} />} />,
|
||||
[tree, port, feedbackOpen],
|
||||
);
|
||||
return { errorBar, note, bar: errorBar ?? note };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { WorkbenchHistory } from "./WorkbenchHistory";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tip = (id: string) => ({ repo: true, truncated: false, commits: [{ id, parents: [], subject: `commit ${id}`, author: "a", at: 0, refs: [] }] });
|
||||
|
||||
function mount(stamp: string) {
|
||||
const port = new MockPort();
|
||||
const log = vi.spyOn(port, "workspaceGitLog").mockResolvedValue(tip("aaaaaaa1111"));
|
||||
const view = render(<WorkbenchHistory port={port} branch="main" stamp={stamp} />);
|
||||
return { port, log, view };
|
||||
}
|
||||
|
||||
describe("the commit history of an open section", () => {
|
||||
it("reads once when opened and not again while nothing moved", async () => {
|
||||
const { port, log, view } = mount("0:0");
|
||||
await screen.findByText("commit aaaaaaa1111");
|
||||
view.rerender(<WorkbenchHistory port={port} branch="main" stamp="0:0" />);
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a commit made after it was opened, without a reload and without blanking the list", async () => {
|
||||
const { port, log, view } = mount("0:0");
|
||||
await screen.findByText("commit aaaaaaa1111");
|
||||
log.mockResolvedValue(tip("bbbbbbb2222"));
|
||||
view.rerender(<WorkbenchHistory port={port} branch="main" stamp="1:0" />);
|
||||
expect(screen.queryByText("commit aaaaaaa1111")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByText("commit bbbbbbb2222")).not.toBeNull());
|
||||
expect(log).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads again when the branch moves", async () => {
|
||||
const { port, log, view } = mount("0:0");
|
||||
await screen.findByText("commit aaaaaaa1111");
|
||||
await act(async () => view.rerender(<WorkbenchHistory port={port} branch="topic" stamp="0:0" />));
|
||||
expect(log).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the list it has when a quiet re-read fails", async () => {
|
||||
const { port, log, view } = mount("0:0");
|
||||
await screen.findByText("commit aaaaaaa1111");
|
||||
log.mockRejectedValue(new Error("busy"));
|
||||
await act(async () => view.rerender(<WorkbenchHistory port={port} branch="main" stamp="1:0" />));
|
||||
expect(screen.queryByText("commit aaaaaaa1111")).not.toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); });
|
||||
|
||||
const mount = () =>
|
||||
render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual={false} shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
|
||||
describe("the workbench start menu once the explorer is chosen", () => {
|
||||
it("is replaced by a prompt to pick a file, not left beside the tree it just opened", async () => {
|
||||
const { container } = mount();
|
||||
expect(container.querySelector(".workbench-launcher")).not.toBeNull();
|
||||
fireEvent.click(container.querySelector('[data-action="workbench.launch-files"]')!);
|
||||
expect(container.querySelector(".workbench-body")!.hasAttribute("data-files")).toBe(true);
|
||||
expect(container.querySelector(".workbench-launcher")).toBeNull();
|
||||
expect(await screen.findByText("从左侧选一个文件")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("comes back when the explorer is hidden again", () => {
|
||||
const { container } = mount();
|
||||
fireEvent.click(container.querySelector('[data-action="workbench.launch-files"]')!);
|
||||
fireEvent.click(container.querySelector('[aria-pressed="true"]')!);
|
||||
expect(container.querySelector(".workbench-launcher")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ILLEGAL = /[<>:"|?*\u0000-\u001f]/;
|
||||
|
||||
function entryNames(tgz: Buffer): string[] {
|
||||
const tar = gunzipSync(tgz);
|
||||
const names: string[] = [];
|
||||
for (let at = 0; at + 512 <= tar.length; ) {
|
||||
const header = tar.subarray(at, at + 512);
|
||||
if (header.every((b) => b === 0)) break;
|
||||
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
||||
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
|
||||
const size = parseInt(header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim() || "0", 8);
|
||||
names.push(prefix ? `${prefix}/${name}` : name);
|
||||
at += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("a package vendored for installs on Windows", () => {
|
||||
const dir = new URL("../vendor/", import.meta.url);
|
||||
|
||||
it("holds no file name Windows cannot create", () => {
|
||||
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
|
||||
expect(names.length).toBeGreaterThan(10);
|
||||
expect(names.filter((n) => ILLEGAL.test(n))).toEqual([]);
|
||||
});
|
||||
|
||||
it("is the package the override says it is", () => {
|
||||
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
|
||||
expect(names).toContain("package/package.json");
|
||||
expect(names).toContain("package/LICENSE");
|
||||
expect(names).toContain("package/dist/redos-detector.js");
|
||||
});
|
||||
|
||||
it("is the one package.json points the override at", () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
expect(pkg.pnpm.overrides["redos-detector"]).toBe("file:./vendor/redos-detector-6.1.4.tgz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-11-10
|
||||
---
|
||||
|
||||
# Vendored packages
|
||||
|
||||
`pnpm.overrides` in `package.json` points `redos-detector` at the tarball here, so that `pnpm install` can finish on Windows.
|
||||
|
||||
## Why
|
||||
|
||||
`@a2ui/web_core` depends on `redos-detector`, and its `isSafeRegex` uses it at run time to refuse regular expressions that can backtrack catastrophically. The check must stay.
|
||||
|
||||
Every published `redos-detector` 6.1.x tarball contains `dist/__mocks__/package-json:version.d.ts`. A colon is not a legal character in a Windows file name, so a package manager cannot write that path and the install stops with `os error 123` (issue #15067). Nothing reads the file: it is a type stub for a build-time module.
|
||||
|
||||
## What is changed
|
||||
|
||||
- `redos-detector-6.1.4.tgz` is the upstream 6.1.4 tarball with that one entry removed. The other 44 entries are byte-identical.
|
||||
- The upstream tarball has the integrity `sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==`, the one the lockfile held before the override.
|
||||
- The package is MIT licensed and carries its own `LICENSE`.
|
||||
|
||||
## Checking it
|
||||
|
||||
- `src/vendor-windows-names.test.ts` fails if the tarball holds a name Windows cannot create, so re-vendoring the unmodified upstream file is caught.
|
||||
- To redo it: `npm pack [email protected]`, check the integrity above, drop the colon-named entry, write the archive back, then run `pnpm install --lockfile-only`.
|
||||
|
||||
## When to remove it
|
||||
|
||||
Delete the tarball, the `pnpm.overrides` entry and the test once `@a2ui/web_core` depends on a `redos-detector` that no longer ships a colon-named file, then run `pnpm install --lockfile-only`.
|
||||
@@ -16,7 +16,7 @@ const ROUTES = [
|
||||
"/appearance",
|
||||
"/agent-presets", "/agent-composition",
|
||||
"/events", "/history", "/status", "/balance", "/submit", "/cancel", "/approve", "/answer",
|
||||
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents",
|
||||
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents", "/processes",
|
||||
"/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/workspace-trust", "/preset",
|
||||
"/model", "/default-model", "/default-mode", "/effort", "/new", "/sessions", "/session/tail", "/delete-session", "/provider-setup",
|
||||
"/tools", "/present-files",
|
||||
|
||||
@@ -2,7 +2,7 @@ module reasonix/desktop
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.10
|
||||
toolchain go1.26.11
|
||||
|
||||
// The desktop tools are a nested module so their CGO build never touches the
|
||||
// CLI's CGO_ENABLED=0 single-static-binary guarantee. The replace lets them
|
||||
|
||||
@@ -150,9 +150,10 @@ allow = ["Bash(go test:*)"] # never prompted
|
||||
|
||||
[peers]
|
||||
# enabled = true # your sessions on this machine can message each other; unpaired messages wait for the receiving session's user to approve them; also a switch in Settings, applied at the next turn (user config only)
|
||||
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
|
||||
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you, except the open_nested switch below); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
|
||||
# max_dispatch_depth = 1 # how many sessions deep a dispatch may chain; 0 removes the ceiling (user config only)
|
||||
# max_dispatched_sessions = 8 # sessions one session may keep open from its dispatches; 0 removes the ceiling (user config only)
|
||||
# open_nested = false # open_projects adds folders inside the session's own workspace without a card; also a switch in Settings (user config only)
|
||||
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply; they start over a pause the host set itself (session switch, unreadable @ reference, reopen) but never over one you pressed (user config only)
|
||||
|
||||
[mods]
|
||||
@@ -223,6 +224,10 @@ sandbox and do not carry it.
|
||||
|
||||
### CLI telemetry
|
||||
|
||||
> The project is no longer maintained. The final release sends no ping,
|
||||
> counter or report, and `reasonix report send` is refused; the text below
|
||||
> describes the earlier behaviour.
|
||||
|
||||
The CLI can send a once-per-day anonymous active-install ping and bounded,
|
||||
content-free event counters to `https://crash.reasonix.io`. Configure the
|
||||
user-global policy with:
|
||||
@@ -2660,6 +2665,13 @@ For a folder not yet in your project list, add it yourself, or let the model
|
||||
request `open_projects`; its card lists the paths and requires your explicit
|
||||
approval even in YOLO mode before adding or trusting them.
|
||||
|
||||
One setting relaxes this:
|
||||
|
||||
- With the user-only `peers.open_nested = true` (also a switch in Settings), a call whose folders are all strictly inside the asking session's own workspace, after links are resolved, is added and trusted without the card, and a notice lists the paths.
|
||||
- Any folder outside the workspace brings the card for the whole call.
|
||||
- It is off by default, and a project's `reasonix.toml` cannot turn it on.
|
||||
- A trusted folder's configuration can run programs, so turn it on only for a repository you trust.
|
||||
|
||||
`open_projects` is unavailable in plan mode until the plan is approved.
|
||||
|
||||
Dispatch refuses unknown or ambiguous projects and the current project itself
|
||||
|
||||
@@ -159,9 +159,10 @@ allow = ["Bash(go test:*)"] # 从不询问
|
||||
|
||||
[peers]
|
||||
# enabled = true # 本机你的各个会话可以互发消息;未配对会话的消息要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置)
|
||||
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
|
||||
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答,下面的 open_nested 开关除外);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
|
||||
# max_dispatch_depth = 1 # 派发最多能连着开几层会话;0 表示不限(只认用户级配置)
|
||||
# max_dispatched_sessions = 8 # 一个会话通过派发最多同时开着几个会话;0 表示不限(只认用户级配置)
|
||||
# open_nested = false # open_projects 打开会话自己工作区之内的文件夹时不弹审批卡;设置里也有开关(只认用户级配置)
|
||||
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧;宿主自己设的暂停(切换会话、@ 引用读不了、重新打开)不拦它们,你主动按下的暂停会拦住,消息留在队列里(只认用户级配置)
|
||||
|
||||
[mods]
|
||||
@@ -217,6 +218,9 @@ bash allowlist 或信任提示。Plan 与常规模式使用相同的 Permissions
|
||||
|
||||
### CLI 上报统计
|
||||
|
||||
> 项目已停止维护。最后一个版本不再发送任何 ping、计数或报告,
|
||||
> `reasonix report send` 会被拒绝;以下内容描述的是此前的行为。
|
||||
|
||||
CLI 可以向 `https://crash.reasonix.io` 发送每日最多一次的匿名活跃安装 ping,
|
||||
以及有界、完全不含内容的事件计数。使用以下用户全局命令配置:
|
||||
|
||||
@@ -1950,6 +1954,13 @@ dispatch = true
|
||||
或让模型通过 `open_projects` 请求;其审批卡会列出路径,即使在 YOLO 模式下也必须
|
||||
由你明确批准,才会添加或信任。
|
||||
|
||||
有一个设置可以放宽这一条:
|
||||
|
||||
- 用户级配置 `peers.open_nested = true`(设置里也有开关)开启后,一次调用里的文件夹只要全部严格位于发起会话自己的工作区之内(解析链接之后),就直接添加并信任,不弹卡,并用通知列出这些路径。
|
||||
- 只要有一个文件夹在工作区之外,整次调用仍然弹卡。
|
||||
- 默认关闭,项目里的 `reasonix.toml` 无法开启它。
|
||||
- 被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。
|
||||
|
||||
计划模式下不能使用 `open_projects`,需要先批准计划。
|
||||
|
||||
不存在或重名不明确的项目、当前项目本身都会被拒绝(当前项目应在本地完成或用
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" viewBox="0 0 296 296" class="segno"><title>QQ group 1093562660 join QR code</title><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -2,7 +2,7 @@ module reasonix
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.10
|
||||
toolchain go1.26.11
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.3.0
|
||||
|
||||
@@ -12,10 +12,18 @@ import (
|
||||
"reasonix/internal/platform/feedback"
|
||||
)
|
||||
|
||||
// feedbackRetired is the end of service: the build makes no report client, so
|
||||
// every frontend is told feedback is unavailable. Tests of the live service
|
||||
// lift it.
|
||||
var feedbackRetired = true
|
||||
|
||||
// feedbackService is the install's report client. A build that cannot make one
|
||||
// leaves the controller without it, which refuses feedback rather than failing
|
||||
// the session.
|
||||
func feedbackService(home string, proxy netclient.ProxySpec, accountsURL string) *feedback.Service {
|
||||
if feedbackRetired {
|
||||
return nil
|
||||
}
|
||||
svc, err := feedback.New(feedback.Config{Home: home, Proxy: proxy, Assert: accountAssertion(proxy, accountsURL)})
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
@@ -39,5 +39,6 @@ func TestMain(m *testing.M) {
|
||||
// A stated home has no process variable to carry this, and the OS keychain
|
||||
// is not isolated by the disposable home.
|
||||
_ = os.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
||||
feedbackRetired = false
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
@@ -758,6 +758,10 @@ type Messages struct {
|
||||
// usage / help
|
||||
UsageBody string // full multi-line help text
|
||||
StandaloneConsoleHint string
|
||||
// MinttyConsoleHint is shown instead of the usage text when stdin or stdout is a Cygwin or MSYS terminal, which a Windows program cannot drive as a console.
|
||||
MinttyConsoleHint string
|
||||
// OpenedNestedWithoutAskingFmt is the notice after open_projects added folders inside the workspace on the user's standing setting; the arguments are a count and the paths.
|
||||
OpenedNestedWithoutAskingFmt string
|
||||
|
||||
// Feedback is the /feedback list standing and refusal wording.
|
||||
Feedback FeedbackText
|
||||
|
||||
@@ -743,7 +743,9 @@ var English = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "CLI telemetry remains disabled because the preference could not be saved: %v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI telemetry is disabled, but pending statistics could not be deleted: %v",
|
||||
|
||||
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
|
||||
MinttyConsoleHint: "The Reasonix terminal UI needs a Windows console, and this terminal (Git Bash / mintty) is not one.\nRun it from cmd, PowerShell or Windows Terminal. `reasonix run \"task\"` still works from here.\n",
|
||||
OpenedNestedWithoutAskingFmt: "Opened %d folder(s) inside this project without asking, as the setting allows (Settings > Agent behavior > Messages between sessions): %s",
|
||||
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
|
||||
UsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -743,7 +743,9 @@ var Chinese = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "由于无法保存偏好设置,CLI 使用统计仍保持关闭:%v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI 使用统计已关闭,但无法删除待发送统计:%v",
|
||||
|
||||
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
|
||||
MinttyConsoleHint: "Reasonix 的终端界面需要 Windows 控制台,而当前终端(Git Bash / mintty)不是。\n请在 cmd、PowerShell 或 Windows Terminal 里运行;在这里仍可用 `reasonix run \"任务\"` 执行单个任务。\n",
|
||||
OpenedNestedWithoutAskingFmt: "按设置(设置 > 智能体行为 > 会话间消息)的允许,已在不询问的情况下打开本项目内的 %d 个文件夹:%s",
|
||||
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
|
||||
UsageBody: `reasonix — 由配置和插件驱动的 coding agent(多模型)
|
||||
|
||||
用法:
|
||||
|
||||
@@ -666,7 +666,9 @@ var ChineseTraditional = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "由於無法儲存偏好設定,CLI 使用統計仍維持關閉:%v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI 使用統計已關閉,但無法刪除待傳送統計:%v",
|
||||
|
||||
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
|
||||
MinttyConsoleHint: "Reasonix 的終端介面需要 Windows 主控台,而目前的終端機(Git Bash / mintty)不是。\n請在 cmd、PowerShell 或 Windows Terminal 裡執行;在這裡仍可用 `reasonix run \"任務\"` 執行單一任務。\n",
|
||||
OpenedNestedWithoutAskingFmt: "依設定(設定 > 智慧代理行為 > 會話間訊息)的允許,已在不詢問的情況下開啟本專案內的 %d 個資料夾:%s",
|
||||
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
|
||||
UsageBody: `reasonix — 由設定和插件驅動的 coding agent(多模型)
|
||||
|
||||
用法:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package agentgraph
|
||||
|
||||
import "reasonix/internal/contract/process"
|
||||
|
||||
// ProcessClass projects the node state onto the shared process vocabulary. A
|
||||
// queued node carries the cause the scheduler held it for.
|
||||
func (s NodeState) ProcessClass(w WaitCause) (process.Class, bool) {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return process.Class{State: process.Pending}, true
|
||||
case StateQueued:
|
||||
return process.Class{State: process.Queued, Cause: waitCause(w)}, true
|
||||
case StateRunning:
|
||||
return process.Class{State: process.Running}, true
|
||||
case StateCompleted:
|
||||
return process.Class{State: process.Completed}, true
|
||||
case StateAdopted:
|
||||
return process.Class{State: process.Completed, Cause: process.CauseAdopted}, true
|
||||
case StateFailed:
|
||||
return process.Class{State: process.Failed}, true
|
||||
case StateCancelled:
|
||||
return process.Class{State: process.Stopped}, true
|
||||
case StateSkipped:
|
||||
return process.Class{State: process.Skipped}, true
|
||||
}
|
||||
return process.Class{}, false
|
||||
}
|
||||
|
||||
func waitCause(w WaitCause) process.Cause {
|
||||
switch w {
|
||||
case WaitSlots:
|
||||
return process.CauseSlots
|
||||
case WaitWriters:
|
||||
return process.CauseWriters
|
||||
case WaitClaim:
|
||||
return process.CauseClaim
|
||||
}
|
||||
return process.CauseNone
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package agentgraph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/contract/process/proctest"
|
||||
)
|
||||
|
||||
func TestNodeStateMapsOntoProcessStates(t *testing.T) {
|
||||
proctest.Total(t, ".", "NodeState", func(v string) (process.Class, bool) { return NodeState(v).ProcessClass("") })
|
||||
}
|
||||
|
||||
func TestQueuedNodeCarriesItsWaitCause(t *testing.T) {
|
||||
for w, want := range map[WaitCause]process.Cause{WaitSlots: process.CauseSlots, WaitWriters: process.CauseWriters, WaitClaim: process.CauseClaim} {
|
||||
got, _ := StateQueued.ProcessClass(w)
|
||||
if got.State != process.Queued || got.Cause != want {
|
||||
t.Errorf("queued for %q: got %+v", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ type PeersConfig struct {
|
||||
// dispatches at once, 0 for no ceiling. Each is a kernel and a lease, so it
|
||||
// bounds a loop that keeps opening panes. User file only.
|
||||
MaxDispatchedSessions int `toml:"max_dispatched_sessions"`
|
||||
// OpenNested lets open_projects add folders inside the asking session's own
|
||||
// workspace without the approval card. A folder outside it still asks. Off
|
||||
// unless the user turns it on; user file only.
|
||||
OpenNested bool `toml:"open_nested"`
|
||||
}
|
||||
|
||||
func defaultPeers() PeersConfig {
|
||||
@@ -46,6 +50,10 @@ func (c *Config) PeersEnabled() bool { return c != nil && c.Peers.Enabled }
|
||||
// PeersDispatch reports whether the model may dispatch a task to another project.
|
||||
func (c *Config) PeersDispatch() bool { return c.PeersEnabled() && c.Peers.Dispatch }
|
||||
|
||||
// PeersOpenNested reports whether open_projects may add folders inside the
|
||||
// asking session's workspace without a card.
|
||||
func (c *Config) PeersOpenNested() bool { return c != nil && c.Peers.OpenNested }
|
||||
|
||||
// PeersMaxDispatchDepth is the user's depth ceiling for chained dispatches, 0 for none.
|
||||
func (c *Config) PeersMaxDispatchDepth() int {
|
||||
if c == nil {
|
||||
@@ -91,7 +99,7 @@ func renderServeAndPeers(b *strings.Builder, c *Config) {
|
||||
func renderPeersSection(b *strings.Builder, c *Config) {
|
||||
p := c.Peers
|
||||
customCeilings := p.MaxDispatchDepth != DefaultMaxDispatchDepth || p.MaxDispatchedSessions != DefaultMaxDispatchedSessions
|
||||
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings {
|
||||
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings && !p.OpenNested {
|
||||
return
|
||||
}
|
||||
b.WriteString("[peers]\n")
|
||||
@@ -100,6 +108,9 @@ func renderPeersSection(b *strings.Builder, c *Config) {
|
||||
if len(p.ReleaseProjects) > 0 {
|
||||
fmt.Fprintf(b, "release_projects = %s # projects that take messages from your other sessions without a card\n", renderStringArray(p.ReleaseProjects))
|
||||
}
|
||||
if p.OpenNested {
|
||||
b.WriteString("open_nested = true # open_projects adds folders inside the asking session's workspace without a card; user file only\n")
|
||||
}
|
||||
if customCeilings {
|
||||
fmt.Fprintf(b, "max_dispatch_depth = %d # how many sessions deep a dispatch may chain; 0 for no ceiling\n", p.MaxDispatchDepth)
|
||||
fmt.Fprintf(b, "max_dispatched_sessions = %d # sessions one session may keep open from its dispatches; 0 for no ceiling\n", p.MaxDispatchedSessions)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
@@ -170,3 +171,53 @@ func TestCeilingsTheUserSetSurviveAnUnrelatedSave(t *testing.T) {
|
||||
t.Fatalf("ceilings after a save = %d, %d; want 0 and 3", got.PeersMaxDispatchDepth(), got.PeersMaxDispatchedSessions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningNestedFoldersWithoutAskingIsOffUnlessTheUserTurnsItOn(t *testing.T) {
|
||||
if Default().PeersOpenNested() {
|
||||
t.Fatal("opening folders without a card is on by default")
|
||||
}
|
||||
cfg, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "")
|
||||
if !cfg.PeersOpenNested() {
|
||||
t.Fatal("the user's own open_nested was not read")
|
||||
}
|
||||
}
|
||||
|
||||
// The setting skips a card that decides whether a folder is trusted, so a cloned
|
||||
// repository must neither switch it on nor undo the user's choice.
|
||||
func TestAProjectFileCannotTurnOpeningFoldersWithoutAskingOn(t *testing.T) {
|
||||
cfg, _ := loadScoped(t, "", "[peers]\nopen_nested = true\n")
|
||||
if cfg.PeersOpenNested() {
|
||||
t.Fatal("a cloned repo's reasonix.toml let folders open without a card")
|
||||
}
|
||||
if !slices.Contains(ignoredKeys(cfg), "peers.open_nested") {
|
||||
t.Fatalf("ignored = %v, want peers.open_nested reported", ignoredKeys(cfg))
|
||||
}
|
||||
if kept, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "[peers]\nopen_nested = false\n"); !kept.PeersOpenNested() {
|
||||
t.Fatal("the project file overrode the user's own choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsOpenNestedAndWritesNothingWhenItIsOff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Peers.OpenNested = true
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !LoadForEdit(path).Peers.OpenNested {
|
||||
t.Fatal("open_nested did not survive a save")
|
||||
}
|
||||
off := LoadForEdit(path)
|
||||
off.Peers.OpenNested = false
|
||||
if err := off.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "open_nested") {
|
||||
t.Fatalf("an off setting was written out:\n%s", raw)
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,8 @@ func (h heldScope) narrowPeers(c *Config) {
|
||||
if c.Peers.MaxDispatchedSessions != h.peers.MaxDispatchedSessions {
|
||||
c.ignoreProject("peers.max_dispatched_sessions", fmt.Sprint(c.Peers.MaxDispatchedSessions), ProjectUserOnly)
|
||||
}
|
||||
if c.Peers.OpenNested != h.peers.OpenNested {
|
||||
c.ignoreProject("peers.open_nested", fmt.Sprintf("%t", c.Peers.OpenNested), ProjectUserOnly)
|
||||
}
|
||||
c.Peers = clonePeers(h.peers)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package process is the shared vocabulary for work the kernel is running: a
|
||||
// closed set of states and the typed causes that explain them.
|
||||
//
|
||||
// It records nothing and owns no lifecycle. Each owner keeps its own states and
|
||||
// transitions and maps them onto this set; a layer above may project a class,
|
||||
// never re-derive one. A cause is left empty when the owner does not know it.
|
||||
package process
|
||||
@@ -0,0 +1,81 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is where a unit of work stands, in terms every owner can be mapped onto.
|
||||
type State string
|
||||
|
||||
const (
|
||||
Pending State = "pending"
|
||||
Queued State = "queued"
|
||||
Running State = "running"
|
||||
Waiting State = "waiting"
|
||||
Completed State = "completed"
|
||||
Failed State = "failed"
|
||||
Stopped State = "stopped"
|
||||
Interrupted State = "interrupted"
|
||||
Skipped State = "skipped"
|
||||
)
|
||||
|
||||
// Cause says why a unit is in its State. Empty means the owner does not record
|
||||
// one, which is not the same as there being none.
|
||||
type Cause string
|
||||
|
||||
const (
|
||||
CauseNone Cause = ""
|
||||
CauseSlots Cause = "slots"
|
||||
CauseWriters Cause = "writers"
|
||||
CauseClaim Cause = "claim"
|
||||
CauseHeld Cause = "held"
|
||||
CauseAdopted Cause = "adopted"
|
||||
CauseBudget Cause = "budget"
|
||||
CauseParked Cause = "parked"
|
||||
)
|
||||
|
||||
// Class is one owner state projected onto the shared vocabulary.
|
||||
type Class struct {
|
||||
State State
|
||||
Cause Cause
|
||||
}
|
||||
|
||||
// States lists every State.
|
||||
var States = []State{Pending, Queued, Running, Waiting, Completed, Failed, Stopped, Interrupted, Skipped}
|
||||
|
||||
// Terminal reports whether a unit in this state will not change state again.
|
||||
func (s State) Terminal() bool {
|
||||
switch s {
|
||||
case Completed, Failed, Stopped, Interrupted, Skipped:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Valid reports whether s is one of States.
|
||||
func (s State) Valid() bool { return slices.Contains(States, s) }
|
||||
|
||||
// Kind is what sort of work a Process is. It is the owner that produced it.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindSubagent Kind = "subagent"
|
||||
KindJob Kind = "job"
|
||||
)
|
||||
|
||||
// Process is one running unit as a reader sees it.
|
||||
type Process struct {
|
||||
// ID is "<kind>:<owner id>", stable for as long as the owner keeps the unit.
|
||||
ID string
|
||||
Kind Kind
|
||||
// Parent is the ID of the process that started this one; empty for a root.
|
||||
Parent string
|
||||
Name string
|
||||
State State
|
||||
Cause Cause
|
||||
Started time.Time
|
||||
}
|
||||
|
||||
// ID builds the process id for an owner's own id.
|
||||
func ID(k Kind, owner string) string { return string(k) + ":" + owner }
|
||||
@@ -0,0 +1,34 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalStatesAreExactlyThoseThatEnd(t *testing.T) {
|
||||
for _, s := range States {
|
||||
live := s == Pending || s == Queued || s == Running || s == Waiting
|
||||
if s.Terminal() == live {
|
||||
t.Errorf("%s: terminal=%v", s, s.Terminal())
|
||||
}
|
||||
if !s.Valid() || strings.TrimSpace(string(s)) != string(s) {
|
||||
t.Errorf("%q is not a clean valid state", s)
|
||||
}
|
||||
}
|
||||
if State("paused").Valid() {
|
||||
t.Error("an unknown state must not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStateTerminal(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := range b.N {
|
||||
_ = States[i%len(States)].Terminal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDNamesTheKindAndTheOwnersOwnID(t *testing.T) {
|
||||
if got := ID(KindJob, "j1"); got != "job:j1" {
|
||||
t.Fatalf("ID = %q, want job:j1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package proctest holds the check that keeps an owner's mapping onto the
|
||||
// process vocabulary total.
|
||||
package proctest
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
)
|
||||
|
||||
// Declared returns the string values of every constant of typeName declared in
|
||||
// the non-test files of dir.
|
||||
func Declared(t testing.TB, dir, typeName string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
gd, ok := d.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, sp := range gd.Specs {
|
||||
vs := sp.(*ast.ValueSpec)
|
||||
id, ok := vs.Type.(*ast.Ident)
|
||||
if !ok || id.Name != typeName || len(vs.Values) != 1 {
|
||||
continue
|
||||
}
|
||||
if lit, ok := vs.Values[0].(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
v, _ := strconv.Unquote(lit.Value)
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Total fails unless every declared value of typeName maps to a valid state.
|
||||
func Total(t testing.TB, dir, typeName string, class func(string) (process.Class, bool)) {
|
||||
t.Helper()
|
||||
vals := Declared(t, dir, typeName)
|
||||
if len(vals) == 0 {
|
||||
t.Fatalf("no constants of %s found in %s", typeName, dir)
|
||||
}
|
||||
for _, v := range vals {
|
||||
c, ok := class(v)
|
||||
if !ok || !c.State.Valid() {
|
||||
t.Errorf("%s %q has no mapping onto the process vocabulary", typeName, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,5 +261,6 @@ var table = []Entry{
|
||||
{ID: "peers.release_projects", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.max_dispatch_depth", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.max_dispatched_sessions", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.open_nested", Class: Guarded, Effect: Immediate},
|
||||
{ID: "storage", Class: Guarded, Effect: Restart},
|
||||
}
|
||||
@@ -281,3 +281,13 @@ func TestTheDesktopScreenTableNamesOnlyDeclaredSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippingTheOpenFoldersCardIsNeverTheModelsToWrite(t *testing.T) {
|
||||
e, ok := Lookup("peers.open_nested")
|
||||
if !ok {
|
||||
t.Fatal("peers.open_nested is not declared")
|
||||
}
|
||||
if e.Class != Guarded {
|
||||
t.Fatalf("class = %v, want Guarded: a setting that removes a trust card must not be written on the model's say-so", e.Class)
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,14 @@ func bareUsage(bare bool) int {
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// bareOutsideConsole answers a bare invocation that has no console: a Cygwin
|
||||
// or MSYS terminal is told why the UI cannot run there, anything else gets the
|
||||
// usage text.
|
||||
func bareOutsideConsole(bare bool) int {
|
||||
if hint := minttyHint(os.Stdin, os.Stdout, onCygwinPty); hint != "" {
|
||||
fmt.Fprint(os.Stderr, hint)
|
||||
return 2
|
||||
}
|
||||
return bareUsage(bare)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return runTUI(args, version)
|
||||
}
|
||||
return bareUsage(len(args) == 0)
|
||||
return bareOutsideConsole(len(args) == 0)
|
||||
}
|
||||
|
||||
rest := args[1:]
|
||||
|
||||