Compare commits

...
51 changed files with 2646 additions and 938 deletions
+377 -6
View File
@@ -328,15 +328,386 @@ Use nanobot for a real task, report what broke, and then pick a focused improvem
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
## Contact
## Maintainers
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
<table>
<tr>
<td align="center"><a href="https://github.com/re-bin"><img src="https://avatars.githubusercontent.com/u/52506698?v=4&s=80" width="80" height="80" alt="Xubin Ren"><br><strong>Xubin Ren</strong></a><br><a href="https://x.com/xubinrencs"><img src="https://img.shields.io/badge/@xubinrencs-000000?style=flat&logo=x&logoColor=white" alt="Xubin Ren on X"></a></td>
<td align="center"><a href="https://github.com/chengyongru"><img src="https://avatars.githubusercontent.com/u/61816729?v=4&s=80" width="80" height="80" alt="Yongru Chen"><br><strong>Yongru Chen</strong></a><br><a href="https://x.com/chengyongru"><img src="https://img.shields.io/badge/@chengyongru-000000?style=flat&logo=x&logoColor=white" alt="Yongru Chen on X"></a></td>
</tr>
</table>
### Contributors
## Community Contributors
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
</a>
<!-- contributors:start -->
<p>
<a href="https://github.com/Athemis"><img src="https://avatars.githubusercontent.com/u/552653?v=4&s=48" width="48" height="48" alt="Athemis"></a>
<a href="https://github.com/axelray-dev"><img src="https://avatars.githubusercontent.com/u/110029405?v=4&s=48" width="48" height="48" alt="axelray-dev"></a>
<a href="https://github.com/yorkhellen"><img src="https://avatars.githubusercontent.com/u/8706550?v=4&s=48" width="48" height="48" alt="yorkhellen"></a>
<a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4&s=48" width="48" height="48" alt="04cb"></a>
<a href="https://github.com/santhreal"><img src="https://avatars.githubusercontent.com/u/64453045?v=4&s=48" width="48" height="48" alt="santhreal"></a>
<a href="https://github.com/yu-xin-c"><img src="https://avatars.githubusercontent.com/u/175149126?v=4&s=48" width="48" height="48" alt="yu-xin-c"></a>
<a href="https://github.com/xcosmosbox"><img src="https://avatars.githubusercontent.com/u/56502269?v=4&s=48" width="48" height="48" alt="xcosmosbox"></a>
<a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16"></a>
<a href="https://github.com/chaohuang-ai"><img src="https://avatars.githubusercontent.com/u/204865953?v=4&s=48" width="48" height="48" alt="chaohuang-ai"></a>
<a href="https://github.com/zayfod"><img src="https://avatars.githubusercontent.com/u/1811339?v=4&s=48" width="48" height="48" alt="zayfod"></a>
<a href="https://github.com/nikolasdehor"><img src="https://avatars.githubusercontent.com/u/116851567?v=4&s=48" width="48" height="48" alt="nikolasdehor"></a>
<a href="https://github.com/JiajunBernoulli"><img src="https://avatars.githubusercontent.com/u/45968640?v=4&s=48" width="48" height="48" alt="JiajunBernoulli"></a>
<a href="https://github.com/flobo3"><img src="https://avatars.githubusercontent.com/u/268352850?v=4&s=48" width="48" height="48" alt="flobo3"></a>
<a href="https://github.com/hamb1y"><img src="https://avatars.githubusercontent.com/u/88080063?v=4&s=48" width="48" height="48" alt="hamb1y"></a>
<a href="https://github.com/SergioSV96"><img src="https://avatars.githubusercontent.com/u/20419761?v=4&s=48" width="48" height="48" alt="SergioSV96"></a>
<a href="https://github.com/KDB-Wind"><img src="https://avatars.githubusercontent.com/u/271925278?v=4&s=48" width="48" height="48" alt="KDB-Wind"></a>
<a href="https://github.com/morandot"><img src="https://avatars.githubusercontent.com/u/274257964?v=4&s=48" width="48" height="48" alt="morandot"></a>
<a href="https://github.com/coldxiangyu163"><img src="https://avatars.githubusercontent.com/u/134986317?v=4&s=48" width="48" height="48" alt="coldxiangyu163"></a>
<a href="https://github.com/boogieLing"><img src="https://avatars.githubusercontent.com/u/64551706?v=4&s=48" width="48" height="48" alt="boogieLing"></a>
<a href="https://github.com/michaelxer"><img src="https://avatars.githubusercontent.com/u/52305679?v=4&s=48" width="48" height="48" alt="michaelxer"></a>
<a href="https://github.com/aiguozhi123456"><img src="https://avatars.githubusercontent.com/u/126325311?v=4&s=48" width="48" height="48" alt="aiguozhi123456"></a>
<a href="https://github.com/pinhua33"><img src="https://avatars.githubusercontent.com/u/251483507?v=4&s=48" width="48" height="48" alt="pinhua33"></a>
<a href="https://github.com/pixan-ai"><img src="https://avatars.githubusercontent.com/u/218441143?v=4&s=48" width="48" height="48" alt="pixan-ai"></a>
<a href="https://github.com/hussein1362"><img src="https://avatars.githubusercontent.com/u/49703886?v=4&s=48" width="48" height="48" alt="hussein1362"></a>
<a href="https://github.com/alekwo"><img src="https://avatars.githubusercontent.com/u/24917047?v=4&s=48" width="48" height="48" alt="alekwo"></a>
<a href="https://github.com/haosenwang1018"><img src="https://avatars.githubusercontent.com/u/167664334?v=4&s=48" width="48" height="48" alt="haosenwang1018"></a>
<a href="https://github.com/IlyaGusev"><img src="https://avatars.githubusercontent.com/u/2670295?v=4&s=48" width="48" height="48" alt="IlyaGusev"></a>
<a href="https://github.com/T3chC0wb0y"><img src="https://avatars.githubusercontent.com/u/68530847?v=4&s=48" width="48" height="48" alt="T3chC0wb0y"></a>
<a href="https://github.com/VITOHJL"><img src="https://avatars.githubusercontent.com/u/166518988?v=4&s=48" width="48" height="48" alt="VITOHJL"></a>
<a href="https://github.com/macroadster"><img src="https://avatars.githubusercontent.com/u/328366?v=4&s=48" width="48" height="48" alt="macroadster"></a>
<a href="https://github.com/Hinotoi-agent"><img src="https://avatars.githubusercontent.com/u/275430060?v=4&s=48" width="48" height="48" alt="Hinotoi-agent"></a>
<a href="https://github.com/kingassune"><img src="https://avatars.githubusercontent.com/u/6126851?v=4&s=48" width="48" height="48" alt="kingassune"></a>
<a href="https://github.com/goodtiding5"><img src="https://avatars.githubusercontent.com/u/179489?v=4&s=48" width="48" height="48" alt="goodtiding5"></a>
<a href="https://github.com/kiplangatkorir"><img src="https://avatars.githubusercontent.com/u/153384040?v=4&s=48" width="48" height="48" alt="kiplangatkorir"></a>
<a href="https://github.com/elkaix"><img src="https://avatars.githubusercontent.com/u/197959891?v=4&s=48" width="48" height="48" alt="elkaix"></a>
<a href="https://github.com/KimGLee"><img src="https://avatars.githubusercontent.com/u/150593189?v=4&s=48" width="48" height="48" alt="KimGLee"></a>
<a href="https://github.com/m11y"><img src="https://avatars.githubusercontent.com/u/1625837?v=4&s=48" width="48" height="48" alt="m11y"></a>
<a href="https://github.com/LingaoM"><img src="https://avatars.githubusercontent.com/u/26378606?v=4&s=48" width="48" height="48" alt="LingaoM"></a>
<a href="https://github.com/DaryeDev"><img src="https://avatars.githubusercontent.com/u/54469750?v=4&s=48" width="48" height="48" alt="DaryeDev"></a>
<a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST"></a>
<a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="xzq-xu"></a>
<a href="https://github.com/pikaxinge"><img src="https://avatars.githubusercontent.com/u/68273313?v=4&s=48" width="48" height="48" alt="pikaxinge"></a>
<a href="https://github.com/arcdrake22"><img src="https://avatars.githubusercontent.com/u/204617897?v=4&s=48" width="48" height="48" alt="arcdrake22"></a>
<a href="https://github.com/JackLuguibin"><img src="https://avatars.githubusercontent.com/u/46274946?v=4&s=48" width="48" height="48" alt="JackLuguibin"></a>
<a href="https://github.com/HaisamAbbas"><img src="https://avatars.githubusercontent.com/u/95044189?v=4&s=48" width="48" height="48" alt="HaisamAbbas"></a>
<a href="https://github.com/anunay999"><img src="https://avatars.githubusercontent.com/u/16853513?v=4&s=48" width="48" height="48" alt="anunay999"></a>
<a href="https://github.com/flaviovs"><img src="https://avatars.githubusercontent.com/u/1832699?v=4&s=48" width="48" height="48" alt="flaviovs"></a>
<a href="https://github.com/C-Li"><img src="https://avatars.githubusercontent.com/u/20661667?v=4&s=48" width="48" height="48" alt="C-Li"></a>
<a href="https://github.com/Ho1yShif"><img src="https://avatars.githubusercontent.com/u/75815862?v=4&s=48" width="48" height="48" alt="Ho1yShif"></a>
<a href="https://github.com/pjhoberman"><img src="https://avatars.githubusercontent.com/u/37924?v=4&s=48" width="48" height="48" alt="pjhoberman"></a>
<a href="https://github.com/nghiahsgs"><img src="https://avatars.githubusercontent.com/u/24955327?v=4&s=48" width="48" height="48" alt="nghiahsgs"></a>
<a href="https://github.com/Bahtya"><img src="https://avatars.githubusercontent.com/u/34988899?v=4&s=48" width="48" height="48" alt="Bahtya"></a>
<a href="https://github.com/tangtaizong666"><img src="https://avatars.githubusercontent.com/u/212687958?v=4&s=48" width="48" height="48" alt="tangtaizong666"></a>
<a href="https://github.com/XJPeng12"><img src="https://avatars.githubusercontent.com/u/50786186?v=4&s=48" width="48" height="48" alt="XJPeng12"></a>
<a href="https://github.com/yanghan-cyber"><img src="https://avatars.githubusercontent.com/u/188783428?v=4&s=48" width="48" height="48" alt="yanghan-cyber"></a>
<a href="https://github.com/ZhouJ-sh"><img src="https://avatars.githubusercontent.com/u/9983860?v=4&s=48" width="48" height="48" alt="ZhouJ-sh"></a>
<a href="https://github.com/Yuxin-Lou"><img src="https://avatars.githubusercontent.com/u/117000057?v=4&s=48" width="48" height="48" alt="Yuxin-Lou"></a>
<a href="https://github.com/LeoFYH"><img src="https://avatars.githubusercontent.com/u/184173704?v=4&s=48" width="48" height="48" alt="LeoFYH"></a>
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude"></a>
<a href="https://github.com/chris-alexander"><img src="https://avatars.githubusercontent.com/u/2815297?v=4&s=48" width="48" height="48" alt="chris-alexander"></a>
<a href="https://github.com/benlenarts"><img src="https://avatars.githubusercontent.com/u/131161?v=4&s=48" width="48" height="48" alt="benlenarts"></a>
<a href="https://github.com/outlook84"><img src="https://avatars.githubusercontent.com/u/96007761?v=4&s=48" width="48" height="48" alt="outlook84"></a>
<a href="https://github.com/Mrart"><img src="https://avatars.githubusercontent.com/u/5235758?v=4&s=48" width="48" height="48" alt="Mrart"></a>
<a href="https://github.com/ramonpaolo"><img src="https://avatars.githubusercontent.com/u/53312850?v=4&s=48" width="48" height="48" alt="ramonpaolo"></a>
<a href="https://github.com/huhu-tiger"><img src="https://avatars.githubusercontent.com/u/76894920?v=4&s=48" width="48" height="48" alt="huhu-tiger"></a>
<a href="https://github.com/tangjiabin"><img src="https://avatars.githubusercontent.com/u/21021242?v=4&s=48" width="48" height="48" alt="tangjiabin"></a>
<a href="https://github.com/yeyitech"><img src="https://avatars.githubusercontent.com/u/231244789?v=4&s=48" width="48" height="48" alt="yeyitech"></a>
<a href="https://github.com/Flinn-X"><img src="https://avatars.githubusercontent.com/u/54433526?v=4&s=48" width="48" height="48" alt="Flinn-X"></a>
<a href="https://github.com/bingqilinweimaotai"><img src="https://avatars.githubusercontent.com/u/111987281?v=4&s=48" width="48" height="48" alt="bingqilinweimaotai"></a>
<a href="https://github.com/Qinnnnnn"><img src="https://avatars.githubusercontent.com/u/14584068?v=4&s=48" width="48" height="48" alt="Qinnnnnn"></a>
<a href="https://github.com/HengWeiBin"><img src="https://avatars.githubusercontent.com/u/45145821?v=4&s=48" width="48" height="48" alt="HengWeiBin"></a>
<a href="https://github.com/waelantar"><img src="https://avatars.githubusercontent.com/u/70063334?v=4&s=48" width="48" height="48" alt="waelantar"></a>
<a href="https://github.com/tanishra"><img src="https://avatars.githubusercontent.com/u/100482827?v=4&s=48" width="48" height="48" alt="tanishra"></a>
<a href="https://github.com/olgagaga"><img src="https://avatars.githubusercontent.com/u/75477960?v=4&s=48" width="48" height="48" alt="olgagaga"></a>
<a href="https://github.com/masterlyj"><img src="https://avatars.githubusercontent.com/u/167326996?v=4&s=48" width="48" height="48" alt="masterlyj"></a>
<a href="https://github.com/xgzlucario"><img src="https://avatars.githubusercontent.com/u/48748794?v=4&s=48" width="48" height="48" alt="xgzlucario"></a>
<a href="https://github.com/dzydzydzy7"><img src="https://avatars.githubusercontent.com/u/32220064?v=4&s=48" width="48" height="48" alt="dzydzydzy7"></a>
<a href="https://github.com/dajiaohuang"><img src="https://avatars.githubusercontent.com/u/108231307?v=4&s=48" width="48" height="48" alt="dajiaohuang"></a>
<a href="https://github.com/concertypin"><img src="https://avatars.githubusercontent.com/u/55056558?v=4&s=48" width="48" height="48" alt="concertypin"></a>
<a href="https://github.com/WangCheng0116"><img src="https://avatars.githubusercontent.com/u/111694270?v=4&s=48" width="48" height="48" alt="WangCheng0116"></a>
<a href="https://github.com/yarikoptic"><img src="https://avatars.githubusercontent.com/u/39889?v=4&s=48" width="48" height="48" alt="yarikoptic"></a>
<a href="https://github.com/lukemilby"><img src="https://avatars.githubusercontent.com/u/966940?v=4&s=48" width="48" height="48" alt="lukemilby"></a>
<a href="https://github.com/gongpx20069"><img src="https://avatars.githubusercontent.com/u/21985921?v=4&s=48" width="48" height="48" alt="gongpx20069"></a>
<a href="https://github.com/tobrien"><img src="https://avatars.githubusercontent.com/u/36787?v=4&s=48" width="48" height="48" alt="tobrien"></a>
<a href="https://github.com/Shiniese"><img src="https://avatars.githubusercontent.com/u/135589327?v=4&s=48" width="48" height="48" alt="Shiniese"></a>
<a href="https://github.com/shawnWXN"><img src="https://avatars.githubusercontent.com/u/47786182?v=4&s=48" width="48" height="48" alt="shawnWXN"></a>
<a href="https://github.com/sbyinin"><img src="https://avatars.githubusercontent.com/u/2064038?v=4&s=48" width="48" height="48" alt="sbyinin"></a>
<a href="https://github.com/nne998"><img src="https://avatars.githubusercontent.com/u/148901?v=4&s=48" width="48" height="48" alt="nne998"></a>
<a href="https://github.com/lahuman"><img src="https://avatars.githubusercontent.com/u/6156679?v=4&s=48" width="48" height="48" alt="lahuman"></a>
<a href="https://github.com/hlgone"><img src="https://avatars.githubusercontent.com/u/152462991?v=4&s=48" width="48" height="48" alt="hlgone"></a>
<a href="https://github.com/franciscomaestre"><img src="https://avatars.githubusercontent.com/u/2027043?v=4&s=48" width="48" height="48" alt="franciscomaestre"></a>
<a href="https://github.com/fat-operator"><img src="https://avatars.githubusercontent.com/u/105777951?v=4&s=48" width="48" height="48" alt="fat-operator"></a>
<a href="https://github.com/shixi-li"><img src="https://avatars.githubusercontent.com/u/40780706?v=4&s=48" width="48" height="48" alt="shixi-li"></a>
<a href="https://github.com/who96"><img src="https://avatars.githubusercontent.com/u/44131846?v=4&s=48" width="48" height="48" alt="who96"></a>
<a href="https://github.com/cyzlmh"><img src="https://avatars.githubusercontent.com/u/24603258?v=4&s=48" width="48" height="48" alt="cyzlmh"></a>
<a href="https://github.com/zhuzhh"><img src="https://avatars.githubusercontent.com/u/41102272?v=4&s=48" width="48" height="48" alt="zhuzhh"></a>
<a href="https://github.com/zpljd258"><img src="https://avatars.githubusercontent.com/u/11162658?v=4&s=48" width="48" height="48" alt="zpljd258"></a>
<a href="https://github.com/cms19859230182-lang"><img src="https://avatars.githubusercontent.com/u/276597748?v=4&s=48" width="48" height="48" alt="cms19859230182-lang"></a>
<a href="https://github.com/amplifierplus"><img src="https://avatars.githubusercontent.com/u/160200579?v=4&s=48" width="48" height="48" alt="amplifierplus"></a>
<a href="https://github.com/LZDQ"><img src="https://avatars.githubusercontent.com/u/45907809?v=4&s=48" width="48" height="48" alt="LZDQ"></a>
<a href="https://github.com/wb213"><img src="https://avatars.githubusercontent.com/u/488412?v=4&s=48" width="48" height="48" alt="wb213"></a>
<a href="https://github.com/shaun0927"><img src="https://avatars.githubusercontent.com/u/70629228?v=4&s=48" width="48" height="48" alt="shaun0927"></a>
<a href="https://github.com/wzrayyy"><img src="https://avatars.githubusercontent.com/u/143233939?v=4&s=48" width="48" height="48" alt="wzrayyy"></a>
<a href="https://github.com/LHMQ878"><img src="https://avatars.githubusercontent.com/u/205284459?v=4&s=48" width="48" height="48" alt="LHMQ878"></a>
<a href="https://github.com/Michael-lhh"><img src="https://avatars.githubusercontent.com/u/41994684?v=4&s=48" width="48" height="48" alt="Michael-lhh"></a>
<a href="https://github.com/Mizarka"><img src="https://avatars.githubusercontent.com/u/253529828?v=4&s=48" width="48" height="48" alt="Mizarka"></a>
<a href="https://github.com/rick2047"><img src="https://avatars.githubusercontent.com/u/16410?v=4&s=48" width="48" height="48" alt="rick2047"></a>
<a href="https://github.com/kuchazi-yy"><img src="https://avatars.githubusercontent.com/u/73976601?v=4&s=48" width="48" height="48" alt="kuchazi-yy"></a>
<a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol-zero-0"></a>
<a href="https://github.com/subalkum"><img src="https://avatars.githubusercontent.com/u/180379485?v=4&s=48" width="48" height="48" alt="subalkum"></a>
<a href="https://github.com/vystartasv"><img src="https://avatars.githubusercontent.com/u/34380849?v=4&s=48" width="48" height="48" alt="vystartasv"></a>
<a href="https://github.com/ZJUCQR"><img src="https://avatars.githubusercontent.com/u/138299253?v=4&s=48" width="48" height="48" alt="ZJUCQR"></a>
<a href="https://github.com/ZegWe"><img src="https://avatars.githubusercontent.com/u/22636524?v=4&s=48" width="48" height="48" alt="ZegWe"></a>
<a href="https://github.com/ZhangYuanhan-AI"><img src="https://avatars.githubusercontent.com/u/18485270?v=4&s=48" width="48" height="48" alt="ZhangYuanhan-AI"></a>
<a href="https://github.com/chtangwin"><img src="https://avatars.githubusercontent.com/u/8316617?v=4&s=48" width="48" height="48" alt="chtangwin"></a>
<a href="https://github.com/dxtime"><img src="https://avatars.githubusercontent.com/u/8173810?v=4&s=48" width="48" height="48" alt="dxtime"></a>
<a href="https://github.com/ethanclaw"><img src="https://avatars.githubusercontent.com/u/262543029?v=4&s=48" width="48" height="48" alt="ethanclaw"></a>
<a href="https://github.com/WufeiHalf"><img src="https://avatars.githubusercontent.com/u/103879607?v=4&s=48" width="48" height="48" alt="WufeiHalf"></a>
<a href="https://github.com/stutiredboy"><img src="https://avatars.githubusercontent.com/u/345208?v=4&s=48" width="48" height="48" alt="stutiredboy"></a>
<a href="https://github.com/stupidloud"><img src="https://avatars.githubusercontent.com/u/56048681?v=4&s=48" width="48" height="48" alt="stupidloud"></a>
<a href="https://github.com/asif786ka"><img src="https://avatars.githubusercontent.com/u/6130514?v=4&s=48" width="48" height="48" alt="asif786ka"></a>
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla"></a>
<a href="https://github.com/cypggs"><img src="https://avatars.githubusercontent.com/u/3694954?v=4&s=48" width="48" height="48" alt="cypggs"></a>
<a href="https://github.com/web-flow"><img src="https://avatars.githubusercontent.com/u/19864447?v=4&s=48" width="48" height="48" alt="web-flow"></a>
<a href="https://github.com/eliumusk"><img src="https://avatars.githubusercontent.com/u/123090877?v=4&s=48" width="48" height="48" alt="eliumusk"></a>
<a href="https://github.com/mikaku9944"><img src="https://avatars.githubusercontent.com/u/66119379?v=4&s=48" width="48" height="48" alt="mikaku9944"></a>
<a href="https://github.com/mamamiyear"><img src="https://avatars.githubusercontent.com/u/14191296?v=4&s=48" width="48" height="48" alt="mamamiyear"></a>
<a href="https://github.com/jr551"><img src="https://avatars.githubusercontent.com/u/2920328?v=4&s=48" width="48" height="48" alt="jr551"></a>
<a href="https://github.com/invictus-z"><img src="https://avatars.githubusercontent.com/u/108621936?v=4&s=48" width="48" height="48" alt="invictus-z"></a>
<a href="https://github.com/imfondof"><img src="https://avatars.githubusercontent.com/u/39022581?v=4&s=48" width="48" height="48" alt="imfondof"></a>
<a href="https://github.com/hyoukadev"><img src="https://avatars.githubusercontent.com/u/17965578?v=4&s=48" width="48" height="48" alt="hyoukadev"></a>
<a href="https://github.com/hata33"><img src="https://avatars.githubusercontent.com/u/79907651?v=4&s=48" width="48" height="48" alt="hata33"></a>
<a href="https://github.com/fengxiaohu"><img src="https://avatars.githubusercontent.com/u/23492381?v=4&s=48" width="48" height="48" alt="fengxiaohu"></a>
<a href="https://github.com/vivganes"><img src="https://avatars.githubusercontent.com/u/2035886?v=4&s=48" width="48" height="48" alt="vivganes"></a>
<a href="https://github.com/themavik"><img src="https://avatars.githubusercontent.com/u/179817126?v=4&s=48" width="48" height="48" alt="themavik"></a>
<a href="https://github.com/flyzstu"><img src="https://avatars.githubusercontent.com/u/94161727?v=4&s=48" width="48" height="48" alt="flyzstu"></a>
<a href="https://github.com/pikaqqqqqq"><img src="https://avatars.githubusercontent.com/u/20340136?v=4&s=48" width="48" height="48" alt="pikaqqqqqq"></a>
<a href="https://github.com/wyjBot"><img src="https://avatars.githubusercontent.com/u/70993189?v=4&s=48" width="48" height="48" alt="wyjBot"></a>
<a href="https://github.com/pblocz"><img src="https://avatars.githubusercontent.com/u/9288574?v=4&s=48" width="48" height="48" alt="pblocz"></a>
<a href="https://github.com/niradler"><img src="https://avatars.githubusercontent.com/u/6292980?v=4&s=48" width="48" height="48" alt="niradler"></a>
<a href="https://github.com/longle325"><img src="https://avatars.githubusercontent.com/u/140832783?v=4&s=48" width="48" height="48" alt="longle325"></a>
<a href="https://github.com/primit1v0"><img src="https://avatars.githubusercontent.com/u/119784372?v=4&s=48" width="48" height="48" alt="primit1v0"></a>
<a href="https://github.com/honjiaxuan"><img src="https://avatars.githubusercontent.com/u/13818528?v=4&s=48" width="48" height="48" alt="honjiaxuan"></a>
<a href="https://github.com/DeeJ4yNg"><img src="https://avatars.githubusercontent.com/u/99658722?v=4&s=48" width="48" height="48" alt="DeeJ4yNg"></a>
<a href="https://github.com/danielphang"><img src="https://avatars.githubusercontent.com/u/1204069?v=4&s=48" width="48" height="48" alt="danielphang"></a>
<a href="https://github.com/yanalialiuk"><img src="https://avatars.githubusercontent.com/u/193742981?v=4&s=48" width="48" height="48" alt="yanalialiuk"></a>
<a href="https://github.com/zhouzhuojie"><img src="https://avatars.githubusercontent.com/u/658840?v=4&s=48" width="48" height="48" alt="zhouzhuojie"></a>
<a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x"></a>
<a href="https://github.com/yrk111222"><img src="https://avatars.githubusercontent.com/u/185151020?v=4&s=48" width="48" height="48" alt="yrk111222"></a>
<a href="https://github.com/Xerxes-cn"><img src="https://avatars.githubusercontent.com/u/58462889?v=4&s=48" width="48" height="48" alt="Xerxes-cn"></a>
<a href="https://github.com/suger-m"><img src="https://avatars.githubusercontent.com/u/240725677?v=4&s=48" width="48" height="48" alt="suger-m"></a>
<a href="https://github.com/mengyhang"><img src="https://avatars.githubusercontent.com/u/148381938?v=4&s=48" width="48" height="48" alt="mengyhang"></a>
<a href="https://github.com/Liwx1014"><img src="https://avatars.githubusercontent.com/u/186271593?v=4&s=48" width="48" height="48" alt="Liwx1014"></a>
<a href="https://github.com/Shizoqua"><img src="https://avatars.githubusercontent.com/u/136805224?v=4&s=48" width="48" height="48" alt="Shizoqua"></a>
<a href="https://github.com/KailBug"><img src="https://avatars.githubusercontent.com/u/66873219?v=4&s=48" width="48" height="48" alt="KailBug"></a>
<a href="https://github.com/19emtuck"><img src="https://avatars.githubusercontent.com/u/956861?v=4&s=48" width="48" height="48" alt="19emtuck"></a>
<a href="https://github.com/tsubasakong"><img src="https://avatars.githubusercontent.com/u/97429702?v=4&s=48" width="48" height="48" alt="tsubasakong"></a>
<a href="https://github.com/wseng"><img src="https://avatars.githubusercontent.com/u/6572161?v=4&s=48" width="48" height="48" alt="wseng"></a>
<a href="https://github.com/3927o"><img src="https://avatars.githubusercontent.com/u/53431636?v=4&s=48" width="48" height="48" alt="3927o"></a>
<a href="https://github.com/FloRainRJY"><img src="https://avatars.githubusercontent.com/u/146079207?v=4&s=48" width="48" height="48" alt="FloRainRJY"></a>
<a href="https://github.com/agbocsardi"><img src="https://avatars.githubusercontent.com/u/17645046?v=4&s=48" width="48" height="48" alt="agbocsardi"></a>
<a href="https://github.com/JilunSun7274"><img src="https://avatars.githubusercontent.com/u/268303062?v=4&s=48" width="48" height="48" alt="JilunSun7274"></a>
<a href="https://github.com/dvejmz"><img src="https://avatars.githubusercontent.com/u/9487006?v=4&s=48" width="48" height="48" alt="dvejmz"></a>
<a href="https://github.com/ddadaal"><img src="https://avatars.githubusercontent.com/u/8363856?v=4&s=48" width="48" height="48" alt="ddadaal"></a>
<a href="https://github.com/jiehaoZ"><img src="https://avatars.githubusercontent.com/u/51368211?v=4&s=48" width="48" height="48" alt="jiehaoZ"></a>
<a href="https://github.com/Lbin91"><img src="https://avatars.githubusercontent.com/u/26209763?v=4&s=48" width="48" height="48" alt="Lbin91"></a>
<a href="https://github.com/Alex-yang00"><img src="https://avatars.githubusercontent.com/u/57132813?v=4&s=48" width="48" height="48" alt="Alex-yang00"></a>
<a href="https://github.com/xek"><img src="https://avatars.githubusercontent.com/u/107911?v=4&s=48" width="48" height="48" alt="xek"></a>
<a href="https://github.com/Harvey-Mackie"><img src="https://avatars.githubusercontent.com/u/38426388?v=4&s=48" width="48" height="48" alt="Harvey-Mackie"></a>
<a href="https://github.com/chenyahui"><img src="https://avatars.githubusercontent.com/u/6067594?v=4&s=48" width="48" height="48" alt="chenyahui"></a>
<a href="https://github.com/angleyanalbedo"><img src="https://avatars.githubusercontent.com/u/100198247?v=4&s=48" width="48" height="48" alt="angleyanalbedo"></a>
<a href="https://github.com/adabarbulescu"><img src="https://avatars.githubusercontent.com/u/94562950?v=4&s=48" width="48" height="48" alt="adabarbulescu"></a>
<a href="https://github.com/yoheinishikubo"><img src="https://avatars.githubusercontent.com/u/17715848?v=4&s=48" width="48" height="48" alt="yoheinishikubo"></a>
<a href="https://github.com/WormW"><img src="https://avatars.githubusercontent.com/u/24667814?v=4&s=48" width="48" height="48" alt="WormW"></a>
<a href="https://github.com/WhalerO"><img src="https://avatars.githubusercontent.com/u/68461696?v=4&s=48" width="48" height="48" alt="WhalerO"></a>
<a href="https://github.com/thomya"><img src="https://avatars.githubusercontent.com/u/5235056?v=4&s=48" width="48" height="48" alt="thomya"></a>
<a href="https://github.com/Tejas1Koli"><img src="https://avatars.githubusercontent.com/u/181818824?v=4&s=48" width="48" height="48" alt="Tejas1Koli"></a>
<a href="https://github.com/Seeratul"><img src="https://avatars.githubusercontent.com/u/126798754?v=4&s=48" width="48" height="48" alt="Seeratul"></a>
<a href="https://github.com/SJK-py"><img src="https://avatars.githubusercontent.com/u/201669535?v=4&s=48" width="48" height="48" alt="SJK-py"></a>
<a href="https://github.com/RongLei-intel"><img src="https://avatars.githubusercontent.com/u/81341556?v=4&s=48" width="48" height="48" alt="RongLei-intel"></a>
<a href="https://github.com/QQQ300kuai"><img src="https://avatars.githubusercontent.com/u/55626566?v=4&s=48" width="48" height="48" alt="QQQ300kuai"></a>
<a href="https://github.com/MiguelPF"><img src="https://avatars.githubusercontent.com/u/1163236?v=4&s=48" width="48" height="48" alt="MiguelPF"></a>
<a href="https://github.com/mterhar"><img src="https://avatars.githubusercontent.com/u/938684?v=4&s=48" width="48" height="48" alt="mterhar"></a>
<a href="https://github.com/Pringlas"><img src="https://avatars.githubusercontent.com/u/28577663?v=4&s=48" width="48" height="48" alt="Pringlas"></a>
<a href="https://github.com/pjbakker"><img src="https://avatars.githubusercontent.com/u/1267780?v=4&s=48" width="48" height="48" alt="pjbakker"></a>
<a href="https://github.com/luojiaaoo"><img src="https://avatars.githubusercontent.com/u/62821977?v=4&s=48" width="48" height="48" alt="luojiaaoo"></a>
<a href="https://github.com/NearlCrews"><img src="https://avatars.githubusercontent.com/u/23341701?v=4&s=48" width="48" height="48" alt="NearlCrews"></a>
<a href="https://github.com/yongPhone"><img src="https://avatars.githubusercontent.com/u/29919651?v=4&s=48" width="48" height="48" alt="yongPhone"></a>
<a href="https://github.com/ZXGERIC"><img src="https://avatars.githubusercontent.com/u/25354180?v=4&s=48" width="48" height="48" alt="ZXGERIC"></a>
<a href="https://github.com/erikmackinnon"><img src="https://avatars.githubusercontent.com/u/40612473?v=4&s=48" width="48" height="48" alt="erikmackinnon"></a>
<a href="https://github.com/rickererer"><img src="https://avatars.githubusercontent.com/u/289160634?v=4&s=48" width="48" height="48" alt="rickererer"></a>
<a href="https://github.com/ferkans-amir"><img src="https://avatars.githubusercontent.com/u/212877286?v=4&s=48" width="48" height="48" alt="ferkans-amir"></a>
<a href="https://github.com/for13to1"><img src="https://avatars.githubusercontent.com/u/115892874?v=4&s=48" width="48" height="48" alt="for13to1"></a>
<a href="https://github.com/futurist"><img src="https://avatars.githubusercontent.com/u/159167?v=4&s=48" width="48" height="48" alt="futurist"></a>
<a href="https://github.com/Maaannnn"><img src="https://avatars.githubusercontent.com/u/105716414?v=4&s=48" width="48" height="48" alt="Maaannnn"></a>
<a href="https://github.com/rubychilds"><img src="https://avatars.githubusercontent.com/u/1305077?v=4&s=48" width="48" height="48" alt="rubychilds"></a>
<a href="https://github.com/init-new-world"><img src="https://avatars.githubusercontent.com/u/36530844?v=4&s=48" width="48" height="48" alt="init-new-world"></a>
<a href="https://github.com/Idealist17"><img src="https://avatars.githubusercontent.com/u/55554642?v=4&s=48" width="48" height="48" alt="Idealist17"></a>
<a href="https://github.com/gola"><img src="https://avatars.githubusercontent.com/u/31429180?v=4&s=48" width="48" height="48" alt="gola"></a>
<a href="https://github.com/greyishsong"><img src="https://avatars.githubusercontent.com/u/49446254?v=4&s=48" width="48" height="48" alt="greyishsong"></a>
<a href="https://github.com/h4nz4"><img src="https://avatars.githubusercontent.com/u/18464660?v=4&s=48" width="48" height="48" alt="h4nz4"></a>
<a href="https://github.com/hoaresky"><img src="https://avatars.githubusercontent.com/u/25839923?v=4&s=48" width="48" height="48" alt="hoaresky"></a>
<a href="https://github.com/hcanyz"><img src="https://avatars.githubusercontent.com/u/8407922?v=4&s=48" width="48" height="48" alt="hcanyz"></a>
<a href="https://github.com/himax12"><img src="https://avatars.githubusercontent.com/u/122690580?v=4&s=48" width="48" height="48" alt="himax12"></a>
<a href="https://github.com/Zeknes"><img src="https://avatars.githubusercontent.com/u/117632598?v=4&s=48" width="48" height="48" alt="Zeknes"></a>
<a href="https://github.com/ZhihaoZhang97"><img src="https://avatars.githubusercontent.com/u/31653817?v=4&s=48" width="48" height="48" alt="ZhihaoZhang97"></a>
<a href="https://github.com/linziyanleo"><img src="https://avatars.githubusercontent.com/u/24808982?v=4&s=48" width="48" height="48" alt="linziyanleo"></a>
<a href="https://github.com/akinolur"><img src="https://avatars.githubusercontent.com/u/126256260?v=4&s=48" width="48" height="48" alt="akinolur"></a>
<a href="https://github.com/alairjt"><img src="https://avatars.githubusercontent.com/u/678781?v=4&s=48" width="48" height="48" alt="alairjt"></a>
<a href="https://github.com/noki1928"><img src="https://avatars.githubusercontent.com/u/200396425?v=4&s=48" width="48" height="48" alt="noki1928"></a>
<a href="https://github.com/barreler126"><img src="https://avatars.githubusercontent.com/u/129291861?v=4&s=48" width="48" height="48" alt="barreler126"></a>
<a href="https://github.com/bllackhu"><img src="https://avatars.githubusercontent.com/u/194945539?v=4&s=48" width="48" height="48" alt="bllackhu"></a>
<a href="https://github.com/brendanlevy-clarahealth"><img src="https://avatars.githubusercontent.com/u/262887138?v=4&s=48" width="48" height="48" alt="brendanlevy-clarahealth"></a>
<a href="https://github.com/cdkey85"><img src="https://avatars.githubusercontent.com/u/7017432?v=4&s=48" width="48" height="48" alt="cdkey85"></a>
<a href="https://github.com/ducheng121"><img src="https://avatars.githubusercontent.com/u/134901105?v=4&s=48" width="48" height="48" alt="ducheng121"></a>
<a href="https://github.com/SamZhu19921116"><img src="https://avatars.githubusercontent.com/u/39799950?v=4&s=48" width="48" height="48" alt="SamZhu19921116"></a>
<a href="https://github.com/justforyoudear"><img src="https://avatars.githubusercontent.com/u/50271514?v=4&s=48" width="48" height="48" alt="justforyoudear"></a>
<a href="https://github.com/choiking"><img src="https://avatars.githubusercontent.com/u/13400202?v=4&s=48" width="48" height="48" alt="choiking"></a>
<a href="https://github.com/cocolato"><img src="https://avatars.githubusercontent.com/u/35182391?v=4&s=48" width="48" height="48" alt="cocolato"></a>
<a href="https://github.com/luomaohao"><img src="https://avatars.githubusercontent.com/u/36148933?v=4&s=48" width="48" height="48" alt="luomaohao"></a>
<a href="https://github.com/codedragoncom"><img src="https://avatars.githubusercontent.com/u/5353092?v=4&s=48" width="48" height="48" alt="codedragoncom"></a>
<a href="https://github.com/d1ago"><img src="https://avatars.githubusercontent.com/u/143279157?v=4&s=48" width="48" height="48" alt="d1ago"></a>
<a href="https://github.com/daliu858"><img src="https://avatars.githubusercontent.com/u/213729810?v=4&s=48" width="48" height="48" alt="daliu858"></a>
<a href="https://github.com/danielyangfei"><img src="https://avatars.githubusercontent.com/u/9975680?v=4&s=48" width="48" height="48" alt="danielyangfei"></a>
<a href="https://github.com/dingyanyi2019"><img src="https://avatars.githubusercontent.com/u/230930993?v=4&s=48" width="48" height="48" alt="dingyanyi2019"></a>
<a href="https://github.com/djmaze"><img src="https://avatars.githubusercontent.com/u/7229?v=4&s=48" width="48" height="48" alt="djmaze"></a>
<a href="https://github.com/dulltackle"><img src="https://avatars.githubusercontent.com/u/45963660?v=4&s=48" width="48" height="48" alt="dulltackle"></a>
<a href="https://github.com/samyzhh"><img src="https://avatars.githubusercontent.com/u/13554741?v=4&s=48" width="48" height="48" alt="samyzhh"></a>
<a href="https://github.com/shen0122"><img src="https://avatars.githubusercontent.com/u/145903102?v=4&s=48" width="48" height="48" alt="shen0122"></a>
<a href="https://github.com/shenchengtsi"><img src="https://avatars.githubusercontent.com/u/228445050?v=4&s=48" width="48" height="48" alt="shenchengtsi"></a>
<a href="https://github.com/sidkang"><img src="https://avatars.githubusercontent.com/u/6175895?v=4&s=48" width="48" height="48" alt="sidkang"></a>
<a href="https://github.com/skiyo"><img src="https://avatars.githubusercontent.com/u/224273?v=4&s=48" width="48" height="48" alt="skiyo"></a>
<a href="https://github.com/sontianye"><img src="https://avatars.githubusercontent.com/u/162393000?v=4&s=48" width="48" height="48" alt="sontianye"></a>
<a href="https://github.com/spartan077"><img src="https://avatars.githubusercontent.com/u/118879019?v=4&s=48" width="48" height="48" alt="spartan077"></a>
<a href="https://github.com/tercerapersona"><img src="https://avatars.githubusercontent.com/u/16053355?v=4&s=48" width="48" height="48" alt="tercerapersona"></a>
<a href="https://github.com/tlguszz1010"><img src="https://avatars.githubusercontent.com/u/62739187?v=4&s=48" width="48" height="48" alt="tlguszz1010"></a>
<a href="https://github.com/vandazia"><img src="https://avatars.githubusercontent.com/u/56904192?v=4&s=48" width="48" height="48" alt="vandazia"></a>
<a href="https://github.com/vincentchen0x2-dev"><img src="https://avatars.githubusercontent.com/u/262490969?v=4&s=48" width="48" height="48" alt="vincentchen0x2-dev"></a>
<a href="https://github.com/tianrking"><img src="https://avatars.githubusercontent.com/u/10758833?v=4&s=48" width="48" height="48" alt="tianrking"></a>
<a href="https://github.com/wcmolin"><img src="https://avatars.githubusercontent.com/u/11606262?v=4&s=48" width="48" height="48" alt="wcmolin"></a>
<a href="https://github.com/dynames0098"><img src="https://avatars.githubusercontent.com/u/16553686?v=4&s=48" width="48" height="48" alt="dynames0098"></a>
<a href="https://github.com/knightconnorp"><img src="https://avatars.githubusercontent.com/u/143191129?v=4&s=48" width="48" height="48" alt="knightconnorp"></a>
<a href="https://github.com/wymcmh"><img src="https://avatars.githubusercontent.com/u/5070729?v=4&s=48" width="48" height="48" alt="wymcmh"></a>
<a href="https://github.com/weijun-xia"><img src="https://avatars.githubusercontent.com/u/293320877?v=4&s=48" width="48" height="48" alt="weijun-xia"></a>
<a href="https://github.com/yaotutu"><img src="https://avatars.githubusercontent.com/u/21394924?v=4&s=48" width="48" height="48" alt="yaotutu"></a>
<a href="https://github.com/yeounhyeok"><img src="https://avatars.githubusercontent.com/u/141844100?v=4&s=48" width="48" height="48" alt="yeounhyeok"></a>
<a href="https://github.com/Endeavour-Yuan"><img src="https://avatars.githubusercontent.com/u/50094541?v=4&s=48" width="48" height="48" alt="Endeavour-Yuan"></a>
<a href="https://github.com/ziuus"><img src="https://avatars.githubusercontent.com/u/64656661?v=4&s=48" width="48" height="48" alt="ziuus"></a>
<a href="https://github.com/dsxyy"><img src="https://avatars.githubusercontent.com/u/8911760?v=4&s=48" width="48" height="48" alt="dsxyy"></a>
<a href="https://github.com/azhengzz"><img src="https://avatars.githubusercontent.com/u/30361780?v=4&s=48" width="48" height="48" alt="azhengzz"></a>
<a href="https://github.com/jhkim43"><img src="https://avatars.githubusercontent.com/u/139941582?v=4&s=48" width="48" height="48" alt="jhkim43"></a>
<a href="https://github.com/kimkitsuragi26"><img src="https://avatars.githubusercontent.com/u/263307076?v=4&s=48" width="48" height="48" alt="kimkitsuragi26"></a>
<a href="https://github.com/kinchahoy"><img src="https://avatars.githubusercontent.com/u/6504381?v=4&s=48" width="48" height="48" alt="kinchahoy"></a>
<a href="https://github.com/A11Might"><img src="https://avatars.githubusercontent.com/u/38397074?v=4&s=48" width="48" height="48" alt="A11Might"></a>
<a href="https://github.com/kronk307"><img src="https://avatars.githubusercontent.com/u/264627887?v=4&s=48" width="48" height="48" alt="kronk307"></a>
<a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo"></a>
<a href="https://github.com/lang07123"><img src="https://avatars.githubusercontent.com/u/7733095?v=4&s=48" width="48" height="48" alt="lang07123"></a>
<a href="https://github.com/tetratorus"><img src="https://avatars.githubusercontent.com/u/4226174?v=4&s=48" width="48" height="48" alt="tetratorus"></a>
<a href="https://github.com/spinvettel"><img src="https://avatars.githubusercontent.com/u/82635206?v=4&s=48" width="48" height="48" alt="spinvettel"></a>
<a href="https://github.com/li-yazhou"><img src="https://avatars.githubusercontent.com/u/17548940?v=4&s=48" width="48" height="48" alt="li-yazhou"></a>
<a href="https://github.com/ALIZE126"><img src="https://avatars.githubusercontent.com/u/79365356?v=4&s=48" width="48" height="48" alt="ALIZE126"></a>
<a href="https://github.com/Rheasilvia"><img src="https://avatars.githubusercontent.com/u/29389840?v=4&s=48" width="48" height="48" alt="Rheasilvia"></a>
<a href="https://github.com/mru4913"><img src="https://avatars.githubusercontent.com/u/31579276?v=4&s=48" width="48" height="48" alt="mru4913"></a>
<a href="https://github.com/mt-huerta"><img src="https://avatars.githubusercontent.com/u/5499466?v=4&s=48" width="48" height="48" alt="mt-huerta"></a>
<a href="https://github.com/mytechdream"><img src="https://avatars.githubusercontent.com/u/114465679?v=4&s=48" width="48" height="48" alt="mytechdream"></a>
<a href="https://github.com/nikube"><img src="https://avatars.githubusercontent.com/u/63295277?v=4&s=48" width="48" height="48" alt="nikube"></a>
<a href="https://github.com/npodbielski"><img src="https://avatars.githubusercontent.com/u/796782?v=4&s=48" width="48" height="48" alt="npodbielski"></a>
<a href="https://github.com/oriengy"><img src="https://avatars.githubusercontent.com/u/50244473?v=4&s=48" width="48" height="48" alt="oriengy"></a>
<a href="https://github.com/popcell"><img src="https://avatars.githubusercontent.com/u/70359868?v=4&s=48" width="48" height="48" alt="popcell"></a>
<a href="https://github.com/qixinbo"><img src="https://avatars.githubusercontent.com/u/6218739?v=4&s=48" width="48" height="48" alt="qixinbo"></a>
<a href="https://github.com/qulllee"><img src="https://avatars.githubusercontent.com/u/113170232?v=4&s=48" width="48" height="48" alt="qulllee"></a>
<a href="https://github.com/rav-melisono"><img src="https://avatars.githubusercontent.com/u/165779938?v=4&s=48" width="48" height="48" alt="rav-melisono"></a>
<a href="https://github.com/razzh7"><img src="https://avatars.githubusercontent.com/u/67299806?v=4&s=48" width="48" height="48" alt="razzh7"></a>
<a href="https://github.com/rise2689"><img src="https://avatars.githubusercontent.com/u/268597299?v=4&s=48" width="48" height="48" alt="rise2689"></a>
<a href="https://github.com/hlibr"><img src="https://avatars.githubusercontent.com/u/5793607?v=4&s=48" width="48" height="48" alt="hlibr"></a>
<a href="https://github.com/gthieleb"><img src="https://avatars.githubusercontent.com/u/21332468?v=4&s=48" width="48" height="48" alt="gthieleb"></a>
<a href="https://github.com/Rafa-Ross"><img src="https://avatars.githubusercontent.com/u/279471146?v=4&s=48" width="48" height="48" alt="Rafa-Ross"></a>
<a href="https://github.com/korruz"><img src="https://avatars.githubusercontent.com/u/79794883?v=4&s=48" width="48" height="48" alt="korruz"></a>
<a href="https://github.com/hyudryu"><img src="https://avatars.githubusercontent.com/u/22283864?v=4&s=48" width="48" height="48" alt="hyudryu"></a>
<a href="https://github.com/breitburg"><img src="https://avatars.githubusercontent.com/u/25728414?v=4&s=48" width="48" height="48" alt="breitburg"></a>
<a href="https://github.com/IlyaSemenov"><img src="https://avatars.githubusercontent.com/u/128121?v=4&s=48" width="48" height="48" alt="IlyaSemenov"></a>
<a href="https://github.com/Tevkanbot"><img src="https://avatars.githubusercontent.com/u/143351134?v=4&s=48" width="48" height="48" alt="Tevkanbot"></a>
<a href="https://github.com/JakeRowe19"><img src="https://avatars.githubusercontent.com/u/117069245?v=4&s=48" width="48" height="48" alt="JakeRowe19"></a>
<a href="https://github.com/JamesWrigley"><img src="https://avatars.githubusercontent.com/u/5361518?v=4&s=48" width="48" height="48" alt="JamesWrigley"></a>
<a href="https://github.com/La-Volpe"><img src="https://avatars.githubusercontent.com/u/5852615?v=4&s=48" width="48" height="48" alt="La-Volpe"></a>
<a href="https://github.com/JavisPeng"><img src="https://avatars.githubusercontent.com/u/18676680?v=4&s=48" width="48" height="48" alt="JavisPeng"></a>
<a href="https://github.com/Jefsky"><img src="https://avatars.githubusercontent.com/u/7386165?v=4&s=48" width="48" height="48" alt="Jefsky"></a>
<a href="https://github.com/letzdoo-js"><img src="https://avatars.githubusercontent.com/u/12003829?v=4&s=48" width="48" height="48" alt="letzdoo-js"></a>
<a href="https://github.com/95256155o"><img src="https://avatars.githubusercontent.com/u/74103710?v=4&s=48" width="48" height="48" alt="95256155o"></a>
<a href="https://github.com/joel611"><img src="https://avatars.githubusercontent.com/u/5180124?v=4&s=48" width="48" height="48" alt="joel611"></a>
<a href="https://github.com/NiceLargeHuo"><img src="https://avatars.githubusercontent.com/u/306099191?v=4&s=48" width="48" height="48" alt="NiceLargeHuo"></a>
<a href="https://github.com/kamalakarrao"><img src="https://avatars.githubusercontent.com/u/15045455?v=4&s=48" width="48" height="48" alt="kamalakarrao"></a>
<a href="https://github.com/KEEPSLAMDUNK"><img src="https://avatars.githubusercontent.com/u/155275575?v=4&s=48" width="48" height="48" alt="KEEPSLAMDUNK"></a>
<a href="https://github.com/krisLu"><img src="https://avatars.githubusercontent.com/u/92515202?v=4&s=48" width="48" height="48" alt="krisLu"></a>
<a href="https://github.com/Krislu1221"><img src="https://avatars.githubusercontent.com/u/258380416?v=4&s=48" width="48" height="48" alt="Krislu1221"></a>
<a href="https://github.com/kyya"><img src="https://avatars.githubusercontent.com/u/13448248?v=4&s=48" width="48" height="48" alt="kyya"></a>
<a href="https://github.com/rreben"><img src="https://avatars.githubusercontent.com/u/4026131?v=4&s=48" width="48" height="48" alt="rreben"></a>
<a href="https://github.com/ATECHPCS"><img src="https://avatars.githubusercontent.com/u/125108010?v=4&s=48" width="48" height="48" alt="ATECHPCS"></a>
<a href="https://github.com/adrianhoehne"><img src="https://avatars.githubusercontent.com/u/19731088?v=4&s=48" width="48" height="48" alt="adrianhoehne"></a>
<a href="https://github.com/Aisht669"><img src="https://avatars.githubusercontent.com/u/36147411?v=4&s=48" width="48" height="48" alt="Aisht669"></a>
<a href="https://github.com/AlbertWang688"><img src="https://avatars.githubusercontent.com/u/36430404?v=4&s=48" width="48" height="48" alt="AlbertWang688"></a>
<a href="https://github.com/AlexanderMerkel"><img src="https://avatars.githubusercontent.com/u/105279319?v=4&s=48" width="48" height="48" alt="AlexanderMerkel"></a>
<a href="https://github.com/khmylov"><img src="https://avatars.githubusercontent.com/u/1044282?v=4&s=48" width="48" height="48" alt="khmylov"></a>
<a href="https://github.com/karimluna"><img src="https://avatars.githubusercontent.com/u/195384419?v=4&s=48" width="48" height="48" alt="karimluna"></a>
<a href="https://github.com/Bayern4ever-dot"><img src="https://avatars.githubusercontent.com/u/67447782?v=4&s=48" width="48" height="48" alt="Bayern4ever-dot"></a>
<a href="https://github.com/abhinavaditya811"><img src="https://avatars.githubusercontent.com/u/40894851?v=4&s=48" width="48" height="48" alt="abhinavaditya811"></a>
<a href="https://github.com/bjoshuanoah"><img src="https://avatars.githubusercontent.com/u/1885253?v=4&s=48" width="48" height="48" alt="bjoshuanoah"></a>
<a href="https://github.com/hanouticelina"><img src="https://avatars.githubusercontent.com/u/36770234?v=4&s=48" width="48" height="48" alt="hanouticelina"></a>
<a href="https://github.com/quanmou"><img src="https://avatars.githubusercontent.com/u/7821404?v=4&s=48" width="48" height="48" alt="quanmou"></a>
<a href="https://github.com/ClaytonWWilson"><img src="https://avatars.githubusercontent.com/u/31804874?v=4&s=48" width="48" height="48" alt="ClaytonWWilson"></a>
<a href="https://github.com/kaseru"><img src="https://avatars.githubusercontent.com/u/5975972?v=4&s=48" width="48" height="48" alt="kaseru"></a>
<a href="https://github.com/danielemden"><img src="https://avatars.githubusercontent.com/u/265470?v=4&s=48" width="48" height="48" alt="danielemden"></a>
<a href="https://github.com/dmarkey"><img src="https://avatars.githubusercontent.com/u/1159924?v=4&s=48" width="48" height="48" alt="dmarkey"></a>
<a href="https://github.com/desmondsow"><img src="https://avatars.githubusercontent.com/u/7720601?v=4&s=48" width="48" height="48" alt="desmondsow"></a>
<a href="https://github.com/intelliot"><img src="https://avatars.githubusercontent.com/u/81505?v=4&s=48" width="48" height="48" alt="intelliot"></a>
<a href="https://github.com/eugenechae"><img src="https://avatars.githubusercontent.com/u/1910247?v=4&s=48" width="48" height="48" alt="eugenechae"></a>
<a href="https://github.com/ehs208"><img src="https://avatars.githubusercontent.com/u/109217208?v=4&s=48" width="48" height="48" alt="ehs208"></a>
<a href="https://github.com/Felix8568"><img src="https://avatars.githubusercontent.com/u/86166271?v=4&s=48" width="48" height="48" alt="Felix8568"></a>
<a href="https://github.com/GabrielWithTina"><img src="https://avatars.githubusercontent.com/u/2384004?v=4&s=48" width="48" height="48" alt="GabrielWithTina"></a>
<a href="https://github.com/georgeatparallel"><img src="https://avatars.githubusercontent.com/u/297992784?v=4&s=48" width="48" height="48" alt="georgeatparallel"></a>
<a href="https://github.com/SHLE1"><img src="https://avatars.githubusercontent.com/u/101321085?v=4&s=48" width="48" height="48" alt="SHLE1"></a>
<a href="https://github.com/lzmjlrt"><img src="https://avatars.githubusercontent.com/u/62170398?v=4&s=48" width="48" height="48" alt="lzmjlrt"></a>
<a href="https://github.com/saimonventura"><img src="https://avatars.githubusercontent.com/u/3719710?v=4&s=48" width="48" height="48" alt="saimonventura"></a>
<a href="https://github.com/SIDD-KIDD"><img src="https://avatars.githubusercontent.com/u/162047739?v=4&s=48" width="48" height="48" alt="SIDD-KIDD"></a>
<a href="https://github.com/sihyeonn"><img src="https://avatars.githubusercontent.com/u/24850223?v=4&s=48" width="48" height="48" alt="sihyeonn"></a>
<a href="https://github.com/Seym0n"><img src="https://avatars.githubusercontent.com/u/119116740?v=4&s=48" width="48" height="48" alt="Seym0n"></a>
<a href="https://github.com/fyhertz"><img src="https://avatars.githubusercontent.com/u/2746007?v=4&s=48" width="48" height="48" alt="fyhertz"></a>
<a href="https://github.com/sohamb117"><img src="https://avatars.githubusercontent.com/u/36938330?v=4&s=48" width="48" height="48" alt="sohamb117"></a>
<a href="https://github.com/Solaris-star"><img src="https://avatars.githubusercontent.com/u/67425364?v=4&s=48" width="48" height="48" alt="Solaris-star"></a>
<a href="https://github.com/Syoc"><img src="https://avatars.githubusercontent.com/u/9057210?v=4&s=48" width="48" height="48" alt="Syoc"></a>
<a href="https://github.com/tedyyan"><img src="https://avatars.githubusercontent.com/u/2662290?v=4&s=48" width="48" height="48" alt="tedyyan"></a>
<a href="https://github.com/xuayan-nokia"><img src="https://avatars.githubusercontent.com/u/87028154?v=4&s=48" width="48" height="48" alt="xuayan-nokia"></a>
<a href="https://github.com/TheAutomatic"><img src="https://avatars.githubusercontent.com/u/5350578?v=4&s=48" width="48" height="48" alt="TheAutomatic"></a>
<a href="https://github.com/TomLisankie"><img src="https://avatars.githubusercontent.com/u/92654?v=4&s=48" width="48" height="48" alt="TomLisankie"></a>
<a href="https://github.com/tamvicky"><img src="https://avatars.githubusercontent.com/u/9824871?v=4&s=48" width="48" height="48" alt="tamvicky"></a>
<a href="https://github.com/MVS-source"><img src="https://avatars.githubusercontent.com/u/72023257?v=4&s=48" width="48" height="48" alt="MVS-source"></a>
<a href="https://github.com/wingkwong"><img src="https://avatars.githubusercontent.com/u/35857179?v=4&s=48" width="48" height="48" alt="wingkwong"></a>
<a href="https://github.com/wenjielei1990"><img src="https://avatars.githubusercontent.com/u/182426847?v=4&s=48" width="48" height="48" alt="wenjielei1990"></a>
<a href="https://github.com/Wenzhang-Chen"><img src="https://avatars.githubusercontent.com/u/212304734?v=4&s=48" width="48" height="48" alt="Wenzhang-Chen"></a>
<a href="https://github.com/wesleyzhangwq"><img src="https://avatars.githubusercontent.com/u/275724973?v=4&s=48" width="48" height="48" alt="wesleyzhangwq"></a>
<a href="https://github.com/XiaoHuo888-hue"><img src="https://avatars.githubusercontent.com/u/315183888?v=4&s=48" width="48" height="48" alt="XiaoHuo888-hue"></a>
<a href="https://github.com/Lyt060814"><img src="https://avatars.githubusercontent.com/u/182195098?v=4&s=48" width="48" height="48" alt="Lyt060814"></a>
<a href="https://github.com/limdingwen"><img src="https://avatars.githubusercontent.com/u/1744967?v=4&s=48" width="48" height="48" alt="limdingwen"></a>
<a href="https://github.com/luc-nguyen-cake"><img src="https://avatars.githubusercontent.com/u/308145655?v=4&s=48" width="48" height="48" alt="luc-nguyen-cake"></a>
<a href="https://github.com/maciejwojcik86"><img src="https://avatars.githubusercontent.com/u/122781058?v=4&s=48" width="48" height="48" alt="maciejwojcik86"></a>
<a href="https://github.com/mvanhorn"><img src="https://avatars.githubusercontent.com/u/455140?v=4&s=48" width="48" height="48" alt="mvanhorn"></a>
<a href="https://github.com/twiddles"><img src="https://avatars.githubusercontent.com/u/242461?v=4&s=48" width="48" height="48" alt="twiddles"></a>
<a href="https://github.com/maxmilian"><img src="https://avatars.githubusercontent.com/u/3001335?v=4&s=48" width="48" height="48" alt="maxmilian"></a>
<a href="https://github.com/mrbob-git"><img src="https://avatars.githubusercontent.com/u/202024716?v=4&s=48" width="48" height="48" alt="mrbob-git"></a>
<a href="https://github.com/MuataSr"><img src="https://avatars.githubusercontent.com/u/177951810?v=4&s=48" width="48" height="48" alt="MuataSr"></a>
<a href="https://github.com/Neutralmilkzzz"><img src="https://avatars.githubusercontent.com/u/216463318?v=4&s=48" width="48" height="48" alt="Neutralmilkzzz"></a>
<a href="https://github.com/nblondiau"><img src="https://avatars.githubusercontent.com/u/6884594?v=4&s=48" width="48" height="48" alt="nblondiau"></a>
<a href="https://github.com/omdv"><img src="https://avatars.githubusercontent.com/u/4576131?v=4&s=48" width="48" height="48" alt="omdv"></a>
<a href="https://github.com/rbankole"><img src="https://avatars.githubusercontent.com/u/25436617?v=4&s=48" width="48" height="48" alt="rbankole"></a>
<a href="https://github.com/orrinwitt"><img src="https://avatars.githubusercontent.com/u/9917194?v=4&s=48" width="48" height="48" alt="orrinwitt"></a>
<a href="https://github.com/pjperez"><img src="https://avatars.githubusercontent.com/u/747936?v=4&s=48" width="48" height="48" alt="pjperez"></a>
<a href="https://github.com/pve"><img src="https://avatars.githubusercontent.com/u/37116?v=4&s=48" width="48" height="48" alt="pve"></a>
<a href="https://github.com/PeterDaveHello"><img src="https://avatars.githubusercontent.com/u/3691490?v=4&s=48" width="48" height="48" alt="PeterDaveHello"></a>
<a href="https://github.com/Molunerfinn"><img src="https://avatars.githubusercontent.com/u/12621342?v=4&s=48" width="48" height="48" alt="Molunerfinn"></a>
<a href="https://github.com/power88"><img src="https://avatars.githubusercontent.com/u/24859241?v=4&s=48" width="48" height="48" alt="power88"></a>
<a href="https://github.com/srajasimman"><img src="https://avatars.githubusercontent.com/u/15092596?v=4&s=48" width="48" height="48" alt="srajasimman"></a>
<a href="https://github.com/groudas"><img src="https://avatars.githubusercontent.com/u/18154989?v=4&s=48" width="48" height="48" alt="groudas"></a>
<a href="https://github.com/RohitDayanand"><img src="https://avatars.githubusercontent.com/u/66650100?v=4&s=48" width="48" height="48" alt="RohitDayanand"></a>
<a href="https://github.com/katafractari"><img src="https://avatars.githubusercontent.com/u/1299228?v=4&s=48" width="48" height="48" alt="katafractari"></a>
<a href="https://github.com/rudy-of-the-corner"><img src="https://avatars.githubusercontent.com/u/188991374?v=4&s=48" width="48" height="48" alt="rudy-of-the-corner"></a>
</p>
<!-- contributors:end -->
<p align="center">
<em> Thanks for visiting ✨ nanobot!</em><br><br>
+13 -6
View File
@@ -425,7 +425,7 @@ class AgentRunner:
) -> AgentRunResult:
final_content: str | None = None
tools_used: list[str] = []
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
usage = {"prompt_tokens": 0, "completion_tokens": 0}
error: str | None = None
stop_reason = "completed"
tool_events: list[dict[str, str]] = []
@@ -1384,11 +1384,6 @@ class AgentRunner:
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
))
@staticmethod
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
for key, value in addition.items():
target[key] = target.get(key, 0) + value
@staticmethod
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
merged = dict(left)
@@ -1396,6 +1391,18 @@ class AgentRunner:
merged[key] = merged.get(key, 0) + value
return merged
@staticmethod
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
"""Fold one model request into the current turn's usage."""
total["request_count"] = total.get("request_count", 0) + 1
prompt_tokens = request.get("prompt_tokens")
if prompt_tokens is not None and prompt_tokens >= 0:
total["context_tokens"] = prompt_tokens
for key, value in request.items():
if key in {"context_tokens", "request_count"} or value < 0:
continue
total[key] = total.get(key, 0) + value
async def _execute_tools(
self,
spec: AgentRunSpec,
+1
View File
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
model: str
model_preset: str | None = None
context_window_tokens: int | None = None
fallback: bool = False
def outbound_message_for_event(
+65 -8
View File
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
)
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
# -- Subscription bookkeeping -------------------------------------------
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
for key in tuple(self._stream_text_buffers):
if key[0] == chat_id:
self._stream_text_buffers.pop(key, None)
for key in tuple(self._reasoning_text_buffers):
if key[0] == chat_id:
self._reasoning_text_buffers.pop(key, None)
async def _discard_connection_owned_chat(
self,
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
include_source=include_source,
transcript_overrides=transcript_overrides,
)
if (
not persisted
and phase in {"answer", "complete"}
and (metadata or {}).get("webui") is True
):
return self._retain_turn_on_transcript_failure(
chat_id,
persisted=persisted,
metadata=metadata,
phase=phase,
)
@staticmethod
def _retain_turn_on_transcript_failure(
chat_id: str,
*,
persisted: bool,
metadata: dict[str, Any] | None,
phase: str,
) -> bool:
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
mark_websocket_turn_transcript_persistence_failed(
chat_id,
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
)
return persisted
def _persist_turn_stream_event(
self,
chat_id: str,
event: dict[str, Any],
*,
completed_text: str | None,
metadata: dict[str, Any] | None,
phase: str,
include_source: bool = False,
) -> bool:
"""Persist the canonical end of a live stream, never its wire chunks."""
if not self._temporary_chats.should_persist_transcript(chat_id):
return True
persisted = self._transcripts.prepare_and_append_stream_event(
chat_id,
event,
completed_text=completed_text,
metadata=metadata,
phase=phase,
include_source=include_source,
)
return self._retain_turn_on_transcript_failure(
chat_id,
persisted=persisted,
metadata=metadata,
phase=phase,
)
async def send(self, msg: OutboundMessage) -> None:
event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
model_name=event.model,
model_preset=event.model_preset,
context_window_tokens=event.context_window_tokens,
fallback=event.fallback,
)
return
if isinstance(event, UserInputEvent):
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
}
if stream_id is not None:
body["stream_id"] = stream_id
self._persist_turn_transcript_event(
stream_key = (chat_id, str(stream_id or ""))
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
self._persist_turn_stream_event(
chat_id,
body,
completed_text=None,
metadata=meta,
phase="reasoning",
)
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
}
if stream_id is not None:
body["stream_id"] = stream_id
self._persist_turn_transcript_event(
stream_key = (chat_id, str(stream_id or ""))
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
self._persist_turn_stream_event(
chat_id,
body,
completed_text=reasoning_text or None,
metadata=meta,
phase="reasoning",
)
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
conns = list(self._subs.get(chat_id, ()))
meta = metadata or {}
stream_key = (chat_id, str(stream_id or ""))
completed_text: str | None = None
if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = (
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._media.rewrite_local_markdown_images(full_text)
completed_text = rewritten
if delta or rewritten != full_text:
body["text"] = rewritten
else:
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
body["resuming"] = True
if stream_end and merge_next:
body["merge_next"] = True
self._persist_turn_transcript_event(
self._persist_turn_stream_event(
chat_id,
body,
completed_text=completed_text,
metadata=meta,
phase="answer",
include_source=True,
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
# carries a durable incomplete marker. The HTTP replay path can
# recover the latter from session history after a gateway restart.
clear_websocket_turn_if_current(chat_id, turn_owner)
self._clear_stream_buffers(chat_id)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
model_name: Any,
model_preset: Any = None,
context_window_tokens: Any = None,
fallback: bool = False,
) -> None:
"""Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ()))
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
body["model_preset"] = model_preset.strip()
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
body["context_window_tokens"] = context_window_tokens
if fallback:
body["fallback"] = True
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
@@ -2073,6 +2073,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
"model_preset": "Deep Research",
"context_window_tokens": 128_000,
}
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=TurnModelUpdatedEvent(
model="deepseek/deepseek-chat",
model_preset="Deep Research",
fallback=True,
),
)
)
fallback_payload = json.loads(chat_one.send.call_args.args[0])
assert fallback_payload["fallback"] is True
chat_two.send.assert_not_awaited()
@@ -2348,8 +2363,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
assert second["event"] == "stream_end"
assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source
assert lines[-1]["event"] == "stream_end"
assert lines[-1]["text"] == "done"
@pytest.mark.asyncio
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
@pytest.mark.asyncio
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
"second",
]
assert ("chat-1", "sid") not in channel._stream_text_buffers
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
assert [line["text"] for line in lines] == ["first ", "first second"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["content"] == "first second"
@pytest.mark.asyncio
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
assert lines[0]["text"] == "hello world"
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
@@ -2604,6 +2629,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
append = MagicMock()
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
append.assert_not_called()
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
append.assert_called_once()
persisted = append.call_args.args[1]
assert persisted["event"] == "stream_end"
assert persisted["text"] == "one two three"
@pytest.mark.asyncio
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
append = MagicMock()
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
append.assert_not_called()
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
append.assert_called_once()
persisted = append.call_args.args[1]
assert persisted["event"] == "reasoning_end"
assert persisted["text"] == "plan then act"
@pytest.mark.asyncio
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-unclosed",
content="",
event=TurnEndEvent(),
))
assert channel._stream_text_buffers == {}
assert channel._reasoning_text_buffers == {}
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
+5 -2
View File
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
)
continue
await self._notify_fallback_model(fallback_model)
fallback_kwargs = {
**kwargs,
"model": fallback_model,
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
fallback_response = await call(fallback_provider, fallback_kwargs)
if fallback_response.finish_reason != "error":
# Do not publish a model switch merely because a fallback was
# attempted. A fallback can fail just like the primary, and
# the WebUI would otherwise show a misleading success signal.
# Publish only after this response is known to be usable.
await self._notify_fallback_model(fallback_model)
logger.info(
"Fallback '{}' succeeded after primary '{}' failed",
fallback_model, primary_model,
+37 -1
View File
@@ -251,6 +251,26 @@ def _refusal_event_key(
)
def _reasoning_summary_event_key(
item_id: object,
summary_index: object,
) -> tuple[str | None, int] | None:
"""Identify one reasoning summary part across its text deltas."""
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
return None
return (
item_id if isinstance(item_id, str) else None,
summary_index,
)
def _separate_reasoning_part(content: str | None, part: str) -> str:
"""Separate summary parts only when the provider supplied no whitespace."""
if content and not content[-1].isspace() and not part[0].isspace():
return "\n" + part
return part
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
"""Return only text not already surfaced by refusal deltas."""
if not streamed_text:
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
usage: dict[str, int] = {}
reasoning_content: str | None = None
streamed_reasoning = False
reasoning_summary_key: tuple[str | None, int] | None = None
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
summary_key = _reasoning_summary_event_key(
event.get("item_id"),
event.get("summary_index"),
)
if (
summary_key is not None
and reasoning_summary_key is not None
and summary_key != reasoning_summary_key
):
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
if summary_key is not None:
reasoning_summary_key = summary_key
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
text = summary.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts) or None
content = ""
for part in parts:
content += _separate_reasoning_part(content, part)
return content or None
def parse_response_output(
+1
View File
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
if context.runtime is not None
else None
),
fallback=True,
),
metadata=context.metadata,
)
+89 -23
View File
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
record.update(transcript_overrides)
return self.append(chat_id, record)
def prepare_and_append_stream_event(
self,
chat_id: str,
event: dict[str, Any],
*,
completed_text: str | None,
metadata: dict[str, Any] | None = None,
phase: str | None = None,
include_source: bool = False,
) -> bool:
"""Annotate every live stream event, but persist only completed segments.
Delta frames are a transport concern: retaining each token-sized chunk
would turn rendering cadence into disk-write cadence. The matching end
event carries the canonical segment text used by history replay.
"""
self.prepare_event(
chat_id,
event,
metadata=metadata,
phase=phase,
include_source=include_source,
)
if event.get("event") in {"delta", "reasoning_delta"}:
return True
record = dict(event)
if completed_text is not None:
record["text"] = completed_text
return self.append(chat_id, record)
def append_user_message(
self,
chat_id: str,
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
kept.append(m)
messages = kept
def stamp_latency(latency_ms: int) -> None:
def stamp_completion(
*,
latency_ms: int | None = None,
usage: dict[str, int] | None = None,
context_window_tokens: int | None = None,
) -> None:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
completion: dict[str, Any] = {"isStreaming": False}
if latency_ms is not None:
completion["latencyMs"] = latency_ms
if usage:
completion["usage"] = usage
if context_window_tokens is not None:
completion["contextWindowTokens"] = context_window_tokens
messages[i] = {
**messages[i],
"latencyMs": latency_ms,
"isStreaming": False,
**completion,
}
return
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec)
if isinstance(final_text, str):
if buffer_message_id is None:
buffer_message_id = find_active_placeholder(messages, turn_fields)
if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
messages.append({
"id": buffer_message_id,
"role": "assistant",
"content": "",
"isStreaming": True,
"createdAt": _created_at_ms(rec, idx),
})
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**turn_fields,
**source_fields,
"createdAt": _created_at_ms(rec, idx),
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**turn_fields,
**source_fields,
}
break
}
break
if merge_next:
buffer_parts = [final_text]
elif source_fields and buffer_message_id is not None:
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
if ev == "reasoning_end":
if suppress_until_turn_end:
continue
text = rec.get("text")
if isinstance(text, str) and text:
close_file_edit_phase_before_activity()
attach_reasoning_chunk(
messages,
text,
idx,
_turn_fields(rec, "reasoning"),
_created_at_ms(rec, idx),
)
close_reasoning(messages)
continue
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
messages[i] = {**m, "isStreaming": False}
prune_reasoning_only()
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
stamp_latency(int(lat))
usage = rec.get("usage")
sanitized_usage = (
{
key: value
for key, value in cast(dict[object, object], usage).items()
if isinstance(key, str) and type(value) is int and value >= 0
}
if isinstance(usage, dict)
else None
)
context_window = rec.get("context_window_tokens")
stamp_completion(
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
usage=sanitized_usage,
context_window_tokens=(
int(context_window)
if isinstance(context_window, (int, float)) and context_window >= 0
else None
),
)
buffer_message_id = None
buffer_parts = []
continue
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Refresh the native contributor avatar wall in README.md."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import TypedDict, cast
from urllib.request import Request, urlopen
REPOSITORY = "HKUDS/nanobot"
README = Path(__file__).resolve().parents[1] / "README.md"
START = "<!-- contributors:start -->"
END = "<!-- contributors:end -->"
PER_PAGE = 100
MAINTAINERS = {"re-bin", "chengyongru"}
class Contributor(TypedDict):
login: str
type: str
html_url: str
avatar_url: str
def fetch_contributors() -> list[Contributor]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "nanobot-readme",
"X-GitHub-Api-Version": "2022-11-28",
}
if token := os.environ.get("GITHUB_TOKEN"):
headers["Authorization"] = f"Bearer {token}"
contributors: list[Contributor] = []
page = 1
while True:
url = f"https://api.github.com/repos/{REPOSITORY}/contributors?per_page={PER_PAGE}&page={page}"
with urlopen(Request(url, headers=headers), timeout=30) as response: # noqa: S310
batch = cast(list[Contributor], json.load(response))
contributors.extend(batch)
if len(batch) < PER_PAGE:
break
page += 1
return [
contributor
for contributor in contributors
if contributor.get("login")
and contributor.get("type") != "Bot"
and not contributor["login"].lower().endswith("[bot]")
and contributor["login"].lower() not in MAINTAINERS
]
def render_wall(contributors: list[Contributor]) -> str:
avatars = [
(
f'<a href="{contributor["html_url"]}">'
f'<img src="{contributor["avatar_url"]}&s=48" '
f'width="48" height="48" alt="{contributor["login"]}"></a>'
)
for contributor in contributors
]
wall = "\n".join(avatars)
return f"{START}\n<p>\n{wall}\n</p>\n{END}"
def update_readme(*, check: bool) -> bool:
current = README.read_text()
before, separator, tail = current.partition(START)
if not separator or END not in tail:
raise SystemExit("README contributor markers are missing")
_, _, after = tail.partition(END)
updated = f"{before}{render_wall(fetch_contributors())}{after}"
if updated == current:
return False
if check:
raise SystemExit("README contributor wall is out of date")
README.write_text(updated)
return True
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail when README.md is out of date")
args = parser.parse_args()
print("Updated README.md" if update_readme(check=args.check) else "README.md is current")
+2
View File
@@ -1070,6 +1070,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
assert result.usage["prompt_tokens"] == 300 # 100 + 200
assert result.usage["completion_tokens"] == 30 # 10 + 20
assert result.usage["cached_tokens"] == 230 # 80 + 150
assert result.usage["context_tokens"] == 200
assert result.usage["request_count"] == 2
@pytest.mark.asyncio
+9 -5
View File
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
assert restored.payload == state.payload
@pytest.mark.asyncio
async def test_reports_the_fallback_model_before_its_request(self) -> None:
async def test_reports_only_the_successful_fallback_model(self) -> None:
primary = _FakeProvider("primary", _error_response())
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
failed_fallback = _FakeProvider("failed", _error_response("backup overloaded"))
successful_fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fallback_models: list[str] = []
async def _observe(model: str) -> None:
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a", provider="backup")],
provider_factory=MagicMock(return_value=fallback),
fallback_presets=[
_fallback("fallback-a", provider="backup"),
_fallback("fallback-b", provider="backup"),
],
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
fallback_model_observer=_observe,
)
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
)
assert result.content == "fallback ok"
assert fallback_models == ["fallback-a"]
assert fallback_models == ["fallback-b"]
@pytest.mark.asyncio
async def test_logs_primary_error_before_fallback(self) -> None:
+8 -6
View File
@@ -379,12 +379,14 @@ async def test_runner_calls_run_level_hooks_on_success():
"done",
"completed",
None,
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
},
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
"request_count": 1,
"context_tokens": 3,
},
["user", "assistant"],
),
("on_finally", "completed", None),
+22 -6
View File
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
@pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 0,
"delta": "thinking ",
},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 0,
"delta": "briefly",
},
{
"type": "response.reasoning_summary_text.delta",
"item_id": "rs_1",
"summary_index": 1,
"delta": "Checking result",
},
{"type": "response.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {"status": "completed"}},
])
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
assert tool_calls == []
assert finish_reason == "stop"
assert usage == {}
assert reasoning == "thinking briefly"
assert deltas == ["thinking ", "briefly"]
assert reasoning == "thinking briefly\nChecking result"
assert deltas == ["thinking ", "briefly", "\nChecking result"]
@pytest.mark.asyncio
async def test_reasoning_summary_from_completed_response(self):
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
"status": "completed",
"output": [
{"type": "reasoning", "summary": [
{"type": "summary_text", "text": "cached "},
{"type": "summary_text", "text": "cached"},
{"type": "summary_text", "text": "summary"},
]},
],
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary"
assert reasoning == "cached\nsummary"
@pytest.mark.asyncio
async def test_capture_commits_exact_items_only_after_completed_event(self):
+49
View File
@@ -393,6 +393,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_canonical_completed_stream_records() -> None:
msgs = replay_transcript_to_ui_messages([
{"event": "user", "chat_id": "canonical", "text": "q"},
{"event": "reasoning_end", "chat_id": "canonical", "text": "think"},
{"event": "stream_end", "chat_id": "canonical", "text": "answer"},
{"event": "turn_end", "chat_id": "canonical", "latency_ms": 42},
])
assert len(msgs) == 2
assert msgs[1]["content"] == "answer"
assert msgs[1]["reasoning"] == "think"
assert msgs[1]["latencyMs"] == 42
def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-usage"
for event in (
{"event": "user", "chat_id": "t-usage", "text": "q"},
{"event": "message", "chat_id": "t-usage", "text": "a"},
{
"event": "turn_end",
"chat_id": "t-usage",
"latency_ms": 18_200,
"usage": {
"prompt_tokens": 12_400,
"completion_tokens": 823,
"cached_tokens": 9_672,
"context_tokens": 8_200,
"request_count": 3,
},
"context_window_tokens": 128_000,
},
):
append_transcript_object(key, event)
messages = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert messages[-1]["usage"] == {
"prompt_tokens": 12_400,
"completion_tokens": 823,
"cached_tokens": 9_672,
"context_tokens": 8_200,
"request_count": 3,
}
assert messages[-1]["contextWindowTokens"] == 128_000
assert messages[-1]["latencyMs"] == 18_200
def test_replay_uses_persisted_created_at_ms() -> None:
msgs = replay_transcript_to_ui_messages(
[
+7 -23
View File
@@ -50,7 +50,7 @@ import {
} from "@/components/ui/tooltip";
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import { relativeTime, visibleSessionPreview } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle,
@@ -906,14 +906,6 @@ export const ChatList = memo(function ChatList({
);
}
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = visibleSessionPreview(s.preview);
@@ -959,8 +951,7 @@ export const ChatList = memo(function ChatList({
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)}
>
<SidebarItemTooltip label={tooltipTitle}>
<button
<button
type="button"
onClick={(event) => {
if (deleteSelectionMode) {
@@ -1029,8 +1020,7 @@ export const ChatList = memo(function ChatList({
</span>
) : null}
</span>
</button>
</SidebarItemTooltip>
</button>
<SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? (
<DropdownMenu
@@ -1435,10 +1425,7 @@ function ActivePaneRows({
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)}
>
<SidebarItemTooltip
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
>
<button
<button
type="button"
onClick={(event) => {
if (deleteSelectionMode) {
@@ -1474,8 +1461,7 @@ function ActivePaneRows({
{isPinned ? <PinnedChatIndicator /> : null}
<SidebarSelectionTrack active={active} handle={pane.handle} />
</span>
</button>
</SidebarItemTooltip>
</button>
<SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? <DropdownMenu
modal={false}
@@ -1652,8 +1638,7 @@ function TemporaryChatSection({
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
)}
>
<SidebarItemTooltip label={title}>
<button
<button
type="button"
onClick={() => onSelect(session.key)}
aria-current={active ? "page" : undefined}
@@ -1666,8 +1651,7 @@ function TemporaryChatSection({
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
</button>
</SidebarItemTooltip>
</button>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? (
<button
+136 -3
View File
@@ -34,7 +34,11 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
fmtDateTime,
formatCompactTokenCount,
formatMessageEndTime,
} from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command";
import { sessionHandleColor } from "@/lib/session-handle";
@@ -50,6 +54,7 @@ import type {
UIMessage,
MessageDeliveryErrorKind,
MessageDeliveryStatus,
TurnUsage,
} from "@/lib/types";
interface MessageBubbleProps {
@@ -60,6 +65,8 @@ interface MessageBubbleProps {
temporary?: boolean;
/** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean;
/** Show opt-in per-turn token usage for completed assistant messages. */
showTurnUsage?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
@@ -176,6 +183,116 @@ function MessageCopyButton({ content }: { content: string }) {
);
}
function compactDuration(milliseconds: number): string {
const seconds = milliseconds / 1_000;
if (seconds < 10) return `${seconds.toFixed(1)}s`;
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${Math.round(seconds % 60)}s`;
}
function TurnUsageMeta({ usage }: { usage: TurnUsage }) {
const { t } = useTranslation();
const context = usage.context_tokens;
const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
const parts: string[] = [];
const details: string[] = [];
if (typeof context === "number" && context >= 0) {
parts.push(t("message.usage.context", {
tokens: formatTokens(context),
defaultValue: "{{tokens}} context",
}));
if (typeof prompt === "number") {
details.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
} else if (typeof prompt === "number") {
parts.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
if (typeof usage.request_count === "number" && usage.request_count > 0) {
const requestKey = usage.request_count === 1
? "message.usage.request"
: "message.usage.requests";
details.push(t(requestKey, {
count: usage.request_count,
defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
}));
}
if (typeof completion === "number") {
const output = t("message.usage.output", {
tokens: formatTokens(completion),
defaultValue: "{{tokens}} out",
});
if (parts.length > 0) details.push(output);
else parts.push(output);
}
if (
typeof usage.cached_tokens === "number"
&& typeof prompt === "number"
&& prompt > 0
) {
details.push(t("message.usage.cached", {
percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
defaultValue: "{{percent}}% cached",
}));
}
if (parts.length === 0) return null;
if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
}
const usageMeta = (
<span
data-turn-usage
tabIndex={details.length ? 0 : undefined}
className={cn(
"text-[11px] leading-none text-muted-foreground/70 tabular-nums",
details.length && "cursor-help",
)}
>
{parts.join(" · ")}
</span>
);
if (details.length === 0) return usageMeta;
return (
<Tooltip>
<TooltipTrigger asChild>{usageMeta}</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-96 whitespace-nowrap"
>
{details.join(" · ")}
</TooltipContent>
</Tooltip>
);
}
function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) {
return (
<span
data-turn-latency
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
>
{compactDuration(latencyMs)}
</span>
);
}
function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string,
@@ -324,6 +441,7 @@ export function MessageBubble({
isTurnStreaming = false,
temporary = false,
showCopyAction = true,
showTurnUsage = false,
cliApps = [],
mcpPresets = [],
slashCommands = [],
@@ -479,7 +597,21 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
const showUsage = (
showTurnUsage
&& message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
);
const showLatency = (
message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
&& typeof message.latencyMs === "number"
&& message.latencyMs >= 0
);
const showAssistantFooterRow =
showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency;
const showAssistantFooterSlot =
message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0);
@@ -545,6 +677,8 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip>
) : null}
{showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
{showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
{showAssistantTimestamp ? (
<MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
@@ -576,7 +710,6 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)}
aria-label={label}
title={text}
>
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
@@ -407,6 +407,22 @@ export function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
description={tx(
"settings.help.showTurnUsage",
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
)}
>
<ToggleButton
checked={localPrefs.showTurnUsage}
onChange={(showTurnUsage) => (
onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage }))
)}
ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
modelSegments: number;
cliCount: number;
mcpCount: number;
fileCount: number;
@@ -91,9 +93,14 @@ function countActivity(
): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
let modelSegments = 0;
const cliCount = cliRuns.length;
const mcpCount = mcpRuns.length;
for (const m of messages) {
if (m.activityKind === "model") {
modelSegments += 1;
continue;
}
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
@@ -110,6 +117,7 @@ function countActivity(
return {
reasoningSteps,
toolCalls,
modelSegments,
cliCount,
mcpCount,
fileCount: fileEdits.length,
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
}
/**
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
* Fixed max height with inner scroll and a single flat list of activity rows.
* One fold wrapping the complete middle of a turn: reasoning, model segments,
* tool traces, and file edits. The final assistant answer stays outside it.
*/
export function AgentActivityCluster({
messages,
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
const {
reasoningSteps,
toolCalls,
modelSegments,
cliCount,
mcpCount,
fileCount,
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
? outerOpenLocal
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || modelSegments > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const durationMs = activityDurationMs(
activityMessages,
isTurnStreaming,
@@ -197,28 +205,16 @@ export function AgentActivityCluster({
startedAtMs,
);
const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = hasNonReasoningActivity
? isTurnStreaming
? t("message.activityWorkingFor", {
duration: activityDuration,
defaultValue: "Working for {{duration}}",
})
: durationMs <= 0
? t("message.activityWorked", { defaultValue: "Worked" })
const activityLabel = isTurnStreaming
? t("message.activityWorkingFor", {
duration: activityDuration,
defaultValue: "Working for {{duration}}",
})
: durationMs <= 0
? t("message.activityWorked", { defaultValue: "Worked" })
: t("message.activityWorkedFor", {
duration: activityDuration,
defaultValue: "Worked for {{duration}}",
})
: isTurnStreaming
? t("message.activityThinkingFor", {
duration: activityDuration,
defaultValue: "Thinking for {{duration}}",
})
: durationMs <= 0
? t("message.activityThought", { defaultValue: "Thought" })
: t("message.activityThoughtFor", {
duration: activityDuration,
defaultValue: "Thought for {{duration}}",
});
const cancelActivityScrollFrame = useCallback(() => {
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
<ThinkingReasoningShell
active={isTurnStreaming}
expanded={outerExpanded}
label={thoughtLabel}
label={activityLabel}
viewportRef={activityScrollRef}
contentRef={activityContentRef}
fadeTop={activityScrollFade.top}
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
active={isTurnStreaming}
cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName}
onOpenFilePreview={onOpenFilePreview}
/>
{fileEdits.length ? (
<FileEditGroup
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
active,
cliAppsByName,
mcpPresetsByName,
onOpenFilePreview,
}: {
messages: UIMessage[];
active: boolean;
cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>;
onOpenFilePreview?: (path: string) => void;
}) {
const items: ReactNode[] = [];
messages.forEach((message, index) => {
if (message.activityKind === "model") {
items.push(
<ActivityModelMessage
key={message.id}
message={message}
active={active}
onOpenFilePreview={onOpenFilePreview}
/>,
);
return;
}
if (isReasoningOnlyAssistant(message)) {
items.push(
<ReasoningRow
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
return <>{items}</>;
}
/**
* Keep an intermediate assistant segment as normal Markdown. The activity
* surface owns ordering and lifecycle, not a reduced rendering mode: users
* should see the same prose, links, and code treatment before and after the
* surrounding turn is folded.
*/
function ActivityModelMessage({
message,
active,
onOpenFilePreview,
}: {
message: UIMessage;
active: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
if (!message.content.trim()) return null;
return (
<div
data-testid="activity-model-message"
data-assistant-selectable={active && message.isStreaming ? undefined : "true"}
className="w-full min-w-0 py-1 text-[15px]"
style={{ lineHeight: "var(--cjk-line-height)" }}
>
<MarkdownText
streaming={active && !!message.isStreaming}
preserveStreamingLayout
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
</div>
);
}
function ActivityTraceList({
lines,
active,
+381 -270
View File
@@ -6,37 +6,27 @@ import {
type KeyboardEvent,
type PointerEvent,
} from "react";
import { CircleHelp, Sparkles } from "lucide-react";
import { Check, CircleHelp, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
floatingItemClassName,
floatingItemFocusClassName,
} from "@/components/ui/floating-surface";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
export interface ModelPresetOption {
name: string;
model?: string | null;
provider?: string | null;
}
interface ModelPresetBadgeProps {
label: string;
modelDetail?: string | null;
modelPreset?: string | null;
modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}
const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
const LONG_PRESS_MS = 400;
const PRESS_SLOP_PX = 8;
const PILL_GAP_PX = 4;
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
const HANDOFF_THRESHOLD = 0.56;
const DOCK_MAX_SCALE = 1.08;
const DOCK_RADIUS = 1.5;
const SETTLE_MS = 200;
interface PresetGesture {
active: boolean;
@@ -55,15 +45,6 @@ interface PresetMotion {
settling: boolean;
}
const LONG_PRESS_MS = 400;
const PRESS_SLOP_PX = 8;
const PILL_GAP_PX = 4;
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
const HANDOFF_THRESHOLD = 0.56;
const DOCK_MAX_SCALE = 1.08;
const DOCK_RADIUS = 1.5;
const SETTLE_MS = 180;
function wrapIndex(index: number, length: number): number {
return ((index % length) + length) % length;
}
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
if (event.cancelable) event.preventDefault();
}
function compactModelName(model?: string | null): string | null {
const value = model?.trim();
if (!value) return null;
return value.split("/").at(-1) || value;
}
export interface ModelPresetOption {
name: string;
model?: string | null;
provider?: string | null;
}
interface ModelPresetBadgeProps {
label: string;
modelDetail?: string | null;
modelPreset?: string | null;
modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void;
onRequestComposerFocus?: () => void;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}
export function ModelPresetBadge({
label,
modelDetail,
modelPreset,
modelPresets = [],
onPresetChange,
onRequestComposerFocus,
provider,
providerLabel,
needsSetup = false,
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
isHero,
onClick,
}: ModelPresetBadgeProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [motion, setMotion] = useState<PresetMotion | null>(null);
const [motionWidth, setMotionWidth] = useState<number | null>(null);
const gestureRef = useRef<PresetGesture | null>(null);
const suppressClickRef = useRef(false);
const activeName = modelPreset?.trim() || "";
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
const activePreset: ModelPresetOption = {
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
model: modelDetail ?? modelPresets[listedIndex]?.model,
provider: provider || modelPresets[listedIndex]?.provider,
};
const fallbackPreset = fallbackModelName
? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim())
: undefined;
const fallbackDisplayLabel = fallbackPreset?.name
|| fallbackModelName?.trim().split(/[/:]/).pop()
|| null;
const displayLabel = fallbackDisplayLabel || label;
const displayModelDetail = fallbackPreset
? fallbackPreset.model
: fallbackModelName
? null
: modelDetail;
const displayProvider = fallbackPreset?.provider
|| (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : provider);
const presets = !activeName
? modelPresets
: listedIndex < 0
? [activePreset, ...modelPresets]
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const opensSetup = Boolean(onClick);
const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
const pillHeight = isHero ? 32 : 36;
const pillStride = pillHeight + PILL_GAP_PX;
const [motion, setMotion] = useState<PresetMotion | null>(null);
const gestureRef = useRef<PresetGesture | null>(null);
const clickAnimationFrameRef = useRef<number | null>(null);
const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
const switchModelLabel = t("thread.composer.switchModel", {
defaultValue: "Switch model for this chat",
});
function clearGesture() {
const selectPreset = (name: string) => {
setOpen(false);
if (name !== activeName) onPresetChange?.(name);
requestAnimationFrame(() => onRequestComposerFocus?.());
};
const clearGesture = () => {
const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer);
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
gestureRef.current = null;
}
};
const clearMotion = () => {
setMotion(null);
setMotionWidth(null);
};
useEffect(() => {
if (!canSwitch) {
clearGesture();
setMotion(null);
clearMotion();
}
return () => {
clearGesture();
if (clickAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(clickAnimationFrameRef.current);
clickAnimationFrameRef.current = null;
}
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
suppressClickTimerRef.current = null;
}
};
return clearGesture;
}, [canSwitch]);
useEffect(() => {
if (!motion?.settling) return;
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
const timer = setTimeout(clearMotion, SETTLE_MS + 80);
return () => clearTimeout(timer);
}, [motion?.settling]);
function updateMotion(gesture: PresetGesture, clientY: number) {
const updateMotion = (gesture: PresetGesture, clientY: number) => {
const raw = -(clientY - gesture.startY) / pillStride;
gesture.step = stepWithHysteresis(raw, gesture.step);
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false });
}
function suppressFollowingClick() {
suppressClickRef.current = true;
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
suppressClickTimerRef.current = window.setTimeout(() => {
suppressClickRef.current = false;
suppressClickTimerRef.current = null;
}, 0);
}
function cycleToNextPreset() {
if (!canSwitch || motion) return;
const nextVirtualIndex = currentIndex + 1;
const next = presets[wrapIndex(nextVirtualIndex, presets.length)];
if (!next || next.name === activeName) return;
// Mount the same five-pill track one step before its destination, then
// settle it into place so clicks share the drag interaction's motion.
setMotion({ index: nextVirtualIndex, remainder: -1, settling: false });
clickAnimationFrameRef.current = window.requestAnimationFrame(() => {
clickAnimationFrameRef.current = null;
setMotion({ index: nextVirtualIndex, remainder: 0, settling: true });
onPresetChange?.(next.name);
setMotion({
index: gesture.baseIndex + gesture.step,
remainder: raw - gesture.step,
settling: false,
});
}
};
function handleClick() {
if (interactive) {
onClick?.();
return;
}
if (suppressClickRef.current) return;
cycleToNextPreset();
}
function handlePointerDown(event: PointerEvent<HTMLElement>) {
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (!canSwitch || gestureRef.current || motion) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
const gesture: PresetGesture = {
active: false,
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
gesture.timer = setTimeout(() => {
if (gestureRef.current !== gesture) return;
gesture.active = true;
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
updateMotion(gesture, gesture.latestY);
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
try {
gesture.target.setPointerCapture(gesture.pointerId);
} catch { /* The pointer may already have ended. */ }
} catch {
// The pointer may already have ended.
}
}, LONG_PRESS_MS);
gestureRef.current = gesture;
}
};
function handlePointerMove(event: PointerEvent<HTMLElement>) {
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
gesture.latestY = event.clientY;
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
}
event.preventDefault();
updateMotion(gesture, event.clientY);
}
};
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) {
const finishGesture = (event: PointerEvent<HTMLButtonElement>, commit: boolean) => {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
clearGesture();
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
event.currentTarget.releasePointerCapture?.(gesture.pointerId);
}
if (gesture.active) suppressFollowingClick();
if (!commit || !gesture.active) {
setMotion(null);
clearMotion();
return;
}
suppressClickRef.current = true;
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
setMotion((current) => current && { ...current, remainder: 0, settling: true });
if (selected && selected.name !== activeName) onPresetChange?.(selected.name);
}
if (selected && selected.name !== activeName) selectPreset(selected.name);
};
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
if (!canSwitch) return;
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const targetByKey: Record<string, number> = {
ArrowUp: currentIndex - 1,
ArrowDown: currentIndex + 1,
@@ -262,141 +260,229 @@ export function ModelPresetBadge({
if (target === undefined) return;
event.preventDefault();
const next = presets[wrapIndex(target, presets.length)];
if (next?.name !== activeName) onPresetChange?.(next.name);
}
if (next?.name !== activeName) selectPreset(next.name);
};
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length);
const previewPreset = presets[previewIndex];
const Container = interactive || canSwitch ? "button" : "span";
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
const tooltipLabel = fallbackModelName
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const badge = (
<Container
data-switching={motion ? "true" : undefined}
data-settling={motion?.settling ? "true" : undefined}
aria-label={label}
aria-orientation={canSwitch ? "vertical" : undefined}
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
aria-valuemin={canSwitch ? 0 : undefined}
aria-valuenow={canSwitch ? previewIndex : undefined}
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
role={canSwitch ? "spinbutton" : undefined}
tabIndex={!interactive && !canSwitch ? 0 : undefined}
type={interactive || canSwitch ? "button" : undefined}
onClick={interactive || canSwitch ? handleClick : undefined}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerLeave={(event) => {
const gesture = gestureRef.current;
if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture();
}}
onPointerUp={(event) => finishGesture(event, true)}
onPointerCancel={(event) => finishGesture(event, false)}
onLostPointerCapture={(event) => finishGesture(event, false)}
onContextMenu={(event) => {
if (gestureRef.current?.active) event.preventDefault();
}}
onDragStart={(event) => event.preventDefault()}
style={{ touchAction: canSwitch ? "manipulation" : undefined }}
className={cn(
"thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none",
interactive && "cursor-pointer",
canSwitch && "cursor-grab select-none focus-visible:outline-none",
motion && "z-10 cursor-grabbing",
isHero ? "h-8" : "h-9",
)}
>
<PresetPill
className={motion && "invisible"}
label={label}
modelDetail={modelDetail}
provider={provider}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
/>
{motion ? (
<span
data-testid="composer-model-pill-viewport"
className={cn(
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
)}
aria-hidden
>
<span
data-testid="composer-model-pill-track"
data-settling={motion.settling ? "true" : undefined}
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform"
onTransitionEnd={(event) => {
if (motion.settling && event.currentTarget === event.target) setMotion(null);
}}
style={{
paddingTop: isHero ? "10px" : "12px",
transform: `translate3d(0, ${trackOffset}px, 0)`,
}}
>
{PILL_OFFSETS.map((offset) => {
const virtualIndex = motion.index + offset;
const preset = presets[wrapIndex(virtualIndex, presets.length)];
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
return (
<PresetPill
key={virtualIndex}
label={preset.name}
modelDetail={preset.model}
provider={preset.provider}
isHero={isHero}
offset={offset}
scale={scale}
/>
);
})}
</span>
</span>
) : null}
</Container>
const pill = (
<PresetPill
label={displayLabel}
modelDetail={displayModelDetail}
provider={displayProvider}
providerLabel={fallbackModelName ? null : providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
fallbackFromLabel={fallbackModelName ? label : null}
isHero={isHero}
/>
);
if (!tooltipLabel) return badge;
if (!canSwitch) {
const Container = opensSetup ? "button" : "span";
return (
<Container
aria-label={fallbackModelName ? `${displayLabel} (fallback from ${label})` : label}
type={opensSetup ? "button" : undefined}
onClick={opensSetup ? onClick : undefined}
className={cn(
"thread-composer-model-badge group inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] appearance-none border-0 bg-transparent p-0 shadow-none",
opensSetup && "cursor-pointer focus-visible:outline-none",
isHero ? "h-8" : "h-9",
)}
>
{pill}
</Container>
);
}
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
collisionPadding={12}
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
}}
>
<PopoverTrigger asChild>
<button
type="button"
data-switching={motion ? "true" : undefined}
aria-label={label}
aria-expanded={open}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerLeave={(event) => {
const gesture = gestureRef.current;
if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture();
}}
onPointerUp={(event) => finishGesture(event, true)}
onPointerCancel={(event) => finishGesture(event, false)}
onLostPointerCapture={(event) => finishGesture(event, false)}
onContextMenu={(event) => {
if (gestureRef.current?.active) event.preventDefault();
}}
onDragStart={(event) => event.preventDefault()}
onKeyDown={handleKeyDown}
onClickCapture={(event) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
}}
style={{
touchAction: "manipulation",
width: motionWidth ? `${motionWidth}px` : undefined,
}}
className={cn(
"thread-composer-model-badge group relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] cursor-pointer appearance-none border-0 bg-transparent p-0 shadow-none focus-visible:outline-none",
motion && "z-10 cursor-grabbing",
!motion && "cursor-grab",
isHero ? "h-8" : "h-9",
)}
>
{tooltipLabel}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{motion ? (
<>
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
{pill}
</span>
<span
data-testid="composer-model-pill-viewport"
className={cn(
"composer-model-pill-viewport pointer-events-none absolute -left-2 right-0 overflow-hidden bg-transparent",
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
)}
aria-hidden
>
<span
data-testid="composer-model-pill-track"
data-settling={motion.settling ? "true" : undefined}
className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
onTransitionEnd={(event) => {
if (motion.settling && event.currentTarget === event.target) clearMotion();
}}
style={{
paddingTop: isHero ? "10px" : "12px",
transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
}}
>
{PILL_OFFSETS.map((offset) => {
const preset = presets[wrapIndex(motion.index + offset, presets.length)];
return (
<PresetPill
key={motion.index + offset}
label={preset.name}
modelDetail={preset.model}
provider={preset.provider}
isHero={isHero}
offset={offset}
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
/>
);
})}
</span>
</span>
</>
) : pill}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
sideOffset={10}
role="dialog"
aria-label={switchModelLabel}
onOpenAutoFocus={(event) => {
event.preventDefault();
const content = event.currentTarget;
if (!(content instanceof HTMLElement)) return;
const selected = content.querySelector<HTMLElement>(
'[role="option"][aria-selected="true"]',
);
selected?.focus();
}}
className={cn(
pickerWidthClassName,
"origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none",
)}
>
<div
role="listbox"
aria-label={switchModelLabel}
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto py-1 scrollbar-thin scrollbar-track-transparent"
>
{presets.map((preset) => (
<PresetOption
key={preset.name}
preset={preset}
selected={preset.name === activeName}
onSelect={selectPreset}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}
function PresetOption({
preset,
selected,
onSelect,
}: {
preset: ModelPresetOption;
selected: boolean;
onSelect: (name: string) => void;
}) {
const detail = compactModelName(preset.model);
return (
<button
type="button"
role="option"
aria-label={preset.name}
aria-selected={selected}
onClick={() => onSelect(preset.name)}
className={cn(
floatingItemClassName,
floatingItemFocusClassName,
"flex min-h-9 w-full cursor-pointer gap-2.5 px-2.5 py-1.5 text-left hover:bg-muted/55",
selected && "bg-muted/55 text-foreground",
)}
>
<PresetProviderIcon
label={preset.name}
modelDetail={detail}
provider={preset.provider}
isHero={false}
/>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden whitespace-nowrap">
<span className="shrink-0 text-[13px] font-medium text-foreground">{preset.name}</span>
{detail && detail !== preset.name ? (
<span className="truncate text-[12px] text-muted-foreground">{detail}</span>
) : null}
</span>
{selected ? <Check className="h-4 w-4 shrink-0 text-foreground/80" aria-hidden /> : null}
</button>
);
}
function PresetPill({
className,
label,
modelDetail,
provider,
providerLabel,
needsSetup = false,
fallbackModelName,
fallbackFromLabel,
isHero,
offset,
scale,
}: {
className?: string | false | null;
label: string;
modelDetail?: string | null;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
fallbackFromLabel?: string | null;
isHero: boolean;
offset?: number;
scale?: number;
@@ -406,13 +492,10 @@ function PresetPill({
const inferredProvider = needsSetup
? null
: provider || inferProviderFromModelName(modelDetail || label);
const brand = providerBrand(inferredProvider);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
const logoTestId = offset !== undefined
? undefined
: needsSetup
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const fallbackTitle = fallbackModelName
? `${fallbackFromLabel || label} · using ${fallbackModelName}`
: title;
useLayoutEffect(() => {
const node = labelRef.current;
@@ -428,14 +511,14 @@ function PresetPill({
<span
data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset}
title={fallbackTitle || undefined}
className={cn(
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
"w-fit",
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
offset !== undefined && "composer-model-pill-dock",
className,
)}
style={scale === undefined ? undefined : {
height: `${isHero ? 32 : 36}px`,
@@ -443,46 +526,14 @@ function PresetPill({
zIndex: Math.round(scale * 100),
}}
>
<span
data-testid={logoTestId}
className={cn(
"grid shrink-0 place-items-center overflow-hidden",
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
)}
style={{
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : logoUrl ? (
<img
src={logoUrl}
alt=""
draggable={false}
decoding="async"
loading="lazy"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
isHero ? "text-[7.5px]" : "text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
)}
</span>
<PresetProviderIcon
label={label}
modelDetail={modelDetail}
provider={inferredProvider}
needsSetup={needsSetup}
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
isHero={isHero}
/>
<span
ref={labelRef}
className={cn(
@@ -495,3 +546,63 @@ function PresetPill({
</span>
);
}
function PresetProviderIcon({
label,
modelDetail,
provider,
needsSetup = false,
testId,
isHero,
}: {
label: string;
modelDetail?: string | null;
provider?: string | null;
needsSetup?: boolean;
testId?: string;
isHero: boolean;
}) {
const inferredProvider = needsSetup
? null
: provider || inferProviderFromModelName(modelDetail || label);
const brand = providerBrand(inferredProvider);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
return (
<span
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center",
needsSetup && "text-amber-800 dark:text-amber-200",
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
)}
aria-hidden
>
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : logoUrl ? (
<img
src={logoUrl}
alt=""
draggable={false}
decoding="async"
loading="lazy"
className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
isHero ? "text-[7.5px]" : "text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
)}
</span>
);
}
@@ -112,6 +112,7 @@ import {
readDraggedSession,
} from "@/lib/session-drag";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { formatCompactTokenCount } from "@/lib/format";
import { cn } from "@/lib/utils";
const VOICE_SHORTCUT_CODE = "KeyD";
@@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string {
}
}
export interface ComposerContextUsage {
contextTokens: number;
contextWindowTokens?: number;
}
function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) {
const { t } = useTranslation();
if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null;
const context = formatCompactTokenCount(usage.contextTokens);
const contextWindow = typeof usage.contextWindowTokens === "number"
&& Number.isFinite(usage.contextWindowTokens)
&& usage.contextWindowTokens > 0
? usage.contextWindowTokens
: null;
const capacity = contextWindow !== null
? ` / ${formatCompactTokenCount(contextWindow)}`
: "";
// The meter is meaningful only with a known capacity. Do not turn an
// otherwise unknown total into a second, text-heavy control beside the
// model picker.
if (contextWindow === null) return null;
const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100));
const status = percentage >= 90
? "critical"
: percentage >= 75
? "caution"
: "normal";
const contextDescription = t("thread.composer.context.tooltip", {
defaultValue: "Context · {{tokens}}{{capacity}}",
tokens: context,
capacity,
});
const meterDescription = t("thread.composer.context.meterDescription", {
defaultValue: "{{context}}. {{percent}}% used.",
context: contextDescription,
percent: percentage,
});
const ringCircumference = 2 * Math.PI * 6;
const ringLength = ringCircumference * percentage / 100;
return (
<TooltipProvider delayDuration={300} skipDelayDuration={80}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-testid="composer-context-usage"
aria-label={meterDescription}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground/75 transition-colors hover:text-foreground/85",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={cn(
"size-[15px] shrink-0 -rotate-90",
status === "critical" && "text-destructive",
status === "caution" && "text-amber-600 dark:text-amber-400",
status === "normal" && "text-muted-foreground/75",
)}
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="opacity-20"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray={`${ringLength} ${ringCircumference}`}
data-testid="composer-context-meter"
/>
</svg>
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
className="w-fit max-w-[calc(100vw-2rem)] rounded-full border-border/70 px-2.5 py-1 text-xs font-medium shadow-[0_8px_24px_rgba(15,23,42,0.13)]"
>
<span className="whitespace-nowrap tabular-nums">{contextDescription}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
interface ThreadComposerProps {
onSend: (
content: string,
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
@@ -895,6 +998,7 @@ export function ThreadComposer({
modelNeedsSetup = false,
fallbackModelName = null,
onModelBadgeClick,
contextUsage = null,
variant = "thread",
slashCommands = [],
cliApps = [],
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
modelPreset={modelPreset}
modelPresets={modelPresets}
onPresetChange={onModelPresetChange}
onRequestComposerFocus={() => textareaRef.current?.focus()}
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
) : null}
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
{showVoiceButton ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<Tooltip>
+14 -5
View File
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
@@ -29,10 +30,9 @@ export type DisplayUnit = TurnUnit;
export function buildDisplayUnits(
messages: UIMessage[],
isStreaming = false,
activeTurnId: string | null = null,
): DisplayUnit[] {
return normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
}
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
@@ -68,8 +68,12 @@ export function ThreadMessages({
onQuoteSelection,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const showTurnUsage = useShowTurnUsage();
const messageListRef = useRef<HTMLDivElement>(null);
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const units = useMemo(
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
[activeTurnId, isStreaming, messages],
);
const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units],
@@ -156,6 +160,7 @@ export function ThreadMessages({
showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -237,6 +242,7 @@ interface ThreadDisplayUnitProps {
showForkBoundary: boolean;
forkBoundaryLabel: string;
temporary: boolean;
showTurnUsage: boolean;
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[];
@@ -255,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
showForkBoundary,
forkBoundaryLabel,
temporary,
showTurnUsage,
cliApps,
mcpPresets,
slashCommands,
@@ -293,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
message={unit.message}
isTurnStreaming={isTurnStreaming}
temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -321,6 +329,7 @@ function threadDisplayUnitPropsEqual(
&& previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary
&& previous.showTurnUsage === next.showTurnUsage
&& previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands
+51 -13
View File
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import {
ThreadComposer,
type ComposerContextUsage,
} from "@/components/thread/ThreadComposer";
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
@@ -83,6 +86,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
);
}
function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const contextTokens = message.usage?.context_tokens;
if (
message.role !== "assistant"
|| message.kind === "trace"
|| message.isStreaming
|| typeof contextTokens !== "number"
|| !Number.isFinite(contextTokens)
|| contextTokens < 0
) {
continue;
}
return {
contextTokens,
...(typeof message.contextWindowTokens === "number"
? { contextWindowTokens: message.contextWindowTokens }
: {}),
};
}
return null;
}
function snapshotPreservesMessage(
current: MessageShape,
candidate: MessageShape,
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
);
return {
label,
model: model?.trim() || null,
model: toModelBadgeLabel(model),
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
@@ -803,8 +830,12 @@ export function ThreadShell({
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const composerContextUsage = useMemo(
() => latestComposerContextUsage(displayMessages),
[displayMessages],
);
const currentGoalState = messagesReady ? goalState : undefined;
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
@@ -882,9 +913,17 @@ export function ThreadShell({
useEffect(() => {
setLocalModelPreset(null);
}, [session?.key, sessionModelPreset]);
const configuredPresetNames = useMemo(
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
[settings],
);
const activeModelPreset = (
localModelPreset
|| sessionModelPreset
(localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
? localModelPreset
: null)
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
? sessionModelPreset
: null)
|| settings?.agent.model_preset
|| "default"
);
@@ -958,13 +997,10 @@ export function ThreadShell({
}
setFallbackModelName(null);
return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return;
const activeModel = event.model_name.trim();
setFallbackModelName(
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
);
if (event.event !== "turn_model_updated" || event.fallback !== true) return;
setFallbackModelName(event.model_name);
});
}, [chatId, client, modelBadge.model]);
}, [chatId, client]);
useEffect(() => {
if (!historyKey || !chatId || loading) return;
@@ -1454,7 +1490,7 @@ export function ThreadShell({
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)}
modelDetail={modelBadge.model}
modelPreset={activeModelPreset}
modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange}
@@ -1463,6 +1499,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant={composerVariant}
slashCommands={availableSlashCommands}
cliApps={cliApps}
@@ -1501,7 +1538,7 @@ export function ThreadShell({
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)}
modelDetail={modelBadge.model}
modelPreset={activeModelPreset}
modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange}
@@ -1510,6 +1547,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant="hero"
slashCommands={availableSlashCommands}
cliApps={cliApps}
+40 -34
View File
@@ -165,11 +165,19 @@
pointer-events: none;
background-color: rgb(236 141 49);
opacity: 0;
transition: opacity 220ms ease-out;
transition: opacity 180ms ease-out;
}
.composer-model-badge[data-fallback="true"] {
border-color: rgb(236 141 49 / 0.38);
}
.composer-model-badge[data-fallback="true"]::before {
opacity: 1;
opacity: 0.1;
}
.dark .composer-model-badge[data-fallback="true"]::before {
opacity: 0.15;
}
.composer-model-badge > * {
@@ -695,11 +703,38 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
}
.thread-composer-model-badge:not([data-switching="true"]):active
> .composer-model-pill {
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
transform: scale(0.98);
}
@keyframes composer-model-pill-viewport-enter {
from {
transform: scale(0.9074);
}
to {
transform: scale(1);
}
}
.composer-model-pill-viewport {
transform-origin: right center;
animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
}
.composer-model-pill-dock {
transform-origin: right center;
transition-property: none;
will-change: transform;
}
.composer-model-pill-track[data-settling="true"],
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes preset-name-shake {
0%,
100% {
@@ -715,37 +750,8 @@
}
}
@keyframes composer-model-pill-viewport-enter {
from {
transform: scale(0.9074);
}
to {
transform: scale(1);
}
}
.composer-model-pill-viewport {
transform-origin: right center;
animation: composer-model-pill-viewport-enter 210ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
}
.composer-model-pill-dock {
transform-origin: right center;
transition-property: none;
will-change: transform;
}
.composer-model-pill-track[data-settling="true"],
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.thread-composer-model-badge:active > .composer-model-pill {
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
transform: none !important;
}
+20 -73
View File
@@ -55,7 +55,6 @@ type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
/**
@@ -63,8 +62,8 @@ const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
*
* Lookup rule: reasoning can only extend the current reasoning placeholder.
* Once ordinary answer text has appeared, the next reasoning chunk starts a
* fresh Thought block so streamed output stays in arrival order:
* Thought -> answer -> Thought -> answer.
* fresh activity surface so streamed output stays in arrival order while the
* final answer remains the only visible answer bubble.
*/
function attachReasoningChunk(
prev: UIMessage[],
@@ -183,16 +182,6 @@ export interface SubmittedTurn {
sideChannel: boolean;
}
function eventExtendsModelActivity(ev: InboundEvent): boolean {
if (
ev.event === "delta"
|| ev.event === "reasoning_delta"
|| ev.event === "file_edit"
) return true;
return ev.event === "message"
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
}
function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
}
@@ -296,14 +285,6 @@ export function useNanobotStream(
const streamTimerRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
* When the model finishes a text segment and calls a tool, the server
* sends ``stream_end`` but the agent is still "thinking" while the tool
* executes. By deferring the flag reset by a short window (1 s) we keep
* the loading spinner alive across tool-call boundaries without needing
* backend changes. */
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissStreamError = useCallback(() => setStreamError(null), []);
@@ -319,26 +300,11 @@ export function useNanobotStream(
pendingStreamEventsRef.current = [];
}, []);
const cancelStreamEndTimer = useCallback(() => {
if (streamEndTimerRef.current === null) return;
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}, []);
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
const turnId = eventTurnId(ev);
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
}, []);
const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => {
cancelStreamEndTimer();
streamEndTimerRef.current = setTimeout(() => {
streamEndTimerRef.current = null;
setIsStreaming(false);
setMessages((prev) => finalizeStreamedTurn(prev, turn));
}, STREAM_END_IDLE_DELAY_MS);
}, [cancelStreamEndTimer]);
const createActivitySegmentId = useCallback((activate = true) => {
activitySegmentCounterRef.current += 1;
const id = `activity-${activitySegmentCounterRef.current}`;
@@ -387,7 +353,6 @@ export function useNanobotStream(
(event) => event.turn.turnId !== rejectedTurnId,
);
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
cancelStreamEndTimer();
setMessages((prev) => {
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
if (rejectedRows.length === 0) return prev;
@@ -438,7 +403,7 @@ export function useNanobotStream(
setRunStartedAt(remainingStartedAt);
setIsStreaming(hasRemainingRun);
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
}, [cancelStreamEndTimer, chatId, client]);
}, [chatId, client]);
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
@@ -659,15 +624,6 @@ export function useNanobotStream(
return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]);
useEffect(() => {
return client.onStatus((status) => {
if (status !== "reconnecting" && status !== "closed") return;
// A transport drop does not prove the backend turn completed. Keep the
// semantic running state intact so queued guidance is not flushed early.
cancelStreamEndTimer();
});
}, [cancelStreamEndTimer, client]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@@ -690,9 +646,8 @@ export function useNanobotStream(
clearPendingStreamWork();
sideChannelTurnIdsRef.current.clear();
suppressStreamUntilTurnEndRef.current = false;
cancelStreamEndTimer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
@@ -774,12 +729,6 @@ export function useNanobotStream(
return;
}
const sideChannelEvent = isSideChannelEvent(ev);
if (
streamEndTimerRef.current !== null
&& !sideChannelEvent
&& eventExtendsModelActivity(ev)
) cancelStreamEndTimer();
if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
@@ -822,14 +771,13 @@ export function useNanobotStream(
});
if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) {
cancelStreamEndTimer();
setIsStreaming(true);
if (!mergeNext) {
setMessages((prev) => finalizeStreamedTurn(prev, turn));
}
return;
}
scheduleStreamEndTimer(turn);
// ``stream_end`` closes the current answer segment, not the turn.
// Tools and follow-up model segments may still arrive before the
// definitive ``turn_end`` event.
setIsStreaming(true);
return;
}
@@ -868,9 +816,8 @@ export function useNanobotStream(
setGoalState(ev.goal_state);
}
setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
cancelStreamEndTimer();
// Definitive signal that the turn is fully complete, so stop the
// loading indicator immediately.
setIsStreaming(false);
const completedAt = Date.now();
setMessages((prev) => {
@@ -884,6 +831,10 @@ export function useNanobotStream(
finalized,
{
...(latencyMs !== undefined ? { latencyMs } : {}),
...(ev.usage ? { usage: ev.usage } : {}),
...(typeof ev.context_window_tokens === "number"
? { contextWindowTokens: ev.context_window_tokens }
: {}),
completedAt,
},
ev.turn_id,
@@ -1013,8 +964,9 @@ export function useNanobotStream(
// A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice.
// Streaming state is closed by ``stream_end`` when present, or by
// ``turn_end`` for non-streamed and tool-heavy turns.
// ``turn_end`` is the turn boundary. ``stream_end`` only closes the
// current text segment so a following tool/reasoning segment remains
// part of the same live activity surface.
clearActivitySegment();
setMessages((prev) => {
const activeId = buffer.current?.messageId;
@@ -1088,7 +1040,6 @@ export function useNanobotStream(
});
return;
}
// ``attached`` frames aren't actionable here.
};
const unsub = client.onChat(chatId, handle);
@@ -1099,12 +1050,11 @@ export function useNanobotStream(
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
clearPendingStreamWork();
cancelStreamEndTimer();
};
}, [
applyStreamError,
cancelStreamEndTimer,
chatId,
closeActiveAssistantStream,
client,
clearActivitySegment,
clearPendingStreamWork,
@@ -1114,7 +1064,6 @@ export function useNanobotStream(
isSideChannelEvent,
onTurnEnd,
schedulePendingStreamFlush,
scheduleStreamEndTimer,
]);
const send = useCallback(
@@ -1133,7 +1082,6 @@ export function useNanobotStream(
: content;
flushPendingStreamEvents();
if (finalizeActiveTurn) {
cancelStreamEndTimer();
setIsStreaming(false);
}
const turnId = crypto.randomUUID();
@@ -1188,7 +1136,7 @@ export function useNanobotStream(
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
return { turnId, userMessageId, sideChannel };
},
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
const stop = useCallback(() => {
@@ -1209,7 +1157,6 @@ export function useNanobotStream(
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
const reconcileTurnComplete = useCallback(() => {
cancelStreamEndTimer();
clearPendingStreamWork();
buffer.current = null;
activeAssistantRef.current = null;
@@ -1218,7 +1165,7 @@ export function useNanobotStream(
suppressStreamUntilTurnEndRef.current = false;
setRunStartedAt(null);
setIsStreaming(false);
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
}, [clearActivitySegment, clearPendingStreamWork]);
const transcribeAudio = useCallback(
(dataUrl: string, options?: { durationMs?: number }) =>
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import {
LOCAL_PREFS_CHANGED_EVENT,
readLocalPreferences,
type LocalPreferences,
} from "@/lib/local-preferences";
export function useShowTurnUsage(): boolean {
const [showTurnUsage, setShowTurnUsage] = useState(
() => readLocalPreferences().showTurnUsage,
);
useEffect(() => {
const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage);
const refreshFromLocalPreferenceEvent = (event: Event) => {
const detail = (event as CustomEvent<Partial<LocalPreferences> | undefined>).detail;
setShowTurnUsage(
detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage,
);
};
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
};
}, []);
return showTurnUsage;
}
+17 -1
View File
@@ -196,6 +196,7 @@
"density": "Density",
"activityMode": "Activity detail",
"fileEditDisplay": "File edit display",
"showTurnUsage": "Show per-turn token usage",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"maxResults": "Max results",
@@ -241,6 +242,7 @@
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.",
@@ -1192,6 +1194,11 @@
"removeQuotedContext": "Remove quoted context",
"modelNotConfigured": "Model not configured",
"configureModel": "Configure model",
"switchModel": "Switch model for this chat",
"context": {
"tooltip": "Context · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% used."
},
"queued": {
"label": "Queued guidance",
"guide": "Guide",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "Show {{count}} more lines",
"fileEditShowFewerLines": "Show fewer lines",
"fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
"usage": {
"context": "{{tokens}} context",
"turnInput": "{{tokens}} turn input",
"request": "{{count}} request",
"requests": "{{count}} requests",
"output": "{{tokens}} out",
"cached": "{{percent}}% cached",
"estimated": "Includes estimated usage"
}
},
"lightbox": {
"title": "Image preview",
+16
View File
@@ -142,6 +142,7 @@
"density": "Densidad",
"activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos",
"timeout": "Tiempo de espera",
@@ -185,6 +186,7 @@
"density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
"showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -1179,6 +1181,11 @@
"removeQuotedContext": "Quitar contexto citado",
"modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo",
"switchModel": "Cambiar el modelo de este chat",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % usado."
},
"queued": {
"label": "Guía en cola",
"guide": "Guiar",
@@ -1395,6 +1402,15 @@
"fileEditShowFewerLines": "Mostrar menos líneas",
"fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
"usage": {
"context": "{{tokens}} de contexto",
"turnInput": "{{tokens}} de entrada en el turno",
"request": "{{count}} solicitud",
"requests": "{{count}} solicitudes",
"output": "{{tokens}} de salida",
"cached": "{{percent}}% en caché",
"estimated": "Incluye uso estimado"
},
"activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "Densité",
"activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers",
"showTurnUsage": "Afficher lutilisation des tokens par tour",
"codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.",
"timeout": "Délai dattente",
@@ -185,6 +186,7 @@
"density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.",
"fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou les différences.",
"showTurnUsage": "Affiche le contexte, lentrée cumulée, la sortie, le taux de cache et le nombre dappels au modèle sous les messages de lassistant. Enregistré uniquement dans ce navigateur.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "Supprimer le contexte cité",
"modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle",
"switchModel": "Changer le modèle de cette conversation",
"context": {
"tooltip": "Contexte · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % utilisé."
},
"queued": {
"label": "Guidage en attente",
"guide": "Guider",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Afficher moins de lignes",
"fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
"usage": {
"context": "{{tokens}} de contexte",
"turnInput": "{{tokens}} en entrée sur ce tour",
"request": "{{count}} requête",
"requests": "{{count}} requêtes",
"output": "{{tokens}} en sortie",
"cached": "{{percent}} % en cache",
"estimated": "Inclut une estimation de lutilisation"
},
"activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "Kerapatan",
"activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan perubahan file",
"showTurnUsage": "Tampilkan penggunaan token per giliran",
"codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum",
"timeout": "Batas waktu",
@@ -185,6 +186,7 @@
"density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
"showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "Hapus konteks kutipan",
"modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model",
"switchModel": "Ganti model untuk percakapan ini",
"context": {
"tooltip": "Konteks · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% digunakan."
},
"queued": {
"label": "Panduan antrean",
"guide": "Pandu",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
"fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
"usage": {
"context": "Konteks {{tokens}}",
"turnInput": "Input giliran {{tokens}}",
"request": "{{count}} permintaan",
"requests": "{{count}} permintaan",
"output": "Output {{tokens}}",
"cached": "{{percent}}% di-cache",
"estimated": "Termasuk penggunaan perkiraan"
},
"activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "表示密度",
"activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示",
"showTurnUsage": "ターンごとのトークン使用量を表示",
"codeWrap": "コードの折り返し",
"maxResults": "最大結果数",
"timeout": "タイムアウト",
@@ -185,6 +186,7 @@
"density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "引用したコンテキストを削除",
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える",
"context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中"
},
"queued": {
"label": "保留中のガイド",
"guide": "ガイド",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "表示行数を減らす",
"fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": {
"context": "コンテキスト {{tokens}}",
"turnInput": "ターン入力 {{tokens}}",
"request": "リクエスト {{count}} 回",
"requests": "リクエスト {{count}} 回",
"output": "出力 {{tokens}}",
"cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます"
},
"activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました",
+16
View File
@@ -142,6 +142,7 @@
"density": "밀도",
"activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시",
"showTurnUsage": "턴별 토큰 사용량 표시",
"codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수",
"timeout": "타임아웃",
@@ -185,6 +186,7 @@
"density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "인용한 문맥 제거",
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환",
"context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중."
},
"queued": {
"label": "대기 중인 안내",
"guide": "안내",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "줄 줄이기",
"fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": {
"context": "컨텍스트 {{tokens}}",
"turnInput": "턴 입력 {{tokens}}",
"request": "요청 {{count}}회",
"requests": "요청 {{count}}회",
"output": "출력 {{tokens}}",
"cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함"
},
"activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함",
+17 -1
View File
@@ -196,6 +196,7 @@
"density": "Densidade",
"activityMode": "Detalhe de atividade",
"fileEditDisplay": "Exibição de edição de arquivo",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Quebra de linha no código",
"brandLogos": "Logos de marca",
"maxResults": "Máx. de resultados",
@@ -241,6 +242,7 @@
"density": "Armazenado apenas neste navegador.",
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
"showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.",
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
"maxResults": "Resultados retornados por cada chamada de web_search.",
@@ -1192,6 +1194,11 @@
"removeQuotedContext": "Remover contexto citado",
"modelNotConfigured": "Modelo não configurado",
"configureModel": "Configurar modelo",
"switchModel": "Alternar o modelo desta conversa",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% usado."
},
"queued": {
"label": "Guia em fila",
"guide": "Guiar",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
"fileEditShowFewerLines": "Mostrar menos linhas",
"fileEditOpenFile": "Abrir arquivo",
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa."
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
"usage": {
"context": "{{tokens}} de contexto",
"turnInput": "{{tokens}} de entrada na rodada",
"request": "{{count}} chamada",
"requests": "{{count}} chamadas",
"output": "{{tokens}} de saída",
"cached": "{{percent}}% em cache",
"estimated": "Inclui uso estimado"
}
},
"lightbox": {
"title": "Pré-visualização de imagem",
+16
View File
@@ -142,6 +142,7 @@
"density": "Mật độ",
"activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp",
"showTurnUsage": "Hiển thị mức dùng token theo lượt",
"codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ",
@@ -185,6 +186,7 @@
"density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
"showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
"modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "Cấu hình mô hình",
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
"context": {
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
},
"queued": {
"label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
"fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"usage": {
"context": "Ngữ cảnh {{tokens}}",
"turnInput": "Đầu vào lượt {{tokens}}",
"request": "{{count}} yêu cầu",
"requests": "{{count}} yêu cầu",
"output": "Đầu ra {{tokens}}",
"cached": "{{percent}}% đã lưu đệm",
"estimated": "Bao gồm mức sử dụng ước tính"
},
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+17 -1
View File
@@ -196,6 +196,7 @@
"density": "密度",
"activityMode": "活动详情",
"fileEditDisplay": "文件编辑显示",
"showTurnUsage": "显示每轮 Token 用量",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
@@ -241,6 +242,7 @@
"density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少智能体活动详情。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
"codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。",
@@ -1191,6 +1193,11 @@
"removeQuotedContext": "移除引用内容",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"switchModel": "切换本次对话所用模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": {
"label": "排队中的引导消息",
"guide": "引导",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": {
"context": "上下文 {{tokens}}",
"turnInput": "本轮输入 {{tokens}}",
"request": "{{count}} 次模型调用",
"requests": "{{count}} 次模型调用",
"output": "输出 {{tokens}}",
"cached": "缓存 {{percent}}%",
"estimated": "包含估算用量"
}
},
"lightbox": {
"title": "图片预览",
+16
View File
@@ -142,6 +142,7 @@
"density": "密度",
"activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示",
"showTurnUsage": "顯示每輪 Token 用量",
"codeWrap": "程式碼換行",
"maxResults": "最大結果數",
"timeout": "逾時",
@@ -185,6 +186,7 @@
"density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少智能體活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "移除引用內容",
"modelNotConfigured": "尚未設定模型",
"configureModel": "設定模型",
"switchModel": "切換此對話使用的模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": {
"label": "佇列中的引導訊息",
"guide": "引導",
@@ -1393,6 +1400,15 @@
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": {
"context": "上下文 {{tokens}}",
"turnInput": "本輪輸入 {{tokens}}",
"request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求",
"output": "輸出 {{tokens}}",
"cached": "快取 {{percent}}%",
"estimated": "包含估算用量"
},
"activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}",
+199 -184
View File
@@ -1,5 +1,8 @@
import type { UIMessage } from "@/lib/types";
/** A completed turn has two surfaces: one activity container and one final
* answer. An active turn temporarily preserves arrival order so visible
* Markdown never moves when a later tool starts. */
export type TurnUnit =
| {
type: "activity";
@@ -9,106 +12,103 @@ export type TurnUnit =
}
| { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false;
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
}
export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
return isReasoningOnlyAssistant(message) || message.kind === "trace" || message.activityKind === "model";
}
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
if (messages.length === 0) return false;
const last = messages[messages.length - 1];
if (!isAgentActivityMember(last)) return false;
const last = messages.at(-1);
if (!last || !isAgentActivityMember(last)) return false;
if (last.isStreaming || last.reasoningStreaming) return true;
let trailingStart = messages.length - 1;
while (
trailingStart > 0
&& isAgentActivityMember(messages[trailingStart - 1])
) {
trailingStart -= 1;
}
const trailing = messages.slice(trailingStart);
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
return true;
}
const previous = messages[trailingStart - 1];
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
return true;
}
const trailingTurnIds = new Set(
trailing
.map((message) => message.turnId)
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
);
if (!previous.turnId) return trailingTurnIds.size > 0;
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
const lastTurnId = last.turnId;
const previous = messages.at(-2);
// A trace without a visible answer is an unfinished turn on replay. Once a
// final assistant answer exists after it, the activity is simply history.
return !previous
|| previous.role !== "assistant"
|| isAgentActivityMember(previous)
|| previous.turnId !== lastTurnId;
}
/**
* Project completed or replayed gateway rows into the stable shape:
*
* user [one live/completed activity surface] [one final answer]
*
* Assistant text that is followed by another activity is an intermediate model
* segment. It remains in the activity timeline, where the renderer keeps its
* normal Markdown surface, but it never creates a second answer bubble. This
* is the same causal model used by Codex-style transcripts.
*/
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
let activeTurnStartedAtMs: number | undefined;
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
if (turnMessages.length === 0) {
const flushTurn = () => {
if (!turnMessages.length) {
activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
return;
}
const turnUnits: TurnUnit[] = [];
const turnStartedAtMs = activeTurnStartedAtMs;
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const ordered = orderMessagesByTurnSeq(turnMessages);
const lastActivityIndex = ordered.reduce(
(index, message, current) => isRawActivity(message) ? current : index,
-1,
);
const answerIndices = ordered
.map((message, index) => ({ message, index }))
.filter(({ message }) => isAssistantAnswer(message));
const finalAnswerIndex = answerIndices.at(-1)?.index;
// A replay can deliver a completed answer before a late trace row. Keep
// that answer visible, but place the late activity in the single activity
// surface before it. An answer followed by more activity is an
// intermediate model segment and stays in that surface in turn order.
const hasFinalAnswer = finalAnswerIndex !== undefined
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(
turnUnits,
activityMessages,
visibleMessages.slice(visibleIndex),
turnStartedAtMs,
);
activityMessages = [];
};
for (const message of orderedTurnMessages) {
if (isAgentActivityMember(message)) {
activityMessages.push(message);
continue;
const activity: UIMessage[] = [];
const answers: UIMessage[] = [];
ordered.forEach((message, index) => {
if (isRawActivity(message)) {
activity.push(message);
} else if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) {
activity.push(reasoningOnlyMessageFromAnswer(message));
}
if (hasFinalAnswer && index === finalAnswerIndex) {
answers.push(stripInlineReasoning(message));
} else {
activity.push(modelActivitySnippet(message));
}
} else {
activity.push(message);
}
});
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages();
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue;
}
flushActivityMessages();
turnUnits.push({ type: "message", message });
visibleIndex += 1;
if (activity.length) {
units.push({
type: "activity",
messages: activity,
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
startedAtMs: activeTurnStartedAtMs,
});
}
if (answers.length) {
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
}
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = [];
activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
@@ -122,131 +122,142 @@ export function normalizeActivityTimeline(
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
if (message.turnId) activeTurnId = message.turnId;
turnMessages.push(message);
}
flushTurn(options);
flushTurn();
return units;
}
/**
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
* reliable way to know whether an assistant text segment is the final answer
* or commentary before another tool call. Reclassifying it when that tool
* arrives makes an already-visible Markdown tree jump between containers.
*
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
* one audit surface plus the final answer. While the turn is active, text and
* contiguous activity phases stay in arrival order. A later tool therefore
* appends a Working surface after existing Markdown instead of reparenting it.
*/
export function projectActivityTimeline(
messages: UIMessage[],
liveTurnId?: string | null,
): TurnUnit[] {
if (liveTurnId === undefined) return normalizeActivityTimeline(messages);
const liveStart = findLiveTurnStart(messages, liveTurnId);
if (liveStart < 0) return normalizeActivityTimeline(messages);
const nextPrompt = messages.findIndex(
(message, index) => index > liveStart && message.role === "user",
);
const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt;
return [
...normalizeActivityTimeline(messages.slice(0, liveStart)),
...projectLiveTurn(messages.slice(liveStart, liveEnd)),
...normalizeActivityTimeline(messages.slice(liveEnd)),
];
}
function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "user") continue;
if (liveTurnId === null || message.turnId === liveTurnId) return index;
}
return -1;
}
function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
const units: TurnUnit[] = [];
const prompt = messages[0];
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
let activity: UIMessage[] = [];
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
const flushActivity = () => {
if (!activity.length) return;
units.push({
type: "activity",
messages: activity,
turnLatencyMs: activityTurnLatencyMs(activity, activity),
startedAtMs,
});
activity = [];
};
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
if (isRawActivity(message)) {
activity.push(message);
continue;
}
if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) {
activity.push(reasoningOnlyMessageFromAnswer(message));
}
flushActivity();
units.push({ type: "message", message: stripInlineReasoning(message) });
continue;
}
activity.push(message);
}
flushActivity();
return units;
}
function isRawActivity(message: UIMessage): boolean {
return isAgentActivityMember(message);
}
function isAssistantAnswer(message: UIMessage): boolean {
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => {
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
.map(({ message }) => message);
}
function normalizeCompletedTurnUnits(
turnUnits: TurnUnit[],
options: NormalizeActivityTimelineOptions,
): TurnUnit[] {
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
let trailingStart = turnUnits.length - 1;
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
trailingStart -= 1;
}
const previous = turnUnits[trailingStart - 1];
if (
!previous
|| previous.type !== "message"
|| previous.message.role !== "assistant"
) {
return turnUnits;
}
return [
...turnUnits.slice(0, trailingStart - 1),
...turnUnits.slice(trailingStart),
previous,
];
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = [];
for (const message of messages) {
if (isAgentActivityMember(message)) continue;
visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message);
}
return visibleMessages;
}
function validCreatedAtMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function pushActivityUnits(
units: TurnUnit[],
activityMessages: UIMessage[],
visibleMessages: UIMessage[],
startedAtMs?: number,
) {
let runMessages: UIMessage[] = [];
let runBucket: "file" | "other" | undefined;
let runSegmentId: string | undefined;
const flushRun = () => {
if (!runMessages.length) return;
units.push({
type: "activity",
messages: runMessages,
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
startedAtMs,
});
runMessages = [];
runBucket = undefined;
runSegmentId = undefined;
function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
const first = answers[0];
const last = answers.at(-1)!;
const media = answers.flatMap((message) => message.media ?? []);
const images = answers.flatMap((message) => message.images ?? []);
const merged: UIMessage = {
...first,
...last,
id: first.id,
content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
createdAt: first.createdAt,
isStreaming: answers.some((message) => message.isStreaming),
};
for (const message of activityMessages) {
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
const segmentId = message.activitySegmentId;
const segmentChanged =
bucket === "file"
&& runBucket === "file"
&& !!runSegmentId
&& !!segmentId
&& runSegmentId !== segmentId;
if ((runBucket && bucket !== runBucket) || segmentChanged) {
flushRun();
}
runBucket = bucket;
if (segmentId) runSegmentId = segmentId;
runMessages.push(message);
}
flushRun();
if (media.length) merged.media = media;
else delete merged.media;
if (images.length) merged.images = images;
else delete merged.images;
return merged;
}
function isFileEditActivityMessage(message: UIMessage): boolean {
return message.kind === "trace" && !!message.fileEdits?.length;
}
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length > 0
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
);
function modelActivitySnippet(message: UIMessage): UIMessage {
return {
...stripInlineReasoning(message),
id: `${message.id}-activity`,
activityKind: "model",
turnPhase: "activity",
// Keep the source stream state so the activity surface can render this
// segment with the same Markdown streaming semantics as a normal answer.
isStreaming: message.isStreaming,
};
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
@@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
return next;
}
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
const latency = visibleMessages[i].latencyMs;
function validCreatedAtMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function activityTurnLatencyMs(activityMessages: UIMessage[], allMessages: UIMessage[]): number | undefined {
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
const latency = allMessages[index].latencyMs;
if (isValidLatency(latency)) return latency;
}
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
const latency = activityMessages[i].latencyMs;
for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
const latency = activityMessages[index].latencyMs;
if (isValidLatency(latency)) return latency;
}
return undefined;
+11
View File
@@ -1,5 +1,16 @@
import i18n, { currentLocale } from "@/i18n";
/** Compact token counts for dense runtime metadata (for example, 74.9K). */
export function formatCompactTokenCount(value: number): string {
if (value < 1_000) return Math.round(value).toLocaleString();
if (value < 1_000_000) {
const digits = value < 100_000 ? 1 : 0;
return `${Number((value / 1_000).toFixed(digits))}K`;
}
const digits = value < 100_000_000 ? 1 : 0;
return `${Number((value / 1_000_000).toFixed(digits))}M`;
}
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
"hi",
"hello",
+3
View File
@@ -8,6 +8,7 @@ export interface LocalPreferences {
codeWrap: boolean;
brandLogos: boolean;
fileEditDisplayMode: FileEditDisplayMode;
showTurnUsage: boolean;
}
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
@@ -19,6 +20,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
codeWrap: true,
brandLogos: false,
fileEditDisplayMode: "summary",
showTurnUsage: false,
};
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
@@ -36,6 +38,7 @@ export function readLocalPreferences(): LocalPreferences {
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
showTurnUsage: parsed.showTurnUsage === true,
};
} catch {
return DEFAULT_LOCAL_PREFS;
+1 -1
View File
@@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
export function stampLastAssistantCompletion(
prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
+26 -1
View File
@@ -39,6 +39,16 @@ export interface UIMediaAttachment {
export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; }
export interface TurnUsage {
prompt_tokens?: number;
completion_tokens?: number;
cached_tokens?: number;
context_tokens?: number;
request_count?: number;
estimated_tokens?: number;
[key: string]: number | undefined;
}
export interface UIMessage {
id: string;
role: Role;
@@ -56,6 +66,9 @@ export interface UIMessage {
fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */
activitySegmentId?: string;
/** Internal projection marker for assistant text emitted before a later tool.
* It is not a wire message and is rendered as a compact activity row. */
activityKind?: "model";
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
@@ -77,6 +90,10 @@ export interface UIMessage {
latencyMs?: number;
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
completedAt?: number;
/** Additive model usage for this turn; context_tokens is the final request only. */
usage?: TurnUsage;
/** Configured context-window capacity for the model used by this turn. */
contextWindowTokens?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Structured provenance for a message delivered by another session. */
@@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata {
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string; temporary?: boolean }
| {
event: "attached";
chat_id: string;
temporary?: boolean;
usage?: TurnUsage;
}
| {
event: "message_accepted";
chat_id: string;
@@ -1313,11 +1335,14 @@ export type InboundEvent =
chat_id: string;
model_name: string;
model_preset?: string | null;
fallback?: boolean;
}
| ({
event: "turn_end";
chat_id: string;
latency_ms?: number;
usage?: TurnUsage;
context_window_tokens?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload;
} & InboundTurnMetadata)
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@@ -139,6 +140,34 @@ function installReducedMotion() {
}
describe("AgentActivityCluster", () => {
it("keeps intermediate assistant output as normal Markdown inside live activity", async () => {
await act(async () => {
await preloadMarkdownText();
});
render(
<AgentActivityCluster
messages={[
{
id: "model-activity",
role: "assistant",
content: "**partial answer**",
activityKind: "model",
isStreaming: true,
createdAt: 1,
},
]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const block = screen.getByTestId("activity-model-message");
await waitFor(() => expect(block.querySelector("strong")).not.toBeNull());
expect(block.querySelector("strong")).toHaveTextContent("partial answer");
expect(screen.queryByTestId("activity-step")).not.toBeInTheDocument();
});
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
vi.advanceTimersByTime(301);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
"aria-expanded",
"false",
);
@@ -422,7 +451,7 @@ describe("AgentActivityCluster", () => {
/>,
);
const button = screen.getByRole("button", { name: "Thought" });
const button = screen.getByRole("button", { name: "Worked" });
expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument();
@@ -449,7 +478,7 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
expect(screen.getByText("Worked for 12s")).toBeInTheDocument();
});
it("labels mixed tool activity as work instead of thought", () => {
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Thought")).toBeInTheDocument();
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
expect(screen.getByText("Worked")).toBeInTheDocument();
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
});
it("renders file edits as one-line activity rows", async () => {
+9
View File
@@ -160,6 +160,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.activityMode",
"settings.rows.fileEditDisplay",
"settings.rows.codeWrap",
"settings.rows.showTurnUsage",
"settings.rows.brandLogos",
"settings.rows.currentModel",
"settings.rows.localServiceAccess",
@@ -171,6 +172,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.activityMode",
"settings.help.fileEditDisplay",
"settings.help.codeWrap",
"settings.help.showTurnUsage",
"settings.help.brandLogos",
"settings.help.currentModel",
"settings.help.localServiceAccess",
@@ -291,6 +293,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.openLink",
"message.openAttachment",
"message.skill",
"message.usage.context",
"message.usage.turnInput",
"message.usage.request",
"message.usage.requests",
"message.usage.output",
"message.usage.cached",
"message.usage.estimated",
"settings.channels.connectionChecks",
"settings.channels.open",
];
+130 -1
View File
@@ -1,8 +1,14 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
DEFAULT_LOCAL_PREFS,
LOCAL_PREFS_STORAGE_KEY,
writeLocalPreferences,
} from "@/lib/local-preferences";
import type {
CliAppInfo,
McpPresetInfo,
@@ -67,6 +73,11 @@ const MCP_PRESETS: McpPresetInfo[] = [
},
];
function PreferenceAwareMessageBubble({ message }: { message: UIMessage }) {
const showTurnUsage = useShowTurnUsage();
return <MessageBubble message={message} showTurnUsage={showTurnUsage} />;
}
const SLASH_COMMANDS: SlashCommand[] = [
{
command: "/model",
@@ -95,6 +106,10 @@ const SLASH_COMMANDS: SlashCommand[] = [
];
describe("MessageBubble", () => {
afterEach(() => {
localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY);
});
it("renders user messages as right-aligned pills", () => {
const message: UIMessage = {
id: "u1",
@@ -245,6 +260,7 @@ describe("MessageBubble", () => {
const quote = screen.getByLabelText("Quoted context");
expect(quote).toHaveTextContent("selected assistant excerpt");
expect(quote).not.toHaveAttribute("title");
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
expect(screen.getByText("What about this?")).toBeInTheDocument();
@@ -1011,4 +1027,117 @@ describe("MessageBubble", () => {
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
});
it("hides turn usage by default and leaves latency visible when the preference changes", async () => {
const message: UIMessage = {
id: "turn-usage-preference",
role: "assistant",
content: "done",
createdAt: Date.now(),
latencyMs: 18_200,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
context_tokens: 8_100,
request_count: 1,
},
};
const { container } = render(<PreferenceAwareMessageBubble message={message} />);
expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
act(() => {
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: true });
});
expect(await screen.findByText("8.1K context")).toHaveAttribute("data-turn-usage");
act(() => {
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: false });
});
await waitFor(() => {
expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
});
expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
});
it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
const turns: UIMessage[] = [
{
id: "turn-a",
role: "assistant",
content: "first reply",
createdAt: Date.now(),
usage: {
prompt_tokens: 111_000,
completion_tokens: 333,
cached_tokens: 55_500,
context_tokens: 56_000,
request_count: 2,
},
},
{
id: "turn-b",
role: "assistant",
content: "second reply",
createdAt: Date.now(),
usage: {
prompt_tokens: 57_000,
completion_tokens: 192,
cached_tokens: 55_290,
context_tokens: 57_000,
request_count: 1,
},
},
];
render(<>{turns.map((message) => (
<MessageBubble key={message.id} message={message} showTurnUsage />
))}</>);
const turnAUsage = screen.getByText("56K context");
const turnBUsage = screen.getByText("57K context");
expect(turnAUsage).toHaveAttribute("data-turn-usage");
expect(turnBUsage).toHaveAttribute("data-turn-usage");
expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument();
fireEvent.focus(turnAUsage);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"111K turn input · 2 requests · 333 out · 50% cached",
);
fireEvent.blur(turnAUsage);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
fireEvent.focus(turnBUsage);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"57K turn input · 1 request · 192 out · 97% cached",
);
});
it("falls back to clearly labeled estimated turn input when context is unavailable", () => {
const message: UIMessage = {
id: "a-estimated-usage",
role: "assistant",
content: "done",
createdAt: Date.now(),
usage: {
prompt_tokens: 1_250,
completion_tokens: 90,
request_count: 1,
estimated_tokens: 1_340,
},
};
render(<MessageBubble message={message} showTurnUsage />);
const usage = screen.getByText("~1.3K turn input");
expect(usage).not.toHaveTextContent("context");
fireEvent.focus(usage);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"1 request · ~90 out · Includes estimated usage",
);
expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached");
});
});
+2
View File
@@ -1724,6 +1724,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research",
fallback: true,
});
expect(chatHandler).toHaveBeenCalledWith({
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research",
fallback: true,
});
});
+40 -1
View File
@@ -1,5 +1,6 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { LOCAL_PREFS_STORAGE_KEY } from "@/lib/local-preferences";
import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
@@ -10,6 +11,44 @@ describe("Settings overview and appearance", () => {
installSettingsViewTestHooks();
it("defaults per-turn token usage off and persists changes across remounts", async () => {
const renderAppearance = () => renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
renderAppearance();
const title = "Show per-turn token usage";
const description = (
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. "
+ "Stored only in this browser."
);
expect(screen.getByText(description)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
fireEvent.click(screen.getByRole("switch", { name: title }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
expect(saved.showTurnUsage).toBe(true);
});
cleanup();
renderAppearance();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "true");
fireEvent.click(screen.getByRole("switch", { name: title }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
expect(saved.showTurnUsage).toBe(false);
});
cleanup();
renderAppearance();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
});
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
+77 -150
View File
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
}
const MODEL_PRESETS = [
{ name: "kimi", provider: "moonshot" },
{ name: "dflash", provider: "deepseek" },
{ name: "dspro", provider: "deepseek" },
{ name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
{ name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
];
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>,
);
return {
badge: screen.getByRole("spinbutton", { name: "kimi" }),
badge: screen.getByRole("button", { name: "kimi" }),
onPresetChange,
};
}
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
fireEvent.pointerDown(badge, {
button,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
});
}
function longPress(badge: HTMLElement, pointerId = 7) {
pointerDown(badge, pointerId);
act(() => {
vi.advanceTimersByTime(400);
});
}
describe("ThreadComposer", () => {
it("locks an async send and keeps the draft when it is rejected", async () => {
let resolveSend!: (accepted: boolean) => void;
@@ -538,12 +521,42 @@ describe("ThreadComposer", () => {
/>,
);
const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" });
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
});
it("shows a compact context meter beside the model selector", async () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-5.6-sol"
modelPreset="gpt-5-6-sol"
modelProvider="openai_codex"
contextUsage={{
contextTokens: 74_900,
contextWindowTokens: 1_000_000,
}}
placeholder="Ask anything..."
/>,
);
const context = screen.getByTestId("composer-context-usage");
expect(context).toHaveClass("size-5", "rounded-full");
expect(context).not.toHaveTextContent("Context 74.9K / 1M");
expect(screen.getByTestId("composer-context-meter")).toBeInTheDocument();
expect(context).toHaveAccessibleName(
"Context · 74.9K / 1M. 7% used.",
);
fireEvent.focus(context);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Context · 74.9K / 1M");
expect(tooltip.parentElement).toHaveClass("rounded-full", "px-2.5", "py-1");
expect(tooltip.parentElement).not.toHaveTextContent("Available");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
expect(modelPill).not.toHaveClass("font-semibold");
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
const providerLogo = screen.getByTestId("composer-model-logo-openai");
expect(providerLogo).toBeInTheDocument();
expect(providerLogo).not.toHaveClass("border", "bg-background");
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]");
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("shows model details in the shared tooltip without a native title", async () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
modelDetail="gpt-4o"
modelProvider="openai"
modelProviderLabel="OpenAI"
placeholder="Type your message..."
/>,
);
const badge = screen.getByLabelText("gpt-4o");
expect(badge).not.toHaveAttribute("title");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI");
});
it("smoothly cycles to the next preset on click", () => {
vi.useFakeTimers();
let runFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
runFrame = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const { badge, onPresetChange } = renderPresetComposer();
fireEvent.click(badge);
expect(badge).toHaveAttribute("data-switching", "true");
const track = screen.getByTestId("composer-model-pill-track");
expect(track).not.toHaveAttribute("data-settling");
expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" });
act(() => runFrame?.(16));
expect(onPresetChange).toHaveBeenCalledWith("dflash");
expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true");
expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" });
act(() => vi.advanceTimersByTime(260));
expect(badge).not.toHaveAttribute("data-switching");
});
it("scrolls complete preset pills after a left-button long press and wraps", () => {
vi.useFakeTimers();
it("opens a model picker and switches presets with one click", async () => {
const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" });
const idleTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(idleTouchMove);
expect(idleTouchMove.defaultPrevented).toBe(false);
pointerDown(badge);
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
act(() => vi.advanceTimersByTime(500));
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
expect(onPresetChange).not.toHaveBeenCalled();
longPress(badge);
expect(badge).toHaveAttribute("data-switching", "true");
const viewport = screen.getByTestId("composer-model-pill-viewport");
expect(viewport).toHaveClass(
"right-0",
"w-max",
"max-w-[calc(44vw+0.5rem)]",
"overflow-hidden",
"-top-3",
"-bottom-3",
);
const track = screen.getByTestId("composer-model-pill-track");
expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1");
const activeTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(activeTouchMove);
expect(activeTouchMove.defaultPrevented).toBe(true);
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
expect(pills).toHaveLength(5);
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
expect(centeredPill).toHaveTextContent("kimi");
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
expect(
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
).toHaveStyle({ transform: "scale(1.0200)" });
fireEvent.pointerMove(badge, {
clientY: 122,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("kimi");
fireEvent.pointerMove(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("dspro");
fireEvent.pointerUp(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(onPresetChange).toHaveBeenCalledWith("dspro");
expect(badge).toHaveClass("w-fit");
fireEvent.click(badge);
expect(onPresetChange).toHaveBeenCalledTimes(1);
expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true");
act(() => {
vi.advanceTimersByTime(260);
});
expect(badge).not.toHaveAttribute("data-switching");
expect(badge).not.toHaveAttribute("data-settling");
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
expect(badge).toHaveClass("w-fit");
expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
expect(within(picker).getAllByRole("option")).toHaveLength(3);
expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
/dflash\s*deepseek-v4-flash/,
);
expect(within(picker).getByRole("option", { name: "kimi" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(document.activeElement).toBe(within(picker).getByRole("option", { name: "kimi" }));
fireEvent.click(within(picker).getByRole("option", { name: "dspro" }));
expect(onPresetChange).toHaveBeenCalledWith("dspro");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(badge).toHaveClass("w-fit");
});
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
it("keeps long-press drag switching alongside the click picker", () => {
vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer();
fireEvent.pointerDown(badge, { pointerId: 1, pointerType: "touch", clientY: 100 });
act(() => vi.advanceTimersByTime(400));
expect(screen.getByTestId("composer-model-pill-viewport")).toBeInTheDocument();
expect(screen.getByTestId("composer-model-pill-layout")).toHaveClass("invisible");
expect(screen.getByTestId("composer-model-pill-track")).not.toHaveClass("transition-transform");
fireEvent.pointerMove(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
fireEvent.pointerUp(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
expect(onPresetChange).toHaveBeenCalledWith("dflash");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
vi.useRealTimers();
});
it("uses the same click picker in hero mode", () => {
const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8");
longPress(badge, 9);
expect(badge).toHaveAttribute("data-switching", "true");
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
fireEvent.click(badge);
fireEvent.click(screen.getByRole("option", { name: "dflash" }));
expect(onPresetChange).toHaveBeenCalledWith("dflash");
});
it("transcribes voice input into the composer without sending", async () => {
+102 -43
View File
@@ -38,7 +38,7 @@ describe("ThreadMessages", () => {
/>,
);
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Working for 5s" })).toBeInTheDocument();
rerender(
<ThreadMessages
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
});
it("keeps live Markdown mounted when a later tool activity arrives", async () => {
await act(async () => {
await preloadMarkdownText();
});
const turnId = "turn-live-order";
const prompt: UIMessage = {
id: "u-live",
role: "user",
content: "research this",
createdAt: 1,
turnId,
turnPhase: "prompt",
turnSeq: 0,
};
const commentary: UIMessage = {
id: "a-commentary",
role: "assistant",
content: "**I will check that.**",
createdAt: 2,
isStreaming: false,
turnId,
turnPhase: "answer",
turnSeq: 1,
};
const { rerender } = render(
<ThreadMessages
messages={[prompt, commentary]}
isStreaming
activeTurnId={turnId}
/>,
);
const paragraph = await screen.findByText("I will check that.");
expect(paragraph.closest("[data-testid='activity-model-message']")).toBeNull();
rerender(
<ThreadMessages
messages={[
prompt,
commentary,
{
id: "tool-live",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
createdAt: 3,
turnId,
turnPhase: "activity",
turnSeq: 2,
},
]}
isStreaming
activeTurnId={turnId}
/>,
);
expect(screen.getByText("I will check that.")).toBe(paragraph);
expect(screen.getByText(/working/i)).toBeInTheDocument();
});
it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn();
render(
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
expect(unitKeysForDisplay(liveUnits)).toEqual([
"turn-turn-1-user",
"turn-turn-1-activity-1",
"turn-turn-1-answer-1",
"turn-turn-1-answer-2",
]);
});
it("keeps file edits as their own activity row inside a turn", () => {
it("keeps file edits inside the single activity surface for a turn", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
expect(units).toHaveLength(1);
expect(units[0].type).toBe("activity");
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"r2",
]);
});
it("keeps ordinary tool activity in one Thought block across segment ids", () => {
it("keeps ordinary tool activity in one activity block across segment ids", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
});
it("times live activity from the user turn start", () => {
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "partial answer",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units).toHaveLength(1);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"a1-activity",
"t1",
]);
render(<ThreadMessages messages={messages} isStreaming />);
const answer = screen.getByText("partial answer");
const liveActivity = screen.getByRole("button", { name: /working/i });
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("moves late activity before a completed assistant answer", () => {
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today.");
const laterActivity = screen.getAllByText(/thought/i).at(-1);
const laterActivity = screen.getByRole("button", { name: /worked/i });
expect(laterActivity).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getAllByText(/thought/i).at(-1);
const thought = screen.getByRole("button", { name: /worked/i });
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(4);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web",
]);
expect(units[2]).toMatchObject({
expect(units[1]).toMatchObject({
type: "message",
message: { id: "answer" },
});
expect(units[3]).toMatchObject({
expect(units[2]).toMatchObject({
type: "message",
message: { id: "next-user" },
});
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
});
it("shows copy on every assistant slice while keeping fork on the last slice", () => {
it("projects assistant slices into one answer with one action set", () => {
const messages: UIMessage[] = [
{
id: "early",
@@ -1050,8 +1107,9 @@ describe("ThreadMessages", () => {
/>,
);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("starting…")).toBeInTheDocument();
expect(screen.getByText("final reply")).toBeInTheDocument();
});
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
});
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
.toHaveLength(1);
});
it("shows copy on adjacent assistant text slices", () => {
it("projects adjacent assistant text slices into one answer", () => {
const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
expect(screen.getByText("part one")).toBeInTheDocument();
expect(screen.getByText("part two")).toBeInTheDocument();
});
it("does not count failed optimistic messages in assistant fork indices", () => {
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
.filter(Boolean);
expect(assistantFlags).toEqual([
["a1", false],
["a2", true],
["a3", true],
]);
+49 -36
View File
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
),
);
const badge = await screen.findByLabelText("fast");
expect(badge).not.toHaveAttribute("title");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-5.5 · OpenAI Codex",
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
});
it("falls back to the current preset while a renamed session reference is stale", async () => {
const client = makeClient();
const settings = settingsWithFastPreset();
settings.agent.model_preset = "fast";
settings.model_presets = settings.model_presets.map((preset) => ({
...preset,
active: preset.name === "fast",
}));
render(
wrap(
client,
<ThreadShell
session={session("renamed-preset", "old-fast")}
title="Renamed preset"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
/>,
"openai-codex/gpt-5.5",
),
);
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("switches through every named preset while preserving call-order priority", async () => {
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
));
const { rerender } = render(view("default"));
const badge = await screen.findByRole("spinbutton", { name: "Default" });
const badge = await screen.findByRole("button", { name: "Default" });
expect(badge).toHaveTextContent("Default");
fireEvent.keyDown(badge, { key: "ArrowDown" });
fireEvent.click(badge);
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order",
"/model fast",
);
expect(await screen.findByText("fast")).toBeInTheDocument();
fireEvent.keyDown(
screen.getByRole("spinbutton", { name: "fast" }),
{ key: "End" },
);
fireEvent.click(screen.getByRole("button", { name: "fast" }));
fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order",
"/model extra",
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
),
);
const badge = await screen.findByLabelText("fast");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-4 · Company Proxy",
);
expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("only highlights fallback model updates without replacing the preset label", async () => {
it("shows the effective fallback model in the composer badge", async () => {
const client = makeClient();
render(wrap(
client,
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
));
expect(await screen.findByText("Default")).toBeInTheDocument();
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
const configuredLogo = await screen.findByTestId("composer-model-logo-openai_codex");
const configuredBadge = configuredLogo.parentElement;
expect(configuredBadge).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback");
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "openai-codex/gpt-5.5",
model_preset: "Default",
});
});
expect(configuredBadge).not.toHaveAttribute("data-fallback");
expect(screen.getByText("Default")).toBeInTheDocument();
act(() => {
client._emitChat("fallback-model", {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat",
fallback: true,
});
});
const logo = screen.getByTestId("composer-model-logo-openai_codex");
const logo = await screen.findByTestId("composer-model-logo-deepseek");
const badge = logo.parentElement;
expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge);
expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).not.toHaveAttribute("title");
expect(logo).not.toHaveAttribute("data-fallback");
const trigger = screen.getByLabelText("Default");
fireEvent.focus(trigger);
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat");
expect(badge).toHaveAttribute(
"title",
"Default · using deepseek/deepseek-chat",
);
expect(logo).toBeInTheDocument();
act(() => {
client._emitChat("fallback-model", {
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback");
});
expect(screen.getByRole("tooltip")).toHaveTextContent(
"Default · gpt-5.5 · OpenAI Codex",
);
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
expect(screen.getByText("Default")).toBeInTheDocument();
});
it("opens model settings from the unconfigured model badge", async () => {
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
));
const { rerender } = render(view(null));
fireEvent.keyDown(
await screen.findByRole("spinbutton", { name: "Default" }),
{ key: "ArrowDown" },
);
fireEvent.click(await screen.findByRole("button", { name: "Default" }));
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
expect(await screen.findByText("fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled();
+1 -1
View File
@@ -234,7 +234,7 @@ describe("ThreadViewport", () => {
/>,
);
const disclosure = screen.getByRole("button", { name: "Thought" });
const disclosure = screen.getByRole("button", { name: "Worked" });
fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1);
+60 -2
View File
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
});
it("stamps provider usage and latest context on the completed answer", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-usage", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-usage", {
event: "delta",
chat_id: "chat-usage",
text: "done",
turn_id: "turn-usage",
});
fake.emit("chat-usage", {
event: "turn_end",
chat_id: "chat-usage",
turn_id: "turn-usage",
latency_ms: 18_200,
context_window_tokens: 128_000,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100,
request_count: 3,
},
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
content: "done",
isStreaming: false,
latencyMs: 18_200,
contextWindowTokens: 128_000,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100,
request_count: 3,
},
});
});
it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
@@ -2488,7 +2533,7 @@ describe("useNanobotStream", () => {
]);
});
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
it("keeps the turn active after stream_end while side-channel replies arrive", () => {
vi.useFakeTimers();
try {
const fake = fakeClient();
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
vi.advanceTimersByTime(1000);
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: true,
});
act(() => {
fake.emit("chat-status-loop", {
event: "turn_end",
chat_id: "chat-status-loop",
turn_id: promptTurnId,
});
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: false,
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[1]).toMatchObject({
content: "Initial findings",
isStreaming: false,
isStreaming: true,
});
act(() => {