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. - 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. - 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"> <!-- contributors:start -->
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" /> <p>
</a> <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"> <p align="center">
<em> Thanks for visiting ✨ nanobot!</em><br><br> <em> Thanks for visiting ✨ nanobot!</em><br><br>
+13 -6
View File
@@ -425,7 +425,7 @@ class AgentRunner:
) -> AgentRunResult: ) -> AgentRunResult:
final_content: str | None = None final_content: str | None = None
tools_used: list[str] = [] 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 error: str | None = None
stop_reason = "completed" stop_reason = "completed"
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
@@ -1384,11 +1384,6 @@ class AgentRunner:
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0) 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 @staticmethod
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]: def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
merged = dict(left) merged = dict(left)
@@ -1396,6 +1391,18 @@ class AgentRunner:
merged[key] = merged.get(key, 0) + value merged[key] = merged.get(key, 0) + value
return merged 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( async def _execute_tools(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
+1
View File
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
model: str model: str
model_preset: str | None = None model_preset: str | None = None
context_window_tokens: int | None = None context_window_tokens: int | None = None
fallback: bool = False
def outbound_message_for_event( 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._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
# -- Subscription bookkeeping ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
for key in tuple(self._stream_text_buffers): for key in tuple(self._stream_text_buffers):
if key[0] == chat_id: if key[0] == chat_id:
self._stream_text_buffers.pop(key, None) 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( async def _discard_connection_owned_chat(
self, self,
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
include_source=include_source, include_source=include_source,
transcript_overrides=transcript_overrides, transcript_overrides=transcript_overrides,
) )
if ( return self._retain_turn_on_transcript_failure(
not persisted chat_id,
and phase in {"answer", "complete"} persisted=persisted,
and (metadata or {}).get("webui") is True 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) owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
mark_websocket_turn_transcript_persistence_failed( mark_websocket_turn_transcript_persistence_failed(
chat_id, chat_id,
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
) )
return persisted 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: async def send(self, msg: OutboundMessage) -> None:
event = outbound_event_from_message(msg) event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None progress_event = event if isinstance(event, ProgressEvent) else None
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
model_name=event.model, model_name=event.model,
model_preset=event.model_preset, model_preset=event.model_preset,
context_window_tokens=event.context_window_tokens, context_window_tokens=event.context_window_tokens,
fallback=event.fallback,
) )
return return
if isinstance(event, UserInputEvent): if isinstance(event, UserInputEvent):
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
} }
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id 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, chat_id,
body, body,
completed_text=None,
metadata=meta, metadata=meta,
phase="reasoning", phase="reasoning",
) )
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
} }
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id 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, chat_id,
body, body,
completed_text=reasoning_text or None,
metadata=meta, metadata=meta,
phase="reasoning", phase="reasoning",
) )
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
meta = metadata or {} meta = metadata or {}
stream_key = (chat_id, str(stream_id or "")) stream_key = (chat_id, str(stream_id or ""))
completed_text: str | None = None
if stream_end: if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = ( buffered = (
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
buffered.append(delta) buffered.append(delta)
full_text = "".join(buffered) full_text = "".join(buffered)
rewritten = self._media.rewrite_local_markdown_images(full_text) rewritten = self._media.rewrite_local_markdown_images(full_text)
completed_text = rewritten
if delta or rewritten != full_text: if delta or rewritten != full_text:
body["text"] = rewritten body["text"] = rewritten
else: else:
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
body["resuming"] = True body["resuming"] = True
if stream_end and merge_next: if stream_end and merge_next:
body["merge_next"] = True body["merge_next"] = True
self._persist_turn_transcript_event( self._persist_turn_stream_event(
chat_id, chat_id,
body, body,
completed_text=completed_text,
metadata=meta, metadata=meta,
phase="answer", phase="answer",
include_source=True, include_source=True,
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
# carries a durable incomplete marker. The HTTP replay path can # carries a durable incomplete marker. The HTTP replay path can
# recover the latter from session history after a gateway restart. # recover the latter from session history after a gateway restart.
clear_websocket_turn_if_current(chat_id, turn_owner) clear_websocket_turn_if_current(chat_id, turn_owner)
self._clear_stream_buffers(chat_id)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns: if not conns:
return return
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
model_name: Any, model_name: Any,
model_preset: Any = None, model_preset: Any = None,
context_window_tokens: Any = None, context_window_tokens: Any = None,
fallback: bool = False,
) -> None: ) -> None:
"""Notify one chat's subscribers which model is handling its current request.""" """Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
body["model_preset"] = model_preset.strip() body["model_preset"] = model_preset.strip()
if isinstance(context_window_tokens, int) and context_window_tokens > 0: if isinstance(context_window_tokens, int) and context_window_tokens > 0:
body["context_window_tokens"] = context_window_tokens body["context_window_tokens"] = context_window_tokens
if fallback:
body["fallback"] = True
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ") 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", "model_preset": "Deep Research",
"context_window_tokens": 128_000, "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() 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["event"] == "stream_end"
assert second["source"] == source assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream") lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source assert lines[-1]["source"] == source
assert lines[-1]["event"] == "stream_end"
assert lines[-1]["text"] == "done"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None: 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() bus = MagicMock()
channel = WebSocketChannel( channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True}, {"enabled": True, "allowFrom": ["*"], "streaming": True},
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
"second", "second",
] ]
assert ("chat-1", "sid") not in channel._stream_text_buffers 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 @pytest.mark.asyncio
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert channel._subs == {} assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1") 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") body = build_webui_thread_response("websocket:chat-1")
assert body is not None assert body is not None
assert body["messages"][-1]["role"] == "assistant" 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 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 @pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None: async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock() bus = MagicMock()
+5 -2
View File
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
) )
continue continue
await self._notify_fallback_model(fallback_model)
fallback_kwargs = { fallback_kwargs = {
**kwargs, **kwargs,
"model": fallback_model, "model": fallback_model,
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
fallback_response = await call(fallback_provider, fallback_kwargs) fallback_response = await call(fallback_provider, fallback_kwargs)
if fallback_response.finish_reason != "error": 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( logger.info(
"Fallback '{}' succeeded after primary '{}' failed", "Fallback '{}' succeeded after primary '{}' failed",
fallback_model, primary_model, 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: def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
"""Return only text not already surfaced by refusal deltas.""" """Return only text not already surfaced by refusal deltas."""
if not streamed_text: if not streamed_text:
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
streamed_reasoning = False streamed_reasoning = False
reasoning_summary_key: tuple[str | None, int] | None = None
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
elif event_type == "response.reasoning_summary_text.delta": elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or "" delta_text = event.get("delta") or ""
if delta_text: 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 reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True streamed_reasoning = True
if on_reasoning_delta: if on_reasoning_delta:
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
text = summary.get("text") text = summary.get("text")
if isinstance(text, str): if isinstance(text, str):
parts.append(text) 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( 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 if context.runtime is not None
else None else None
), ),
fallback=True,
), ),
metadata=context.metadata, metadata=context.metadata,
) )
+79 -13
View File
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
record.update(transcript_overrides) record.update(transcript_overrides)
return self.append(chat_id, record) 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( def append_user_message(
self, self,
chat_id: str, chat_id: str,
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
kept.append(m) kept.append(m)
messages = kept 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): for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace": 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] = {
**messages[i], **messages[i],
"latencyMs": latency_ms, **completion,
"isStreaming": False,
} }
return return
@@ -2215,20 +2256,17 @@ def replay_transcript_to_ui_messages(
turn_fields = _turn_fields(rec, "answer") turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec) source_fields = _source_fields(rec)
if isinstance(final_text, str): 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: if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx) buffer_message_id = _new_id("buf", idx)
messages.append( messages.append({
{
"id": buffer_message_id, "id": buffer_message_id,
"role": "assistant", "role": "assistant",
"content": final_text, "content": "",
"isStreaming": True, "isStreaming": True,
**turn_fields,
**source_fields,
"createdAt": _created_at_ms(rec, idx), "createdAt": _created_at_ms(rec, idx),
}, })
)
else:
for i, m in enumerate(messages): for i, m in enumerate(messages):
if m.get("id") == buffer_message_id: if m.get("id") == buffer_message_id:
messages[i] = { messages[i] = {
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
if ev == "reasoning_end": if ev == "reasoning_end":
if suppress_until_turn_end: if suppress_until_turn_end:
continue 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) close_reasoning(messages)
continue continue
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
messages[i] = {**m, "isStreaming": False} messages[i] = {**m, "isStreaming": False}
prune_reasoning_only() prune_reasoning_only()
lat = rec.get("latency_ms") lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0: usage = rec.get("usage")
stamp_latency(int(lat)) 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_message_id = None
buffer_parts = [] buffer_parts = []
continue 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["prompt_tokens"] == 300 # 100 + 200
assert result.usage["completion_tokens"] == 30 # 10 + 20 assert result.usage["completion_tokens"] == 30 # 10 + 20
assert result.usage["cached_tokens"] == 230 # 80 + 150 assert result.usage["cached_tokens"] == 230 # 80 + 150
assert result.usage["context_tokens"] == 200
assert result.usage["request_count"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
+9 -5
View File
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
assert restored.payload == state.payload assert restored.payload == state.payload
@pytest.mark.asyncio @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()) 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] = [] fallback_models: list[str] = []
async def _observe(model: str) -> None: async def _observe(model: str) -> None:
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
fb = FallbackProvider( fb = FallbackProvider(
primary=primary, primary=primary,
fallback_presets=[_fallback("fallback-a", provider="backup")], fallback_presets=[
provider_factory=MagicMock(return_value=fallback), _fallback("fallback-a", provider="backup"),
_fallback("fallback-b", provider="backup"),
],
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
fallback_model_observer=_observe, fallback_model_observer=_observe,
) )
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
) )
assert result.content == "fallback ok" assert result.content == "fallback ok"
assert fallback_models == ["fallback-a"] assert fallback_models == ["fallback-b"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_logs_primary_error_before_fallback(self) -> None: async def test_logs_primary_error_before_fallback(self) -> None:
+2
View File
@@ -384,6 +384,8 @@ async def test_runner_calls_run_level_hooks_on_success():
"completion_tokens": 2, "completion_tokens": 2,
"total_tokens": 5, "total_tokens": 5,
"provider_tokens": 5, "provider_tokens": 5,
"request_count": 1,
"context_tokens": 3,
}, },
["user", "assistant"], ["user", "assistant"],
), ),
+22 -6
View File
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self): async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([ 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.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {"status": "completed"}}, {"type": "response.completed", "response": {"status": "completed"}},
]) ])
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
assert tool_calls == [] assert tool_calls == []
assert finish_reason == "stop" assert finish_reason == "stop"
assert usage == {} assert usage == {}
assert reasoning == "thinking briefly" assert reasoning == "thinking briefly\nChecking result"
assert deltas == ["thinking ", "briefly"] assert deltas == ["thinking ", "briefly", "\nChecking result"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reasoning_summary_from_completed_response(self): async def test_reasoning_summary_from_completed_response(self):
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
"status": "completed", "status": "completed",
"output": [ "output": [
{"type": "reasoning", "summary": [ {"type": "reasoning", "summary": [
{"type": "summary_text", "text": "cached "}, {"type": "summary_text", "text": "cached"},
{"type": "summary_text", "text": "summary"}, {"type": "summary_text", "text": "summary"},
]}, ]},
], ],
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
_, _, _, _, reasoning = await consume_sse_with_reasoning(response) _, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary" assert reasoning == "cached\nsummary"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_capture_commits_exact_items_only_after_completed_event(self): 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 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: def test_replay_uses_persisted_created_at_ms() -> None:
msgs = replay_transcript_to_ui_messages( msgs = replay_transcript_to_ui_messages(
[ [
+1 -17
View File
@@ -50,7 +50,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { relativeTime, visibleSessionPreview } from "@/lib/format";
import { import {
COLLAPSED_CHATS_VISIBLE_COUNT, COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle, 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 isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key); const isArchived = archived.has(s.key);
const preview = visibleSessionPreview(s.preview); const preview = visibleSessionPreview(s.preview);
@@ -959,7 +951,6 @@ export const ChatList = memo(function ChatList({
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
> >
<SidebarItemTooltip label={tooltipTitle}>
<button <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
@@ -1030,7 +1021,6 @@ export const ChatList = memo(function ChatList({
) : null} ) : null}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? ( {!deleteSelectionMode ? (
<DropdownMenu <DropdownMenu
@@ -1434,9 +1424,6 @@ function ActivePaneRows({
deleteSelectionMode && selected deleteSelectionMode && selected
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
>
<SidebarItemTooltip
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
> >
<button <button
type="button" type="button"
@@ -1475,7 +1462,6 @@ function ActivePaneRows({
<SidebarSelectionTrack active={active} handle={pane.handle} /> <SidebarSelectionTrack active={active} handle={pane.handle} />
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? <DropdownMenu {!deleteSelectionMode ? <DropdownMenu
modal={false} modal={false}
@@ -1652,7 +1638,6 @@ function TemporaryChatSection({
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]", : "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" type="button"
onClick={() => onSelect(session.key)} onClick={() => onSelect(session.key)}
@@ -1667,7 +1652,6 @@ function TemporaryChatSection({
{title} {title}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} /> <SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? ( {onClose ? (
<button <button
+136 -3
View File
@@ -34,7 +34,11 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard"; import { copyTextToClipboard } from "@/lib/clipboard";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; import {
fmtDateTime,
formatCompactTokenCount,
formatMessageEndTime,
} from "@/lib/format";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command"; import { matchingSlashCommand } from "@/lib/slash-command";
import { sessionHandleColor } from "@/lib/session-handle"; import { sessionHandleColor } from "@/lib/session-handle";
@@ -50,6 +54,7 @@ import type {
UIMessage, UIMessage,
MessageDeliveryErrorKind, MessageDeliveryErrorKind,
MessageDeliveryStatus, MessageDeliveryStatus,
TurnUsage,
} from "@/lib/types"; } from "@/lib/types";
interface MessageBubbleProps { interface MessageBubbleProps {
@@ -60,6 +65,8 @@ interface MessageBubbleProps {
temporary?: boolean; temporary?: boolean;
/** When false, hide this message's copy button. Default true. */ /** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean; showCopyAction?: boolean;
/** Show opt-in per-turn token usage for completed assistant messages. */
showTurnUsage?: boolean;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; 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( function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined, kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string, t: (key: string) => string,
@@ -324,6 +441,7 @@ export function MessageBubble({
isTurnStreaming = false, isTurnStreaming = false,
temporary = false, temporary = false,
showCopyAction = true, showCopyAction = true,
showTurnUsage = false,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
@@ -479,7 +597,21 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : ""; const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0; 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 = const showAssistantFooterSlot =
message.role === "assistant" message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
@@ -545,6 +677,8 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent> <TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
{showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
{showAssistantTimestamp ? ( {showAssistantTimestamp ? (
<MessageTimestamp <MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})} {...(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", "border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)} )}
aria-label={label} aria-label={label}
title={text}
> >
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden /> <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]"> <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")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/> />
</SettingsRow> </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 <SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")} title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx( description={tx(
@@ -11,6 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText"; import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { ActivityStep } from "@/components/thread/activity/ActivityStep"; import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model"; import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts { interface ActivityCounts {
reasoningSteps: number; reasoningSteps: number;
toolCalls: number; toolCalls: number;
modelSegments: number;
cliCount: number; cliCount: number;
mcpCount: number; mcpCount: number;
fileCount: number; fileCount: number;
@@ -91,9 +93,14 @@ function countActivity(
): ActivityCounts { ): ActivityCounts {
let reasoningSteps = 0; let reasoningSteps = 0;
let toolCalls = 0; let toolCalls = 0;
let modelSegments = 0;
const cliCount = cliRuns.length; const cliCount = cliRuns.length;
const mcpCount = mcpRuns.length; const mcpCount = mcpRuns.length;
for (const m of messages) { for (const m of messages) {
if (m.activityKind === "model") {
modelSegments += 1;
continue;
}
if (isReasoningOnlyAssistant(m)) { if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1; reasoningSteps += 1;
continue; continue;
@@ -110,6 +117,7 @@ function countActivity(
return { return {
reasoningSteps, reasoningSteps,
toolCalls, toolCalls,
modelSegments,
cliCount, cliCount,
mcpCount, mcpCount,
fileCount: fileEdits.length, fileCount: fileEdits.length,
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
} }
/** /**
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows. * One fold wrapping the complete middle of a turn: reasoning, model segments,
* Fixed max height with inner scroll and a single flat list of activity rows. * tool traces, and file edits. The final assistant answer stays outside it.
*/ */
export function AgentActivityCluster({ export function AgentActivityCluster({
messages, messages,
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
const { const {
reasoningSteps, reasoningSteps,
toolCalls, toolCalls,
modelSegments,
cliCount, cliCount,
mcpCount, mcpCount,
fileCount, fileCount,
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
? outerOpenLocal ? outerOpenLocal
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming); : 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 hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const durationMs = activityDurationMs( const durationMs = activityDurationMs(
activityMessages, activityMessages,
isTurnStreaming, isTurnStreaming,
@@ -197,8 +205,7 @@ export function AgentActivityCluster({
startedAtMs, startedAtMs,
); );
const activityDuration = formatActivityDuration(durationMs); const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = hasNonReasoningActivity const activityLabel = isTurnStreaming
? isTurnStreaming
? t("message.activityWorkingFor", { ? t("message.activityWorkingFor", {
duration: activityDuration, duration: activityDuration,
defaultValue: "Working for {{duration}}", defaultValue: "Working for {{duration}}",
@@ -208,17 +215,6 @@ export function AgentActivityCluster({
: t("message.activityWorkedFor", { : t("message.activityWorkedFor", {
duration: activityDuration, duration: activityDuration,
defaultValue: "Worked for {{duration}}", 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(() => { const cancelActivityScrollFrame = useCallback(() => {
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
<ThinkingReasoningShell <ThinkingReasoningShell
active={isTurnStreaming} active={isTurnStreaming}
expanded={outerExpanded} expanded={outerExpanded}
label={thoughtLabel} label={activityLabel}
viewportRef={activityScrollRef} viewportRef={activityScrollRef}
contentRef={activityContentRef} contentRef={activityContentRef}
fadeTop={activityScrollFade.top} fadeTop={activityScrollFade.top}
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
active={isTurnStreaming} active={isTurnStreaming}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
onOpenFilePreview={onOpenFilePreview}
/> />
{fileEdits.length ? ( {fileEdits.length ? (
<FileEditGroup <FileEditGroup
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
active, active,
cliAppsByName, cliAppsByName,
mcpPresetsByName, mcpPresetsByName,
onOpenFilePreview,
}: { }: {
messages: UIMessage[]; messages: UIMessage[];
active: boolean; active: boolean;
cliAppsByName: Map<string, CliAppInfo>; cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>; mcpPresetsByName: Map<string, McpPresetInfo>;
onOpenFilePreview?: (path: string) => void;
}) { }) {
const items: ReactNode[] = []; const items: ReactNode[] = [];
messages.forEach((message, index) => { messages.forEach((message, index) => {
if (message.activityKind === "model") {
items.push(
<ActivityModelMessage
key={message.id}
message={message}
active={active}
onOpenFilePreview={onOpenFilePreview}
/>,
);
return;
}
if (isReasoningOnlyAssistant(message)) { if (isReasoningOnlyAssistant(message)) {
items.push( items.push(
<ReasoningRow <ReasoningRow
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
return <>{items}</>; 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({ function ActivityTraceList({
lines, lines,
active, active,
+310 -199
View File
@@ -6,37 +6,27 @@ import {
type KeyboardEvent, type KeyboardEvent,
type PointerEvent, type PointerEvent,
} from "react"; } from "react";
import { CircleHelp, Sparkles } from "lucide-react"; import { Check, CircleHelp, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { import {
Tooltip, floatingItemClassName,
TooltipContent, floatingItemFocusClassName,
TooltipProvider, } from "@/components/ui/floating-surface";
TooltipTrigger, import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
} from "@/components/ui/tooltip";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface ModelPresetOption { const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
name: string; const LONG_PRESS_MS = 400;
model?: string | null; const PRESS_SLOP_PX = 8;
provider?: string | null; const PILL_GAP_PX = 4;
} const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
const HANDOFF_THRESHOLD = 0.56;
interface ModelPresetBadgeProps { const DOCK_MAX_SCALE = 1.08;
label: string; const DOCK_RADIUS = 1.5;
modelDetail?: string | null; const SETTLE_MS = 200;
modelPreset?: string | null;
modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}
interface PresetGesture { interface PresetGesture {
active: boolean; active: boolean;
@@ -55,15 +45,6 @@ interface PresetMotion {
settling: boolean; 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 { function wrapIndex(index: number, length: number): number {
return ((index % length) + length) % length; return ((index % length) + length) % length;
} }
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
if (event.cancelable) event.preventDefault(); 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({ export function ModelPresetBadge({
label, label,
modelDetail, modelDetail,
modelPreset, modelPreset,
modelPresets = [], modelPresets = [],
onPresetChange, onPresetChange,
onRequestComposerFocus,
provider, provider,
providerLabel, providerLabel,
needsSetup = false, needsSetup = false,
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
isHero, isHero,
onClick, onClick,
}: ModelPresetBadgeProps) { }: 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 activeName = modelPreset?.trim() || "";
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName); const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
const activePreset: ModelPresetOption = { const activePreset: ModelPresetOption = {
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
model: modelDetail ?? modelPresets[listedIndex]?.model, model: modelDetail ?? modelPresets[listedIndex]?.model,
provider: provider || modelPresets[listedIndex]?.provider, 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 const presets = !activeName
? modelPresets ? modelPresets
: listedIndex < 0 : listedIndex < 0
? [activePreset, ...modelPresets] ? [activePreset, ...modelPresets]
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick); const opensSetup = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
const pillHeight = isHero ? 32 : 36; const pillHeight = isHero ? 32 : 36;
const pillStride = pillHeight + PILL_GAP_PX; const pillStride = pillHeight + PILL_GAP_PX;
const [motion, setMotion] = useState<PresetMotion | null>(null); const switchModelLabel = t("thread.composer.switchModel", {
const gestureRef = useRef<PresetGesture | null>(null); defaultValue: "Switch model for this chat",
const clickAnimationFrameRef = useRef<number | null>(null); });
const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
function clearGesture() { const selectPreset = (name: string) => {
setOpen(false);
if (name !== activeName) onPresetChange?.(name);
requestAnimationFrame(() => onRequestComposerFocus?.());
};
const clearGesture = () => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer); if (gesture?.timer) clearTimeout(gesture.timer);
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
gestureRef.current = null; gestureRef.current = null;
} };
const clearMotion = () => {
setMotion(null);
setMotionWidth(null);
};
useEffect(() => { useEffect(() => {
if (!canSwitch) { if (!canSwitch) {
clearGesture(); clearGesture();
setMotion(null); clearMotion();
} }
return () => { return clearGesture;
clearGesture();
if (clickAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(clickAnimationFrameRef.current);
clickAnimationFrameRef.current = null;
}
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
suppressClickTimerRef.current = null;
}
};
}, [canSwitch]); }, [canSwitch]);
useEffect(() => { useEffect(() => {
if (!motion?.settling) return; if (!motion?.settling) return;
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80); const timer = setTimeout(clearMotion, SETTLE_MS + 80);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [motion?.settling]); }, [motion?.settling]);
function updateMotion(gesture: PresetGesture, clientY: number) { const updateMotion = (gesture: PresetGesture, clientY: number) => {
const raw = -(clientY - gesture.startY) / pillStride; const raw = -(clientY - gesture.startY) / pillStride;
gesture.step = stepWithHysteresis(raw, gesture.step); gesture.step = stepWithHysteresis(raw, gesture.step);
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); setMotion({
} index: gesture.baseIndex + gesture.step,
remainder: raw - gesture.step,
function suppressFollowingClick() { settling: false,
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);
}); });
} };
function handleClick() { const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (interactive) { if (!canSwitch || gestureRef.current || motion) return;
onClick?.();
return;
}
if (suppressClickRef.current) return;
cycleToNextPreset();
}
function handlePointerDown(event: PointerEvent<HTMLElement>) {
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
if (event.pointerType === "mouse" && event.button !== 0) return; if (event.pointerType === "mouse" && event.button !== 0) return;
const gesture: PresetGesture = { const gesture: PresetGesture = {
active: false, active: false,
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
gesture.timer = setTimeout(() => { gesture.timer = setTimeout(() => {
if (gestureRef.current !== gesture) return; if (gestureRef.current !== gesture) return;
gesture.active = true; gesture.active = true;
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
updateMotion(gesture, gesture.latestY); updateMotion(gesture, gesture.latestY);
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false }); gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
try { try {
gesture.target.setPointerCapture(gesture.pointerId); gesture.target.setPointerCapture(gesture.pointerId);
} catch { /* The pointer may already have ended. */ } } catch {
// The pointer may already have ended.
}
}, LONG_PRESS_MS); }, LONG_PRESS_MS);
gestureRef.current = gesture; gestureRef.current = gesture;
} };
function handlePointerMove(event: PointerEvent<HTMLElement>) { const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return; if (!gesture || gesture.pointerId !== event.pointerId) return;
gesture.latestY = event.clientY; gesture.latestY = event.clientY;
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
} }
event.preventDefault(); event.preventDefault();
updateMotion(gesture, event.clientY); updateMotion(gesture, event.clientY);
} };
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) { const finishGesture = (event: PointerEvent<HTMLButtonElement>, commit: boolean) => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return; if (!gesture || gesture.pointerId !== event.pointerId) return;
clearGesture(); clearGesture();
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
event.currentTarget.releasePointerCapture?.(gesture.pointerId); event.currentTarget.releasePointerCapture?.(gesture.pointerId);
} }
if (gesture.active) suppressFollowingClick();
if (!commit || !gesture.active) { if (!commit || !gesture.active) {
setMotion(null); clearMotion();
return; return;
} }
suppressClickRef.current = true;
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
setMotion((current) => current && { ...current, remainder: 0, settling: true }); 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>) { const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!canSwitch) return;
const targetByKey: Record<string, number> = { const targetByKey: Record<string, number> = {
ArrowUp: currentIndex - 1, ArrowUp: currentIndex - 1,
ArrowDown: currentIndex + 1, ArrowDown: currentIndex + 1,
@@ -262,31 +260,54 @@ export function ModelPresetBadge({
if (target === undefined) return; if (target === undefined) return;
event.preventDefault(); event.preventDefault();
const next = presets[wrapIndex(target, presets.length)]; const next = presets[wrapIndex(target, presets.length)];
if (next?.name !== activeName) onPresetChange?.(next.name); if (next?.name !== activeName) selectPreset(next.name);
};
const pill = (
<PresetPill
label={displayLabel}
modelDetail={displayModelDetail}
provider={displayProvider}
providerLabel={fallbackModelName ? null : providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
fallbackFromLabel={fallbackModelName ? label : null}
isHero={isHero}
/>
);
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>
);
} }
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length); return (
const previewPreset = presets[previewIndex]; <Popover
const Container = interactive || canSwitch ? "button" : "span"; open={open}
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; onOpenChange={(nextOpen) => {
const tooltipLabel = fallbackModelName setOpen(nextOpen);
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
}}
const badge = ( >
<Container <PopoverTrigger asChild>
<button
type="button"
data-switching={motion ? "true" : undefined} data-switching={motion ? "true" : undefined}
data-settling={motion?.settling ? "true" : undefined}
aria-label={label} aria-label={label}
aria-orientation={canSwitch ? "vertical" : undefined} aria-expanded={open}
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} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerLeave={(event) => { onPointerLeave={(event) => {
@@ -300,29 +321,33 @@ export function ModelPresetBadge({
if (gestureRef.current?.active) event.preventDefault(); if (gestureRef.current?.active) event.preventDefault();
}} }}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
style={{ touchAction: canSwitch ? "manipulation" : undefined }} 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( 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", "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",
interactive && "cursor-pointer",
canSwitch && "cursor-grab select-none focus-visible:outline-none",
motion && "z-10 cursor-grabbing", motion && "z-10 cursor-grabbing",
!motion && "cursor-grab",
isHero ? "h-8" : "h-9", isHero ? "h-8" : "h-9",
)} )}
> >
<PresetPill
className={motion && "invisible"}
label={label}
modelDetail={modelDetail}
provider={provider}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
/>
{motion ? ( {motion ? (
<>
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
{pill}
</span>
<span <span
data-testid="composer-model-pill-viewport" data-testid="composer-model-pill-viewport"
className={cn( 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]", "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", isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
)} )}
aria-hidden aria-hidden
@@ -330,73 +355,134 @@ export function ModelPresetBadge({
<span <span
data-testid="composer-model-pill-track" data-testid="composer-model-pill-track"
data-settling={motion.settling ? "true" : undefined} 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" className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
onTransitionEnd={(event) => { onTransitionEnd={(event) => {
if (motion.settling && event.currentTarget === event.target) setMotion(null); if (motion.settling && event.currentTarget === event.target) clearMotion();
}} }}
style={{ style={{
paddingTop: isHero ? "10px" : "12px", paddingTop: isHero ? "10px" : "12px",
transform: `translate3d(0, ${trackOffset}px, 0)`, transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
}} }}
> >
{PILL_OFFSETS.map((offset) => { {PILL_OFFSETS.map((offset) => {
const virtualIndex = motion.index + offset; const preset = presets[wrapIndex(motion.index + offset, presets.length)];
const preset = presets[wrapIndex(virtualIndex, presets.length)];
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
return ( return (
<PresetPill <PresetPill
key={virtualIndex} key={motion.index + offset}
label={preset.name} label={preset.name}
modelDetail={preset.model} modelDetail={preset.model}
provider={preset.provider} provider={preset.provider}
isHero={isHero} isHero={isHero}
offset={offset} offset={offset}
scale={scale} scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
/> />
); );
})} })}
</span> </span>
</span> </span>
) : null} </>
</Container> ) : pill}
); </button>
</PopoverTrigger>
if (!tooltipLabel) return badge; <PopoverContent
return ( align="end"
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent
side="top" side="top"
align="center" sideOffset={10}
sideOffset={8} role="dialog"
collisionPadding={12} aria-label={switchModelLabel}
className="max-w-[min(24rem,calc(100vw-2rem))] break-all" 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",
)}
> >
{tooltipLabel} <div
</TooltipContent> role="listbox"
</Tooltip> aria-label={switchModelLabel}
</TooltipProvider> 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({ function PresetPill({
className,
label, label,
modelDetail, modelDetail,
provider, provider,
providerLabel,
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
fallbackFromLabel,
isHero, isHero,
offset, offset,
scale, scale,
}: { }: {
className?: string | false | null;
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
fallbackFromLabel?: string | null;
isHero: boolean; isHero: boolean;
offset?: number; offset?: number;
scale?: number; scale?: number;
@@ -406,13 +492,10 @@ function PresetPill({
const inferredProvider = needsSetup const inferredProvider = needsSetup
? null ? null
: provider || inferProviderFromModelName(modelDetail || label); : provider || inferProviderFromModelName(modelDetail || label);
const brand = providerBrand(inferredProvider); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); const fallbackTitle = fallbackModelName
const logoTestId = offset !== undefined ? `${fallbackFromLabel || label} · using ${fallbackModelName}`
? undefined : title;
: needsSetup
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
useLayoutEffect(() => { useLayoutEffect(() => {
const node = labelRef.current; const node = labelRef.current;
@@ -428,14 +511,14 @@ function PresetPill({
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset} data-preset-offset={offset}
title={fallbackTitle || undefined}
className={cn( 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", "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",
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]", "w-fit",
"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", "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", 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]", isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
offset !== undefined && "composer-model-pill-dock", offset !== undefined && "composer-model-pill-dock",
className,
)} )}
style={scale === undefined ? undefined : { style={scale === undefined ? undefined : {
height: `${isHero ? 32 : 36}px`, height: `${isHero ? 32 : 36}px`,
@@ -443,17 +526,55 @@ function PresetPill({
zIndex: Math.round(scale * 100), zIndex: Math.round(scale * 100),
}} }}
> >
<PresetProviderIcon
label={label}
modelDetail={modelDetail}
provider={inferredProvider}
needsSetup={needsSetup}
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
isHero={isHero}
/>
<span <span
data-testid={logoTestId} ref={labelRef}
className={cn( className={cn(
"grid shrink-0 place-items-center overflow-hidden", "thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background", labelOverflows && "thread-composer-model-label-fade",
)}
>
{label}
</span>
</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]", 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 aria-hidden
> >
{needsSetup ? ( {needsSetup ? (
@@ -465,7 +586,7 @@ function PresetPill({
draggable={false} draggable={false}
decoding="async" decoding="async"
loading="lazy" loading="lazy"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")} className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
onLoad={onLogoLoad} onLoad={onLogoLoad}
onError={onLogoError} onError={onLogoError}
/> />
@@ -483,15 +604,5 @@ function PresetPill({
<Sparkles className="h-3 w-3 text-muted-foreground/65" /> <Sparkles className="h-3 w-3 text-muted-foreground/65" />
)} )}
</span> </span>
<span
ref={labelRef}
className={cn(
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
labelOverflows && "thread-composer-model-label-fade",
)}
>
{label}
</span>
</span>
); );
} }
@@ -112,6 +112,7 @@ import {
readDraggedSession, readDraggedSession,
} from "@/lib/session-drag"; } from "@/lib/session-drag";
import { formatQuotedUserMessage } from "@/lib/user-message-quote"; import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { formatCompactTokenCount } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const VOICE_SHORTCUT_CODE = "KeyD"; 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 { interface ThreadComposerProps {
onSend: ( onSend: (
content: string, content: string,
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean; modelNeedsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
onModelBadgeClick?: () => void; onModelBadgeClick?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
@@ -895,6 +998,7 @@ export function ThreadComposer({
modelNeedsSetup = false, modelNeedsSetup = false,
fallbackModelName = null, fallbackModelName = null,
onModelBadgeClick, onModelBadgeClick,
contextUsage = null,
variant = "thread", variant = "thread",
slashCommands = [], slashCommands = [],
cliApps = [], cliApps = [],
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
modelPreset={modelPreset} modelPreset={modelPreset}
modelPresets={modelPresets} modelPresets={modelPresets}
onPresetChange={onModelPresetChange} onPresetChange={onModelPresetChange}
onRequestComposerFocus={() => textareaRef.current?.focus()}
provider={modelProvider} provider={modelProvider}
providerLabel={modelProviderLabel} providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup} needsSetup={modelNeedsSetup}
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
onClick={modelNeedsSetup ? onModelBadgeClick : undefined} onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/> />
) : null} ) : null}
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
{showVoiceButton ? ( {showVoiceButton ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}> <TooltipProvider delayDuration={220} skipDelayDuration={80}>
<Tooltip> <Tooltip>
+14 -5
View File
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction"; 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"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
interface ThreadMessagesProps { interface ThreadMessagesProps {
@@ -29,10 +30,9 @@ export type DisplayUnit = TurnUnit;
export function buildDisplayUnits( export function buildDisplayUnits(
messages: UIMessage[], messages: UIMessage[],
isStreaming = false, isStreaming = false,
activeTurnId: string | null = null,
): DisplayUnit[] { ): DisplayUnit[] {
return normalizeActivityTimeline(messages, { return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
preserveTrailingActivity: isStreaming,
});
} }
export function assistantForkFlags(units: DisplayUnit[]): boolean[] { export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
@@ -68,8 +68,12 @@ export function ThreadMessages({
onQuoteSelection, onQuoteSelection,
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showTurnUsage = useShowTurnUsage();
const messageListRef = useRef<HTMLDivElement>(null); 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( const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units], [forkBoundaryMessageCount, units],
@@ -156,6 +160,7 @@ export function ThreadMessages({
showForkBoundary={index === forkBoundaryAfterUnitIndex} showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")} forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -237,6 +242,7 @@ interface ThreadDisplayUnitProps {
showForkBoundary: boolean; showForkBoundary: boolean;
forkBoundaryLabel: string; forkBoundaryLabel: string;
temporary: boolean; temporary: boolean;
showTurnUsage: boolean;
cliApps: CliAppInfo[]; cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[]; mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[]; slashCommands: SlashCommand[];
@@ -255,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
showForkBoundary, showForkBoundary,
forkBoundaryLabel, forkBoundaryLabel,
temporary, temporary,
showTurnUsage,
cliApps, cliApps,
mcpPresets, mcpPresets,
slashCommands, slashCommands,
@@ -293,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
message={unit.message} message={unit.message}
isTurnStreaming={isTurnStreaming} isTurnStreaming={isTurnStreaming}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -321,6 +329,7 @@ function threadDisplayUnitPropsEqual(
&& previous.showForkBoundary === next.showForkBoundary && previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel && previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary && previous.temporary === next.temporary
&& previous.showTurnUsage === next.showTurnUsage
&& previous.cliApps === next.cliApps && previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets && previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands && previous.slashCommands === next.slashCommands
+51 -13
View File
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionHandleLabel } from "@/components/SessionHandleLabel"; import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; 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 type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; 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( function snapshotPreservesMessage(
current: MessageShape, current: MessageShape,
candidate: MessageShape, candidate: MessageShape,
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
); );
return { return {
label, label,
model: model?.trim() || null, model: toModelBadgeLabel(model),
provider, provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null, providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup, needsSetup,
@@ -803,8 +830,12 @@ export function ThreadShell({
}, []); }, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const currentRunStartedAt = messagesReady ? runStartedAt : null; const composerContextUsage = useMemo(
() => latestComposerContextUsage(displayMessages),
[displayMessages],
);
const currentGoalState = messagesReady ? goalState : undefined; const currentGoalState = messagesReady ? goalState : undefined;
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null); const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo( const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null, () => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
@@ -882,9 +913,17 @@ export function ThreadShell({
useEffect(() => { useEffect(() => {
setLocalModelPreset(null); setLocalModelPreset(null);
}, [session?.key, sessionModelPreset]); }, [session?.key, sessionModelPreset]);
const configuredPresetNames = useMemo(
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
[settings],
);
const activeModelPreset = ( const activeModelPreset = (
localModelPreset (localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|| sessionModelPreset ? localModelPreset
: null)
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
? sessionModelPreset
: null)
|| settings?.agent.model_preset || settings?.agent.model_preset
|| "default" || "default"
); );
@@ -958,13 +997,10 @@ export function ThreadShell({
} }
setFallbackModelName(null); setFallbackModelName(null);
return client.onChat(chatId, (event) => { return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return; if (event.event !== "turn_model_updated" || event.fallback !== true) return;
const activeModel = event.model_name.trim(); setFallbackModelName(event.model_name);
setFallbackModelName(
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
);
}); });
}, [chatId, client, modelBadge.model]); }, [chatId, client]);
useEffect(() => { useEffect(() => {
if (!historyKey || !chatId || loading) return; if (!historyKey || !chatId || loading) return;
@@ -1454,7 +1490,7 @@ export function ThreadShell({
: t("thread.composer.placeholderThread") : t("thread.composer.placeholderThread")
} }
modelLabel={modelBadgeLabel} modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)} modelDetail={modelBadge.model}
modelPreset={activeModelPreset} modelPreset={activeModelPreset}
modelPresets={modelPresetOptions} modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange} onModelPresetChange={handleModelPresetChange}
@@ -1463,6 +1499,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant={composerVariant} variant={composerVariant}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
@@ -1501,7 +1538,7 @@ export function ThreadShell({
: t("thread.composer.placeholderHero") : t("thread.composer.placeholderHero")
} }
modelLabel={modelBadgeLabel} modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)} modelDetail={modelBadge.model}
modelPreset={activeModelPreset} modelPreset={activeModelPreset}
modelPresets={modelPresetOptions} modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange} onModelPresetChange={handleModelPresetChange}
@@ -1510,6 +1547,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant="hero" variant="hero"
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
+40 -34
View File
@@ -165,11 +165,19 @@
pointer-events: none; pointer-events: none;
background-color: rgb(236 141 49); background-color: rgb(236 141 49);
opacity: 0; 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 { .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 > * { .composer-model-badge > * {
@@ -695,11 +703,38 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
} }
.thread-composer-model-badge:not([data-switching="true"]):active .thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
> .composer-model-pill {
transform: scale(0.98); 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 { @keyframes preset-name-shake {
0%, 0%,
100% { 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) { @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; transform: none !important;
} }
+20 -73
View File
@@ -55,7 +55,6 @@ type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] } | { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields }; | { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000; 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. * Lookup rule: reasoning can only extend the current reasoning placeholder.
* Once ordinary answer text has appeared, the next reasoning chunk starts a * Once ordinary answer text has appeared, the next reasoning chunk starts a
* fresh Thought block so streamed output stays in arrival order: * fresh activity surface so streamed output stays in arrival order while the
* Thought -> answer -> Thought -> answer. * final answer remains the only visible answer bubble.
*/ */
function attachReasoningChunk( function attachReasoningChunk(
prev: UIMessage[], prev: UIMessage[],
@@ -183,16 +182,6 @@ export interface SubmittedTurn {
sideChannel: boolean; 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 { function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : 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 streamTimerRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false); const suppressStreamUntilTurnEndRef = useRef(false);
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set()); 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), []); const dismissStreamError = useCallback(() => setStreamError(null), []);
@@ -319,26 +300,11 @@ export function useNanobotStream(
pendingStreamEventsRef.current = []; pendingStreamEventsRef.current = [];
}, []); }, []);
const cancelStreamEndTimer = useCallback(() => {
if (streamEndTimerRef.current === null) return;
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}, []);
const isSideChannelEvent = useCallback((ev: InboundEvent) => { const isSideChannelEvent = useCallback((ev: InboundEvent) => {
const turnId = eventTurnId(ev); const turnId = eventTurnId(ev);
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId); 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) => { const createActivitySegmentId = useCallback((activate = true) => {
activitySegmentCounterRef.current += 1; activitySegmentCounterRef.current += 1;
const id = `activity-${activitySegmentCounterRef.current}`; const id = `activity-${activitySegmentCounterRef.current}`;
@@ -387,7 +353,6 @@ export function useNanobotStream(
(event) => event.turn.turnId !== rejectedTurnId, (event) => event.turn.turnId !== rejectedTurnId,
); );
sideChannelTurnIdsRef.current.delete(rejectedTurnId); sideChannelTurnIdsRef.current.delete(rejectedTurnId);
cancelStreamEndTimer();
setMessages((prev) => { setMessages((prev) => {
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId); const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
if (rejectedRows.length === 0) return prev; if (rejectedRows.length === 0) return prev;
@@ -438,7 +403,7 @@ export function useNanobotStream(
setRunStartedAt(remainingStartedAt); setRunStartedAt(remainingStartedAt);
setIsStreaming(hasRemainingRun); setIsStreaming(hasRemainingRun);
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false; if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
}, [cancelStreamEndTimer, chatId, client]); }, [chatId, client]);
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]); useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
@@ -659,15 +624,6 @@ export function useNanobotStream(
return () => document.removeEventListener("visibilitychange", flushOnReturn); return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]); }, [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 // Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404 // ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered. // history response after the optimistic first message has already rendered.
@@ -690,9 +646,8 @@ export function useNanobotStream(
clearPendingStreamWork(); clearPendingStreamWork();
sideChannelTurnIdsRef.current.clear(); sideChannelTurnIdsRef.current.clear();
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
cancelStreamEndTimer();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); }, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
useEffect(() => { useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true); if (hasPendingToolCalls) setIsStreaming(true);
@@ -774,12 +729,6 @@ export function useNanobotStream(
return; return;
} }
const sideChannelEvent = isSideChannelEvent(ev); const sideChannelEvent = isSideChannelEvent(ev);
if (
streamEndTimerRef.current !== null
&& !sideChannelEvent
&& eventExtendsModelActivity(ev)
) cancelStreamEndTimer();
if (ev.event === "delta") { if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : ""; const chunk = typeof ev.text === "string" ? ev.text : "";
@@ -822,14 +771,13 @@ export function useNanobotStream(
}); });
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) { if (ev.resuming) {
cancelStreamEndTimer();
setIsStreaming(true); setIsStreaming(true);
if (!mergeNext) {
setMessages((prev) => finalizeStreamedTurn(prev, turn));
}
return; 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; return;
} }
@@ -868,9 +816,8 @@ export function useNanobotStream(
setGoalState(ev.goal_state); setGoalState(ev.goal_state);
} }
setRunStartedAt(null); setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any // Definitive signal that the turn is fully complete, so stop the
// pending debounce timer and stop the loading indicator immediately. // loading indicator immediately.
cancelStreamEndTimer();
setIsStreaming(false); setIsStreaming(false);
const completedAt = Date.now(); const completedAt = Date.now();
setMessages((prev) => { setMessages((prev) => {
@@ -884,6 +831,10 @@ export function useNanobotStream(
finalized, finalized,
{ {
...(latencyMs !== undefined ? { latencyMs } : {}), ...(latencyMs !== undefined ? { latencyMs } : {}),
...(ev.usage ? { usage: ev.usage } : {}),
...(typeof ev.context_window_tokens === "number"
? { contextWindowTokens: ev.context_window_tokens }
: {}),
completedAt, completedAt,
}, },
ev.turn_id, ev.turn_id,
@@ -1013,8 +964,9 @@ export function useNanobotStream(
// A complete (non-streamed) assistant message. If a stream was in // A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice. // 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`` is the turn boundary. ``stream_end`` only closes the
// ``turn_end`` for non-streamed and tool-heavy turns. // current text segment so a following tool/reasoning segment remains
// part of the same live activity surface.
clearActivitySegment(); clearActivitySegment();
setMessages((prev) => { setMessages((prev) => {
const activeId = buffer.current?.messageId; const activeId = buffer.current?.messageId;
@@ -1088,7 +1040,6 @@ export function useNanobotStream(
}); });
return; return;
} }
// ``attached`` frames aren't actionable here.
}; };
const unsub = client.onChat(chatId, handle); const unsub = client.onChat(chatId, handle);
@@ -1099,12 +1050,11 @@ export function useNanobotStream(
closedAssistantStreamIdsRef.current.clear(); closedAssistantStreamIdsRef.current.clear();
clearActivitySegment(); clearActivitySegment();
clearPendingStreamWork(); clearPendingStreamWork();
cancelStreamEndTimer();
}; };
}, [ }, [
applyStreamError, applyStreamError,
cancelStreamEndTimer,
chatId, chatId,
closeActiveAssistantStream,
client, client,
clearActivitySegment, clearActivitySegment,
clearPendingStreamWork, clearPendingStreamWork,
@@ -1114,7 +1064,6 @@ export function useNanobotStream(
isSideChannelEvent, isSideChannelEvent,
onTurnEnd, onTurnEnd,
schedulePendingStreamFlush, schedulePendingStreamFlush,
scheduleStreamEndTimer,
]); ]);
const send = useCallback( const send = useCallback(
@@ -1133,7 +1082,6 @@ export function useNanobotStream(
: content; : content;
flushPendingStreamEvents(); flushPendingStreamEvents();
if (finalizeActiveTurn) { if (finalizeActiveTurn) {
cancelStreamEndTimer();
setIsStreaming(false); setIsStreaming(false);
} }
const turnId = crypto.randomUUID(); const turnId = crypto.randomUUID();
@@ -1188,7 +1136,7 @@ export function useNanobotStream(
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions); client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
return { turnId, userMessageId, sideChannel }; return { turnId, userMessageId, sideChannel };
}, },
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], [chatId, clearActivitySegment, client, flushPendingStreamEvents],
); );
const stop = useCallback(() => { const stop = useCallback(() => {
@@ -1209,7 +1157,6 @@ export function useNanobotStream(
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
const reconcileTurnComplete = useCallback(() => { const reconcileTurnComplete = useCallback(() => {
cancelStreamEndTimer();
clearPendingStreamWork(); clearPendingStreamWork();
buffer.current = null; buffer.current = null;
activeAssistantRef.current = null; activeAssistantRef.current = null;
@@ -1218,7 +1165,7 @@ export function useNanobotStream(
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
setRunStartedAt(null); setRunStartedAt(null);
setIsStreaming(false); setIsStreaming(false);
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); }, [clearActivitySegment, clearPendingStreamWork]);
const transcribeAudio = useCallback( const transcribeAudio = useCallback(
(dataUrl: string, options?: { durationMs?: number }) => (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", "density": "Density",
"activityMode": "Activity detail", "activityMode": "Activity detail",
"fileEditDisplay": "File edit display", "fileEditDisplay": "File edit display",
"showTurnUsage": "Show per-turn token usage",
"codeWrap": "Code wrapping", "codeWrap": "Code wrapping",
"brandLogos": "Brand logos", "brandLogos": "Brand logos",
"maxResults": "Max results", "maxResults": "Max results",
@@ -241,6 +242,7 @@
"density": "Stored only in this browser.", "density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "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.", "codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.", "brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Results returned by each web_search call.",
@@ -1192,6 +1194,11 @@
"removeQuotedContext": "Remove quoted context", "removeQuotedContext": "Remove quoted context",
"modelNotConfigured": "Model not configured", "modelNotConfigured": "Model not configured",
"configureModel": "Configure model", "configureModel": "Configure model",
"switchModel": "Switch model for this chat",
"context": {
"tooltip": "Context · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% used."
},
"queued": { "queued": {
"label": "Queued guidance", "label": "Queued guidance",
"guide": "Guide", "guide": "Guide",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "Show {{count}} more lines", "fileEditShowMoreLines": "Show {{count}} more lines",
"fileEditShowFewerLines": "Show fewer lines", "fileEditShowFewerLines": "Show fewer lines",
"fileEditOpenFile": "Open file", "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": { "lightbox": {
"title": "Image preview", "title": "Image preview",
+16
View File
@@ -142,6 +142,7 @@
"density": "Densidad", "density": "Densidad",
"activityMode": "Detalle de actividad", "activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos", "fileEditDisplay": "Vista de edición de archivos",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Ajuste de código", "codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos", "maxResults": "Resultados máximos",
"timeout": "Tiempo de espera", "timeout": "Tiempo de espera",
@@ -185,6 +186,7 @@
"density": "Solo se guarda en este navegador.", "density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "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.", "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.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -1179,6 +1181,11 @@
"removeQuotedContext": "Quitar contexto citado", "removeQuotedContext": "Quitar contexto citado",
"modelNotConfigured": "Modelo no configurado", "modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Cambiar el modelo de este chat",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % usado."
},
"queued": { "queued": {
"label": "Guía en cola", "label": "Guía en cola",
"guide": "Guiar", "guide": "Guiar",
@@ -1395,6 +1402,15 @@
"fileEditShowFewerLines": "Mostrar menos líneas", "fileEditShowFewerLines": "Mostrar menos líneas",
"fileEditOpenFile": "Abrir archivo", "fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "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}}", "activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado", "activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}", "activityThoughtFor": "Pensó durante {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "Densité", "density": "Densité",
"activityMode": "Détail dactivité", "activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers", "fileEditDisplay": "Affichage des modifications de fichiers",
"showTurnUsage": "Afficher lutilisation des tokens par tour",
"codeWrap": "Retour à la ligne du code", "codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.", "maxResults": "Résultats max.",
"timeout": "Délai dattente", "timeout": "Délai dattente",
@@ -185,6 +186,7 @@
"density": "Enregistré seulement dans ce navigateur.", "density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.", "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.", "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.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.", "maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.", "timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "Supprimer le contexte cité", "removeQuotedContext": "Supprimer le contexte cité",
"modelNotConfigured": "Modèle non configuré", "modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle", "configureModel": "Configurer le modèle",
"switchModel": "Changer le modèle de cette conversation",
"context": {
"tooltip": "Contexte · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % utilisé."
},
"queued": { "queued": {
"label": "Guidage en attente", "label": "Guidage en attente",
"guide": "Guider", "guide": "Guider",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Afficher moins de lignes", "fileEditShowFewerLines": "Afficher moins de lignes",
"fileEditOpenFile": "Ouvrir le fichier", "fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "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}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée", "activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}", "activityThoughtFor": "Réflexion terminée en {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "Kerapatan", "density": "Kerapatan",
"activityMode": "Detail aktivitas", "activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan perubahan file", "fileEditDisplay": "Tampilan perubahan file",
"showTurnUsage": "Tampilkan penggunaan token per giliran",
"codeWrap": "Bungkus kode", "codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum", "maxResults": "Hasil maksimum",
"timeout": "Batas waktu", "timeout": "Batas waktu",
@@ -185,6 +186,7 @@
"density": "Hanya disimpan di browser ini.", "density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.", "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.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.", "maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.", "timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "Hapus konteks kutipan", "removeQuotedContext": "Hapus konteks kutipan",
"modelNotConfigured": "Model belum dikonfigurasi", "modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model", "configureModel": "Konfigurasi model",
"switchModel": "Ganti model untuk percakapan ini",
"context": {
"tooltip": "Konteks · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% digunakan."
},
"queued": { "queued": {
"label": "Panduan antrean", "label": "Panduan antrean",
"guide": "Pandu", "guide": "Pandu",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris", "fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
"fileEditOpenFile": "Buka file", "fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "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}}", "activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir", "activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}", "activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+16
View File
@@ -142,6 +142,7 @@
"density": "表示密度", "density": "表示密度",
"activityMode": "アクティビティ詳細", "activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示", "fileEditDisplay": "ファイル編集表示",
"showTurnUsage": "ターンごとのトークン使用量を表示",
"codeWrap": "コードの折り返し", "codeWrap": "コードの折り返し",
"maxResults": "最大結果数", "maxResults": "最大結果数",
"timeout": "タイムアウト", "timeout": "タイムアウト",
@@ -185,6 +186,7 @@
"density": "このブラウザーにのみ保存されます。", "density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。", "activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。", "fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。", "maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "引用したコンテキストを削除", "removeQuotedContext": "引用したコンテキストを削除",
"modelNotConfigured": "モデルが未設定です", "modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定", "configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える",
"context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中"
},
"queued": { "queued": {
"label": "保留中のガイド", "label": "保留中のガイド",
"guide": "ガイド", "guide": "ガイド",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "表示行数を減らす", "fileEditShowFewerLines": "表示行数を減らす",
"fileEditOpenFile": "ファイルを開く", "fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": {
"context": "コンテキスト {{tokens}}",
"turnInput": "ターン入力 {{tokens}}",
"request": "リクエスト {{count}} 回",
"requests": "リクエスト {{count}} 回",
"output": "出力 {{tokens}}",
"cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます"
},
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました", "activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました", "activityThoughtFor": "{{duration}}考えました",
+16
View File
@@ -142,6 +142,7 @@
"density": "밀도", "density": "밀도",
"activityMode": "활동 상세", "activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시", "fileEditDisplay": "파일 편집 표시",
"showTurnUsage": "턴별 토큰 사용량 표시",
"codeWrap": "코드 줄바꿈", "codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수", "maxResults": "최대 결과 수",
"timeout": "타임아웃", "timeout": "타임아웃",
@@ -185,6 +186,7 @@
"density": "이 브라우저에만 저장됩니다.", "density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.", "activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.", "fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "인용한 문맥 제거", "removeQuotedContext": "인용한 문맥 제거",
"modelNotConfigured": "모델이 설정되지 않음", "modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정", "configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환",
"context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중."
},
"queued": { "queued": {
"label": "대기 중인 안내", "label": "대기 중인 안내",
"guide": "안내", "guide": "안내",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "줄 줄이기", "fileEditShowFewerLines": "줄 줄이기",
"fileEditOpenFile": "파일 열기", "fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": {
"context": "컨텍스트 {{tokens}}",
"turnInput": "턴 입력 {{tokens}}",
"request": "요청 {{count}}회",
"requests": "요청 {{count}}회",
"output": "출력 {{tokens}}",
"cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함"
},
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함", "activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함", "activityThoughtFor": "{{duration}} 동안 생각함",
+17 -1
View File
@@ -196,6 +196,7 @@
"density": "Densidade", "density": "Densidade",
"activityMode": "Detalhe de atividade", "activityMode": "Detalhe de atividade",
"fileEditDisplay": "Exibição de edição de arquivo", "fileEditDisplay": "Exibição de edição de arquivo",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Quebra de linha no código", "codeWrap": "Quebra de linha no código",
"brandLogos": "Logos de marca", "brandLogos": "Logos de marca",
"maxResults": "Máx. de resultados", "maxResults": "Máx. de resultados",
@@ -241,6 +242,7 @@
"density": "Armazenado apenas neste navegador.", "density": "Armazenado apenas neste navegador.",
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "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.", "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.", "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.", "brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
"maxResults": "Resultados retornados por cada chamada de web_search.", "maxResults": "Resultados retornados por cada chamada de web_search.",
@@ -1192,6 +1194,11 @@
"removeQuotedContext": "Remover contexto citado", "removeQuotedContext": "Remover contexto citado",
"modelNotConfigured": "Modelo não configurado", "modelNotConfigured": "Modelo não configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Alternar o modelo desta conversa",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% usado."
},
"queued": { "queued": {
"label": "Guia em fila", "label": "Guia em fila",
"guide": "Guiar", "guide": "Guiar",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas", "fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
"fileEditShowFewerLines": "Mostrar menos linhas", "fileEditShowFewerLines": "Mostrar menos linhas",
"fileEditOpenFile": "Abrir arquivo", "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": { "lightbox": {
"title": "Pré-visualização de imagem", "title": "Pré-visualização de imagem",
+16
View File
@@ -142,6 +142,7 @@
"density": "Mật độ", "density": "Mật độ",
"activityMode": "Chi tiết hoạt động", "activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp", "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ã", "codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa", "maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ", "timeout": "Thời gian chờ",
@@ -185,6 +186,7 @@
"density": "Chỉ lưu trong trình duyệt này.", "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.", "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.", "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ỏ.", "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.", "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.", "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", "removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
"modelNotConfigured": "Chưa cấu hình mô hình", "modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "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": { "queued": {
"label": "Hướng dẫn đang chờ", "label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn", "guide": "Hướng dẫn",
@@ -1394,6 +1401,15 @@
"fileEditShowFewerLines": "Hiển thị ít dòng hơn", "fileEditShowFewerLines": "Hiển thị ít dòng hơn",
"fileEditOpenFile": "Mở tệp", "fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "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}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ", "activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+17 -1
View File
@@ -196,6 +196,7 @@
"density": "密度", "density": "密度",
"activityMode": "活动详情", "activityMode": "活动详情",
"fileEditDisplay": "文件编辑显示", "fileEditDisplay": "文件编辑显示",
"showTurnUsage": "显示每轮 Token 用量",
"codeWrap": "代码换行", "codeWrap": "代码换行",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"maxResults": "最大结果数", "maxResults": "最大结果数",
@@ -241,6 +242,7 @@
"density": "只保存在此浏览器中。", "density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少智能体活动详情。", "activityMode": "选择默认显示多少智能体活动详情。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
"codeWrap": "让长代码行在小屏幕上也易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
@@ -1191,6 +1193,11 @@
"removeQuotedContext": "移除引用内容", "removeQuotedContext": "移除引用内容",
"modelNotConfigured": "模型未配置", "modelNotConfigured": "模型未配置",
"configureModel": "配置模型", "configureModel": "配置模型",
"switchModel": "切换本次对话所用模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": { "queued": {
"label": "排队中的引导消息", "label": "排队中的引导消息",
"guide": "引导", "guide": "引导",
@@ -1424,7 +1431,16 @@
"fileEditShowMoreLines": "显示剩余 {{count}} 行", "fileEditShowMoreLines": "显示剩余 {{count}} 行",
"fileEditShowFewerLines": "收起部分行", "fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "打开文件", "fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。" "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": {
"context": "上下文 {{tokens}}",
"turnInput": "本轮输入 {{tokens}}",
"request": "{{count}} 次模型调用",
"requests": "{{count}} 次模型调用",
"output": "输出 {{tokens}}",
"cached": "缓存 {{percent}}%",
"estimated": "包含估算用量"
}
}, },
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",
+16
View File
@@ -142,6 +142,7 @@
"density": "密度", "density": "密度",
"activityMode": "活動細節", "activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示", "fileEditDisplay": "檔案編輯顯示",
"showTurnUsage": "顯示每輪 Token 用量",
"codeWrap": "程式碼換行", "codeWrap": "程式碼換行",
"maxResults": "最大結果數", "maxResults": "最大結果數",
"timeout": "逾時", "timeout": "逾時",
@@ -185,6 +186,7 @@
"density": "只儲存在此瀏覽器中。", "density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少智能體活動細節。", "activityMode": "選擇預設顯示多少智能體活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。", "fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次呼叫 web_search 所回傳的結果數。", "maxResults": "每次呼叫 web_search 所回傳的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。", "timeout": "搜尋供應商請求逾時前的秒數。",
@@ -1178,6 +1180,11 @@
"removeQuotedContext": "移除引用內容", "removeQuotedContext": "移除引用內容",
"modelNotConfigured": "尚未設定模型", "modelNotConfigured": "尚未設定模型",
"configureModel": "設定模型", "configureModel": "設定模型",
"switchModel": "切換此對話使用的模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": { "queued": {
"label": "佇列中的引導訊息", "label": "佇列中的引導訊息",
"guide": "引導", "guide": "引導",
@@ -1393,6 +1400,15 @@
"fileEditShowFewerLines": "收起部分行", "fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "開啟檔案", "fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": {
"context": "上下文 {{tokens}}",
"turnInput": "本輪輸入 {{tokens}}",
"request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求",
"output": "輸出 {{tokens}}",
"cached": "快取 {{percent}}%",
"estimated": "包含估算用量"
},
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考", "activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}", "activityThoughtFor": "已思考 {{duration}}",
+200 -185
View File
@@ -1,5 +1,8 @@
import type { UIMessage } from "@/lib/types"; 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 = export type TurnUnit =
| { | {
type: "activity"; type: "activity";
@@ -9,106 +12,103 @@ export type TurnUnit =
} }
| { type: "message"; message: UIMessage }; | { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean { export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false; 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); return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
} }
export function isAgentActivityMember(message: UIMessage): boolean { 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 { export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
if (messages.length === 0) return false; const last = messages.at(-1);
const last = messages[messages.length - 1]; if (!last || !isAgentActivityMember(last)) return false;
if (!isAgentActivityMember(last)) return false; if (last.isStreaming || last.reasoningStreaming) return true;
let trailingStart = messages.length - 1; const lastTurnId = last.turnId;
while ( const previous = messages.at(-2);
trailingStart > 0 // A trace without a visible answer is an unfinished turn on replay. Once a
&& isAgentActivityMember(messages[trailingStart - 1]) // final assistant answer exists after it, the activity is simply history.
) { return !previous
trailingStart -= 1; || previous.role !== "assistant"
} || isAgentActivityMember(previous)
|| previous.turnId !== lastTurnId;
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);
} }
/**
* 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( export function normalizeActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] { ): TurnUnit[] {
const units: TurnUnit[] = []; const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = []; let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined; let activeTurnId: string | undefined;
let activeTurnStartedAtMs: number | undefined; let activeTurnStartedAtMs: number | undefined;
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => { const flushTurn = () => {
if (turnMessages.length === 0) { if (!turnMessages.length) {
activeTurnId = undefined; activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
return; return;
} }
const turnUnits: TurnUnit[] = []; const ordered = orderMessagesByTurnSeq(turnMessages);
const turnStartedAtMs = activeTurnStartedAtMs; const lastActivityIndex = ordered.reduce(
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages); (index, message, current) => isRawActivity(message) ? current : index,
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages); -1,
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(
turnUnits,
activityMessages,
visibleMessages.slice(visibleIndex),
turnStartedAtMs,
); );
activityMessages = []; 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);
for (const message of orderedTurnMessages) { const activity: UIMessage[] = [];
if (isAgentActivityMember(message)) { const answers: UIMessage[] = [];
activityMessages.push(message); ordered.forEach((message, index) => {
continue; 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 (activity.length) {
units.push({
type: "activity",
messages: activity,
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
startedAtMs: activeTurnStartedAtMs,
});
}
if (answers.length) {
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
} }
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;
}
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = []; turnMessages = [];
activeTurnId = undefined; activeTurnId = undefined;
activeTurnStartedAtMs = undefined; activeTurnStartedAtMs = undefined;
@@ -122,131 +122,142 @@ export function normalizeActivityTimeline(
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt); activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue; continue;
} }
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) { if (message.turnId) activeTurnId = message.turnId;
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message); turnMessages.push(message);
} }
flushTurn(options); flushTurn();
return units; 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[] { function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if ( if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages; return messages;
} }
return messages return messages
.map((message, index) => ({ message, index })) .map((message, index) => ({ message, index }))
.sort((left, right) => { .sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message); .map(({ message }) => message);
} }
function normalizeCompletedTurnUnits( function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
turnUnits: TurnUnit[], const first = answers[0];
options: NormalizeActivityTimelineOptions, const last = answers.at(-1)!;
): TurnUnit[] { const media = answers.flatMap((message) => message.media ?? []);
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits; const images = answers.flatMap((message) => message.images ?? []);
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits; const merged: UIMessage = {
...first,
let trailingStart = turnUnits.length - 1; ...last,
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") { id: first.id,
trailingStart -= 1; content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
} createdAt: first.createdAt,
isStreaming: answers.some((message) => message.isStreaming),
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;
}; };
if (media.length) merged.media = media;
for (const message of activityMessages) { else delete merged.media;
const bucket = isFileEditActivityMessage(message) ? "file" : "other"; if (images.length) merged.images = images;
const segmentId = message.activitySegmentId; else delete merged.images;
const segmentChanged = return merged;
bucket === "file"
&& runBucket === "file"
&& !!runSegmentId
&& !!segmentId
&& runSegmentId !== segmentId;
if ((runBucket && bucket !== runBucket) || segmentChanged) {
flushRun();
}
runBucket = bucket;
if (segmentId) runSegmentId = segmentId;
runMessages.push(message);
}
flushRun();
} }
function isFileEditActivityMessage(message: UIMessage): boolean { function modelActivitySnippet(message: UIMessage): UIMessage {
return message.kind === "trace" && !!message.fileEdits?.length; return {
} ...stripInlineReasoning(message),
id: `${message.id}-activity`,
function assistantHasInlineReasoning(message: UIMessage): boolean { activityKind: "model",
return ( turnPhase: "activity",
message.role === "assistant" // Keep the source stream state so the activity surface can render this
&& message.kind !== "trace" // segment with the same Markdown streaming semantics as a normal answer.
&& message.content.trim().length > 0 isStreaming: message.isStreaming,
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming) };
);
} }
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
@@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
return next; return next;
} }
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined { function validCreatedAtMs(value: unknown): number | undefined {
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) { return typeof value === "number" && Number.isFinite(value) ? value : undefined;
const latency = visibleMessages[i].latencyMs; }
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; if (isValidLatency(latency)) return latency;
} }
for (let i = activityMessages.length - 1; i >= 0; i -= 1) { for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
const latency = activityMessages[i].latencyMs; const latency = activityMessages[index].latencyMs;
if (isValidLatency(latency)) return latency; if (isValidLatency(latency)) return latency;
} }
return undefined; return undefined;
+11
View File
@@ -1,5 +1,16 @@
import i18n, { currentLocale } from "@/i18n"; 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([ const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
"hi", "hi",
"hello", "hello",
+3
View File
@@ -8,6 +8,7 @@ export interface LocalPreferences {
codeWrap: boolean; codeWrap: boolean;
brandLogos: boolean; brandLogos: boolean;
fileEditDisplayMode: FileEditDisplayMode; fileEditDisplayMode: FileEditDisplayMode;
showTurnUsage: boolean;
} }
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
@@ -19,6 +20,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
codeWrap: true, codeWrap: true,
brandLogos: false, brandLogos: false,
fileEditDisplayMode: "summary", fileEditDisplayMode: "summary",
showTurnUsage: false,
}; };
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode { export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
@@ -36,6 +38,7 @@ export function readLocalPreferences(): LocalPreferences {
codeWrap: parsed.codeWrap !== false, codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true, brandLogos: parsed.brandLogos === true,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
showTurnUsage: parsed.showTurnUsage === true,
}; };
} catch { } catch {
return DEFAULT_LOCAL_PREFS; return DEFAULT_LOCAL_PREFS;
+1 -1
View File
@@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
export function stampLastAssistantCompletion( export function stampLastAssistantCompletion(
prev: UIMessage[], prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">, completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
turnId?: string, turnId?: string,
): UIMessage[] { ): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) { 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 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 { export interface UIMessage {
id: string; id: string;
role: Role; role: Role;
@@ -56,6 +66,9 @@ export interface UIMessage {
fileEdits?: UIFileEdit[]; fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */ /** Activity rows created during the same agent phase share one collapsible block. */
activitySegmentId?: string; 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. */ /** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[]; images?: UIImage[];
/** Signed or local UI-renderable media attachments. */ /** Signed or local UI-renderable media attachments. */
@@ -77,6 +90,10 @@ export interface UIMessage {
latencyMs?: number; latencyMs?: number;
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */ /** Client epoch milliseconds when the definitive ``turn_end`` was received. */
completedAt?: number; 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. */ /** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource; source?: UIMessageSource;
/** Structured provenance for a message delivered by another session. */ /** Structured provenance for a message delivered by another session. */
@@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata {
export type InboundEvent = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { 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"; event: "message_accepted";
chat_id: string; chat_id: string;
@@ -1313,11 +1335,14 @@ export type InboundEvent =
chat_id: string; chat_id: string;
model_name: string; model_name: string;
model_preset?: string | null; model_preset?: string | null;
fallback?: boolean;
} }
| ({ | ({
event: "turn_end"; event: "turn_end";
chat_id: string; chat_id: string;
latency_ms?: number; latency_ms?: number;
usage?: TurnUsage;
context_window_tokens?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */ /** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload; goal_state?: GoalStateWsPayload;
} & InboundTurnMetadata) } & InboundTurnMetadata)
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences"; import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@@ -139,6 +140,34 @@ function installReducedMotion() {
} }
describe("AgentActivityCluster", () => { 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", () => { it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue(); const raf = installAnimationFrameQueue();
try { try {
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
vi.advanceTimersByTime(301); vi.advanceTimersByTime(301);
}); });
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute( expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
"aria-expanded", "aria-expanded",
"false", "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"); expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg"); const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument(); 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", () => { it("labels mixed tool activity as work instead of thought", () => {
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
/>, />,
); );
expect(screen.getByText("Thought")).toBeInTheDocument(); expect(screen.getByText("Worked")).toBeInTheDocument();
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument(); expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
}); });
it("renders file edits as one-line activity rows", async () => { 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.activityMode",
"settings.rows.fileEditDisplay", "settings.rows.fileEditDisplay",
"settings.rows.codeWrap", "settings.rows.codeWrap",
"settings.rows.showTurnUsage",
"settings.rows.brandLogos", "settings.rows.brandLogos",
"settings.rows.currentModel", "settings.rows.currentModel",
"settings.rows.localServiceAccess", "settings.rows.localServiceAccess",
@@ -171,6 +172,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.activityMode", "settings.help.activityMode",
"settings.help.fileEditDisplay", "settings.help.fileEditDisplay",
"settings.help.codeWrap", "settings.help.codeWrap",
"settings.help.showTurnUsage",
"settings.help.brandLogos", "settings.help.brandLogos",
"settings.help.currentModel", "settings.help.currentModel",
"settings.help.localServiceAccess", "settings.help.localServiceAccess",
@@ -291,6 +293,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.openLink", "message.openLink",
"message.openAttachment", "message.openAttachment",
"message.skill", "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.connectionChecks",
"settings.channels.open", "settings.channels.open",
]; ];
+130 -1
View File
@@ -1,8 +1,14 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; 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 { MessageBubble } from "@/components/MessageBubble";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
DEFAULT_LOCAL_PREFS,
LOCAL_PREFS_STORAGE_KEY,
writeLocalPreferences,
} from "@/lib/local-preferences";
import type { import type {
CliAppInfo, CliAppInfo,
McpPresetInfo, 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[] = [ const SLASH_COMMANDS: SlashCommand[] = [
{ {
command: "/model", command: "/model",
@@ -95,6 +106,10 @@ const SLASH_COMMANDS: SlashCommand[] = [
]; ];
describe("MessageBubble", () => { describe("MessageBubble", () => {
afterEach(() => {
localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY);
});
it("renders user messages as right-aligned pills", () => { it("renders user messages as right-aligned pills", () => {
const message: UIMessage = { const message: UIMessage = {
id: "u1", id: "u1",
@@ -245,6 +260,7 @@ describe("MessageBubble", () => {
const quote = screen.getByLabelText("Quoted context"); const quote = screen.getByLabelText("Quoted context");
expect(quote).toHaveTextContent("selected assistant excerpt"); expect(quote).toHaveTextContent("selected assistant excerpt");
expect(quote).not.toHaveAttribute("title");
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument(); expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
expect(screen.getByText("What about this?")).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(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.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", chat_id: "chat-a",
model_name: "deepseek/deepseek-chat", model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research", model_preset: "Deep Research",
fallback: true,
}); });
expect(chatHandler).toHaveBeenCalledWith({ expect(chatHandler).toHaveBeenCalledWith({
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a", chat_id: "chat-a",
model_name: "deepseek/deepseek-chat", model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research", 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 { expect, it, vi } from "vitest";
import { LOCAL_PREFS_STORAGE_KEY } from "@/lib/local-preferences";
import type { SettingsPayload } from "@/lib/types"; import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
@@ -10,6 +11,44 @@ describe("Settings overview and appearance", () => {
installSettingsViewTestHooks(); 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 () => { it("persists the file edit display local preference", async () => {
renderSettingsView({ renderSettingsView({
initialSection: "appearance", initialSection: "appearance",
+77 -150
View File
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
} }
const MODEL_PRESETS = [ const MODEL_PRESETS = [
{ name: "kimi", provider: "moonshot" }, { name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
{ name: "dflash", provider: "deepseek" }, { name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
{ name: "dspro", provider: "deepseek" }, { name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
]; ];
function renderPresetComposer(variant: "thread" | "hero" = "thread") { function renderPresetComposer(variant: "thread" | "hero" = "thread") {
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>, />,
); );
return { return {
badge: screen.getByRole("spinbutton", { name: "kimi" }), badge: screen.getByRole("button", { name: "kimi" }),
onPresetChange, 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", () => { describe("ThreadComposer", () => {
it("locks an async send and keeps the draft when it is rejected", async () => { it("locks an async send and keeps the draft when it is rejected", async () => {
let resolveSend!: (accepted: boolean) => void; 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).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]"); expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); 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", () => { it("keeps the thread composer compact while matching the hero style", () => {
render( render(
<ThreadComposer <ThreadComposer
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill"); const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
expect(modelPill).toHaveClass("font-medium", "text-foreground/70"); expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
expect(modelPill).not.toHaveClass("font-semibold"); 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..."); const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]"); expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]"); expect(input.className).toContain("text-[16px]");
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
it("shows model details in the shared tooltip without a native title", async () => { it("opens a model picker and switches presets with one click", 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();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9"); expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" }); expect(badge).toHaveClass("w-fit");
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");
fireEvent.click(badge); fireEvent.click(badge);
expect(onPresetChange).toHaveBeenCalledTimes(1); const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
expect(badge).toHaveAttribute("data-settling", "true"); expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
expect(track).toHaveAttribute("data-settling", "true"); expect(badge).toHaveClass("w-fit");
act(() => { expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
vi.advanceTimersByTime(260); expect(within(picker).getAllByRole("option")).toHaveLength(3);
}); expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
expect(badge).not.toHaveAttribute("data-switching"); /dflash\s*deepseek-v4-flash/,
expect(badge).not.toHaveAttribute("data-settling"); );
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(); 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"); const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8"); expect(badge).toHaveClass("h-8");
longPress(badge, 9); fireEvent.click(badge);
expect(badge).toHaveAttribute("data-switching", "true"); fireEvent.click(screen.getByRole("option", { name: "dflash" }));
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); expect(onPresetChange).toHaveBeenCalledWith("dflash");
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
}); });
it("transcribes voice input into the composer without sending", async () => { 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( rerender(
<ThreadMessages <ThreadMessages
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph); 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 () => { it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn(); const onQuoteSelection = vi.fn();
render( render(
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits)); expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
expect(unitKeysForDisplay(liveUnits)).toEqual([ expect(unitKeysForDisplay(liveUnits)).toEqual([
"turn-turn-1-user", "turn-turn-1-user",
"turn-turn-1-activity-1",
"turn-turn-1-answer-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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(1);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]); expect(units[0].type).toBe("activity");
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); "r1",
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]); "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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); "r1",
expect(units[2]).toMatchObject({ "t1",
]);
expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument(); expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
}); });
it("times live activity from the user turn start", () => { it("times live activity from the user turn start", () => {
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true); const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3); expect(units).toHaveLength(1);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1]).toMatchObject({ "t0",
type: "message", "a1-activity",
message: { "t1",
id: "a1", ]);
content: "partial answer",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
const answer = screen.getByText("partial answer"); const answer = screen.getByText("partial answer");
const liveActivity = screen.getByRole("button", { name: /working/i }); 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", () => { it("moves late activity before a completed assistant answer", () => {
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); expect(units[1]).toMatchObject({
expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today."); 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).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); 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 今天开打了。"); const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy(); expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true); 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought", "thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web", "web",
]); ]);
expect(units[2]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { id: "answer" }, message: { id: "answer" },
}); });
expect(units[3]).toMatchObject({ expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { id: "next-user" }, message: { id: "next-user" },
}); });
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "early", 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.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("starting…")).toBeInTheDocument();
expect(screen.getByText("final reply")).toBeInTheDocument(); expect(screen.getByText("final reply")).toBeInTheDocument();
}); });
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />); 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); expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
}); });
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
.toHaveLength(1); .toHaveLength(1);
}); });
it("shows copy on adjacent assistant text slices", () => { it("projects adjacent assistant text slices into one answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 }, { id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 },
]; ];
render(<ThreadMessages messages={messages} isStreaming={false} />); 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", () => { it("does not count failed optimistic messages in assistant fork indices", () => {
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
.filter(Boolean); .filter(Boolean);
expect(assistantFlags).toEqual([ expect(assistantFlags).toEqual([
["a1", false],
["a2", true], ["a2", true],
["a3", true], ["a3", true],
]); ]);
+49 -36
View File
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
), ),
); );
const badge = await screen.findByLabelText("fast"); expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(badge).not.toHaveAttribute("title"); expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
fireEvent.focus(badge); });
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-5.5 · OpenAI Codex", 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 () => { it("switches through every named preset while preserving call-order priority", async () => {
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view("default")); 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"); 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( expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order", "preset-order",
"/model fast", "/model fast",
); );
expect(await screen.findByText("fast")).toBeInTheDocument(); expect(await screen.findByText("fast")).toBeInTheDocument();
fireEvent.keyDown( fireEvent.click(screen.getByRole("button", { name: "fast" }));
screen.getByRole("spinbutton", { name: "fast" }), fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith( expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order", "preset-order",
"/model extra", "/model extra",
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
), ),
); );
const badge = await screen.findByLabelText("fast"); expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-4 · Company Proxy",
);
expect(screen.queryByRole("button", { name: "Model not configured" })).not.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(); const client = makeClient();
render(wrap( render(wrap(
client, client,
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
)); ));
expect(await screen.findByText("Default")).toBeInTheDocument(); 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).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge"); expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback"); expect(configuredBadge).not.toHaveAttribute("data-fallback");
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
event: "turn_model_updated", event: "turn_model_updated",
chat_id: "fallback-model", chat_id: "fallback-model",
model_name: "openai-codex/gpt-5.5", model_name: "openai-codex/gpt-5.5",
model_preset: "Default",
}); });
}); });
expect(configuredBadge).not.toHaveAttribute("data-fallback"); expect(configuredBadge).not.toHaveAttribute("data-fallback");
expect(screen.getByText("Default")).toBeInTheDocument();
act(() => { act(() => {
client._emitChat("fallback-model", { client._emitChat("fallback-model", {
event: "turn_model_updated", event: "turn_model_updated",
chat_id: "fallback-model", chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat", 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; const badge = logo.parentElement;
expect(badge).not.toBeNull(); expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge); expect(badge).toBe(configuredBadge);
expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true"); expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).not.toHaveAttribute("title"); expect(badge).toHaveAttribute(
expect(logo).not.toHaveAttribute("data-fallback"); "title",
const trigger = screen.getByLabelText("Default"); "Default · using deepseek/deepseek-chat",
fireEvent.focus(trigger); );
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat"); expect(logo).toBeInTheDocument();
act(() => { act(() => {
client._emitChat("fallback-model", { client._emitChat("fallback-model", {
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
screen.getByTestId("composer-model-logo-openai_codex").parentElement, screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback"); ).not.toHaveAttribute("data-fallback");
}); });
expect(screen.getByRole("tooltip")).toHaveTextContent( expect(screen.getByText("Default")).toBeInTheDocument();
"Default · gpt-5.5 · OpenAI Codex",
);
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
}); });
it("opens model settings from the unconfigured model badge", async () => { it("opens model settings from the unconfigured model badge", async () => {
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view(null)); const { rerender } = render(view(null));
fireEvent.keyDown( fireEvent.click(await screen.findByRole("button", { name: "Default" }));
await screen.findByRole("spinbutton", { name: "Default" }), fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
{ key: "ArrowDown" },
);
expect(await screen.findByText("fast")).toBeInTheDocument(); expect(await screen.findByText("fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled(); 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 }); fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1); expect(takeUserControl).toHaveBeenCalledTimes(1);
+60 -2
View File
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false); 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", () => { it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), { 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(); vi.useFakeTimers();
try { try {
const fake = fakeClient(); const fake = fakeClient();
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
vi.advanceTimersByTime(1000); 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.isStreaming).toBe(false);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({ expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: false, isStreaming: false,
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[1]).toMatchObject({ expect(result.current.messages[1]).toMatchObject({
content: "Initial findings", content: "Initial findings",
isStreaming: false, isStreaming: true,
}); });
act(() => { act(() => {