From 62b1459238792e490013d2745a458fca6c764c8a Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:24:51 -0600 Subject: [PATCH 01/28] fix(mobile): transparent jet on the iOS splash screen The iOS splash reused the square app icon tile. Since the Control Plane rename (0be7513ba6) that tile is fully opaque white, so the splash showed a white box around the jet, most visibly on the dark (#0a0a0a) splash. Render a transparent jet per variant from the mark SVGs in the existing icon export script and point expo-splash-screen at it. The jet keeps the size it had inside the tile. Android artwork is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/app.config.ts | 8 +++++--- apps/mobile/assets/ios-splash-icon-dev.png | Bin 0 -> 17942 bytes .../mobile/assets/ios-splash-icon-nightly.png | Bin 0 -> 17964 bytes apps/mobile/assets/ios-splash-icon-prod.png | Bin 0 -> 17961 bytes assets/README.md | 5 ++++- scripts/export-android-icons.ts | 19 ++++++++++++++++-- 6 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/assets/ios-splash-icon-dev.png create mode 100644 apps/mobile/assets/ios-splash-icon-nightly.png create mode 100644 apps/mobile/assets/ios-splash-icon-prod.png diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 2435e80013784..d7a975f0ed124 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -35,7 +35,7 @@ if ( const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), - splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + splashIcon: "./assets/ios-splash-icon-dev.png", androidAdaptiveForeground: "./assets/android-icon-foreground-dev.png", androidSplashIcon: "./assets/android-splash-icon-dev.png", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -46,7 +46,7 @@ const DEVELOPMENT_ASSETS = { const PREVIEW_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), - splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + splashIcon: "./assets/ios-splash-icon-nightly.png", androidAdaptiveForeground: "./assets/android-icon-foreground-nightly.png", androidSplashIcon: "./assets/android-splash-icon-nightly.png", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -57,7 +57,7 @@ const PREVIEW_ASSETS = { const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), - splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + splashIcon: "./assets/ios-splash-icon-prod.png", androidAdaptiveForeground: "./assets/android-icon-foreground-prod.png", androidSplashIcon: "./assets/android-splash-icon-prod.png", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -366,6 +366,8 @@ const config: ExpoConfig = { [ "expo-splash-screen", { + // iOS draws the splash image unmasked over backgroundColor, so it must be the + // transparent jet. The square app icon tile shows as a white box on the dark splash. image: variant.assets.splashIcon, resizeMode: "contain", backgroundColor: "#ffffff", diff --git a/apps/mobile/assets/ios-splash-icon-dev.png b/apps/mobile/assets/ios-splash-icon-dev.png new file mode 100644 index 0000000000000000000000000000000000000000..ce98b5c09dc6cd5d3ba677930064148cc16aecde GIT binary patch literal 17942 zcmeHvcUV(dxAzGhsWyt#8D+50RC(;eiDh{6BSusE-ddC zhG7LH_pE}5UF8SQI*k$iRlwh+)5{NbDL$2hYRw?*k^`WiU-ln@=FN+sjD5r>4n^En zNTz;yHGit1B|8*0^OC!=vS76v&>k+WEW^`>jj_-Jn7%z1S$ZeSDqy%TT@k9qFU79Q6b4D)fNBI&jzZc8oA57;sNr%~%j`%&fw)20d=hC^kUg-&O{zM z0slA!+>3wuN?E;d!#)&F&tb`ESj8|m+kW~_V5rp+AeL~D)&#o``S|9kCwN{ z4`)~OWpe?-g=rjPA)jgvg18o%^681%$~oXGbol~|txm*15Bj0#xw$Ardi3>jKG=A3Pfe7S8KU%+QX#>rl^s1PaN%9?Jq!|& z6y;4i*6VtLFjO>uFBubX6PZdzRm!q&_k1s|a)<{{G5HXI5eBRmQ#_>o?5aZHE*|_T zt80kDN0fpTu+T3bZ@{5@3ZOF%689VAa+LsU;rGtQ{0S8Y z8rDk;Ewh%75k)4C#%;hurxl=DCulX4z#2as1$GsDM%pTjOMTCSf314k)!rvp8IeEt zuK1Wp8;&%|jxKE+wpCO`h+oF;jqQFW$-tD^dHTZLM;IhWVl%Si>G{3DE5x5Cto#7QJ|fH_#z_LCLofXh6cJao4v2y( za}2mHjxh&c(kZ_QM;I*tB6QNQ42GA4LleLC?ADT^I_MfdJ!ErjVp z9dQ$PwyAnk${aF{2&m)%G(na1et6pLoh8WNs{OsaTEGT~Z&&p_V)Zx={@hZ~07us?A(ffk{aUt8In((gDRRC#YWw!E&Jrc&Yr-bO>xFz;d1; zE^k%&-UnGKDgFbnp5^&b7HeuQYIa5fpuuZ$-CyateGQ3P3zZ)jT(cgF?N_pNfYJWK zP=jLSSs<8~ST3v(VCI071?Y-~o9k5Z&;dYy*gZ1^OBqsGtY6h!b=lj^p94za-jVfjHjIVP@@(S=5JT3 zH;oxbPsG5vSrr!pV|Y*VGKoWU()#%uEYejnyuw#` z50I6%k*(HaZo1O2U1N=u#Gzw=_V6@y86GVtM@nhBKPRxzdRJ}LvYM?IN>I}3$_hVl zVokZ6dHz{DnKgr3!cf%MqlV3PjYze^{eT#MTa+SlAE9Cmrc4L|K74 zIEy31XgLfiOog8}V8!NUmZXcD$v()2&@D};lkx}TpvDH9I01#Zr5preho``0Y&gLM z#;E-xK9T?egHN9lf7{YB2)3)cEm}za8}C$j1z#Q#g2>S!0hI-SLh-s889D^kp5dYNMZsYNHV=V8T(6;Md2 zFYLd$$oj_sK6^(C(sEN+9^@-WEP?icYB&ZhAeHAr0B!@TquyEvUn)WEBPmthX?W}J^0u%uYo?OU7ooDw~kp@P>Kas%r6 zF80*4;fk7&nzC>1!ZolBzl#cGi}Rtd_LyhR(jT zQR;0K698_tcx5W;@23T-)JCZfC`tH?=0pZeUa^2o7bq=EY`vSXS`m3SRMgB7AyK&j zCM^tQjK>8NK5aN^fnsY0=_APo+4D$@lhsNlD8#I_Fockz3ic7J3I${}=$+c!6w^R@ zk1th9Z1CG>xlo2zbSRb?Zt0$KXQJcJ9Cxo%T8dD4V}p4ZQi0q7%vW9AAPWvbS$bak z04dy&+`ph$w83IMw(Vh9aGiz59zcD#6aeG$E9ZnEV|i4VE8=Fs@t^(h%?c3)93+Nb zQqf2Bm33Y?8>T{svABm0MSSAOzBXorNn4k=59>`^mCl-lA^4Wkh`3bH3BjUtg0~nt zt#6_RP^B^D91%UD>xB2vmB}wmf@V|K_cl1!j4*3eIHnQCT!Z3+u%7)e?iTsO!=n<& zE-+U6rm#;{&vI5CGEy+rG@ zh&J>t1cKq?>@_`nYpJDtN%7*-_DJT&)ppF)jnBc3HBwz>5W%MgVLKZsWV)&}@O*s& z!p%HH*cvBW;lPj9dLjLg5O3A@3m^y3FRMUTWx=~*1KPGx2^An{YXC1b*P=Hbeh4}* zz*5<84`F-biOJd!o30;7MUtOiA0!y`>YMpl-RntG`uFQxe3p?ZMagz^L4Z>41Vv4s z3Pbk6BgqBuynO8@e28~>^eSX3aSRYoih}*^TFBaBkIt3Ad#RwZu-# zE#}|JQZv=`0lJORjw1$QoK{o?((ClAMAsiPH=*f0?BvisMF7#@V}J9;`=TY zG(GreZsSb7Pp(5RjeH7$^0p)715`cLc(w5f$i;(6+0ido?Q&7XNJ=FMp<+Cvvn@2I zfinB@d3X0Da;2YFOemx70w_D^PWlF%Jt6ir&Q}aw=3kZyRtf$MkMSEg>O*BFk;1$XrVWH+-;8$Ovqk1VFkL`S`Ac^b=3@0b1Olu(Jqgk*>2~erB=&H4 z$!nCp6yUh7jNk zEA8)8K>#F!8>ACSaHxHr>VXHEl)EEj;yf=$5JhS&Eb42?F&ZMk0}uDm=HO}$is|xo z{k_P1tP8MSKPBO@vzs!=g*GMf65P*6I*<{sU5qqm`beSjQd`>iN#2H+bGy^IGXF{U^u5@-FB4()tVFjD+sQZ@@94m6y8zCJSpG zLFS+Kf#LQ9-htb|m`T8sB-U_GbOmXG%#c*mg0VCQy_yJ+Q>4R}G>+AcNh+|p@%IUs1GiZoc)#;4EK zdEJVg$UtQGY?Q!t`g^jDnrf`2rGgU$-d-HUsM$IpjUDYZjvN6s+<{L-s*uj+AkHqY zWgr(NK0XYDtvfXvo4k*nw_7@hchU4mJolNh4DaOaG^cCSOy9qNgHMN9gNaYe@J8q< znb;NH8}nikx}6##3^flm)P+oaW$dYW#eV~Eb!XaG(+oMLcu5>4IgF1Nz%r`Q40Z|E zyA|HDd>>>z(vH0VRCHn31}rT0y5QQXHPu!B2#|H`^4ho-_x$etI}cmdb5Z-SUdkYG z^Daw7*}g$(3UB@amZR%Qgmn5^)U+^Pt6v$PH7zR49>}JskYK`qjc-en6V#=vuM9}7 zZAI3!U_Z%+>C1}m=GItBqwV#F z1 zWZFM5>&XwBTd{z0TTes!nZie|inLZRMH!ePh?Zy!XlO0o-}edEnX0ZZw8fD{h&1KW z6Pk^6p=kj$qTHoAoKOrz7V#}G25TGra)SU@$VCaH9}?(V(OqqNCC(@)23k?-txUmH z9{jWmFYHv!wjYC=iv$yePmpos!HPHTH28{c2rMU$dUH8ir&Shc19w$T4VHC8nVPJA zh$A93@K3;>GB5pLM~F zFQn07ZeHx;9|AY^-=G<9?yz>9WYBgpWsR}`rF3JHB4nB!f-}Gu(Zz>ZztQ_bbY%b%Q8nbq zT5L;$OUZgiF)S}btwtk(2g2PFSR1}IOE9uS;HUDaTE&web&-RlsBq#kqxlv;oV)RH zcgQ+2+jD{$(4j-^PH8WNF z?mxyz?RnXo4ywgdGORKo#we&pLRLB;3oMsTtxX%$J3_Z$dAfu&neuyg-gN-;k$Hri{Mt^<~P_B0ycuB;D zRTpXWFZ=)p`#RuGrO%1~_&4P@=;|P*eRG@1e^-}R-Hwc`fXk6L8*Biz?MaZ#6n_CY zw|e;C31162R94J7GH~)0Xz$#SEK#rMkp=OPZfpdLvC6Ar7OXjM13cI$E)qcM;P+EW z4B|(iHZL(@I1&hNC6ON&_HGceU=4VrgR!|0vVN{ ztyJ1q|IxQ&SPEQ(2z2B5d91DsxD$-Dk!0xE zAo=7mIBuI`47M-y%VHNe^oeHQ))C6#I|*Q~zzc07wXEdc zw(lSHP}sn1g1R3y_Dt6aOd+4oya6H4f=j18pi;<6{d@#!qy3AV59AWy82Bw2%Xfw4 zWh9pN5dkj`+;9n|H^U{o1rA?#Bw1c#Ub*Oc0bLgiz~#;j8fIq%n8bnk zoggpU*se=&FcBp=CGKP=1gHTrkPL_!Za60~JT+(-JLP-gjU1qZfe571fQ8P<_#~xl+K61G)KC=7L1I863>l`x{2GM{7iB?a*fg!m6tOBYd+5&h`_&J?Vw8_L9 zw}X$FL_-KSFoLP*VZL849Kg2)$C?(yM0Z4s-WvA&$d+Ef$;2vnbLAc`ZS@y8fVBa^ z{P4&|4UtCtWe-rmoX^&*b(WT=RH%L1HIk{Zj<^poN&3mQPwj8pc5f1?47h$P;0`atxy|1y>`YqdhM`33fu>feCQq`fNGxV60e`MKCz=A6F=zNCnL z@-6>m_Al+gKU=t>wtcF;Uz9dH7e~n1kUqM;|X*0KX$f0*L=6@%mzNi3{w? z0zW)g6SLpo9W-oqXi-h9UWqNcdTD92|z1?8!s1oxMG1X_{NkX$FAQhnHB<9 zGc~<7QePYD{}sI7>V8X`#`gBFuDj-M+OdZIo7C_5FWPTvpFDb7+(RTm#%}+A1&BDJ1E&Xps zn=9L_+z$8+<-e+q+NNJ$5xR3gPxD(06Fbv5Ymoi_n%(QxO5zIGo0yow!6fM=%bc=H zOqU`|h$qoP&HwCNxJCPy4yLYrhwk_eE6e@S^FApGOyFak%Qq^se|Z3LF5-#bzh@RG zBr&+41KE9{Yv>aVzKsAj7T9nhw@l8!3mNc`m_kl9<7O_7OLuKqZQVFK8qBFwhm-fE=J z|K=LIOIw(F7Mjp@j_D*|8&mjS4rsATO1uPW1M@9FVe04{SQUuZ@$mvM`5x}k&dXM`JYbAoFi6p|ETF z@*&wUCQ78TFawGVBf0V0Y~`McAMx5M@q+T+H}*4q{$Bw}dky*7YNxeXtY_{-1KoHq z*Ke@@n)_fVoH#_2HCo=s)bU%p?S*9D_OavdJu7xw#_K+g5g zzy3`p`c^1>Ba`&#zsz9n>3TTP_LuX&tWiiEV(@qF=zpIK@fs4O*VIHq-Xy4h3kugx( zbFDfQg#4}1Pp2x)Af60CLJ&wrP^ppM24Fl>SO|E7DGUY>_6T<1ev~xuCLr)Y02hen z73fUFJ|3`MSkS+IYZ@Z z&+rPgYqTIi?rg-%)LN3aojOz#d!9>n+o$6$Q@KVUj~bWNVBDcy2C|>rD(`iOLDuhX zi3j!>)a@7+HcA4+BPEP9ty^Co`FNaWg`PIP(El7qUH<&j|8d%Gqc>pu<1qa33u*rN zA44Bs@j^#IhGPYWBY5;VK_nUy);xUaP4yc1SQgg9%djz_e`d)wFw*p# zFN0UBud?jdzYp&v$HAkughB2WZEzK=$ntpcUbf`vv6F_PA`GAM@+IRFBWHqpt>Uzo zTLj`ap00N9K#MLEn}cmqy#t()ZuZUkX6Lb$wlu`H2l&x6iX^J5CPi6O*wI0nW2JtM zcX9Hlmmg&5dfz(4S`P_55!0%JpmvAjrF)V@jIK(0v!r+O!97FepUG`3(t1^+wCA~0 zonkhVkPujl$76lP^BfAYtpyuFmAbaru$|G^%g1EgyhhO+(WCz==LsIXxIComIaE;H z8V#LW+sVL$DCm@m7`y)k#2TyJDle!fYv?S$j?^q#N_OqnYf^)Sptb4Yoh=p<0Z!aW zFZp5OEA!d@)?QXf@a6{UUSL3QlW2@a*`*FCE}wyVhyK6?*%@&zd1$HQn3OKgbfH8B zf3chH)2!i_b(8~=7Un02`GnfBT9Wq}H;Xe$s+>LRh`@ON{zt-3*L6zROt!5Df<7#^ zfDMw40jYSr^y?x@Hqc=*a#&AYkr&pJpi0}L!U_D^Q_l0|or5&x60Du%~Ckg*)l5q72vbl?7m@Op4(dW%pTN?buct0q3E#Hn8>Z{<$?tV$6>g$!#g)!nGBt>NV0d zC(&}@9us4kr`xqKP6Oy2_^^2Q*5RRgtQUm8z*WLlsRgn{(P&>$@lepnT9Ld9Um@XoqncNv03S+yR+6tgA9W2A` z<&whD(4Ng<>^h0E@Z}GNKPW97%*4vnyiMKtGQm1)W#xH1wtwWuIzN(JQ_t*zlIQC~ zWboaA4Y<3_xLu?2^ezr4YfLu&?6@cssccIbQYJUF__8HxB6C5b&QtB)^>HC zira6wgk8q$fs$JIHKsr2fa-TqHes$8m43IpcugQi*|rowGv0^~G_MQWZUAi>=*6o{ zbV|^@IKbx!KNYmL&fU=^eOZE&GM#Yu`kV&eR>p|6l)X)^e_@Eq%Zxi(Twlk}aNcCLUGbtAhvS#lVAvrH*VpDm7YCKN>Qx8q=%x>-)C2 z!f;@LY%P;bzAFv1`dy@XPaKvlKOoL%r5|q!#qr?j%UL|c-X)uc9nzPOg6&f0MmGM$ z1}ZD$Zu?_n-D-CZhlqQ7T;h;O2;9K>PKsF}4qeKF^4PwdFKO1)9QuUS2c7UiaUA_4 zDAtrrf{#1PK;w|>%FXLEsdJz5ZXp4uf9=ZD05=O4Hy*&Ur-M|BugE#if){6Kv41ow z7Vxfr(zImjaKDU^yFo`9w^{ksm z|Aq}V$15f~b5A?4z7V2ao(_{Yz?*85^(?7o<;%l2v>UK`;8tEJ8d~7jnPn^Eu7k^_ zwsLk}7#r6(+O#7ntqyj%CA6{fvxgtTo4pYpy;fI%3i@%f%x#YP=+H$ix&_Fj80=C) zr=(3ZB!8QMw8vdo4G3a1Fc+)2HnI!+&=i0t0#&-UR^43t!kcyV0yg`rM8)M5!=EvH z;B$%D{*}4i&fFs2EY}ED0Yg0(^_aSGaP8_v+*@-NWqJfGCZKh2s2h6}d@dTNl)0^b zVNH!clRH6y8*;nKkAO>31We%K`gw7eXozpBHTZ_*vM?`%cbfvY5ZAX;@H=PTug3n& z{rVM2=T!EZn4H$HhwZpcdW3d^)7A=x2VC?Vacs%ddO!-B_xW3A-O7uscJ$~2uwJrb zth`UW3DYvbQx6T~P-$6qCA+qXtvj+q=&JKv7#4A11;xP5v#SNGjl5g+=o7a3ut$9& zxJ&(ynyd%D2Pl91+Pm0P+XeZkuUUBnSSjm8Zuy0}BxSoqD_|4p7$*KnzcUle*^*F^ zNjwKcI+3O-xXEbZS^NV{(bjtAa$QAlp7C3>60q8}u+-53@yNMKHm(wk z&Mt*D#q}l@2<|*%dRRlQb#wN2WC4QD{j$N zyX2uhdz7n}MoGQBLJah&eJ43O(=YgVsi*#SPBf6o&l4Z)$AZhsCHvYhm)_Mmp>WOZ6gcIObIazI1*`9Z%k(Z65ZU&> z6ED-5X4Y9d6nLP*BU_`${RB_2yG|pe8HOMws<5u8r1;ZfY_AOZy(cdDyH7X~92X0p z-FRQ>rY@?hGa!`r=QG) z^2P4~msIFdJ44{?K1Zf|`U6*2iZz`J-m~rBJYY%ne1Ka-f%>*TQCTBi949Yd2lG@9 z1#KnpN;#o$@O|904Ek_&S=+|9Il2M3={4R7%J}lGQBDh=Uij5@+JZ|HvuSbi7&Pu* zq8SeEND|T8QN5<8K<`lfK%56BIgXC!8ea8@L+$7S# z)*ZOYGN3v$=_?GUu!4`kAKfZHd{MJYu8&=6hTq$DCF&8|ySGrShY(7rZUgPWL(3

172oP$qaG2a`pasJl)2~Q(Q>HAY0OS|Sy2swo?ar; zMrA`RSQL0MtZ(0L+=Tbm!CgoM?iP&;1WoxmPw%iEBfV));}jk}%lO0rUN>$P@BGaQ z|Aa#00GQdLe@U`Mg%jCp42kaAYEIlH>&;(pkb)S&2!EWa*3iKL>_$~NFX+r2(D~4p zn-&9iP;IYeYPK3*+$$86bgy>D@L+Oiy$ZcmC%t&n$N8uTC{M)`bk|3tW$(e)U}67_ zH)}EAYhA3Z|KTAOZE4rVD@FqX2?5Sd0VJeGR%YszcPtL%$)Kk`*M&VUp(=tWbIQHU zdVN4oXL-y%1fIM56YS(1hHH!0!Y0-gQ4DdRuv73fSVm8FKL*XZ1xrYe_Y$LJRQGZ} z2)hVg4(hW(09+Xg+w82T#4mvb@WHs30z} zFpo6_g{aKj!)TD4B#@w1+Dv`IrwLTX*!a;6@Jh7Y$tn43zQY4$aqBVQ1JM!df_t{1 zE$%9Zhp#||7CHs3u5!;kxCKMt@uN=`bs6mJ;49ldSIt){TilP_$$Jmk*CR*7_u65_ zRV&3CEA+7_3z{&Z=tg60fcNa%eXAn&z+7*X>#TX$xLdb*=8y93l#;GMlUN~0YW;ytFLKlGeuS==G5^inh+(hv9%?(PMSJj5 zd`l=Wk~0E>B!A%w|2kzY>?U&8|Aj`uiVlGq=Ti)Wrigc5_`$yZ&}p>;T)g6CAMvhV2bX({0j!1hl{L&O2{#-&*X{ zJ=455$w@`;3y1Sp=Q-Z&JoL_DP)C+0@@e6VzCTeHj-Od~?$g{IIXgI5Q86*FvG!Fv zQjaDZzhUW1SnwmQPT%|k;M#==FkZ<~r|UUF+o6oqp1)N}CbtGIWvY5Qi!)aDXtO~M zW9vmTu&E*s@K+MP4!1Du7lK3K_nd+#-z=#4;GGe)O_jdNn~2T2*($2c24ykIDI6#~ zbKrHVT2)?EY_)ZbAe8Z~Ys00|a7&SN9Ztof&B_30orjF{Y;+Ii!~_Ff;kdb?1YuDI zJLE7+9m+A@f;zUD&fw4!6#k~Ce1;3^{62$;)az+F4*0n=sws-szFRe~h)M&ncd3cg zxMe0PcTIXvem)rsCGEK+OlPETQJ2!+safn0Zc-E#0ZXV_)`E69*d#?{Ft!_3)N$$E{lE=|m@l|52s~OflNx7w3h$ z?vx?3{-p$!PAP0rGlPEWeHRUdSvgQBd~B)zYW+et6aGJqYVg##IwFKNyt~;b<+MfR zy5vd5u55o21eoLfCz=TzTg-oxiFOptCxL)msX6hS9b$SQGN>Qk^W-idjzPo5^Ic3{ zCHf&0x}ZnPd9Bh)2w5}#ygdpN?Jg)nCYLVRxzPV+Qfu}haPNPr1oL0p%!klmFNJo( zKlC>LIfc-i5~z=e?OT&ea|#$8GApo<8v?PJo{4cLWicbBbgT|1-*5j-0W$I9hUT}R z+S4f{D?^upE5JSU&R2v=>{i@A0N#qhKd(VaOJ+ox9SejcqOvoMAX&0Wy6pyZtYsfn z4ZN_$jVcz8zmWCD$NVJ^WU4#kL|Bj5GKMt-zFOXU!xE^mAX26jXP9~WbhR0`qVdEP zg>UqOTtm04u_>~tKRnkptNX+S&~a7Nqy1Z%@HvSHe_y%kPdrrup?9Ya-JQVv8c?R| zS-+QuOg?3HEUas7MexG+=$6NJ45IA|WFU(`+&H#1py@=?(c*`3E^f#`c84l_e+#2T zd4=6)Cs@G36o5a*iYE#}T*w;YPn*BDQ~nJ*lvPT76Ck-2agkm(o!$dN?JSVlfd6O} z76~Han!S#>{0X~Sy#mEI0FX~JD_zxa(i54Xa*IrbXTTZQ~94jxSt}B{S^EFMqfgV@WGzU#mZCNm9 z$bHUqzSR#YEn`_CY}$573YR~%I+4!eDn_}h-hzbyJ2y@N9DM@jFGYX+`71Zi}Aq=AILW5iaHMjPJjDeiH0^&->-<}duY*!l^F$&qkng0 z+XbJf_ic~0x(pH%s{Nt`aVVAm&6VvVyBoAdU*NyiBvg>$hEGvrbd30DZ%z#ImJqMB zB!i#o5^7<=*OlGyYTy2x?amS3iPr}kek^6Z-W9YY`5f2T1Uh^OoiVzj#tdOhHLtue zSgU?Fs7Fxurts$6f!s3+x+6ZN(Lsiahk*Ui^)U&kH&i~J9oqZICDHhC-J~k(nyt&4 zaj})vStD_>7QJ)}<#CA^D5nU3?7V$ddxxvj03YQ<-P!{J6{o5@aW0|Wcb%G#Yz`)^ z{CGZO>Tg#{XFr#GmL8trpmJx2M!&TTXxv|2M{55Kkslw2%$r)|dE4DmJ1(e$V%#qJ zUCgVSIK#OZBiz03?2*8iB=Y$!pvhr#btUG&sh=Gdm&O9!ve!%aD5F(m;tWAJ=X{IE zrH7cN#I`NnI{l#wNTi3&^MvvV!G*q7k3b!h31*_A8dsqj7 zd-~!1Su?kcLC3t@0Gia}3fUGX^uyJcTgFdouuyuKT;}bsDs}mb%vc%UvS!e44KY@0 zFN!$=LsutnBKxm&=lek4xZnaP=|$uC(o=HN>_`|sFl3Y9I|DC_Hr z?%Y|Fhg(0|Y{wW4`}pSm4w3Lf9cQu}^pCw8WaVnV5ir9C3q)(V_|CB{jQAbYPD*Qv*2U5D9daoy@Ogi66s*m0D&Qh1hY(P}G=@FYP@88^h}DO4B7jIiHpRda(V z-y?1|+>WH8`yHo(ez~w9kfy~{SZq7~%q7f+_3pa{XBzGRhnSk#!6=)*s9JBIGZT22 z<#1F7Wc{6zB+fYj7KPde3YD^MjFdk&yRa( z`PEq*99C;HJ!zTPSp*zx#!>?8=M?ItImpmnRl2trKFWC#YFNE=4moNCd4< zGRsq}MajsfK*`v9#Ze5xrZiA@7lbj&uwSP%e=1ViteNaF_&-HbZO%3t|vc) zBp8lU?=)OmZ@WF&EP8MC^;IUM?A^#y9*6khUb~;gR6TJw zwVG!xPwG~Q=NBnNwfS=yW9!GbQ7-`yeCq9N{)Fq_1{}Z!TrrxbUUeq094wWAl$M-l zu*WFlwE*1TlpUcaGy){pAf%Lpy-u~GNC-8{n^~OjS^b)_GE;lb{d(m-N@jxyUD)fd zjS<336UfWsI{W^>1Ad5Kwwf=|<=eo)C|yDCS@k!QIu_OIsXs@&>70Lg(sDfmQ!C{( z3i1Tba!CVjJZ3uM7LQ!4#Yi#C8Rw+*&}B0BF>x2g@;)aV6Z(9Q+?6W-%Veo=K{Q~7 z$o1Yx9aL7^jDth#Rt5{=-s75@x)aMYB9>L{X*hQ`84Y5l^2F{;J57& z0;ic?kuC&v*-<_=Zu%=uGkqdXc4pT1*l^a^YjuiC0O%6iBBC6PZ%=XVWkXs*!K+@J zH5^ZV-17TLnAfacc+@jBEp^n1=7iH0q7W8?@G55`&aa-?GV=J7A(U&uDUVC^PSSjt z4r}`KWSvExKUL27*srN+xzNC^ZPYywRi2XF z%VxsdYq(;dA)9ZO-+AD~SN)My*Mxr!9GB37<_qi<#wc)ASaouNm(rFV=}tfHVbv!; zQuyHBpJ_uMetIhY8hARgqbB#NvhZ{|qndQNNujNbYb$S+8nqzFB722KC%E?s(#%d> zeE>1HC6PJ_2wP&W@R35yt%?pZssHmADiI%u_lojFRr{t>Rqu|$$AV~XsMj5H41(D6 zkpC|L*zj9r@D*~gKj}a80?R;CeLVfQthyqK9(KyZ88nw^eJzg_PH`n65r4oTj{5HS zExv?QS zU}n7j`bByVR>)tHsPDsn)(mvktH=azLuF0L-gWV~1Pkds-xs9Np&|=G`rRblox5=u zg|2qpCzA*IRRGRnov_UHvN4v- zegf*Jqaf+RkBfNPLh%f(TGnc!E~I-K3Yie!cCMbaeb6I-!-zU{{K*3d(TujQ8b!JF zyJWfydlL2+qPeW&^nepC>qt^O=>fz~p4lg|F0 ziw@@U*|5?lwe{*Au&))YQ3ZkF3gZ;t!zh?AsJcI~=9*M?d<&SmBn1wdUf6Idnq?ty zD4*S$4OqdiwSTtqi!5X)xHZ{_MSPtA?ACK^{g{Bn<6j5>aWAHv2u@xj2TmOOHNU!~ zNj-)1EzXMEa2)j?G!n%%$+S5-;2)E>|3dWCnP!af^EV0KqDVg-_eHl3dnJnOl4i>T zA%?hYv?T?>31awm=y9N1+f+C}KK#(|e{?y4U54xxOyxy4I z_rc6Yyq;3osi={yl5>8e)ffMi-mNI}$2YANyQ1HolJ&po9ukK!-|8@bS=u77 z^ue7r;XDfyrz_^K`dk&|`w!>m9aarLtZR^YaBsm)Uy^p?-u>5Pa^%LMJW6B6=-+B_ zu4Ork^pdeC%BfAjsQ>-*zX<%#ATV7yDw!`fY5=6+qeMRMcUnFSzG6cuPB2%>Jbh|? zh%|4DP;}63gR{me9j+?H~_`vcIIpEX+U|fYk=2c8zSn} z?5y;hILh@TR-ZFT*%r{1*ErIYDAYC_LCkXqc#r@X#Xq!Xyo@dDaDs6LY^{T? zB_@Cvd`VZvGdv@t?M5Zt=f^Hw&kzQjr;JtT7_rF9D7~i`vH|b{f6%T0{-Ja+0ow8T`%0?$ z`UHEa3!-%sv2-67+TgWUq9OFx(!(0+goPTQKRLEI1HaW5W$b1s;5)pQ07<;2W)ZHQ z`ecB(v^0rmHsk@}YnwpK$y^xSSMoO^(M80zCuXzGFOqs6n^oNA6|Zf8ReXf=b9F+C zD8oIPT@crqvJr3|5{emur`~RbG{dN~CJsFei}Qeb?mkM8r1*hN{HDINa>-6# zCC}E20dN3??ZFEpP)ec8wl@@f&uaqxC3PtX&m~(Y7(Vrg2QO;0bU=6M8ORlE4WB^J zv=9#-cDRU=eKr5u2uY61w=*&7S$B!>H)W`Lz4S961s}U+LnzAPz|w}up{JJlWbP+A3G}m znpHSo>vx?yt8;`!@p7sV;&|a+Ko_j5nSeV!#zUQ}JFqyHa7jc!Q>rSAi3w*D@8y^~ zV}va6!uIBrs*s8Sx}FISUhj-|tpP9?&&gZRYZ(;@pyK=WqVE1Jz$k7z4CahViy_2k z@ZBz*50Fm1@Md6E9Vd~gBfzA{SAA-$meW5@>mZtQV^Z4kXDB?#>}q}D1plzRBL%rh zSd9Lr0F(t1^PnB6f8hoZQaRs!cc(5TzHawVEByFjyY$UK#m`3U{prrF0`Qg~XnMz_ zh)<*>f^yTv7&yo>e3s2>>;@!heh84>oCl#f+iU?nem(doeJh|`Y;ktz*`paYT*lXn za_;zM%#n^{?x_|J21Z$FiNt_EZ%BF}MtO!Pmw)WkP6`cU6CbuULVSBap$f-(%gMPz zS3=U5<1RK0jF{q_#&oA|B|u5vdbrx3i_X>{-pPjn?=I{tqQhiYXmBKb7otTUXz{gd z=fMZB6d~nF_Rf+rs15;di`nBMX*LhOlm$(J?1e&P8KF%Bf^pA{7f-EC7YD;s{mHYY`~W;mgFQ;`yqH;s%EDhp2xQCG z<1oq!DMUCUJL?!jgGJk`stI?e3PI!}c)~piAW*!!2ZXcktqk;eer5~i*0?QDT<&P^ zJFl(F&;Cmj%tJTEE-UIGO(O(Lw;O|9tEzmkivH68vrB;R#BGI!^Y*de!8`MpkC`K+ zP9M}|INw$7GlfV ziyjygNl<|XP0f*t_28=eO1KZP+}oOeH!8^~$zH8_8&W!K#u{Mhx}iDPxxKIg@Cj<~ zg5H$uEkLlzl|;f;90T zap`m({Kv9uBXwuLtRYJM$o;P#aZ1k1bZ3F3@a2**fD%ySyE}KzDAOpaTvma|DuHbk z1M6i+z5`f&eNU*WX2%*JR0c0zYavYW2sHVKKL6AZ8C#gY`YbKLZwxLdxQ~?0EF_(( z^5Pl8r)N}Y6e&=~30INIG+YcN?Z+iRPYMK5cdGOvU6FVVWf7itn6ZGSPjHD(WFbp; zRRTIJ?W`~ZhpguAhDf=&q)wpkF0KF(JUky15HPiV6*2MDsuu&p_(F!^V|t=0?MUK? z8~F#EUFL0o;f$~FrXW=7Yon_>0e!gU`(!7#KjSPcj6HT6iLc zTm?u2fnfISRG6cT?!s$%KWzivVb%R%kd*=Yl-;s(l-V(8h>)cLSgUFaV4)q$D^yp! z`>9JlFAcyvz=qPt;E)30<&`MylpjJS5`ga}0r(7GYb5oUL)H6JfHU}uJy&5Q3hg&4I+NfNIaoq*@hJfaWhWMPc0+Q2x$Ta;CJI2OIE zU&L!wiZnymPkbvccWvdtd!d)~K?XG#D+9ipT3@2+QkwKo0k#sSxUs1K6QmJ3)dfKD zD2?|Op3tq&@dT8r=dwS2>#&%#lFCU$;}*r%e6?Gdo1K(?>Z+0Nuj@*J<_3RpM`~p) zdQt8m%Ju`!9%+FD6JXHt_HnMh?d6bl^D_WoV7G(j!Q_!<7y!phFO^}>EH38B{hi?|`!#D835J{}$ z>~#0x^JI}JF7zz=;&I)0!=8LX8-N>Y`&fTU=2)(hI(uH0e(Djx`+ln$4D++E+pBU0 zm$XkJmS{DA8tKkAo%1@w1nGr7R$xh;W{=kFCHn8)a_FI$BKdbJ4adMF3%6R!h}hlD zFaUK5+qwqQN1AiVn67{qFhQ7dTagAwHjdF5ryj_KGJIWJR9rA^mj}RXJf@kK_G3r$ zySW|MTi(fSh=5xA)bB<+8{fT7NkclJ_wAz44qTISf2b?;ok=md{w04V*clq|;rw+w zie1o&VA~df>p=RPxCu_Xd1e2_+x;LmZwg>u^qnj13?SXk0LyU3QGDXy7zB6Og6tBHECs-)an49jSV%IcUDTN;SB<&GkK1E|*)DFlKF41PS- z%YQcX*8K%3Nzkt2{kw8cczk-O;x`6?Og4!|Y(vwQad{6_!U&vmhf)xxq`RQx?mp_A zC>zFFiPH2CgHwyWYRj&5mb8>d*W0H)k9G(HI%IE%tPc(GbszHOi+f#CeHdh?cqCYO zs%z2R$?9AKq*SD~+(x!vGCOlyqU%x|8T%wbcH{4ejIXZ6lj924`MU04_+%|^+ZIxL>Q&X?F&vjGjm=&WDH-C1f>d} zECBm_&=vXoU4^gX?WzLaF3kk!{$zhcdRF18^~6y;b;?j0Q1lhAYBTDsq7%NUAp1YZ z5kwrvk}`P}-Yxt&Cp=5tP6&uQFEXuL#MkonXF#e27`5LaDV%S|C%IKnYSCanW6gmq zvf2x)aD#AC&f6DlSzA)A0H0Wk^NKXrWA>5RqZh5^!>DbBM*%hHPTVoyqLU|TIj}A{ z24Jx-zrjNihm!#X>{XcUn4<&`n7P6hC+sA5&YE!)zs>L%80nRZtZUldkL;h+dkzxv zkgevEWg6&To|2V?+#%Wnq#N4?nu2miKhi7s0{cCk%RQdY5ehapU<`IrP#qhVEjL-< zkp$}+o+QRFI?zzFh%JeSYnR4i7f0tPHpQXP2SWv*t$1}AQ>aQH!rDvvI^M>mIakwV zR3=829?p?^;-CjMJ~*0+Au_9*K7eNBYJUK1nc$#>H|ce_1+1U5>+3UJ2HlGJj4Z7{ zpIb0}!qT)4aMQZ29DY$%`QcifBHIEYt|t@%-6Ysm-S9(DRP$bgB^TwX{1>lNogy|{ zWnw0>D-b{3K|lt+SvAI31;p6$6RSF(fTe zDr^AquW4SN0^k}OdM+m#_K-$#rc|q>-2N&_`lOYY9sO!Wp@llpYvpa4F2#(<6QN{DM3iv~6i zP5@nFZoQ`&IST-#?kZRpkW6~Ht!bZziSp3;TwxB$7!+mCsSOH(yWs=bY3Z**x8R9 zkY2JN(1SXJ8DUUyJ?e=hlcuaNzr{IOn4w#|KZ1zcuZ7p5ZK5Dc{VriKu`U@tA9oSJ zFCYZDci8qUuhauERd2QPhCk-=!3}pp$QZgrIkmbU>Bo>mEL<4bl6$U-&oHFJ(;CoD z6@qoxk?sIz+^HpF(j>44{mKTTf#t#b>^fdz_NXq>@yLjzsqCSe?tIb;KX_L46f~}W z{y2&9Sqj^e+WtLE+d-sA{n^$GU0hl`rcwfXoNIuc`DpbJ#Aunz*wCptJ3ZTm{pkswQpoE@eTtJWY8qM=xwGW2`9LlawUF%0m(M|=hRl80# zV!JrG3vcU^><+3C_6)lHbzR*~hNbm`h6miTSI_qki6S%e))F{|<3$|%c=;*i$@{fk z6r_keHzug7A6JFp3q-UWH;rDt)nk4Ge>lba*!xUsY@thk|z4Ea2K3EHX2*cSJ zYc?$kGA?)%`P9lb#G2e$wxsVJUR(f)Q^rVV7zAP>TL;cYDB?h6>Q?C56T8+q9K+Zj zc9TRY8s@us3IXPiz2%eh{D{$Kk|o&t&m(c_)`fN36>d{;kM8(J1+@2uR zmDdGK;;CdcIvhE{?=2KXzpa_OdEk{_V_HA5>%3$e3-4Bg(OOHz<~F;YT&rj^)Rt-vEgnH zVe`OI9RD112U%K-JeCKKy*uV)UK~Mj+6^_#&w`{n_f$J2#9D_@jwzsnSpBktaHFE2 zcN0t)>4E1X$(FYhHY9x`E%1XJc+!lVp^<;6&rwcjW?oF$uwz{0-IO`tZoJMc_nO_& z{ zUSH*u0E5cGdF^$N(mnd2;2)4bZWBVp8s6W2!ASV?Q z7%oc-WPVmn&sk0z-}!Ai7{PSGxUH+r_0E?ic+aDE|DAn!j%4h4X$NDse9}{<@jxE( ziI+0bI|#}l3xYp#{(L64UXr>m!omeH9B}~a7632sqFKI|&&e<``12_qcB0tRvhmVE zHLJ|cOq2eIdb^9EazW_dWc?%a@HU>(G575kR(8H&DbbNW7+Nn!3;b`k{^W-%7Yczy zB!++B`STg9C2C$e_LvMuaEde0_#^z!CwB6lTNo(LEC3f&Y3|{%H-2LbV$an3FJXQf z4aNZXQqG4p`H@JB>&9T`_yea)5O%KnTAF@g;<$N+L? zP%bBaNV)g=H=Kw@1SrHxrh??wt}d_Z)>*&g*aQB<_~W#ds6UqeQTs;@7C@OWL>ygp zWPki0h9_Q~Hs7pR<-T((OXDAxZZ^>dpM3z4MXbd~3Bcq;#;m*`%V?0H%t%+g=P*yG z{4?aw=N~IT5JXD@EB3D(P+Vj2v&JC-`@{QD$J)9<<2M-i5&_t6A;@8vg+C$(K>@pc z8_&p*KY$^hNFqEr=GB9{bJ9BNUcieYrdjxd86?_Dy%Gp2!hp;UCd7Y6oSUDsWW>Mt z!eWj(yxnNmt>1tnfH5P9*o8zOxSveWTYLB-|2K0a$>2ou@7N(RWV(;IgTNymwq43( zRqBry04!0zW?T>UKiLue@@0(29|V-$S}Xdk{n=s1oRiyGy7hUtOU2s#N7K5=gb#XU zZJbvDI&nb#lLgOi+{Dg5rvC#QXy30-cqtaKjAX%q1$YLZj*gZL`U8(ROoZJXY*;va#NTLUE-f4oO>;#$tKsE8p8F(wyM6O3D%n%2Fp-B@-~O!_aX zk&t93>3#W?{KwE?Z>qq%OsB3V&zK-H1tV}WEkoE5d=a4Wqmz@9$|X5^MU`jtPci2v zA_EB+bEY6k65LEC(6mLg{@s{do2Jhsh2NqSgioKxlYlO<;g!jR=tVV z6`J4O`IME=Ox1|ne{2v>AmTiFIdx7kX8}_(;^{wMKGv6bNX7`ptm@jg0v>=p3k~oE zyZ!$CrCq}Qu!zIYe}7~c0B53BU%z<6@|1}-GEb^z=PZS@<~OqtWZI8J-#GW?`5D1K zHdVoM@M#e#7Wm8zL89=_m*5((km)i0ei6zNUX?Xh2aDLp0UNT-*h1Bn+#M+=-4vkm zeqOxBgs7q}fMphhd@BULAhG}NUz&S2$LV=Si}FKpkl?0G)v!54$t9v#Tu6hD5(ne{ z|GvBjDZ3R?yzoCFNDl&jgRTSO!2dsg0hlxWVi|_u!nE-J`3n;stDL|7?*&C(f(wcgO@ubU8wlYzj{m7kTpM-9SOY z`@M@BMfo?~@}aLF*3Xz?CD@yv7rGRR*3@wK|?%^aV~F9wPktg1VWdM|JpItdWPd%WJ8qg8Jyls0l z@X|uxvPEu{Y>5hvl~S9nIb`lGfzO>Cqie+~+ukL$O!Gl_?+8dV!TRxDt8bio&Bthx z&_c*igyWlqJR`PquKH_T-!euz!8zm1O`Xn-X}pD`+j_#QB6BhpN0$(>iELI@CLNaC z;@X8HgsU%API!)Xftl`b$Ja=APcM27Eu#7MPPkNIiLb{*PKz*OE3p0P1+K(W&QR!B zV8a?lc0T2JUtiz)+AtTm3GFQ`AKmj}n(d|?n$^%0< z)usq)y=1q(5)V6pU)%yvrbyuSw6(rDhw2{=yKWs*R^)??LY>~smnTA1mr%}wqyDK} z68N8UkKvx}TWb@kSGb@zkL%N2lDvP99UnM$@4N!<3)OGRl@>n zlCbU$hRoaq8j{tn4VUhbu}@N*IdQU!Q@>&kXLf_IY6J~SCPveq`DdUd@D~R|;h6C1Nh_XR``M^@X9h zuP;x0rp%MCj7&Kk?~(fDe9@U43T_+ka@Ij z$^m9yk~H$U)6R7QV6_m{l76Sg^AcNbA2%%E@5IPJV<_(UAPaCzFgbp;X_Mk@%bfFd zMmi_1-SDA*7I}B<=OGaM99^74OZ)T9aSmlmu*F%UiN9j=!5dCTo%*}k50pi7cR3Ox z=~3PlpVrpOWRx6&K^T*u<3qZUMUJ6RVg1XekF1f3ymW{c3f5rOa#IDTg1dDKbA-YP zA_2j7?77{$GB?1jI!<}15x?Rc`x$5`0IkJSW^uQY1BafS;IIWuz$mZLDU)npz+A(u z*{kv#P*w0;?``+v;&X|LIbBuRA(j@q#|g<2#CM2rIJKj|9(@Rv?b~QMkw|_Fi^ax( zv%Ng4uM36Ie$6iw3nuE5uTe>jnC|Sa+Q?iScp+_mRtujh_RFX&{;JtLm%0K9I+l*C z3z>yITNOQ4dd5H9rNTJr-3+9PEY|z#*LJ+#EUpjmbfUVOQcoJ@be4EjBr>@3vbl{{-nl2T|R!ehXAudh;uFQ5582JJCG*h}Z>n%&d7F5E(EW zeSXWRgG+K^2TMhRhpTOS=w|Ue(i&JDbF#?gophhWU=~d!232mJ%&hBIvJb0$8mWMo@_;fRH6-6xRN4^ z@)94w5XvNy;_eo% zyLg8SD%bJTpwOJfTaD~Pt!1oZ=MP+?2I(tDA^8*7;N@l8@n&Vh}hK1uX)H;O8Txp!aHwWi|C zZ~NmourGP=y!~Ns6%set3`oP3F1<}nat(>wAxN$ntol#%HazdVsjJ?gvabDhX9bwX zHGxN{DHqdJB+8%1F5#>Or^9DwXMKE3P*CGR*NWS&=AAbE)-CiMMA$W3iCJ4O#KKB% zO}(`66Y77;@oru4M%1%ab2UG(EGpTTxySO>`?~HA_-zjS;>dwD1*hhW)1o|HTI0@|tbnSdQAJ_yLo7 zNTx$ZbtUbs7&FnH1@=cw+)vujIdu|8nHnGWjndr>&M610BqG!#>@Q;4>)$N|)_a+b zBG&jeyUvqt`$`{1iTN~IuHrzMeW(i#7%#`?q?qxGw8a+me5VaAl41gxQ#aINj$xL1 zR-!yZcNMNW)ikNk3E{6yOir#6on_l>yzGB^P4zVtt1BUK)#WJXa>?1>18!W3L2tQO z%?57srA_ng3C<~#pUer`(PL!Tyigdi`aFdf^CsnfSGWW={uis3?AC-1|JW6k@V3!>0VEV`@`FWN|jcAD@cHY*YhGDZMORHZRip?LZ5#r3+xHf z@83%uAD0RxMEa3&Am(-~$gdn(e_)#0F&z#isPOppFZzJ3T%%&RRPxMj#R{VUd)+ZP zSdvIdCJ-EuboaenJRRbYb6f5zoBdl1;ocjNiBR|DvqlXQWWm&)mfZ6)ZfT&yltC`-8*75glEC#@3bL$hP-EORzz2g1Jp~Kl$3$Nkf?Q1k~4%dW&29BaB za`Sb@RrI>fRQH)(3`}o33ry@pjzj57Q)9NrPW0V;L2~9QcdL>KL*SHw!%t)4O=dhJ zs7s8oZgG<-T;>oieWt@DPz%p2sk9N0zIRNDa@Pl(70d5mb1nTaPsHI$DX)W&qOB|m zF}ge*9k~S=iec#jq_y3HGN6vhW37!QrZbxu*FGrD@Cq4Bwq3wD>RpD0kJuUce)V*; z;GSbPy9^C>2>S@xsEdhlQ*Z9`hih&jAyQww%z@3(!ZSPE^;&Qx)nwm~);kigcOd2t zZjnIS!nuzF>zLy>@?=Rh5MUk8s-c8$;EY*e3SZv8O18&4=y!dQF)>T)II{ln;W5;sDbqGCM1m7=Y(@|A297=8)>|`1x}l6C;H+O7 zE-W7+_Cch+mQkPirj#i=L+={sBV-fE_ltB>O(}E3_`&cYe} zOXGFxO=mNbGbX_G5Y(LnB|SH3K!beBVF+C8z1G^-SDHb{1SdEe&T`byS!G%1N)XUAe&mYa+4TeZoO_Xu>(bp z{Uv!^YZt(NcV{N@aviwFr0j6s3e7}V*m)Fz!f~$$9Mf*aodktrpJJP%=XdZ*y z!Md70AI|!*!@gF|m!NZnWe?_U?To>p4z!wsT%3cAS1?@LbfNuftrj3wMZBO_{~fR8 z9dPnAN<2skvapwy1Sdq|7*5F#f#Bu48|=^6u)cC&_f?pe3XV_k-ksq3@p#lSyyekT z@I>W@*y3iEf-a>ifiTW%MQoqZeG-bchT>0Nw9(qq99necNT-~N-7}6acw?K5S+)E0 zta-lm8~3QlIZCBHS5tB0w*B#{=b600D{ddXJ(qj`N|S1C1n-W%a%{Uz>5J3W6#3j7 z_m|67lYspaJ2d`2>eeut;QydBr&z9drQTb;S(jE9O{$=HQvX z%Z^yerLrD~65F!uh*tFX9R3hiTMqh?n$CIX+Uwejf~9i zCR%JqQsYEh%H5_xODN7n-zbBQE|o&Uw)F^uE5x)SkztWQ=HEL{UiP zg|2u`j#`!(u|(tr!ekd3m;XH=!W5bdV1b?1WD{k!qo0QBbUp&Sxixa2+f(|Hu$;_ryU*PQ8FOXp%m`V)aFP(a7n zcW#H0_&6XXpQ|kUJv21~!XJ|tGSJ`{AuP?ap-y0uIdkN#$Lgu6KokYt0iKk0KUhSu zfJjyl-WN#BhjoQNb3ljR_YqzA-Gm%5iJj$Y-@VQSI>s0OifLhM#=o5)GdX+G`*Raw`N(rZEZ!y zQayKAWsik2!@3kHqR>TADDbJ6@^9vYsOB3UR=Bq>;@qB6o zQqS>+X2dS$?*KL=W_z!G?+7TUTQPo+I9!5@zb76cd4%31DC2Qa2C3OlQ)>jYzXuKM zXsKS|x8KOym|^_5p{D{qmn3pv-KRnnif#IBaK+%Yh_A+wY?yObuky9^)jhlm%7hU( ze$j~Jwmbp*5B*rm25lZBdmqv}KPwLmjm#C7(sF-%14YU|?&PHwA<1{;-MYxj*hZ#A ztXyD#52rP%{9dY=K}~?*K+g6FhvIQcleh0;;iVQKkNt2w%b)$*1?IUB8z)Ti?FSV0 zZ1`d~7;jP!a5si49bR=d=C^?04Rs_w)supfcqo?d{4yb%7kCdT80{Zabxv&waQ znYAgnBPQlDg%9>8B(WslE@N{hAWn&-7@|v#+MB{}{Ad@CtV))uW`*qa?x*~;85Qyn zYyWNVSOCV>c{`M=QC4t;C$Q_o_GN9t60DJ406()?7V=O}`z_TCq9p*SjfaI&g{fWM zx)ZTdbGoJnH1u37ohYAn2b#FP8r#GQtGp}sz@A4rP+Zuv%E9+fYo{yvpdV7pxZ(E% zcy?mvm-I>;5&rO7?h!$eqX5_LJ>E65gP9@&80j^f9+y$-IQ&#`deFm=71K*wdIGi} zBF)=V55;V&FQQOp0~u?vXeFAHEs5mpa)rgG-77AJaYXzvO?S8I`?kDtq-31(${vk9;nogs%HaWdN&(O;?zjs=y>7d!|;M2!1m+zf|nX5g3v%NMHu7cFgQ zPL0>a@1gBgaYADjn00sTPdmY@O}#$VLtj`^B(h7LW&rH7N*`IIOA<9!1&-jZJg?FbpHFo2k?>>8eKA7=VEsl@ZyzqQE=Gv(SGuJc1AaZX9z0vS7U0C!V zolPNY(Xie54Bbz9on{-I`d|lNA((#F72sSH8_Hrn{sJj=jlK7mtUlazxfZ^5uPggu z$K!pdb%NIrW0%vaOz5DQW6OfQ>Y+V&2n)oQ|K%p|Jz_^>aZ=5^=8NxDgQ?4QU+%)) znP_-I`NR}Xs$`X9Dz#nc*3=B~GxctV!=8TNtc?(mu4Zi6 z%!V{j18}m@S*K(2?zDHX?riVQ*tVzio}S`~l1wZ3N}wYZZm!XosHykV(14J#<&A+3 z2KC>znH}h1)K=TvSg&fA& zeai;^PRrmeM$OtSgu?;#mOedQeT(;4ZWftz?k%fr?7bEUYb^|mKB_h?D$H0cWf!T~ z3)_WAF=A`CaenSwz4@D43H$)aT{u!@vJM_|%6LgIus(Z<`kbUpzE050DEeLg`sjby z<)J4lJ0n#_D+t8Av=6oSmIRDt=kqbUc&VPprw9k03G=FL2H^x%KS_naCWvcn?A;W* zw(gRq>K5}-J#VBoAp8Lj3p8_IZY%GMc;zW;#?Zdv&mSc3$!S1H!5p|}iTXlnjGEwu zW0D<2YA1klWL}kTJqx`K5{{HJZD>!KKV7tA4<0G-f(m!J2Y#UI-j>Y3B6&6Al_&mr z-~o!6rt9btnpeDqm}!dFCN|{m7}Dl-dJTVVj7nC5NvY`lMiXYg72FGg{_-FF*}Dv1 zPJ5l&k++fc29gb@C5oCFugq@nGN_GnNaUUr%TV#im4J9~6UFfz`F{br!0-6ZYr0NO zJ@An&JYBGNgC3G~x9@UcT{;`1CgtFa#~ht!qMxy*$CKMD+wj9F{-dq^%ecM0%a&~k zzqKWCoP}NrN9q?*GydwifAF^|+dA-W{+oE^FeL`1J^D9m_(4!~j>#r2e1}&ibXZPc zYvuEUq!eTm)0ZbN{w7^8NExzzoHn{{w&6{<5?sx7Pr{f7Rbg~Web`A!K_!48>8y#5ot*$J^LrcqMt-Vwwyx#FLk}8zrDkOt|HA)%v7|0uc))~ zK$Hb_rTm+Q*2e z6hXFwJv0>bWS78rrxF~*hZZ$?AM$TRWU?lB{a0coj7-SOeSZsLyGD^?nFrV(-~d`g zSlFwS|Kz_E>62(bf+)M5lRiocvUQF+y~9Azt07eM+9w&0g_mE4rHi0FSa*LYf0Z2) zR2FLlbp(U+x=%~<^%yS`ELQ}$JSiG_fc-K@adwy*KSofzXS&e}k#effRaKnfLR~r0 zkipxx2?z%VK9^QR1>STn7pI&ks*vG{-u;{EE+(*1h@qz6qZqy@sgW4KREKS?{J|cB z{#tq3S7H3;Z(X|tHtwlZvJGnrq+f3I*n@XqfVrS7HdmTfF7r68+>U;t@y@@fkACO+ zszMx?W(bS`+<_~+ekKye6e0ZdZ$-lo$`46SpGZP~S9&lRy!4N%H@Po6!0t=+R@Lp_ zKE33U&!fux5K%L(viKLN(17~QSvmPZWN_X;V{LrrcQXq6S{~UyW_klPJ%7m+I#hIk zb3y=xgc*9jbGN|3AC$#V@fnuISoxG8l$YAJbJi@;vUVy3l};%JuT9ms&pB+&ygRrf o$Qqf-|NG~E5%?cL0Q|lkm9Bp3`L|890z}~X(>kZpHLU*rKOxIJ^#A|> literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/ios-splash-icon-prod.png b/apps/mobile/assets/ios-splash-icon-prod.png new file mode 100644 index 0000000000000000000000000000000000000000..903ad9ff0696428c50b7ead6e9600138d0818033 GIT binary patch literal 17961 zcmeHvXH-*Lx9AF0njk7wBpyXYq$mhT%@Gs@6$At+5>S*TMUY++4+?g$Ac8bSKt+m3 zZ{ZLXEEK8IyEK8&10>mR?L^PL-*?9y*X(nywU^#BIcXrkE5Qo@ z5IA;J-xL52_>}{!60y&!-teUi*Kfx=oh}IEYdTNj$SbRQc;vNn$S1vrDY)K+t;cf~QVUarc3#A$jrAy3aaUgDuEu}s4K_QN^|Oy*h*bZfWMK$; zfiMswH9UB)w)zPE#Et~b&l;;*f@=svam1?Gf9@F66lW-()Ui}u$5IEU5MYI{nSiibPv$MI_2Y>@>#Y2BRwjHSxHL>?W{$CmY>B&-jI=x?UnWF# zcS6wcgVhCO>7kn`*z~+BBp%h|I~#WOk~=RkfFAd5Ii%8f69fvUYn}h78oEM7T3-UO zK&k$&(AOxgQmD`K_BK~)Uw~xQqRwduI@&#n45k(A1=W*)V5u-#IQI#aJZ~C7Jlu1j zv2XLaBhwF|2Z-0sv~18-E-#(AZ!HR*nV%VKMxnOn!srd{VN}5)%y6nrNGZBZ!WyEO zV}KE2_)BZ1Hdfv?hBY4uV`-*HbLl1!MOsf$N>+j(6|2Cue9=}nY9DnAaYW|=1UctX z_~@FGR^VDD-!u=R_AsOgLL2ofu2WMNE7-8vkLTTUD94~F0>QWl^}@Q-Q7U- zMQTBc@9xjDFb72`N=r=JRE-%h1dYu9| zVz|x#S`SRT$VimBwR(6_jG(d};YcPUv zKePbX+A;M$jwrJTXeQE8Yi5Ez+K$RnV%>vClhgyDMFNESrAON4-NYd&48)(jZ*pe6|*%dOaLMy5+iZRddxo_>26g(z1nBc6LP z8iRULXu+vi27vhw}I8aaOf6f7ZIsF%bO4bWm#` zs5o&WdHf}$tJ=7*DZPA$92wbY$Nhbv7G*VkGZv|t`D_cze~yWblB+jG}X+ zhxYX`4g=^_qOF@9LznC+F8c#4Yp#4EK<^$}ZR9P0KHjB(&7Rx^ z<`g@6zM*6?k48`SJ}VNUmu%)EdTp-SX8|!miO9+#B9NudY~Mt5aKPOzXDI3vUEm`g zqD9W#5$`&KHeE+M7}HZ(*5FcY!dQU?v=fN)JMt_v)=QjLYM}rQVZ0y5pw#K|p9~@k z7IF|tVS1w>gkYPiwJDZGK^P@(PW=*}sIysU&g{1Z`u-(s_QMXuZ}%Y)$Tx^U zNZQ=}8gkI}#N!j=cTVo7Ayd9Yl3c8hjTI{0aNhDNiK-ld?0TWxS%bDRtl4MfiX zqmbmkkz+-e1uJm~bu={A3A_6Yk+5k*NVvTcR>*1J8QkcXD^+9o>E zw{O97&X?%zZSLE4F2)HF&~CjAWp>EZpxfa)p_3cayHV%)F$*+p#EWUV zrC)PNal$F@rbd{cSE5ONNFNuy&W_Wo^fVg2uVXYbeqOBY8=M4|RRn=|m0#Qp6G<;7<0T>LSFDECKAs1gOO6qvLtt0dWA z$)5&^$lG%eP@6VChm5I&)5Sd4cjuoda77snz&4&E@w3JRT+u(Ai3Yy8Y(rwXUI6G1 z=UUrWApfi}HYP3;|FDbK>b2EJUYs_-ieLW!H{$f)F4&Adicp_+|0guFQ7xy{Z! zMY??wC>AhLuFt(03#?cPNOTCZZNO0DHp5zj+jDIIagSJ;&B#z1k+IkS2}_1a3`J{? z=wuwGQ9RLcGgK-1FE%VgI-GV5&ST}LQ8I-NLB#E;>y2beIFM$}WLY~Qt#D?Jvd#y9 z5AOnS2>ap+Er~6Gl`W(P;2&lJh5UF_))#1SE{F`S3CtXj1#Q-7nHhetGD+xSa4mPH zX}NXYezOO{9#9vMWwCCzSefgELo7%Df-Lh-9|)D|-<5@;2&W|^(-L|QExi1@!cROf zsk%I{xu~ENUFA;Yy~2}v!DsEb1Bk6K6L5biAW=Cgx$Q%Hh+|ta`JjE@uM3}O5cui) zNy$Ha2JhQK=ieM?KB8u)0f&yI=Q>Dg=4g1+Nm;;Cd0xJPMac9jK9j{U3fgp|4 zaDh<)XaJim4V2*tnBIrE=8xEM3T5R^K!|fOC8*RPJW!917+hVW^XW56=TM(eF*;8+ zDeblI{r*U^3-7Xs`qiz!8HrMfRvYmp-ps$5>!xCCNOj& zU_EYIP(C#F7CG3niK~LhyNt>frQVhLJC*hr zRsf1MM}3KqTZ>tVTc_VZ=mKH?4K=ziG1&Spy2ixvBw>x8J>ZWdcCXj1>!3xoXAs>k zkaQ!Y)fGlt$~{gTEtKpp45v5kEgm%i5u0Hio--Hx|tU?y=%U{BZcc{5=re< ziQk3$v6*meC-T_XI z*18ZZCj!^&R+k3U?d*OUO>h=yzJ+r8`dMn0uiCz(*DEnT!L1c#cF~A;%15B}RghGF z%4rz+IFBtkNLjso;_Ekft#?z`VIKhC{61MNKv9dBc%;prD9Hn#}>G)>HHDTV% zMcJ1LlZ4nk2IuB7ffkqJSMY%!@3_CQhonbfeH;4{G2Q}qIR`Az7Hwlb9bb5sDzkzi zUvZt^nhD1%qCD&icAhs*&W3~eS;05wAewh-F@l70jhl;t$Z$AGU{@e!_@XWAK0aFD+be=pzhNc{?7o=9Kt$NDaKR$_p4Dl-L7u~K*F*@3 z!DvWYb#EYjz6DdXf1H=7DbQdQC}!W{bQmr{*;L_^UU(r88@Bwc`L*=qC(GFSxVAl> z?9_Xw;SRLQY+)pI<2wz0MQumd#Y|DD(^9V=y;eILU``!%$)aGZYS78>(`S^mv?79B zX^TWi>8A~ea2u*12lp^U()h$@_a4g|({9%XNNI+|kSljvA!FS142e-R^YqgIQhh=Y zkvC%-PVKu!*8Ic^JH7pRjF9}NHj1wMZ89PA5kO6Ep$;{YggZ-urV$xAk-CLp^PHpB zAj4_^)l|gyeFMMV;PyB%#^~{^uoc?&8WoyT1;?1YeqZiq7~5hiiv&N52qY(g@V+<# zIUWn1b<0X?jaXU0;z5LayaO~Rx*>75XBsq0esOOI?V-8uum@W#y~vQK@oAKfGxI_2 z2jwn1XKF(jj}Xk!*jCtv8mk{)d2b-euu0|E-EgxrD**Dd&4klYPju%1Qogz#iIc<3 zeLEz#73R}{6TF;N+7U?GZVcR6;S15dmbJ;jzNK3#>k+l2V12zvlKcV*&*Q^t8{>j1bz}@ zG~2OVW9!eb&_y4Pk2|j^k_m2)f%bJ-#mmrJa2ou1wZ0g~j1sNrB9D+AH}#+s1mXC0H{41Rwc{zOr}Vv+!7WtL!ZR+-9EXqKkx_ji z`JooA?@8W(Zj200Cn#JQYK9%Erud%_j%<=~pd!`5g2p41NRw4*8RUb3KJ>Me`cX&VC z6^%Ynp%HXa$pi^QVy=kJz#UYelz%!aVo0okM@p>oiLdChCrO9$@!k;#+4>ySm=*(8 z3h9aHz$M-c_fqauOEwzW9gM0gL(qOppkE}$hye%;^)42csZPM>cdKx`LPgPaqz~3F z5qu*c^6>uoNy6Wh9FFh9r-78a!9hKg!#o_8Zlwz#PNWZw$^F9EM|x`N%m;tiG7x1l zNx0vIml#|ITcxHE#5c`$s&DyJC&tkAe>|Xjz>@7senn>-EI`plbUIR_dWEV8eQUu6 zx1f&`(Q&LK9}nmGH_ymO$5v`=sW4VLw1J6#4(_!wO#rQMUo5?*)tIzqLJpBIB8Jeu zi(g+lo3Ibk%C=fMCwoAR@4yKSdN2_MzLE#emHDCBPE`unUsr~)f`vOPLJw3->;lHi z_{{!?2?tPOv!9*rH84?PgVrO-)#!gfqy^7vO6 z4oCawh^Pd#=J~I6_2?$z(YXA3J=Bcf;ZfLD?FzRjC(U3;Y&!V0BUne5|H`BQl;d(Y zmr{*5jDToihcmj*+sMeS*!q3fiu8Gea+pIP<4!E`f+8N9A;<0W?Ydn?4Hx(y~50X&^WG!PT4o_9US&jKB2>0Bsb`Fqs9is z0C-eXquC58;@{*bcRIZzlfE!6{3U}c7U(ytSApDr=+X&q2k;X%Z2E+~{wKQXJ76F+ zcCR6p?m^UK_u80v$V8QhTq%UcQaKjbwvbruwFCoZ;w&25l?~#s&Nc3+SSeJKWTb77 z!3C_Za&>}1gL?s_P|`aE{9m^jVGIu>a|tsd`)}q5$Td0Ttm*2wyKGv8Nq1v<5;m-vfZuP&ws-tal$R>Z>J~TZjG~|9AsMJ)yHSq+5eO4w z$PBGa9QKiB7>?8?CZa|0fE2?>MlWNByl(Ntu#Knq?&&dpIN?B7f83aTyyO^+W1#e> zUd;6KZD#115r`mckbxgK*GPmhct|g8HxwT5BYzm856Pq(a6|;yI5P6N=`VO5zQ}d- z6kHfMwoGjPFeHFHhsg$Na|aBUwaBFZOiHv3lBW764B-FsB2XoLRDkFfOS+Wi8QE;* zb+?3ZC`62Ln2G4Ji3{IyCNFP}G6Y-N4L|3gMpaGbS3)|gJqzpeaR0%4b2Q>`8Ro1?_u=%d)EW=yI*(CE_# zf4RV(D#~=l@6YI?nU;PHTi!g{x?kxgYFpwRiD|{kj(=OE$<_Jr^3z`)`>opVPwagz zxewubjB`Mk$@{m4zdv_)!l;sR{I#;<_leDXea^prp*X0N{~-@Krs*oQBe%eQmSv&! znP!KJGYxt);o0UMZvI74X{FqMdon^Hc@Hqc>1n#+Ci+ZcL#_W230)>y-GS2A@XIx* zLs6AcJy0hS^{p#H2Hd#iKIlrQ|9A9md(zr}EB0TwA0^ZImZT;WdpwCk|FSNs9_nA* zTm-VYrvKVO>Ir`4tB^IJg>)+p%B$cbuP!Q|`Zb{pN`}nyKu~{GgS; zEaAbyvg#_!RxF#LsrOsd9rf{svm?KMiAysgS8tQv-DgRMa%76_qEbHCQt{j3=qG9) zY*yQX;uDQCvc2b16#C|I&=9A?iNV@TjV|KYA|HpR4`~4dNR?B&G@J#If9ox|JC*=wsBsM-vKS z1~>gi=C`Q``TI8c2Sq{Bk)^G;@0xGV!~a&U2b#2H(V2XV2=pMzZojKLBCELn*DC)g z+oVMkT#5~INtI(M&srUo+9m%-XVWxlb2J1TL#a8N2o8Gx2@RF@A3nlP&Lb{mF!h<5 zMlZs;wBRFY!6IfFfW^~qxIVL{swvwvmJbO!2#=&w+;;*Sxg z|N0y!(Q~3_8>>Ds7ZSCoTV6)AG8HFnv)if2;Hl zaX82;{Lx{inpf{8v9w}F1d7=-R(Q2JSxWUlU(wr*EQ>Oi zK$ri$7H&V1;$l`h-=3s{2|X-Ut@n-%^yfn2V1cxa)`Juv1cieR;RP_p{)b;U;rs@2Poe7m&p1LxOd<85XIVP@KYiKs zTMDLNP|*$lvo9>IBxU~Z8JQ5mV+K1^4WVm0KGph{L5ov1wYqvVd8Gy0eOID&q#uK> zXbng35po}zGe_nupns~Gtt-wA7?FQC6bL-JapNd#^Unr0XG~v!?;zm|vM*mW`Ilnr z1)kpURW*Z*WseeOzuljiE?^&-`soPYlu24!1(EFuvmTOi8#dhMKqmU|s~2j+{1o5` z+8evd{)2F0Tx5pZO-WjN!ip_DnhOo*CT0HrI(ZQZzu8q6H9jXvo=xX*1K)JsktYfyg2B;7jIH= z{==c(ZBu;4X2Z`LE@yfuT`rJ`kcWRHE zFx^L!UXI@-aC19PLm_3w@fm;vZ^jlI8RCmzs$#DML+7xc1l%zh5~t6Sc9GgYVRN-z`>gJD{CbvnRvZ58jpL8gm~wd~n< zxrj41gN{#GUrvT}o`1s;y-5Jkm9YD!-IL7*j$S`N=Sz>cI^idI@YrVx!bnXHqgn#T zU43hfJWsXj4Z*P`J%-^VIh^tMI-c&I2ltGo7g}G>sSa4)NgEhA9M$ptn6fml|Ex#^ zffMKF{e`=);KTVWj%fY$uy^KEPtxt2mf_yD(+l}4lr0S|Hq2=>%fZ)<*+4qcXR^z(}Q#M9Eu3U)7uYEB#5yXS8Q-Sl$b!n4!+0R_)tp&20!?(JL4q z4h+nE>8+f?0B_?UT>n*v)CD)mBjQ~Y!lA|P?Mq3GjTxZ!WM$Z_=FW1FN5C{SnBIP! zn|^lgozV}^8Og1{e&clUMTTx<@DAPc^Se`}7=6oymd6A7(UtIJ&K@V)!i8}=pOi&d zT#@d#kpft?cqjHvQM~LO$(Q~Z8K3Ksi^{27&Qi?zl#8z90kbVWi${idNAbhw&Z`@f z2cB*L_Ll_L(TZ~8R`1olpq;Pu`7qn+SL!c}8f7KXoKGQqK~Jwg~)s6~s_x!kpwHoDTr1rPU! zU5S>Fn5j|K_^d=v{04Xc=8A?u-b2X-?dJ-UdOTQTWon&tWM;etedb03x~3bH5ld9VZ93JBsQ2ePacJVzwO;eC+^PxX_ip^({7e#2bdl|oK%^hWa8{e-pqc&F--xXDXr-YL;Y zsjEqPl4?w|H6*pRHu|P=)GqXs!iN8THz2Q~x%L>6r96vCQ@bY6)uWmtyOp8nL8IhA z_v_3<%1U^pBd)w1NotiVb6T~4$i-b;CkcK5xc)`ex8q;ht0~B|UdDkCA{u1yRPCr) zTa+-?h)i(luc7an8tmExu!ouLAZ{&Oz+J<;`MSaB*sV=)PmZdQ7jm?@Em|16$g1d} z!EE3~%T&9s@%mVAI3Yd#88Y%$5j^5|>1E0E_QktblSg@?+t%o})daG%+|~Nvn@L$U zrN)9que5-m@BP}BQlGvbKx)$kVKV<59tur7fj2TFs#NsmEZ7J5ef{fx`{&g{4p4hx z!uRSL|LI%01>+|gDFns_2WoQX!qpQ{EdGTb8{Z~90xn{V+eH!cI&)L&;7!6J{!RY; zB&zNHxu9K={Xc#f%>>&Nbay1(a6oB7-t0<#av+W4=+Ay+tk&(kr;VzoP2VkeH2`~w zv7YyEmFK01rwZeFw3}sXv4{tQd71a9#rHT<<|6gB_{@0XK}o|G6PSB8#*?r z1yBhy1FbSX4-PQ>7nN{O^Rk-}PxQ4&T1;EyXw_am((24`mms{LaGN-IYQXXt_oWAu z$%;F!Fr!f|VW0B-Nh6-d_X8x;)zbkLeUl+w7?ArnWzIBM40sk50p&+1B}S-n5Yi*b|{u{ULyXC=@nd+ z>j_vBxNX16o@yxN8{QA zliM%w>TEbX!yd^-4=<=Y*#Ne^pPOT=n8Kg*0)fC+L1giFrrOLn$oxT4Xq`0S$t~F= z=f-_9B*Ie~o@)*kP8v1vaCSjpCcU0Q>FWzMNoUOy zBRp$Pu4fC+@d$Zju6D+|8I`TUb7ySqgG`K_)U*xVYG)*3y2Qf$FuUgD;a+G6ann}6 z9KC8SA6qmh&jv4d(1s@tH}I1j<-ashNY58O9$(LN&0Jtk-SFzyAReIz){HyylpWr} zko-B5qzre!IwMc#Cl1JlkxJQ8e7&uX@Daa02J{aG4SWMJ5C zOy!fNwab~7$#bj6StX?D{BqrbMx7~-P<;tL;^(L&8z6pth1T>WS1Hv>!pUX|p_TTN zh5W#O`sqqR{E{#>!EMsOBZ{}du%kl_9?r)3)qI|IfrF|P)REYtBdz|URsOAAvo+hb zuA9?V6?wtwo^X?C{n7o)4|mDw(6bV%M8J~2c7l?xcy2aYcT4|1AM(Dx78N`E>9qb*TP*o=&&4^We zX_qvxoFNCSca2IPthmtbbf3dks^A^eG3Mk$O$M?t5%mjj? zRZr7o@hce|+?052P1Kx=HY0q3?D=7JsoZlZ9VzQ2BNu8|6LE>b&z?EA5?K`6fGk&< zvGxVNvK_ke1$Vi|OL0oZLH#U#QebVWUE!r&P`4XDjG5yjp`O7di``Yi_QRfL#nvuX z)KYnJ`eW^^ z(bdO*RXmMgz6tCvFK^j;i`exs?=mvbI-e;j3GE@?5Df3``pRKejnsO>$j?Qod?;=q znrvP&RZY3Fgi9)9WG#x}9}e%Wu5ng9Q!Gewvt_%M$10nQ379*DhK*%Cp{+Kvm~nZYK^IK6 zt?1tk-JjxXnxxzslzoDSQd_(E2)b!oZNy?T`JC-n@;KK9zW=pS+k`APv6bh;cp}kX|dJTjt1EVmyE% z^DYeI^MXCMt(IU6FpaJ}MSq|9GNg!^=Cu@(>Q~$iwBo#p<8EMG2+m{!G5@*QyXZZ1N-W&o3uh-p z;kPKPk-lJ4f%6EuXKc$u-#P4uM~pVGg)aDENQld7!_n!KC5)J9> ze-5HefUR#{!5esnL;NWVU#i1~0ak)539ZLiZ1U2I8dVF_q~qxXC6kHykpf#|xNG^1K_6M-}j1+>F3ZUJ&T&vC?1mhDr1SU-FI) z4}v*~TA`J}BeSeEnzC{lb+o(Z2|0i2W3a}_p7?zY))ug&;DX?b==N*Dj2SiJZs5qr zSv68)#Z=FMGXT5#cbJzdKDtMsnFZq_NVs`nx-c8teH*NnZ1SYPnl_)?4;k{eL!mL+B1ng*3L%HT;i#AVvZ@t zmZqmG%0|2ASH5aB6P`TNUOu)~-Zx)4v(1JpxJ-f1iIJSwCqaGPl>n!Kg2%?U_H6gX|7JTs*waE1e1LAp+8voCAy@%`r0 zr5^|JAjMh=&`Me8w?vS%>MpW7(qMyK2h80jXBZS~7XRJyT?|4Oir;CL9E2QTagjYk zi!JSCYA}^)Tsh;)LS8v=W-sle*k4gL>mZx8iH$a4ClvWG@EyulQ6!CeRs>-Kt&COb z<6F#it7z+N7~K4HHgG`Aw_Td{nguQIULy78LgV;Y`d3;4ENXy*gn4OCrC~nM(%mNY z=`>3U5yTKQju$wYS+N$WucwjPO(5aC89{k35Q3Jd>MmdDJNHBsyj;or_Ut)J-7-bm z?LMa)C@_n(brV za!Y&B;GKbUA&~<%P!7E2YtJSY=gJp(w698Dy)I)S;N_(jKMV{S050ZHrT2(T@4p1O z_)WC=l3vDMhv3F?M$DJT{O}x0ERPfHIghEbX5q)1XC(w%6jNrDBit~=&vv2t$X%A* z&5VMAL?RvLbo2@U?!vo5dNxa zu?yQ!d5EuM(x$%)$pe#)tf=xyQ)n&JVU7AAcTZ(4NE`z8PK*0)kKN%0Gn>H6>YEgv zc4L`i`Hh5D0Mye4db8Ulljd#KbmrTmN1_{c~g4YhBTM99rl8PAm<&a&ENhg09eDPflzM zL?gapC-zo%@z=8v9|qc6B1YuZ2{epR@tTAs@ZQ+52ry*F!VxdHS% zPTCt$WaC3k&tm6`%K@7zU$QLr@}?&6H~_`Cc>YpNXY6(N$s(KDhT%7a#gdOFZ-u9R zihK9{gU=b3Ax>=+y)mTMQ4% zT*>}j6R_@~u zC<2#b;z7!@x7egNf(s|^m?uwne;_Pg^=c+)MV1BJ8)rHP^?S$%Xr3o-+(+8%#6&&` z`hBlPN+=Cini=k)PWnEjw+*D3QLi9mCMy6WB=|;hoqM+wevzIeen3bkrnj8g*WQq8 zdQ)WlJUVBjbmV|7s0)8IUk#cXB|n{4bewAr+g>mZcP-ft(%q}VEbTuaQN$lCI^XOxxmik<T!02{*Pl^MpY0VmyZsqyptb%9w_vdeVc$5FOjeywd0J)j|d^f{?BL%KYa z9+g44%!7gN#Q?Ec(mK>3_SwNGPsY%cOV=u4&&HzDgq^neY6nSO^h_un_o^x59WO|FHA|b$TG~P&{(GF4&cqKn?_xY$}>#(Req7n+e%VP09 zc~nJ_>4{03%SYORm!3r}B4+~R2$sPOtCoUyrp~OD^6T#0MuY^Qvd{jYor`n*pPPq* zHQXJ9>PPU>u!gk`zGF0~-ILgY7CiQ?_l0mMa^FUgbuxNB8HxPAg&g~?ol9Z-CztpBAwzIZq1=`myal+m6qv3}lyGd)lDdL*<8dQL~x zh0Nl!q_>=He%0_xb|J7J(tFW)`g_oB*(4;de8Ky>0ALQ%YRKJZCEJVWQITqhVuOt?fzh_8C@%fV!)+QmDv4g^<}^AsyAFevzu`4G zaJjCr`*IgUy7F?X8$NEda((Q|+0B)kD6M9DX(Syh>9H^4slS+KJ~MS_Z0tT&M3?S7 zGaVLFfA4v?ric124vaqmd5la{a2K}Me_8pBK5hvL#o{$nBMfH;{%#db%E=^+zQ5`G z=a4%As-XLDaUx>lvAjmJU);yn6R8*Vd<&IkaOn>ZCxCM7(ln7cz8gCT~PTd>awnM+lvm-AwF>`0*xbMN#Yi zqcX*|59?ULx$_=)?=Kdwyz&EaBacvprS1qZ?;_oZU*p9Mc_+x{#yU;cx` zgxLY-HxnD+8jG(;p8O(wv6Hr}$i=DDZ3oH9Do&2G_&do0o=8q(!LLP}x$qb<_+;QK zAw(r%4WD*?1M|f=_{L9S8`M~c0)Uf@{=Wc}+-C1lZQC}N9*zxU977xH(iBAMh5yo|=tFIH+_?zZW&qn(G z;m7t;oK|+8Fyywam&Oj=fnk#Y8h-v#-wIDj^QQuEV$(Bf zAw@J+9eAstw=U(opN z!Le>*dXEhp_a0CG`JZUHX`$OxtX|s)vF+}nVZaTD_+1SV+#wfRVIGO)FezMN2mJxK z8W>u4B1dPv&t4U(QYHL@dKDRvw5(W)DFxebI(pz2y!L}T5~=#Q3_lVZsTBcG9yo=2 z+;9LThi58k_2mcz@?lW%iMtJI*eg|v_f%Hp@U5)-1&gX-AKd;9p4cAdp5*p7ZW zB*L;u)hzMH1U22XT~q5st@S$lr_lBK_qbI^R6%6zK+;yn?JU`ET=dq$a&S#*@2fq; z!cuA;!hEPcV$9RDl_iM&JN2z5doL}SV-s0IRK0O8&zo$8|KQk7i|44*x<;ym!H`&# o0Xm%i@1Os*!2cHu Date: Tue, 29 Sep 2026 15:35:20 -0600 Subject: [PATCH 02/28] fix(browser): reconnect thread automation after host timeout --- .../src/mcp/PreviewAutomationBroker.test.ts | 54 +++++++++ .../src/provider/assistantRuntime.test.ts | 9 +- apps/server/src/provider/assistantRuntime.ts | 6 +- .../client-runtime/src/rpc/client.test.ts | 108 ++++++++++++++++++ packages/client-runtime/src/rpc/client.ts | 21 +++- packages/client-runtime/src/state/preview.ts | 3 + packages/client-runtime/src/state/runtime.ts | 8 +- 7 files changed, 200 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 0371c97c5fd99..16e117b956b25 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -596,6 +596,60 @@ it.effect("routes requests for background threads through an environment-level h ), ); +it.effect("keeps coordinator and spawned agent default tabs independent on a shared host", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const agentScope = { + ...scope, + threadId: ThreadId.make("spawned-agent"), + providerSessionId: "spawned-agent-session", + }; + const coordinatorTab = PreviewTabId.make("coordinator-tab"); + const agentTab = PreviewTabId.make("agent-tab"); + const routed: RoutedRequest[] = []; + const connected = yield* Deferred.make(); + yield* Stream.runForEach( + (yield* broker.connect(makeHost())).pipe( + Stream.tap((event) => + event.type === "connected" ? Deferred.succeed(connected, undefined) : Effect.void, + ), + Stream.filterMap((event) => + event.type === "request" + ? Result.succeed({ ...event.request, connectionId: event.connectionId }) + : Result.failVoid, + ), + ), + (request) => { + routed.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { + tabId: request.threadId === scope.threadId ? coordinatorTab : agentTab, + }, + }); + }, + ).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + + yield* broker.invoke({ scope, operation: "open", input: {} }); + yield* broker.invoke({ scope: agentScope, operation: "open", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope: agentScope, operation: "snapshot", input: {} }); + + expect(routed.map((request) => [request.threadId, request.tabId])).toEqual([ + [scope.threadId, undefined], + [agentScope.threadId, undefined], + [scope.threadId, coordinatorTab], + [agentScope.threadId, agentTab], + ]); + }), + ), +); + it.effect("never routes a provider session to a host from another environment", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/assistantRuntime.test.ts b/apps/server/src/provider/assistantRuntime.test.ts index 1dd891bc78de0..98b9f63ec5bed 100644 --- a/apps/server/src/provider/assistantRuntime.test.ts +++ b/apps/server/src/provider/assistantRuntime.test.ts @@ -131,19 +131,20 @@ describe("buildAssistantRuntimeBlock", () => { } }); - it("tells the coordinator to send agents to the built-in browser, and every agent to use it", () => { + it("gives coordinators and agents their own thread browser instructions", () => { const project = makeProject(); const build = (thread: ReturnType) => buildAssistantRuntimeBlock({ project, thread, memory: "", roleFile: "" }); const coordinatorRule = - "When you delegate, tell each agent to use the built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots, and not the user's own browser."; + "When you delegate, tell each agent to use its own thread's built-in Control Plane browser with the preview_* tools. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const agentRule = - "Use the built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. If the preview_* tools are not loaded yet, load them, then call preview_status or preview_open before concluding the built-in browser is unavailable. Do not launch or drive the user's desktop browsers. Only when a task explicitly needs the user's logged-in session, open the URL in their default browser with `open `."; + "Use your own thread's built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. Call preview_open without tabId to initialize or reuse your thread's browser, then use preview_navigate, preview_snapshot and the other preview_* tools. If the tools are not loaded yet, load them first. Keep browser work in this thread, including sign-in: let the user log in to this thread's browser when needed. Do not use computer-use tools or launch or drive the user's desktop browsers unless the user explicitly requests that method. If preview_open reports no automation host, report the tool error; switching to another thread's browser or asking the user to open the Browser panel does not repair the host connection."; const coordinator = build(makeThread("coordinator")); expect(coordinator?.inline).toContain(coordinatorRule); expect(coordinator?.pointer).toContain(coordinatorRule); - expect(coordinator?.inline).not.toContain(agentRule); + expect(coordinator?.inline).toContain(agentRule); + expect(coordinator?.pointer).toContain(agentRule); for (const agent of [ build(makeThread("agent", { title: "Research", pinnedAt: PINNED_AT })), diff --git a/apps/server/src/provider/assistantRuntime.ts b/apps/server/src/provider/assistantRuntime.ts index ad2f863367483..f5c8faeb535ad 100644 --- a/apps/server/src/provider/assistantRuntime.ts +++ b/apps/server/src/provider/assistantRuntime.ts @@ -33,10 +33,10 @@ const COORDINATOR_SETTLE_RULE = "Settle agents with cp_agent_settle once their work is complete and merged, or when they are one-off and have reported. It fails while an agent is working; a settled agent wakes on a new message."; const COORDINATOR_BROWSER_RULE = - "When you delegate, tell each agent to use the built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots, and not the user's own browser."; + "When you delegate, tell each agent to use its own thread's built-in Control Plane browser with the preview_* tools. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const AGENT_BROWSER_RULE = - "Use the built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. If the preview_* tools are not loaded yet, load them, then call preview_status or preview_open before concluding the built-in browser is unavailable. Do not launch or drive the user's desktop browsers. Only when a task explicitly needs the user's logged-in session, open the URL in their default browser with `open `."; + "Use your own thread's built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. Call preview_open without tabId to initialize or reuse your thread's browser, then use preview_navigate, preview_snapshot and the other preview_* tools. If the tools are not loaded yet, load them first. Keep browser work in this thread, including sign-in: let the user log in to this thread's browser when needed. Do not use computer-use tools or launch or drive the user's desktop browsers unless the user explicitly requests that method. If preview_open reports no automation host, report the tool error; switching to another thread's browser or asking the user to open the Browser panel does not repair the host connection."; /** Agent side: a turn that ends reports to the coordinator, so it must not end as a wait. */ const AGENT_TURN_RULE = @@ -198,6 +198,7 @@ export function buildAssistantRuntimeBlock( "When an agent you started or messaged finishes, its final message arrives here as a message from it, including any question it has for the user. Nothing polls: after delegating, end your turn. Relay an agent's question to the user, then send the answer with cp_thread_send and the agent's threadId.", `At most ${AGENT_RUNNING_CAP} agents run at once in this Project. Use cp_agent_list, cp_agent_read and cp_agent_stop to check on them or stop them. A message to a busy agent waits until its turn ends; stop it first to redirect it. Pass threadId to cp_thread_send, since titles can match threads outside this Project.`, COORDINATOR_SETTLE_RULE, + AGENT_BROWSER_RULE, COORDINATOR_BROWSER_RULE, SCHEDULE_RULE, input.memory.trim() @@ -213,6 +214,7 @@ export function buildAssistantRuntimeBlock( `Before passing standing: true, write the agent's role to \`${roleFilePattern}\` (slug: its title in lowercase with dashes).`, "Do not reply to acknowledgements. When you message an agent with cp_thread_send, pass the threadId its result returns on later sends.", COORDINATOR_SETTLE_RULE, + AGENT_BROWSER_RULE, COORDINATOR_BROWSER_RULE, SCHEDULE_RULE, close, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 9e4e8a600d559..460ca1b862dae 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -88,6 +88,114 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect.each(["completion", "rpc failure", "remote interruption"] as const)( + "re-registers a preview host after stream %s while its socket remains connected", + (termination) => + Effect.scoped( + Effect.gen(function* () { + const subscriptions = yield* Ref.make(0); + const firstConnected = yield* Deferred.make(); + const replacementConnected = yield* Deferred.make(); + const connections: string[] = []; + const client = { + [WS_METHODS.previewAutomationConnect]: () => + Stream.unwrap( + Ref.getAndUpdate(subscriptions, (count) => count + 1).pipe( + Effect.map((count) => + Stream.succeed({ + type: "connected" as const, + connectionId: `host-${count}`, + }).pipe( + Stream.concat( + count > 0 + ? Stream.never + : termination === "completion" + ? Stream.empty + : termination === "remote interruption" + ? Stream.failCause(Cause.interrupt(123)) + : Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Host request stream ended", + cause: new Error("Host request stream ended"), + }), + }), + ), + ), + ), + ), + ), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor, retryCount } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const fiber = yield* subscribe( + WS_METHODS.previewAutomationConnect, + { clientId: "preview-host", environmentId: TARGET.environmentId }, + { restartAfter: "250 millis" }, + ).pipe( + Stream.runForEach((event) => { + connections.push(event.connectionId); + return Deferred.succeed( + connections.length === 1 ? firstConnected : replacementConnected, + undefined, + ); + }), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkScoped, + ); + yield* Deferred.await(firstConnected); + yield* TestClock.adjust("249 millis"); + expect(yield* Ref.get(subscriptions)).toBe(1); + yield* TestClock.adjust("1 millis"); + yield* Deferred.await(replacementConnected); + yield* Fiber.interrupt(fiber); + expect(connections).toEqual(["host-0", "host-1"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ), + ); + + it.effect.each(["owner", "session", "active owner", "active session"] as const)( + "cancels preview host re-registration when its %s disconnects", + (cancellation) => + Effect.scoped( + Effect.gen(function* () { + const subscriptions = yield* Ref.make(0); + const ended = yield* Deferred.make(); + const started = yield* Deferred.make(); + const client = { + [WS_METHODS.previewAutomationConnect]: () => + Stream.fromEffect( + Ref.update(subscriptions, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + ), + ).pipe( + Stream.drain, + Stream.concat(cancellation.startsWith("active") ? Stream.never : Stream.empty), + Stream.ensuring(Deferred.succeed(ended, undefined)), + ), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const fiber = yield* subscribe( + WS_METHODS.previewAutomationConnect, + { clientId: "preview-host", environmentId: TARGET.environmentId }, + { restartAfter: "250 millis" }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkScoped, + ); + yield* Deferred.await(cancellation.startsWith("active") ? started : ended); + if (cancellation.endsWith("owner")) yield* Fiber.interrupt(fiber); + else yield* SubscriptionRef.set(activeSession, Option.none()); + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(subscriptions)).toBe(1); + }), + ), + ); + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => Effect.gen(function* () { const event: ServerConfigStreamEvent = { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index e80a2f0f4b12f..725a13a6a4ec6 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -182,6 +182,8 @@ interface SubscriptionOptions { cause: Cause.Cause>, ) => Effect.Effect; readonly retryExpectedFailureAfter?: Duration.Input; + /** Reopen a terminated subscription on the current session without replaying requests. */ + readonly restartAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } @@ -251,6 +253,12 @@ function subscribeDynamicMapped( : Effect.void, ), Stream.catchCause((cause) => { + // A broker eviction interrupts just this remote stream, + // while its WebSocket session remains usable. Local + // cancellation still interrupts the subscription owner. + if (options?.restartAfter !== undefined && Cause.hasInterruptsOnly(cause)) { + return Stream.empty; + } const hasOnlyExpectedFailures = cause.reasons.length > 0 && cause.reasons.every((reason) => reason._tag === "Fail"); @@ -262,7 +270,9 @@ function subscribeDynamicMapped( if (isTransportFailure) { return Stream.fromEffect( Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", + options?.restartAfter === undefined + ? "Durable RPC subscription lost its transport; waiting for the next session." + : "Durable RPC subscription ended; reopening on the current session.", { cause: Cause.pretty(cause), method: tag, @@ -291,7 +301,14 @@ function subscribeDynamicMapped( }), ), ); - return subscribeToSession(); + const restartAfter = options?.restartAfter; + if (restartAfter === undefined) return subscribeToSession(); + const restartSubscription = (): Stream.Stream> => + subscribeToSession().pipe( + Stream.concat(Stream.fromEffect(Effect.sleep(restartAfter)).pipe(Stream.drain)), + Stream.concat(Stream.suspend(restartSubscription)), + ); + return restartSubscription(); }, }), ), diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 86ca157047baa..f97e2c37dcfc0 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -48,6 +48,9 @@ export function createPreviewEnvironmentAtoms( automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:preview:automation-requests", tag: WS_METHODS.previewAutomationConnect, + // The broker evicts an unanswered host without closing the WebSocket. + // Re-register only the command stream; never replay the timed-out action. + restartAfter: "250 millis", // Automation requests are commands, not cached query data. Dispose the // stream immediately with its owner so stale requests cannot replay when // a thread remounts and the server can clear disconnected hosts promptly. diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 84946bf9e165a..d87737122cca0 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -1,5 +1,6 @@ import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; @@ -654,6 +655,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily< readonly label: string; readonly tag: TTag; readonly idleTtlMs?: number; + readonly restartAfter?: Duration.Input; readonly transform?: ( stream: Stream.Stream< EnvironmentRpcStreamValue, @@ -667,7 +669,11 @@ export function createEnvironmentRpcSubscriptionAtomFamily< label: options.label, ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), subscribe: (input: EnvironmentRpcInput) => { - const stream = subscribe(options.tag, input); + const stream = subscribe( + options.tag, + input, + options.restartAfter === undefined ? undefined : { restartAfter: options.restartAfter }, + ); return options.transform === undefined ? (stream as Stream.Stream, EnvironmentSupervisor | R>) : options.transform(stream); From 97f47a188b5dbe3d4d2f74b0c2a96a26e398eab4 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:57 -0600 Subject: [PATCH 03/28] chore(release): prepare Control Plane 0.0.57 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eef715ab0dd42..3fb4448cbabe2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.56", + "version": "0.0.57", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index fb230a4a17dee..575d010df67ba 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.56", + "version": "0.0.57", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index c3114ede47d80..bcbc836fcd075 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.56", + "version": "0.0.57", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d9441a8193836..89a29a8eed687 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.56", + "version": "0.0.57", "private": true, "files": [ "dist" From 8cf00a3ac32f5a600c4b8b431fce84112cae3144 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:13:04 -0600 Subject: [PATCH 04/28] fix(server): let coordinators settle agents after follow-ups --- apps/server/src/mcp/toolkits/agents/tools.ts | 4 +-- .../AgentCompletionReactor.test.ts | 36 +++++++++---------- .../orchestration/AgentCompletionReactor.ts | 27 +++----------- .../server/src/orchestration/agentProtocol.ts | 5 --- apps/server/src/orchestration/agentPushes.ts | 20 ++--------- .../src/provider/assistantRuntime.test.ts | 6 +++- apps/server/src/provider/assistantRuntime.ts | 4 +-- docs/user/projects.md | 4 ++- 8 files changed, 37 insertions(+), 69 deletions(-) diff --git a/apps/server/src/mcp/toolkits/agents/tools.ts b/apps/server/src/mcp/toolkits/agents/tools.ts index fd49cd259c52c..5c74da9d36c42 100644 --- a/apps/server/src/mcp/toolkits/agents/tools.ts +++ b/apps/server/src/mcp/toolkits/agents/tools.ts @@ -226,7 +226,7 @@ export type AgentCreateResult = typeof AgentCreateResult.Type; export const AgentListInput = Schema.Struct({ includeSettled: Schema.optional( - Schema.Boolean.annotate({ description: "Also list agents that settled after reporting." }), + Schema.Boolean.annotate({ description: "Also list agents that have been settled." }), ), }); export type AgentListInput = typeof AgentListInput.Type; @@ -379,7 +379,7 @@ export type ScheduleDeleteResult = typeof ScheduleDeleteResult.Type; const AgentCreateTool = Tool.make("cp_agent_create", { description: - "Start an agent in this Project with a first message. Its final message comes back to you.", + "Start an agent in this Project with a first message. Its final message comes back to you, and it stays available for follow-ups. Only the coordinator settles a one-off agent with cp_agent_settle once its work is complete and no follow-ups remain.", parameters: AgentCreateInput, success: AgentCreateResult, failure: AgentToolError, diff --git a/apps/server/src/orchestration/AgentCompletionReactor.test.ts b/apps/server/src/orchestration/AgentCompletionReactor.test.ts index 3ecb178c57f61..40f995d62c883 100644 --- a/apps/server/src/orchestration/AgentCompletionReactor.test.ts +++ b/apps/server/src/orchestration/AgentCompletionReactor.test.ts @@ -367,7 +367,7 @@ const A = (id: string) => ThreadId.make(id); describe("AgentCompletionReactor", () => { test( - "pushes a coordinator's request into the coordinator, starts it, and settles the agent", + "pushes a coordinator's request into the coordinator, starts it, and leaves the agent unsettled", Effect.gen(function* () { const f = yield* makeFixture; yield* f.createProject; @@ -399,7 +399,8 @@ describe("AgentCompletionReactor", () => { replyTo: null, }); assert.deepStrictEqual(yield* f.receipts("cp-start:"), [`accepted cp-start:${pushId}`]); - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); + assert.deepStrictEqual(yield* f.receipts("cp-push-settle:"), []); }), ); @@ -426,6 +427,7 @@ describe("AgentCompletionReactor", () => { ); assert.deepStrictEqual(yield* f.receipts("cp-start:"), [`accepted cp-start:${pushId}`]); assert.isFalse(yield* f.settled(standing)); + assert.deepStrictEqual(yield* f.receipts("cp-push-settle:"), []); }), ); @@ -849,7 +851,7 @@ describe("AgentCompletionReactor", () => { texts.get(pushIdOf(agent, followUp)), resultText("Counter", agent, "finished", "Printed done."), ); - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); }), ); @@ -886,7 +888,7 @@ describe("AgentCompletionReactor", () => { ); test( - "carries an open message-mode question and dismisses it when the agent settles", + "carries an open message-mode question without dismissing it after reporting", Effect.gen(function* () { const f = yield* makeFixture; yield* f.createProject; @@ -919,12 +921,10 @@ describe("AgentCompletionReactor", () => { `${resultText("Haiku", agent, "finished", "(no final message)")}\n\nQuestions for the user:\n- Which city?`, ], ); - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); + assert.deepStrictEqual(yield* f.receipts("cp-push-settle:"), []); const resolved = yield* f.activities(agent, "user-input.resolved"); - assert.deepStrictEqual( - resolved.map((row) => row.requestId), - ["question-city"], - ); + assert.deepStrictEqual(resolved, []); }), ); @@ -1550,11 +1550,11 @@ describe("AgentCompletionReactor with scheduled prompts", () => { ); test( - "settles a nudged agent again once the reply of its continuation turn is delivered", + "keeps a nudged agent available after its initial, empty and continuation reports", Effect.gen(function* () { const f = yield* makeFixture; yield* f.createProject; - const agent = A("agent-nudged-settle"); + const agent = A("agent-nudged-follow-ups"); yield* f.createThread(agent, "Nudged", { createdBy: COORDINATOR }); yield* f.request(agent, M("request-1"), COORDINATOR); @@ -1562,24 +1562,24 @@ describe("AgentCompletionReactor with scheduled prompts", () => { Effect.gen(function* () { yield* f.finishTurn(agent, T("turn-1"), "Waiting on the tests."); yield* drain; - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); - // The coordinator nudges it. That turn ends empty and the reactor - // settles the agent on the empty result. + // The coordinator nudges it. Its empty result leaves it available. yield* f.request(agent, M("request-nudge"), COORDINATOR); assert.isFalse(yield* f.settled(agent)); yield* f.finishTurn(agent, T("turn-nudge"), ""); yield* drain; - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); - // The agent carries on in a turn nothing started: real activity wakes it. + // The agent carries on in a turn nothing started. yield* f.beginTurn(agent, T("turn-reply")); assert.isFalse(yield* f.settled(agent)); - // Its final report is delivered, and that settles it for good. + // Even a report that the PR merged leaves room for follow-ups. yield* f.endTurn(agent, T("turn-reply"), "Merged, PR #42."); yield* drain; - assert.isTrue(yield* f.settled(agent)); + assert.isFalse(yield* f.settled(agent)); + assert.deepStrictEqual(yield* f.receipts("cp-push-settle:"), []); }), ); diff --git a/apps/server/src/orchestration/AgentCompletionReactor.ts b/apps/server/src/orchestration/AgentCompletionReactor.ts index 3fc4df7ebfbe0..47e94ae9bf395 100644 --- a/apps/server/src/orchestration/AgentCompletionReactor.ts +++ b/apps/server/src/orchestration/AgentCompletionReactor.ts @@ -4,9 +4,9 @@ * * A pass over a Project has two phases: * 1. Every finished request whose result is still owed is appended into its - * recipient at once (frozen when the agent finished), and a one-off agent - * settles. A request that ended with no reply, and was answered by a later - * turn no message started, owes that reply once more. + * recipient at once (frozen when the agent finished). A request that ended + * with no reply, and was answered by a later turn no message started, owes + * that reply once more. * 2. Each idle thread starts its oldest appended delivery: a pushed result, a * `cp_thread_send` that was held for it, or a Project schedule's prompt. * Pushes pause after a budget until the user, a manager or a schedule @@ -65,7 +65,6 @@ import { agentPushFailedId, agentPushId, agentPushPausedId, - agentPushSettleId, } from "./agentProtocol.ts"; import { formatAgentResult, @@ -209,7 +208,7 @@ export const make = Effect.gen(function* () { const questions = openMessageQuestions( yield* activities.listUserInputLifecycleByThreadId({ threadId: agentThreadId }), ); - const appended = yield* engine + yield* engine .dispatch({ type: "thread.message.user.append", commandId: CommandId.make(pushId), @@ -234,7 +233,6 @@ export const make = Effect.gen(function* () { createdAt, }) .pipe( - Effect.as(true), Effect.catchIf(isOrchestrationCommandRejection, (error) => Effect.logWarning("agent result append rejected", { agentThreadId, @@ -253,26 +251,9 @@ export const make = Effect.gen(function* () { createdAt, }), ), - Effect.as(false), ), ), ); - // A later message to the agent is new work, so it stays unsettled. - if (!appended || agent.pinnedAt !== null || !result.isLatestRequest) return; - yield* engine - .dispatch({ - type: "thread.settle", - commandId: CommandId.make(agentPushSettleId(agentThreadId, requestId)), - threadId: agentThreadId, - }) - .pipe( - Effect.catchIf(isOrchestrationCommandRejection, (error) => - Effect.logDebug("agent not settled after its result", { - agentThreadId, - detail: error.message, - }), - ), - ); }); const startDelivery = (delivery: AgentDelivery, commandId: string, createdAt: string) => diff --git a/apps/server/src/orchestration/agentProtocol.ts b/apps/server/src/orchestration/agentProtocol.ts index 7852b6d6152ed..429debcfa2a3b 100644 --- a/apps/server/src/orchestration/agentProtocol.ts +++ b/apps/server/src/orchestration/agentProtocol.ts @@ -65,11 +65,6 @@ export function agentDeliveryRetryId(deliveryMessageId: string): string { return `${AGENT_DELIVERY_RETRY_PREFIX}${deliveryMessageId}`; } -/** Command id that settles a one-off agent after its result is appended. */ -export function agentPushSettleId(agentThreadId: string, requestMessageId: string): string { - return `cp-push-settle:${agentThreadId}:${requestMessageId}`; -} - /** * Id of the "results paused" notice in `threadId` for `recipientThreadId`: * one per release, so a paused recipient is told once until it is released. diff --git a/apps/server/src/orchestration/agentPushes.ts b/apps/server/src/orchestration/agentPushes.ts index da6b2363e01f9..f12d439c5d148 100644 --- a/apps/server/src/orchestration/agentPushes.ts +++ b/apps/server/src/orchestration/agentPushes.ts @@ -59,8 +59,6 @@ export interface AgentResult { readonly outcome: AgentResultOutcome; /** The agent's last complete reply after the request, or null. */ readonly text: string | null; - /** No user message reached the agent after the request. */ - readonly isLatestRequest: boolean; } /** @@ -100,7 +98,6 @@ const AgentResultRow = Schema.Struct({ startFailed: Schema.BooleanFromBit, startFailureDetail: Schema.NullOr(Schema.String), text: Schema.NullOr(Schema.String), - isLatestRequest: Schema.BooleanFromBit, }); const AgentDeliveryRow = Schema.Struct({ @@ -284,7 +281,7 @@ export function makeAgentPushQueries(sql: SqlClient.SqlClient) { Result: AgentResultRow, execute: ({ agentThreadId, turnId }) => sql` WITH reply AS ( - SELECT am.text AS text, am.rowid AS message_row + SELECT am.text AS text FROM projection_thread_messages am WHERE am.thread_id = ${agentThreadId} AND am.role = 'assistant' AND am.turn_id = ${turnId} @@ -307,12 +304,7 @@ export function makeAgentPushQueries(sql: SqlClient.SqlClient) { ) AS "lastError", 0 AS "startFailed", NULL AS "startFailureDetail", - (SELECT text FROM reply) AS "text", - NOT EXISTS ( - SELECT 1 FROM projection_thread_messages u, reply - WHERE u.thread_id = ${agentThreadId} AND u.role = 'user' - AND u.rowid > reply.message_row - ) AS "isLatestRequest" + (SELECT text FROM reply) AS "text" `, }); @@ -375,12 +367,7 @@ export function makeAgentPushQueries(sql: SqlClient.SqlClient) { ) ORDER BY am.rowid DESC LIMIT 1 - ) AS "text", - NOT EXISTS ( - SELECT 1 FROM projection_thread_messages u - WHERE u.thread_id = ${agentThreadId} AND u.role = 'user' - AND u.rowid > request.message_row - ) AS "isLatestRequest" + ) AS "text" FROM request `, }); @@ -536,7 +523,6 @@ export function makeAgentPushQueries(sql: SqlClient.SqlClient) { onSome: (row): AgentResult => ({ outcome: resultOutcome(row), text: row.text, - isLatestRequest: row.isLatestRequest, }), }), ), diff --git a/apps/server/src/provider/assistantRuntime.test.ts b/apps/server/src/provider/assistantRuntime.test.ts index 98b9f63ec5bed..30169387bd796 100644 --- a/apps/server/src/provider/assistantRuntime.test.ts +++ b/apps/server/src/provider/assistantRuntime.test.ts @@ -170,10 +170,14 @@ describe("buildAssistantRuntimeBlock", () => { const build = (thread: ReturnType) => buildAssistantRuntimeBlock({ project, thread, memory: "", roleFile: "" }); const rule = - "Settle agents with cp_agent_settle once their work is complete and merged, or when they are one-off and have reported. It fails while an agent is working; a settled agent wakes on a new message."; + "Settle agents with cp_agent_settle once their work is complete (for example, merged) and no follow-ups remain. Reporting a result does not settle an agent. It fails while an agent is working; a settled agent wakes on a new message."; const coordinator = build(makeThread("coordinator")); expect(coordinator?.inline).toContain(rule); + expect(coordinator?.inline).toContain( + "An agent is one-off by default and stays available for follow-ups after it reports.", + ); + expect(coordinator?.inline).not.toContain("settles after it reports"); // Cursor, Grok and Antigravity see only the pointer. expect(coordinator?.pointer).toContain(rule); for (const agent of [ diff --git a/apps/server/src/provider/assistantRuntime.ts b/apps/server/src/provider/assistantRuntime.ts index f5c8faeb535ad..c0a1902a9bafe 100644 --- a/apps/server/src/provider/assistantRuntime.ts +++ b/apps/server/src/provider/assistantRuntime.ts @@ -30,7 +30,7 @@ const SCHEDULE_RULE = /** Coordinator only: cp_agent_settle refuses every other caller. */ const COORDINATOR_SETTLE_RULE = - "Settle agents with cp_agent_settle once their work is complete and merged, or when they are one-off and have reported. It fails while an agent is working; a settled agent wakes on a new message."; + "Settle agents with cp_agent_settle once their work is complete (for example, merged) and no follow-ups remain. Reporting a result does not settle an agent. It fails while an agent is working; a settled agent wakes on a new message."; const COORDINATOR_BROWSER_RULE = "When you delegate, tell each agent to use its own thread's built-in Control Plane browser with the preview_* tools. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; @@ -194,7 +194,7 @@ export function buildAssistantRuntimeBlock( `Your memory is ${memoryPath}. Keep it current. The copy below was read when this session started, and the user may have changed the file since, so re-read it from disk before relying on it and edit it in place. Never silently rewrite rules the user wrote.`, `The root ${ROLE_FILE} is shared by you and every agent. Coordinator-only routing and user preferences go in ${MEMORY_FILE}.`, "Do not reply to acknowledgements.", - `Delegate work to agents with cp_agent_create. An agent is one-off by default and settles after it reports. Pass standing: true only for a role you will reuse, and first write its role to \`${roleFilePattern}\` (slug: its title in lowercase with dashes).`, + `Delegate work to agents with cp_agent_create. An agent is one-off by default and stays available for follow-ups after it reports. Pass standing: true only for a role you will reuse, and first write its role to \`${roleFilePattern}\` (slug: its title in lowercase with dashes).`, "When an agent you started or messaged finishes, its final message arrives here as a message from it, including any question it has for the user. Nothing polls: after delegating, end your turn. Relay an agent's question to the user, then send the answer with cp_thread_send and the agent's threadId.", `At most ${AGENT_RUNNING_CAP} agents run at once in this Project. Use cp_agent_list, cp_agent_read and cp_agent_stop to check on them or stop them. A message to a busy agent waits until its turn ends; stop it first to redirect it. Pass threadId to cp_thread_send, since titles can match threads outside this Project.`, COORDINATOR_SETTLE_RULE, diff --git a/docs/user/projects.md b/docs/user/projects.md index 0bcf0d9228a1d..46c4cb02b088a 100644 --- a/docs/user/projects.md +++ b/docs/user/projects.md @@ -67,7 +67,9 @@ agent, even while the coordinator is busy, and the coordinator picks it up when Notifications come from the coordinator's reply, not from each agent finishing work it was asked to do. -- A one-off agent settles after it reports. Message it to bring it back. +- Reporting a result leaves an agent available for follow-ups. The coordinator settles a + one-off agent once its work is complete (for example, merged) and no follow-ups remain. + Message a settled agent to bring it back. - A standing agent can start its own one-off agents. Their results go to the standing agent, which passes on the combined answer. - The coordinator and standing agents can't start another agent while 4 are running in the From 13baae19e8d23c52db27d3955bc43c17f308e801 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:30:22 -0600 Subject: [PATCH 05/28] feat: start agents on linked machines per server settings Coordinators and standing agents can start one-off agents on other machines linked to their server. Placement (this machine, one machine, or balanced by load) is a server-owned setting under Settings > Connections > Agent machines. Results, read, stop and send work across machines through a registry and a bridge that pushes results into the asking thread. Co-Authored-By: Claude Sonnet 5.5 --- .../OrchestrationEngineHarness.integration.ts | 1 + .../agentMachines/AgentMachineClient.test.ts | 513 ++++++++++++++ .../src/agentMachines/AgentMachineClient.ts | 285 ++++++++ .../src/agentMachines/AgentMachines.test.ts | 572 +++++++++++++++ .../server/src/agentMachines/AgentMachines.ts | 502 +++++++++++++ .../agentMachines/RemoteAgentBridge.test.ts | 560 +++++++++++++++ .../src/agentMachines/RemoteAgentBridge.ts | 376 ++++++++++ .../agentMachines/RemoteAgentStore.test.ts | 245 +++++++ .../src/agentMachines/RemoteAgentStore.ts | 199 ++++++ .../src/agentMachines/RemoteAgents.test.ts | 565 +++++++++++++++ apps/server/src/agentMachines/RemoteAgents.ts | 544 ++++++++++++++ apps/server/src/agentMachines/agentPhase.ts | 18 + .../src/agentMachines/placement.test.ts | 289 ++++++++ apps/server/src/agentMachines/placement.ts | 164 +++++ .../src/agentMachines/projectMatch.test.ts | 143 ++++ apps/server/src/agentMachines/projectMatch.ts | 66 ++ .../agentMachines/remoteAgentState.test.ts | 375 ++++++++++ .../src/agentMachines/remoteAgentState.ts | 234 ++++++ apps/server/src/agentMachines/testFixtures.ts | 230 ++++++ apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/mcp/McpHttpServer.test.ts | 4 + .../toolkits/agents/handlers.remote.test.ts | 667 ++++++++++++++++++ .../src/mcp/toolkits/agents/handlers.test.ts | 2 + .../src/mcp/toolkits/agents/handlers.ts | 428 ++++++++--- .../toolkits/agents/scheduleHandlers.test.ts | 2 + apps/server/src/mcp/toolkits/agents/tools.ts | 34 +- .../src/mcp/toolkits/threads/handlers.test.ts | 92 +++ .../src/mcp/toolkits/threads/handlers.ts | 65 +- apps/server/src/mcp/toolkits/threads/tools.ts | 2 + .../orchestration/AgentCompletionReactor.ts | 8 +- .../Layers/OrchestrationReactor.test.ts | 1 + apps/server/src/server.test.ts | 4 + apps/server/src/server.ts | 9 + apps/server/src/ws.ts | 6 + .../AgentMachinesSettings.logic.test.ts | 266 +++++++ .../settings/AgentMachinesSettings.logic.ts | 235 ++++++ .../settings/AgentMachinesSettings.tsx | 591 ++++++++++++++++ .../settings/ConnectionsSettings.tsx | 2 + .../settings/LoadBalancingSettings.tsx | 4 +- .../src/components/settings/settingsSearch.ts | 8 + docs/internals/multi-machine-agents.md | 266 +++++++ docs/user/remote-access.md | 34 + packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/agentPlacement.ts | 120 ++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/settings.test.ts | 83 +++ packages/contracts/src/settings.ts | 4 + packages/shared/src/serverSettings.test.ts | 88 +++ packages/shared/src/serverSettings.ts | 16 + 50 files changed, 8824 insertions(+), 116 deletions(-) create mode 100644 apps/server/src/agentMachines/AgentMachineClient.test.ts create mode 100644 apps/server/src/agentMachines/AgentMachineClient.ts create mode 100644 apps/server/src/agentMachines/AgentMachines.test.ts create mode 100644 apps/server/src/agentMachines/AgentMachines.ts create mode 100644 apps/server/src/agentMachines/RemoteAgentBridge.test.ts create mode 100644 apps/server/src/agentMachines/RemoteAgentBridge.ts create mode 100644 apps/server/src/agentMachines/RemoteAgentStore.test.ts create mode 100644 apps/server/src/agentMachines/RemoteAgentStore.ts create mode 100644 apps/server/src/agentMachines/RemoteAgents.test.ts create mode 100644 apps/server/src/agentMachines/RemoteAgents.ts create mode 100644 apps/server/src/agentMachines/agentPhase.ts create mode 100644 apps/server/src/agentMachines/placement.test.ts create mode 100644 apps/server/src/agentMachines/placement.ts create mode 100644 apps/server/src/agentMachines/projectMatch.test.ts create mode 100644 apps/server/src/agentMachines/projectMatch.ts create mode 100644 apps/server/src/agentMachines/remoteAgentState.test.ts create mode 100644 apps/server/src/agentMachines/remoteAgentState.ts create mode 100644 apps/server/src/agentMachines/testFixtures.ts create mode 100644 apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts create mode 100644 apps/web/src/components/settings/AgentMachinesSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/AgentMachinesSettings.logic.ts create mode 100644 apps/web/src/components/settings/AgentMachinesSettings.tsx create mode 100644 docs/internals/multi-machine-agents.md create mode 100644 packages/contracts/src/agentPlacement.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 84adfd0b21ffd..975b580ef1471 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -419,6 +419,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.succeed(AgentCompletionReactor.AgentCompletionReactor, { start: () => Effect.void, drain: Effect.void, + enqueueProject: () => Effect.void, }), ), Layer.provideMerge(Layer.mock(ScheduleRunner)({ start: () => Effect.void })), diff --git a/apps/server/src/agentMachines/AgentMachineClient.test.ts b/apps/server/src/agentMachines/AgentMachineClient.test.ts new file mode 100644 index 0000000000000..da4dfb0b4f657 --- /dev/null +++ b/apps/server/src/agentMachines/AgentMachineClient.test.ts @@ -0,0 +1,513 @@ +import { + AuthAccessTokenType, + AuthEnvironmentBootstrapTokenType, + AuthTokenExchangeGrantType, + CommandId, + ThreadId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http"; + +import { + AGENT_MACHINE_PROTOCOL_VERSION, + AGENT_MACHINE_SCOPE, + PEER_COMMAND_ALLOWLIST, + PEER_REQUEST_TIMEOUT, + PeerError, + classifyPeerFailure, + dispatchPeerCommand, + exchangePairingCredential, + fetchPeerDescriptor, + fetchPeerShell, + fetchPeerThread, + isPeerCommand, + requirePeerIdentity, + validatePeerBaseUrl, + type PeerCommand, +} from "./AgentMachineClient.ts"; + +const JsonString = Schema.fromJsonString(Schema.Unknown); +const encodeJson = Schema.encodeSync(JsonString); +const decodeJson = Schema.decodeUnknownSync(JsonString); + +const BASE_URL = "http://mini.tailnet.ts.net:3773"; + +const failureOf = (effect: Effect.Effect) => + Effect.flip(effect).pipe(Effect.map((error) => ({ kind: error.kind, detail: error.detail }))); + +describe("validatePeerBaseUrl", () => { + it.effect("accepts https anywhere and http to loopback, private and tailnet hosts", () => + Effect.gen(function* () { + for (const [raw, origin] of [ + ["https://example.com", "https://example.com"], + ["https://example.com:8443", "https://example.com:8443"], + ["http://localhost:3773", "http://localhost:3773"], + ["http://127.0.0.1:3773", "http://127.0.0.1:3773"], + ["http://10.0.0.5:3773", "http://10.0.0.5:3773"], + ["http://192.168.1.20", "http://192.168.1.20"], + ["http://100.64.0.1:3773", "http://100.64.0.1:3773"], + ["http://100.101.102.103", "http://100.101.102.103"], + ["http://mini.tailnet.ts.net:3773", "http://mini.tailnet.ts.net:3773"], + [" http://mini.tailnet.ts.net ", "http://mini.tailnet.ts.net"], + ] as const) { + expect(yield* validatePeerBaseUrl(raw), raw).toBe(origin); + } + }), + ); + + it.effect("refuses plain http over the internet and anything that is not a URL", () => + Effect.gen(function* () { + for (const raw of [ + "http://example.com", + "http://8.8.8.8:3773", + "http://100.128.0.1", + "ftp://10.0.0.5", + "not a url", + "", + "mini.tailnet.ts.net:3773", + ]) { + const error = yield* Effect.flip(validatePeerBaseUrl(raw)); + expect(error.kind, raw).toBe("invalid-url"); + } + }), + ); + + it.effect("drops the path, query and fragment so a pairing token never survives", () => + Effect.gen(function* () { + const origin = yield* validatePeerBaseUrl( + "https://mini.tailnet.ts.net/pair?token=SECRET#token=SECRET", + ); + expect(origin).toBe("https://mini.tailnet.ts.net"); + expect(yield* validatePeerBaseUrl("http://10.0.0.5:3773/some/path/#credential=SECRET")).toBe( + "http://10.0.0.5:3773", + ); + }), + ); +}); + +const command = (type: string) => + ({ + type, + commandId: `cmd-${type}`, + threadId: "thread-1", + }) as unknown as ClientOrchestrationCommand; + +describe("peer command allowlist", () => { + it("allows exactly the six agent commands", () => { + expect([...PEER_COMMAND_ALLOWLIST].toSorted()).toEqual( + [ + "thread.archive", + "thread.create", + "thread.session.stop", + "thread.settle", + "thread.turn.interrupt", + "thread.turn.start", + ].toSorted(), + ); + for (const type of PEER_COMMAND_ALLOWLIST) { + expect(isPeerCommand(command(type)), type).toBe(true); + } + }); + + it("rejects destructive and project commands", () => { + for (const type of [ + "thread.delete", + "project.delete", + "project.create", + "thread.meta.update", + ]) { + expect(isPeerCommand(command(type)), type).toBe(false); + } + }); +}); + +describe("classifyPeerFailure", () => { + const kindOf = (error: unknown) => classifyPeerFailure(error).kind; + + it("maps 401 and auth failures to unauthorized", () => { + expect(kindOf({ _tag: "EnvironmentAuthInvalidError" })).toBe("unauthorized"); + expect(kindOf({ _tag: "EnvironmentHttpUnauthorizedError" })).toBe("unauthorized"); + expect(kindOf({ _tag: "ResponseError", response: { status: 401 } })).toBe("unauthorized"); + }); + + it("maps 403 and scope failures to unauthorized", () => { + for (const tag of [ + "EnvironmentScopeRequiredError", + "EnvironmentOperationForbiddenError", + "EnvironmentHttpForbiddenError", + ]) { + expect(kindOf({ _tag: tag }), tag).toBe("unauthorized"); + } + expect(kindOf({ _tag: "ResponseError", response: { status: 403 } })).toBe("unauthorized"); + }); + + it("maps a timeout to unreachable", () => { + expect(kindOf({ _tag: "TimeoutError" })).toBe("unreachable"); + }); + + it("maps other environment errors to rejected", () => { + for (const tag of [ + "EnvironmentInternalError", + "EnvironmentRequestInvalidError", + "EnvironmentResourceNotFoundError", + ]) { + expect(kindOf({ _tag: tag }), tag).toBe("rejected"); + } + }); + + it("maps anything unknown to unreachable", () => { + for (const error of [ + new Error("socket hang up"), + { _tag: "TransportError" }, + { _tag: "ResponseError", response: { status: 500 } }, + null, + undefined, + "boom", + ]) { + expect(kindOf(error)).toBe("unreachable"); + } + }); + + it("passes a PeerError through unchanged", () => { + const error = new PeerError({ kind: "wrong-environment", detail: "Different server." }); + expect(classifyPeerFailure(error)).toBe(error); + }); +}); + +// ── HTTP ─────────────────────────────────────────────────────────── + +interface Recorded { + readonly method: string; + readonly url: string; + readonly headers: Readonly>; + readonly body: string | null; +} + +const bodyText = (request: HttpClientRequest.HttpClientRequest): string | null => { + const body = request.body; + switch (body._tag) { + case "Uint8Array": + return new TextDecoder().decode(body.body); + case "Raw": + return typeof body.body === "string" ? body.body : String(body.body); + default: + return null; + } +}; + +/** A fake peer: each request is recorded and answered by `respond`. */ +const fakePeer = ( + respond: (request: Recorded) => { readonly status: number; readonly body: unknown }, +) => { + const requests: Array = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => { + const recorded: Recorded = { + method: request.method, + url: url.toString(), + headers: request.headers, + body: bodyText(request), + }; + requests.push(recorded); + const { status, body } = respond(recorded); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ); + return { requests, layer }; +}; + +const descriptor = (overrides: Record = {}) => ({ + environmentId: "env-mini", + label: "Mac Mini", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.40", + orchestrationProtocolVersion: AGENT_MACHINE_PROTOCOL_VERSION, + capabilities: {}, + ...overrides, +}); + +const authInvalid = { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-1", +}; + +describe("fetchPeerDescriptor and requirePeerIdentity", () => { + it.effect("reads the descriptor from the well-known route", () => { + const peer = fakePeer(() => ({ status: 200, body: descriptor() })); + return Effect.gen(function* () { + const result = yield* fetchPeerDescriptor(BASE_URL); + expect(result.environmentId).toBe("env-mini"); + expect(result.label).toBe("Mac Mini"); + expect(peer.requests.map((r) => [r.method, r.url])).toEqual([ + ["GET", `${BASE_URL}/.well-known/t3/environment`], + ]); + expect(peer.requests[0]!.headers["authorization"]).toBeUndefined(); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect("reads a descriptor without a protocol version as protocol 1", () => { + const { orchestrationProtocolVersion: _omit, ...legacy } = descriptor(); + const peer = fakePeer(() => ({ status: 200, body: legacy })); + return fetchPeerDescriptor(BASE_URL).pipe( + Effect.map((result) => expect(result.environmentId).toBe("env-mini")), + Effect.provide(peer.layer), + ); + }); + + it.effect("refuses a peer that speaks another orchestration protocol", () => { + const peer = fakePeer(() => ({ + status: 200, + body: descriptor({ orchestrationProtocolVersion: AGENT_MACHINE_PROTOCOL_VERSION + 1 }), + })); + return Effect.gen(function* () { + const failure = yield* failureOf(fetchPeerDescriptor(BASE_URL)); + expect(failure.kind).toBe("incompatible"); + expect(failure.detail).toContain(`protocol ${AGENT_MACHINE_PROTOCOL_VERSION + 1}`); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect( + "requirePeerIdentity passes for the linked environment id and fails for another", + () => { + const peer = fakePeer(() => ({ status: 200, body: descriptor() })); + return Effect.gen(function* () { + const ok = yield* requirePeerIdentity(BASE_URL, "env-mini"); + expect(ok.environmentId).toBe("env-mini"); + const failure = yield* failureOf(requirePeerIdentity(BASE_URL, "env-studio")); + expect(failure.kind).toBe("wrong-environment"); + }).pipe(Effect.provide(peer.layer)); + }, + ); + + it.effect("an unreadable descriptor is unreachable, not a crash", () => { + const peer = fakePeer(() => ({ status: 200, body: { hello: "world" } })); + return failureOf(fetchPeerDescriptor(BASE_URL)).pipe( + Effect.map((failure) => expect(failure.kind).toBe("unreachable")), + Effect.provide(peer.layer), + ); + }); +}); + +describe("exchangePairingCredential", () => { + it.effect("posts a form-encoded exchange for read and operate only", () => { + const peer = fakePeer(() => ({ + status: 200, + body: { + access_token: "session-token", + issued_token_type: AuthAccessTokenType, + token_type: "Bearer", + expires_in: 2_592_000, + scope: AGENT_MACHINE_SCOPE, + }, + })); + return Effect.gen(function* () { + const result = yield* exchangePairingCredential({ + baseUrl: BASE_URL, + credential: "PAIRING-SECRET", + clientLabel: "Coordinator on MacBook", + }); + expect(result).toEqual({ token: "session-token", expiresInSeconds: 2_592_000 }); + + expect(peer.requests).toHaveLength(1); + const request = peer.requests[0]!; + expect([request.method, request.url]).toEqual(["POST", `${BASE_URL}/oauth/token`]); + expect(request.headers["content-type"]).toContain("application/x-www-form-urlencoded"); + const form = new URLSearchParams(request.body ?? ""); + expect(Object.fromEntries(form)).toEqual({ + grant_type: AuthTokenExchangeGrantType, + subject_token: "PAIRING-SECRET", + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + scope: "orchestration:read orchestration:operate", + client_label: "Coordinator on MacBook", + }); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect("refuses a DPoP-bound token it cannot use", () => { + const peer = fakePeer(() => ({ + status: 200, + body: { + access_token: "dpop-token", + issued_token_type: AuthAccessTokenType, + token_type: "DPoP", + expires_in: 60, + scope: AGENT_MACHINE_SCOPE, + }, + })); + return failureOf( + exchangePairingCredential({ baseUrl: BASE_URL, credential: "c", clientLabel: "home" }), + ).pipe( + Effect.map((failure) => expect(failure.kind).toBe("rejected")), + Effect.provide(peer.layer), + ); + }); + + it.effect("a rejected credential (401) is unauthorized", () => { + const peer = fakePeer(() => ({ status: 401, body: authInvalid })); + return failureOf( + exchangePairingCredential({ baseUrl: BASE_URL, credential: "used", clientLabel: "home" }), + ).pipe( + Effect.map((failure) => expect(failure.kind).toBe("unauthorized")), + Effect.provide(peer.layer), + ); + }); +}); + +const emptyShell = { + snapshotSequence: 7, + projects: [], + threads: [], + updatedAt: "2026-09-29T12:00:00.000Z", +}; + +describe("fetchPeerShell", () => { + it.effect("sends the bearer token to the shell route", () => { + const peer = fakePeer(() => ({ status: 200, body: emptyShell })); + return Effect.gen(function* () { + const shell = yield* fetchPeerShell(BASE_URL, "session-token"); + expect(shell.snapshotSequence).toBe(7); + expect(peer.requests.map((r) => [r.method, r.url])).toEqual([ + ["GET", `${BASE_URL}/api/orchestration/shell`], + ]); + expect(peer.requests[0]!.headers["authorization"]).toBe("Bearer session-token"); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect("an expired token (401) is unauthorized", () => { + const peer = fakePeer(() => ({ status: 401, body: authInvalid })); + return failureOf(fetchPeerShell(BASE_URL, "expired")).pipe( + Effect.map((failure) => expect(failure.kind).toBe("unauthorized")), + Effect.provide(peer.layer), + ); + }); + + it.effect("a missing scope (403) is unauthorized", () => { + const peer = fakePeer(() => ({ + status: 403, + body: { + _tag: "EnvironmentScopeRequiredError", + code: "insufficient_scope", + requiredScope: "orchestration:read", + traceId: "trace-2", + }, + })); + return failureOf(fetchPeerShell(BASE_URL, "read-less")).pipe( + Effect.map((failure) => expect(failure.kind).toBe("unauthorized")), + Effect.provide(peer.layer), + ); + }); + + it.effect("a peer that never answers is unreachable after the request timeout", () => + Effect.gen(function* () { + const silent = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.never), + ); + const fiber = yield* fetchPeerShell(BASE_URL, "t").pipe( + Effect.flip, + Effect.provide(silent), + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.sum(PEER_REQUEST_TIMEOUT, Duration.millis(1))); + const error = yield* Fiber.join(fiber); + expect(error.kind).toBe("unreachable"); + }), + ); +}); + +describe("dispatchPeerCommand", () => { + it.effect("refuses a command outside the allowlist without any HTTP call", () => { + const peer = fakePeer(() => ({ status: 200, body: { sequence: 1 } })); + return Effect.gen(function* () { + for (const type of ["thread.delete", "project.delete", "project.create"]) { + const failure = yield* failureOf( + dispatchPeerCommand(BASE_URL, "token", command(type) as PeerCommand), + ); + expect(failure.kind).toBe("rejected"); + expect(failure.detail).toContain(type); + } + assert.strictEqual(peer.requests.length, 0); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect("posts an allowed command as JSON with the bearer token", () => { + const peer = fakePeer(() => ({ status: 200, body: { sequence: 12 } })); + const stop = { + type: "thread.session.stop", + commandId: CommandId.make("cp-remote:stop-1"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-09-29T12:00:00.000Z", + } satisfies PeerCommand; + return Effect.gen(function* () { + yield* dispatchPeerCommand(BASE_URL, "session-token", stop); + expect(peer.requests).toHaveLength(1); + const request = peer.requests[0]!; + expect([request.method, request.url]).toEqual([ + "POST", + `${BASE_URL}/api/orchestration/dispatch`, + ]); + expect(request.headers["authorization"]).toBe("Bearer session-token"); + expect(decodeJson(request.body ?? "null")).toEqual(stop); + }).pipe(Effect.provide(peer.layer)); + }); + + it.effect("an opaque peer failure (500) is rejected", () => { + const peer = fakePeer(() => ({ + status: 500, + body: { + _tag: "EnvironmentInternalError", + code: "internal_error", + reason: "orchestration_dispatch_failed", + traceId: "trace-3", + }, + })); + return failureOf( + dispatchPeerCommand(BASE_URL, "t", command("thread.archive") as PeerCommand), + ).pipe( + Effect.map((failure) => expect(failure.kind).toBe("rejected")), + Effect.provide(peer.layer), + ); + }); +}); + +describe("fetchPeerThread", () => { + it.effect("is none when the peer has no such thread", () => { + const peer = fakePeer(() => ({ + status: 404, + body: { + _tag: "EnvironmentResourceNotFoundError", + code: "not_found", + reason: "thread_not_found", + traceId: "trace-4", + }, + })); + return Effect.gen(function* () { + const result = yield* fetchPeerThread(BASE_URL, "session-token", "thread-9", 3); + expect(Option.isNone(result)).toBe(true); + const url = new URL(peer.requests[0]!.url); + expect(url.pathname).toBe("/api/orchestration/threads/thread-9"); + expect(url.searchParams.get("turnLimit")).toBe("3"); + expect(peer.requests[0]!.headers["authorization"]).toBe("Bearer session-token"); + }).pipe(Effect.provide(peer.layer)); + }); +}); diff --git a/apps/server/src/agentMachines/AgentMachineClient.ts b/apps/server/src/agentMachines/AgentMachineClient.ts new file mode 100644 index 0000000000000..dcb12e8182b45 --- /dev/null +++ b/apps/server/src/agentMachines/AgentMachineClient.ts @@ -0,0 +1,285 @@ +/** + * The home server's HTTP client for a linked machine. It uses only the + * environment routes every server already serves, with a bearer token limited + * to `orchestration:read orchestration:operate`. Fork-owned; see + * docs/internals/multi-machine-agents.md. + * + * @module AgentMachineClient + */ +import { + AuthAccessTokenType, + AuthEnvironmentBootstrapTokenType, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthTokenExchangeGrantType, + EnvironmentHttpApi, + type ClientOrchestrationCommand, + type ExecutionEnvironmentDescriptor, + type OrchestrationShellSnapshot, + type OrchestrationThreadDetailSnapshot, +} from "@t3tools/contracts"; +import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +/** Orchestration protocol this build speaks; a peer that differs is refused. */ +export const AGENT_MACHINE_PROTOCOL_VERSION = 1; +export const PEER_REQUEST_TIMEOUT = Duration.seconds(3); +export const AGENT_MACHINE_SCOPE = `${AuthOrchestrationReadScope} ${AuthOrchestrationOperateScope}`; + +export class PeerError extends Schema.TaggedError()("PeerError", { + kind: Schema.Literals([ + "invalid-url", + "unreachable", + "unauthorized", + "incompatible", + "wrong-environment", + "rejected", + ]), + detail: Schema.String, +}) { + override get message(): string { + return this.detail; + } +} + +/** + * The origin to talk to: https, or http to a loopback, private-range or + * tailnet host. The path, query and fragment (a pairing token) are dropped. + */ +export function validatePeerBaseUrl(raw: string): Effect.Effect { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return Effect.fail( + new PeerError({ kind: "invalid-url", detail: "That is not a valid address." }), + ); + } + const secure = url.protocol === "https:"; + const privateHttp = + url.protocol === "http:" && + (isLocalLoopbackHost(url.hostname) || isPrivateNetworkHost(url.hostname)); + if (!secure && !privateHttp) { + return Effect.fail( + new PeerError({ + kind: "invalid-url", + detail: + "Use https, or http to a machine on your tailnet or private network. Plain http over the internet is refused.", + }), + ); + } + return Effect.succeed(url.origin); +} + +/** Allowed over the peer client: anything else could delete or rewrite the peer's work. */ +export const PEER_COMMAND_ALLOWLIST = [ + "thread.create", + "thread.turn.start", + "thread.turn.interrupt", + "thread.session.stop", + "thread.archive", + "thread.settle", +] as const satisfies ReadonlyArray; + +export type PeerCommand = Extract< + ClientOrchestrationCommand, + { type: (typeof PEER_COMMAND_ALLOWLIST)[number] } +>; + +export const isPeerCommand = (command: ClientOrchestrationCommand): command is PeerCommand => + (PEER_COMMAND_ALLOWLIST as ReadonlyArray).includes(command.type); + +const noRedirects = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + +const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); + +/** Maps any transport or HTTP failure to a `PeerError`. Peer dispatch errors are opaque 500s. */ +export function classifyPeerFailure(error: unknown): PeerError { + if (Schema.is(PeerError)(error)) return error; + const tag = (error as { readonly _tag?: string } | null)?._tag ?? ""; + const status = (error as { readonly response?: { readonly status?: number } } | null)?.response + ?.status; + if ( + tag === "EnvironmentAuthInvalidError" || + tag === "EnvironmentHttpUnauthorizedError" || + status === 401 + ) { + return new PeerError({ + kind: "unauthorized", + detail: "The linked machine rejected the token.", + }); + } + if ( + tag === "EnvironmentScopeRequiredError" || + tag === "EnvironmentOperationForbiddenError" || + tag === "EnvironmentHttpForbiddenError" || + status === 403 + ) { + return new PeerError({ + kind: "unauthorized", + detail: "The linked machine refused the token's access.", + }); + } + if (tag === "TimeoutError") { + return new PeerError({ + kind: "unreachable", + detail: "The linked machine did not answer in time.", + }); + } + if (tag.startsWith("Environment")) { + return new PeerError({ kind: "rejected", detail: "The linked machine rejected the request." }); + } + return new PeerError({ kind: "unreachable", detail: "Could not reach the linked machine." }); +} + +const call = (effect: Effect.Effect) => + effect.pipe( + Effect.timeout(PEER_REQUEST_TIMEOUT), + noRedirects, + Effect.mapError(classifyPeerFailure), + ); + +const makeClient = (baseUrl: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl }); + +/** The peer's own descriptor. Its environment id is checked before any token is sent. */ +export const fetchPeerDescriptor = ( + baseUrl: string, +): Effect.Effect => + call( + Effect.gen(function* () { + const client = yield* makeClient(baseUrl); + const descriptor = yield* client.metadata.descriptor(); + const version = descriptor.orchestrationProtocolVersion ?? 1; + if (version !== AGENT_MACHINE_PROTOCOL_VERSION) { + return yield* new PeerError({ + kind: "incompatible", + detail: `The linked machine speaks protocol ${version}; this machine speaks ${AGENT_MACHINE_PROTOCOL_VERSION}. Update the older one.`, + }); + } + return descriptor; + }), + ); + +/** Confirms the server at `baseUrl` is still the machine that was linked. */ +export const requirePeerIdentity = (baseUrl: string, environmentId: string) => + fetchPeerDescriptor(baseUrl).pipe( + Effect.flatMap((descriptor) => + descriptor.environmentId === environmentId + ? Effect.succeed(descriptor) + : Effect.fail( + new PeerError({ + kind: "wrong-environment", + detail: "A different server answers at this address. Link the machine again.", + }), + ), + ), + ); + +/** Trades a pairing credential for a bearer session limited to read and operate. */ +export const exchangePairingCredential = (input: { + readonly baseUrl: string; + readonly credential: string; + readonly clientLabel: string; +}) => + call( + Effect.gen(function* () { + const client = yield* makeClient(input.baseUrl); + const result = yield* client.auth.token({ + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + scope: AGENT_MACHINE_SCOPE, + client_label: input.clientLabel, + }, + headers: {}, + } as Parameters[0]); + if (result.token_type !== "Bearer") { + return yield* new PeerError({ + kind: "rejected", + detail: "The linked machine issued a token this machine cannot use.", + }); + } + return { + token: result.access_token, + expiresInSeconds: result.expires_in, + }; + }), + ); + +export const fetchPeerShell = ( + baseUrl: string, + token: string, +): Effect.Effect => + call( + Effect.gen(function* () { + const client = yield* makeClient(baseUrl); + return yield* client.orchestration.shellSnapshot({ headers: bearer(token) }); + }), + ); + +/** None when the peer has no such thread. */ +export const fetchPeerThread = ( + baseUrl: string, + token: string, + threadId: string, + turnLimit: number, +): Effect.Effect< + Option.Option, + PeerError, + HttpClient.HttpClient +> => + call( + Effect.gen(function* () { + const client = yield* makeClient(baseUrl); + return yield* client.orchestration + .threadSnapshot({ + headers: bearer(token), + params: { threadId }, + payload: { turnLimit }, + } as Parameters[0]) + .pipe( + Effect.map(Option.some), + Effect.catchIf( + (error) => + (error as { readonly _tag?: string })._tag === "EnvironmentResourceNotFoundError", + () => Effect.succeed(Option.none()), + ), + ); + }), + ) as Effect.Effect< + Option.Option, + PeerError, + HttpClient.HttpClient + >; + +/** + * Sends one allowlisted command. The peer answers every failure with an opaque + * 500, so a failure here means "unknown": read the thread to learn what + * happened, and retry with the same command id. + */ +export const dispatchPeerCommand = (baseUrl: string, token: string, command: PeerCommand) => + isPeerCommand(command) + ? call( + Effect.gen(function* () { + const client = yield* makeClient(baseUrl); + yield* client.orchestration.dispatch({ + headers: bearer(token), + payload: command, + } as Parameters[0]); + }), + ) + : Effect.fail( + new PeerError({ + kind: "rejected", + detail: `Command ${(command as { type: string }).type} is not allowed on a linked machine.`, + }), + ); diff --git a/apps/server/src/agentMachines/AgentMachines.test.ts b/apps/server/src/agentMachines/AgentMachines.test.ts new file mode 100644 index 0000000000000..cfc4fa22c2589 --- /dev/null +++ b/apps/server/src/agentMachines/AgentMachines.test.ts @@ -0,0 +1,572 @@ +import { ProjectId, type OrchestrationProjectShell } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { AGENT_MACHINE_PROTOCOL_VERSION } from "./AgentMachineClient.ts"; +import { AgentMachines, AgentMachinesLive, agentMachineSecretName } from "./AgentMachines.ts"; +import { RemoteAgentStore } from "./RemoteAgentStore.ts"; +import { + MemoryRemoteAgentStore, + TEST_NOW, + makePeerThread, + makeRecord, + peerProject, +} from "./testFixtures.ts"; + +const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); + +const MINI_URL = "http://100.101.102.103:4000"; +const HOME_PROJECT_ID = ProjectId.make("home-project"); + +const homeProject: OrchestrationProjectShell = { + ...peerProject, + id: HOME_PROJECT_ID, + workspaceRoot: "/work/acme", + assistant: { coordinatorThreadId: "home-coordinator" as never }, +}; + +const descriptorBody = (environmentId = "env-mini", label = "Mac Mini") => ({ + environmentId, + label, + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.40", + orchestrationProtocolVersion: AGENT_MACHINE_PROTOCOL_VERSION, + capabilities: {}, +}); + +interface PeerState { + descriptor: ReturnType; + projects: ReadonlyArray; + threads: ReadonlyArray>; + reachable: boolean; + tokenStatus: number; +} + +/** One linked machine answering the routes the service uses; every request is recorded. */ +const makePeer = (overrides: Partial = {}) => { + const state: PeerState = { + descriptor: descriptorBody(), + projects: [peerProject], + threads: [], + reachable: true, + tokenStatus: 200, + ...overrides, + }; + const requests: Array<{ method: string; path: string; authorization: string | undefined }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => { + requests.push({ + method: request.method, + path: url.pathname, + authorization: request.headers["authorization"], + }); + const answer = (status: number, body: unknown) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ); + if (!state.reachable) return Effect.fail(new Error("connect ECONNREFUSED") as never); + if (url.pathname === "/.well-known/t3/environment") return answer(200, state.descriptor); + if (url.pathname === "/oauth/token") { + return state.tokenStatus === 200 + ? answer(200, { + access_token: "peer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 2_592_000, + scope: "orchestration:read orchestration:operate", + }) + : answer(state.tokenStatus, { + _tag: "EnvironmentHttpUnauthorizedError", + message: "no", + }); + } + if (url.pathname === "/api/orchestration/shell") { + return answer(200, { + snapshotSequence: 1, + projects: state.projects, + threads: state.threads, + updatedAt: TEST_NOW, + }); + } + return answer(404, {}); + }), + ); + return { state, requests, layer }; +}; + +interface HarnessInput { + readonly placement?: Parameters[0]; + readonly peer?: ReturnType; + readonly homeThreads?: ReadonlyArray>; +} + +const harness = (input: HarnessInput = {}) => { + const peer = input.peer ?? makePeer(); + const layer = AgentMachinesLive.pipe( + Layer.provideMerge(MemoryRemoteAgentStore), + Layer.provideMerge(peer.layer), + Layer.provideMerge(ServerSettings.layerTest(input.placement ?? {})), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed("env-home" as never), + getDescriptor: Effect.succeed(descriptorBody("env-home", "This Mac") as never), + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [homeProject], + threads: [...(input.homeThreads ?? [])], + updatedAt: TEST_NOW, + }), + }), + ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-machines-" })), + Layer.provideMerge(NodeServices.layer), + ); + const run = ( + effect: Effect.Effect< + A, + E, + | AgentMachines + | RemoteAgentStore + | ServerSettings.ServerSettingsService + | ServerSecretStore.ServerSecretStore + >, + ) => effect.pipe(Effect.provide(layer)); + return { peer, run }; +}; + +const linkedMini = { + agentPlacement: { + machines: { + "env-mini": { label: "Mac Mini", baseUrl: MINI_URL, enabled: true, preference: 50 as const }, + }, + }, +}; + +/** Stores a token for the linked machine, as a link would have. */ +const storeToken = (machineId = "env-mini", expiresAt = "2026-10-29T00:00:00.000Z") => + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set( + agentMachineSecretName(machineId), + new TextEncoder().encode(JSON.stringify({ token: "peer-token", expiresAt })), + ); + }); + +describe("agentMachineSecretName", () => { + it("encodes the peer-supplied id so it cannot name another file", () => { + const name = agentMachineSecretName("../../etc/passwd"); + expect(name).toMatch(/^agent-machine-token-[A-Za-z0-9_-]+$/); + expect(name).not.toContain("/"); + expect(name).not.toContain("."); + }); +}); + +describe("AgentMachines.manage link", () => { + it.effect("exchanges the pairing link, stores the token and adds the machine", () => { + const h = harness(); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const settings = yield* ServerSettings.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const result = yield* machines.manage({ + action: "link", + pairingUrl: `${MINI_URL}/pair#token=ABC123`, + }); + expect(result.linked).toEqual({ id: "env-mini", label: "Mac Mini" }); + expect(result.machines.map((machine) => [machine.id, machine.status])).toEqual([ + ["env-mini", "connected"], + ]); + const saved = (yield* settings.getSettings).agentPlacement.machines["env-mini"]; + expect(saved).toEqual({ + label: "Mac Mini", + baseUrl: MINI_URL, + enabled: true, + preference: 50, + }); + const stored = yield* secrets.get(agentMachineSecretName("env-mini")); + expect(Option.isSome(stored)).toBe(true); + expect(new TextDecoder().decode(Option.getOrThrow(stored))).toContain("peer-token"); + // The credential never reaches settings. + expect(JSON.stringify(yield* settings.getSettings)).not.toContain("peer-token"); + expect(JSON.stringify(yield* settings.getSettings)).not.toContain("ABC123"); + }), + ); + }); + + it.effect("keeps a re-linked machine's enabled and preference", () => { + const h = harness({ + placement: { + agentPlacement: { + machines: { + "env-mini": { + label: "Mini (mine)", + baseUrl: "http://old.example.ts.net", + enabled: false, + preference: 100, + }, + }, + }, + }, + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const settings = yield* ServerSettings.ServerSettingsService; + yield* machines.manage({ action: "link", pairingUrl: `${MINI_URL}/pair#token=ABC` }); + expect((yield* settings.getSettings).agentPlacement.machines["env-mini"]).toEqual({ + label: "Mini (mine)", + baseUrl: MINI_URL, + enabled: false, + preference: 100, + }); + }), + ); + }); + + it.effect("uses an address override when the link's address is not reachable from here", () => { + const h = harness(); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const settings = yield* ServerSettings.ServerSettingsService; + yield* machines.manage({ + action: "link", + pairingUrl: "http://127.0.0.1:1/pair#token=ABC", + baseUrl: MINI_URL, + }); + expect((yield* settings.getSettings).agentPlacement.machines["env-mini"]?.baseUrl).toBe( + MINI_URL, + ); + }), + ); + }); + + it.effect("refuses a link to this machine itself", () => { + const h = harness({ peer: makePeer({ descriptor: descriptorBody("env-home", "This Mac") }) }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const failure = yield* Effect.flip( + machines.manage({ action: "link", pairingUrl: `${MINI_URL}/pair#token=ABC` }), + ); + expect(failure.detail).toContain("points at this machine"); + expect(h.peer.requests.some((r) => r.path === "/oauth/token")).toBe(false); + }), + ); + }); + + it.effect("refuses plain http to a public address before any request", () => { + const h = harness(); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const failure = yield* Effect.flip( + machines.manage({ action: "link", pairingUrl: "http://example.com/pair#token=ABC" }), + ); + expect(failure.detail).toContain("Use https"); + expect(h.peer.requests).toEqual([]); + }), + ); + }); + + it.effect( + "refuses a link with no token, and a peer that rejects it, leaving nothing behind", + () => { + const h = harness({ peer: makePeer({ tokenStatus: 401 }) }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const settings = yield* ServerSettings.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const noToken = yield* Effect.flip( + machines.manage({ action: "link", pairingUrl: `${MINI_URL}/pair` }), + ); + expect(noToken.detail).toContain("not a valid pairing link"); + const rejected = yield* Effect.flip( + machines.manage({ action: "link", pairingUrl: `${MINI_URL}/pair#token=ABC` }), + ); + expect(rejected.detail).toBeTruthy(); + expect((yield* settings.getSettings).agentPlacement.machines).toEqual({}); + expect(Option.isNone(yield* secrets.get(agentMachineSecretName("env-mini")))).toBe(true); + }), + ); + }, + ); +}); + +describe("AgentMachines.manage unlink and check", () => { + it.effect("unlink removes the entry and token and marks that machine's agents lost", () => { + const h = harness({ placement: linkedMini }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const settings = yield* ServerSettings.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const store = yield* RemoteAgentStore; + yield* storeToken(); + yield* store.put(makeRecord({ threadId: "a", machineId: "env-mini" })); + yield* store.put(makeRecord({ threadId: "b", machineId: "other" })); + const result = yield* machines.manage({ action: "unlink", id: "env-mini" }); + expect(result.machines).toEqual([]); + expect((yield* settings.getSettings).agentPlacement.machines).toEqual({}); + expect(Option.isNone(yield* secrets.get(agentMachineSecretName("env-mini")))).toBe(true); + expect(Option.getOrThrow(yield* store.get("a")).state).toBe("lost"); + expect(Option.getOrThrow(yield* store.get("b")).state).toBe("open"); + }), + ); + }); + + it.effect("check reports a connected machine with its token expiry and matched Projects", () => { + const h = harness({ placement: linkedMini }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken("env-mini", "2026-10-29T00:00:00.000Z"); + const { machines: statuses } = yield* machines.manage({ action: "check" }); + expect(statuses).toEqual([ + { + id: "env-mini", + status: "connected", + tokenExpiresAt: "2026-10-29T00:00:00.000Z", + matchedProjectCount: 1, + projectCount: 1, + }, + ]); + }), + ); + }); + + it.effect("check reports an unreachable machine as offline", () => { + const h = harness({ placement: linkedMini, peer: makePeer({ reachable: false }) }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const { machines: statuses } = yield* machines.manage({ action: "check" }); + expect(statuses[0]).toMatchObject({ id: "env-mini", status: "offline" }); + }), + ); + }); + + it.effect("check reports a machine with no stored token as needing a new link", () => { + const h = harness({ placement: linkedMini }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const { machines: statuses } = yield* machines.manage({ action: "check" }); + expect(statuses[0]).toMatchObject({ id: "env-mini", status: "needs-relink" }); + expect(h.peer.requests.some((r) => r.path === "/api/orchestration/shell")).toBe(false); + }), + ); + }); + + it.effect("check reports a different server at the address, and never sends it the token", () => { + const h = harness({ + placement: linkedMini, + peer: makePeer({ descriptor: descriptorBody("env-impostor", "Not the Mini") }), + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const { machines: statuses } = yield* machines.manage({ action: "check" }); + expect(statuses[0]).toMatchObject({ id: "env-mini", status: "wrong-environment" }); + expect(h.peer.requests.filter((r) => r.authorization !== undefined)).toEqual([]); + }), + ); + }); + + it.effect("check counts Projects with no match on the machine", () => { + const h = harness({ placement: linkedMini, peer: makePeer({ projects: [] }) }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const { machines: statuses } = yield* machines.manage({ action: "check" }); + expect(statuses[0]).toMatchObject({ matchedProjectCount: 0, projectCount: 1 }); + }), + ); + }); +}); + +describe("AgentMachines.placementInputs", () => { + const project = { id: HOME_PROJECT_ID, title: "Acme", workspaceRoot: "/work/acme" }; + + it.effect("stays local without probing when every agent runs here", () => { + const h = harness({ placement: linkedMini }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + expect(inputs.localOnly).toBe(true); + expect(inputs.candidates.map((candidate) => candidate.id)).toEqual(["local"]); + expect(h.peer.requests).toEqual([]); + }), + ); + }); + + it.effect("balanced mode probes each enabled machine, matching its Project and load", () => { + const busy = makePeerThread("busy", { + session: { + threadId: "busy" as never, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: TEST_NOW, + }, + }); + const h = harness({ + placement: { + agentPlacement: { mode: "balanced", machines: linkedMini.agentPlacement.machines }, + }, + peer: makePeer({ threads: [busy, makePeerThread("idle")] }), + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + const store = yield* RemoteAgentStore; + yield* storeToken(); + // A placement this home made a moment ago that the peer does not show yet. + yield* store.put( + makeRecord({ threadId: "just-placed", machineId: "env-mini", createdAt: TEST_NOW }), + ); + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + const mini = inputs.candidates.find((candidate) => candidate.id === "env-mini"); + expect(mini).toMatchObject({ label: "Mac Mini", ineligibleReason: null, load: 2 }); + expect(inputs.peerProjectIds.get("env-mini")).toBe(peerProject.id); + expect(inputs.candidates[0]).toMatchObject({ id: "local", ineligibleReason: null }); + }), + ); + }); + + it.effect("marks a machine without the Project ineligible, with the reason", () => { + const h = harness({ + placement: { + agentPlacement: { mode: "balanced", machines: linkedMini.agentPlacement.machines }, + }, + peer: makePeer({ projects: [] }), + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + expect(inputs.candidates.find((c) => c.id === "env-mini")?.ineligibleReason).toContain( + "no Project named Acme", + ); + expect(inputs.peerProjectIds.size).toBe(0); + }), + ); + }); + + it.effect("marks an unreachable machine ineligible instead of failing", () => { + const h = harness({ + placement: { + agentPlacement: { mode: "balanced", machines: linkedMini.agentPlacement.machines }, + }, + peer: makePeer({ reachable: false }), + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + expect(inputs.candidates.find((c) => c.id === "env-mini")?.ineligibleReason).not.toBeNull(); + expect(inputs.candidates[0]?.ineligibleReason).toBeNull(); + }), + ); + }); + + it.effect("single mode probes only the chosen machine, and skips disabled ones", () => { + const h = harness({ + placement: { + agentPlacement: { + mode: "single", + singleMachineId: "env-mini", + machines: { + ...linkedMini.agentPlacement.machines, + "env-studio": { + label: "Studio", + baseUrl: "http://studio.ts.net", + enabled: false, + preference: 50, + }, + }, + }, + }, + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + expect(inputs.candidates.map((candidate) => candidate.id)).toEqual(["local", "env-mini"]); + }), + ); + }); + + it.effect("an explicit machine probes just that machine, by label", () => { + const h = harness({ + placement: { + agentPlacement: { mode: "balanced", machines: linkedMini.agentPlacement.machines }, + }, + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + yield* storeToken(); + const inputs = yield* machines.placementInputs(project, { requested: "mac mini" }); + expect(inputs.candidates.map((candidate) => candidate.id)).toEqual(["local", "env-mini"]); + }), + ); + }); + + it.effect( + "a token the peer refuses reads as needing a new link and is not retried at once", + () => { + const h = harness({ + placement: { + agentPlacement: { mode: "balanced", machines: linkedMini.agentPlacement.machines }, + }, + }); + return h.run( + Effect.gen(function* () { + const machines = yield* AgentMachines; + // No stored token: the machine needs a new link. + const inputs = yield* machines.placementInputs(project, { requested: undefined }); + expect(inputs.candidates.find((c) => c.id === "env-mini")?.ineligibleReason).toContain( + "linked again", + ); + expect(yield* machines.isReachable("env-mini")).toBe(false); + }), + ); + }, + ); +}); diff --git a/apps/server/src/agentMachines/AgentMachines.ts b/apps/server/src/agentMachines/AgentMachines.ts new file mode 100644 index 0000000000000..2e0f3094ed252 --- /dev/null +++ b/apps/server/src/agentMachines/AgentMachines.ts @@ -0,0 +1,502 @@ +/** + * Linked machines: linking, tokens, health, and the inputs placement needs. + * The bearer token lives in the secret store, never in settings, and is sent + * only after the peer's descriptor proves it is still the machine that was + * linked. Fork-owned; see docs/internals/multi-machine-agents.md. + * + * @module AgentMachines + */ +import { + LOCAL_AGENT_MACHINE, + AgentMachinesError, + type AgentMachineConfig, + type AgentPlacementSettings, + type AgentMachinesInput, + type AgentMachinesResult, + type AgentMachineStatus, + type OrchestrationShellSnapshot, + type OrchestrationThreadDetailSnapshot, +} from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + dispatchPeerCommand, + exchangePairingCredential, + fetchPeerDescriptor, + fetchPeerShell, + fetchPeerThread, + PeerError, + requirePeerIdentity, + validatePeerBaseUrl, + type PeerCommand, +} from "./AgentMachineClient.ts"; +import { type PlacementCandidate } from "./placement.ts"; +import { matchPeerProject, type ProjectMatch } from "./projectMatch.ts"; +import { PLACEMENT_GRACE_MS, machineLoad } from "./remoteAgentState.ts"; +import { RemoteAgentStore } from "./RemoteAgentStore.ts"; +import { isAgentCountedRunning } from "../mcp/toolkits/agents/agentScope.ts"; + +const SNAPSHOT_TTL_MS = 5_000; +/** A machine that failed a probe is not retried this soon. */ +const OFFLINE_TTL_MS = 30_000; +const IDENTITY_TTL_MS = 60_000; + +const TokenSecret = Schema.Struct({ token: Schema.String, expiresAt: Schema.String }); +const TokenSecretJson = Schema.fromJsonString(TokenSecret); +const decodeTokenSecret = Schema.decodeUnknownOption(TokenSecretJson); +const encodeTokenSecret = Schema.encodeSync(TokenSecretJson); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** Encoded like `usageLimitSourceSecretName`: the id comes from the peer and names a file. */ +export const agentMachineSecretName = (machineId: string): string => + `agent-machine-token-${Buffer.from(machineId, "utf8").toString("base64url")}`; + +export interface HomeProject { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly repositoryIdentity?: { readonly canonicalKey: string } | null | undefined; +} + +export interface PlacementInputs { + readonly settings: AgentPlacementSettings; + /** The settings put every agent on this machine, so no linked machine was probed. */ + readonly localOnly: boolean; + readonly candidates: ReadonlyArray; + /** The matching Project on each reachable linked machine. */ + readonly peerProjectIds: ReadonlyMap; +} + +export interface AgentMachinesShape { + readonly manage: ( + input: AgentMachinesInput, + ) => Effect.Effect; + /** This machine plus every enabled linked machine, with reachability, Project match and load. */ + readonly placementInputs: ( + project: HomeProject, + options: { readonly requested: string | undefined }, + ) => Effect.Effect; + /** Cached for a few seconds unless `fresh`; the bridge polls fresh. */ + readonly shell: ( + machineId: string, + options?: { readonly fresh?: boolean }, + ) => Effect.Effect; + readonly thread: ( + machineId: string, + threadId: string, + turnLimit: number, + ) => Effect.Effect, PeerError>; + readonly dispatch: (machineId: string, command: PeerCommand) => Effect.Effect; + /** Marks a machine as needing a fresh link after the peer refused its token. */ + readonly markNeedsRelink: (machineId: string) => Effect.Effect; + readonly isReachable: (machineId: string) => Effect.Effect; + /** How this machine is named to a linked one. */ + readonly homeLabel: Effect.Effect; +} + +export class AgentMachines extends Context.Service()( + "t3/agentMachines/AgentMachines", +) {} + +interface Health { + status: AgentMachineStatus["status"]; + detail?: string; + checkedAtMs: number; +} + +const make = Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const store = yield* RemoteAgentStore; + const httpClient = yield* HttpClient.HttpClient; + + const health = new Map(); + const shells = new Map(); + const identities = new Map(); + + const nowMs = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + const withHttp = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + const placementSettings = settingsService.getSettings.pipe( + Effect.map((settings) => settings.agentPlacement), + Effect.mapError(() => new AgentMachinesError({ detail: "Could not read settings." })), + ); + + const readToken = (machineId: string) => + secrets.get(agentMachineSecretName(machineId)).pipe( + Effect.map((bytes) => + Option.flatMap(bytes, (value) => decodeTokenSecret(textDecoder.decode(value))), + ), + Effect.mapError( + () => new PeerError({ kind: "unauthorized", detail: "Could not read the stored token." }), + ), + ); + + const machineConfig = (machineId: string) => + settingsService.getSettings.pipe( + Effect.map((settings) => settings.agentPlacement.machines[machineId]), + Effect.mapError( + () => new PeerError({ kind: "unreachable", detail: "Could not read settings." }), + ), + Effect.flatMap((config) => + config === undefined + ? Effect.fail(new PeerError({ kind: "rejected", detail: "That machine is not linked." })) + : Effect.succeed(config), + ), + ); + + const record = (machineId: string, next: Omit) => + nowMs.pipe( + Effect.tap((at) => Effect.sync(() => health.set(machineId, { ...next, checkedAtMs: at }))), + ); + + /** Runs `use` against a linked machine after its identity is confirmed, keeping health current. */ + const withPeer = ( + machineId: string, + use: (peer: { + baseUrl: string; + token: string; + config: AgentMachineConfig; + }) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const config = yield* machineConfig(machineId); + const stored = yield* readToken(machineId); + if (Option.isNone(stored)) { + yield* record(machineId, { status: "needs-relink", detail: "No stored token." }); + return yield* new PeerError({ + kind: "unauthorized", + detail: "This machine needs to be linked again.", + }); + } + const verifiedAt = identities.get(machineId); + const now = yield* nowMs; + if (verifiedAt === undefined || now - verifiedAt > IDENTITY_TTL_MS) { + yield* requirePeerIdentity(config.baseUrl, machineId); + identities.set(machineId, now); + } + const result = yield* use({ baseUrl: config.baseUrl, token: stored.value.token, config }); + yield* record(machineId, { status: "connected" }); + return result; + }).pipe( + withHttp, + Effect.tapError((error) => + record(machineId, { + status: + error.kind === "unauthorized" + ? "needs-relink" + : error.kind === "incompatible" + ? "incompatible" + : error.kind === "wrong-environment" + ? "wrong-environment" + : "offline", + detail: error.detail, + }).pipe(Effect.tap(() => Effect.sync(() => identities.delete(machineId)))), + ), + ); + + const shell: AgentMachinesShape["shell"] = (machineId, options) => + Effect.gen(function* () { + const now = yield* nowMs; + const cached = shells.get(machineId); + if (options?.fresh !== true && cached !== undefined && now - cached.atMs < SNAPSHOT_TTL_MS) { + return cached.snapshot; + } + const failed = health.get(machineId); + if ( + options?.fresh !== true && + failed !== undefined && + failed.status !== "connected" && + now - failed.checkedAtMs < OFFLINE_TTL_MS + ) { + return yield* new PeerError({ + kind: failed.status === "needs-relink" ? "unauthorized" : "unreachable", + detail: failed.detail ?? "The linked machine is unavailable.", + }); + } + const snapshot = yield* withPeer(machineId, ({ baseUrl, token }) => + fetchPeerShell(baseUrl, token), + ); + shells.set(machineId, { snapshot, atMs: now }); + return snapshot; + }); + + const localLoad = Effect.gen(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + return snapshot.threads.filter( + (thread) => thread.archivedAt === null && isAgentCountedRunning(thread, now), + ).length; + }).pipe(Effect.catch(() => Effect.succeed(0))); + + const ineligibleReason = (match: ProjectMatch, projectTitle: string): string | null => { + switch (match.kind) { + case "matched": + return null; + case "missing": + return `no Project named ${projectTitle} on that machine`; + case "ambiguous": + return `${match.count} Projects match ${projectTitle} on that machine`; + } + }; + + const placementInputs: AgentMachinesShape["placementInputs"] = (project, options) => + Effect.gen(function* () { + const settings = yield* placementSettings; + const localCandidate = Effect.gen(function* () { + const descriptor = yield* environment.getDescriptor; + return { + id: LOCAL_AGENT_MACHINE, + label: descriptor.label, + ineligibleReason: null, + load: yield* localLoad, + } satisfies PlacementCandidate; + }); + // Every agent stays here unless the caller names a machine: no probes. + if (settings.mode === "local" && options.requested === undefined) { + return { + settings, + localOnly: true, + candidates: [yield* localCandidate], + peerProjectIds: new Map(), + } satisfies PlacementInputs; + } + const records = yield* store.list.pipe( + Effect.mapError(() => new AgentMachinesError({ detail: "Could not read remote agents." })), + ); + const now = yield* nowMs; + const wanted = options.requested?.trim().toLowerCase(); + const enabled = Object.entries(settings.machines).filter( + ([id, config]) => + config.enabled && + (settings.mode === "balanced" || + (options.requested !== undefined + ? id.toLowerCase() === wanted || config.label.toLowerCase() === wanted + : id === settings.singleMachineId)), + ); + const peerProjectIds = new Map(); + const remote = yield* Effect.forEach( + enabled, + ([id, config]) => + shell(id).pipe( + Effect.map((snapshot): PlacementCandidate => { + const match = matchPeerProject(project, snapshot.projects); + if (match.kind === "matched") peerProjectIds.set(id, match.projectId); + const recent = records.filter( + (agent) => + agent.machineId === id && + agent.state !== "lost" && + now - DateTime.toEpochMillis(DateTime.makeUnsafe(agent.createdAt)) < + PLACEMENT_GRACE_MS, + ); + return { + id, + label: config.label, + ineligibleReason: ineligibleReason(match, project.title), + load: machineLoad({ snapshot, recentPlacements: recent }), + }; + }), + Effect.catch((error) => + Effect.succeed({ + id, + label: config.label, + ineligibleReason: + error.kind === "unauthorized" ? "needs to be linked again" : error.detail, + load: 0, + }), + ), + ), + { concurrency: "unbounded" }, + ); + return { + settings, + localOnly: false, + candidates: [yield* localCandidate, ...remote], + peerProjectIds, + } satisfies PlacementInputs; + }); + + const statusOf = (machineId: string, homeProjects: ReadonlyArray) => + Effect.gen(function* () { + // A check always probes, so a machine that came back shows as connected. + health.delete(machineId); + shells.delete(machineId); + const outcome = yield* Effect.result(shell(machineId)); + const stored = yield* readToken(machineId).pipe( + Effect.catch(() => Effect.succeed(Option.none())), + ); + const tokenExpiresAt = Option.match(stored, { + onNone: () => undefined, + onSome: (v) => v.expiresAt, + }); + if (outcome._tag === "Failure") { + const known = health.get(machineId); + return { + id: machineId, + status: known?.status ?? "offline", + detail: outcome.failure.detail, + ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }), + matchedProjectCount: 0, + projectCount: homeProjects.length, + } satisfies AgentMachineStatus; + } + const matched = homeProjects.filter( + (project) => matchPeerProject(project, outcome.success.projects).kind === "matched", + ).length; + return { + id: machineId, + status: "connected", + ...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }), + matchedProjectCount: matched, + projectCount: homeProjects.length, + } satisfies AgentMachineStatus; + }); + + const check = Effect.gen(function* () { + const settings = yield* placementSettings; + const local = yield* snapshots + .getShellSnapshot() + .pipe(Effect.mapError(() => new AgentMachinesError({ detail: "Could not read Projects." }))); + const homeProjects = local.projects.filter( + (project) => project.assistant?.archivedAt == null && project.assistant != null, + ); + return yield* Effect.forEach( + Object.keys(settings.machines), + (id) => statusOf(id, homeProjects), + { concurrency: "unbounded" }, + ); + }); + + const link = (input: Extract) => + Effect.gen(function* () { + const fail = (detail: string) => new AgentMachinesError({ detail }); + const target = yield* Effect.try({ + try: () => resolveRemotePairingTarget({ pairingUrl: input.pairingUrl }), + catch: () => fail("That is not a valid pairing link. Create one on the other machine."), + }); + const asPeerFailure = Effect.mapError((error: PeerError) => fail(error.detail)); + const baseUrl = yield* validatePeerBaseUrl(input.baseUrl ?? target.httpBaseUrl).pipe( + asPeerFailure, + ); + const descriptor = yield* withHttp(fetchPeerDescriptor(baseUrl)).pipe(asPeerFailure); + const selfId = yield* environment.getEnvironmentId; + if (descriptor.environmentId === selfId) { + return yield* fail("That link points at this machine."); + } + const exchanged = yield* withHttp( + exchangePairingCredential({ + baseUrl, + credential: target.credential, + clientLabel: `Agent placement from ${(yield* environment.getDescriptor).label}`, + }), + ).pipe(asPeerFailure); + const now = yield* DateTime.now; + const expiresAt = DateTime.formatIso( + DateTime.add(now, { seconds: exchanged.expiresInSeconds }), + ); + const secretName = agentMachineSecretName(descriptor.environmentId); + yield* secrets + .set( + secretName, + textEncoder.encode(encodeTokenSecret({ token: exchanged.token, expiresAt })), + ) + .pipe(Effect.mapError(() => fail("Could not store the machine's token."))); + const existing = (yield* placementSettings).machines[descriptor.environmentId]; + yield* settingsService + .updateSettings({ + agentPlacement: { + machines: { + [descriptor.environmentId]: { + label: existing?.label ?? descriptor.label, + baseUrl, + enabled: existing?.enabled ?? true, + preference: existing?.preference ?? 50, + }, + }, + }, + }) + .pipe( + Effect.tapError(() => secrets.remove(secretName).pipe(Effect.ignore)), + Effect.mapError(() => fail("Could not save the machine.")), + ); + health.delete(descriptor.environmentId); + shells.delete(descriptor.environmentId); + identities.delete(descriptor.environmentId); + return { id: descriptor.environmentId, label: descriptor.label }; + }); + + const unlink = (id: string) => + Effect.gen(function* () { + const fail = (detail: string) => new AgentMachinesError({ detail }); + yield* settingsService + .updateSettings({ agentPlacement: { machines: { [id]: null } } }) + .pipe(Effect.mapError(() => fail("Could not save settings."))); + yield* secrets.remove(agentMachineSecretName(id)).pipe(Effect.ignore); + const at = DateTime.formatIso(yield* DateTime.now); + const records = yield* store.list.pipe(Effect.orElseSucceed(() => [])); + for (const agent of records) { + if (agent.machineId !== id || agent.state === "lost") continue; + yield* store + .update(agent.threadId, (current) => ({ ...current, state: "lost", endedAt: at })) + .pipe(Effect.ignore); + } + health.delete(id); + shells.delete(id); + identities.delete(id); + }); + + const manage: AgentMachinesShape["manage"] = (input) => + Effect.gen(function* () { + switch (input.action) { + case "link": { + const linked = yield* link(input); + return { linked, machines: yield* check }; + } + case "unlink": { + yield* unlink(input.id); + return { machines: yield* check }; + } + case "check": + return { machines: yield* check }; + } + }); + + return { + manage, + placementInputs, + shell, + thread: (machineId, threadId, turnLimit) => + withPeer(machineId, ({ baseUrl, token }) => + fetchPeerThread(baseUrl, token, threadId, turnLimit), + ), + dispatch: (machineId, command) => + withPeer(machineId, ({ baseUrl, token }) => dispatchPeerCommand(baseUrl, token, command)), + markNeedsRelink: (machineId) => + record(machineId, { + status: "needs-relink", + detail: "The linked machine refused the token.", + }).pipe(Effect.asVoid), + homeLabel: environment.getDescriptor.pipe(Effect.map((descriptor) => descriptor.label)), + isReachable: (machineId) => + Effect.sync(() => health.get(machineId)?.status).pipe( + Effect.map((status) => status === undefined || status === "connected"), + ), + } satisfies AgentMachinesShape; +}); + +export const AgentMachinesLive = Layer.effect(AgentMachines, make); diff --git a/apps/server/src/agentMachines/RemoteAgentBridge.test.ts b/apps/server/src/agentMachines/RemoteAgentBridge.test.ts new file mode 100644 index 0000000000000..aee28fcde4f45 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgentBridge.test.ts @@ -0,0 +1,560 @@ +import { + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import { AgentCompletionReactor } from "../orchestration/AgentCompletionReactor.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + RemoteAgentBridge, + makeRemoteAgentBridge, + REMOTE_LOST_AFTER_MS, +} from "./RemoteAgentBridge.ts"; +import { RemoteAgentsLive } from "./RemoteAgents.ts"; +import { RemoteAgentStore, type RemoteAgentRecord } from "./RemoteAgentStore.ts"; +import { + MemoryRemoteAgentStore, + TEST_NOW, + makeFakePeer, + makePeerThread, + makeRecord, +} from "./testFixtures.ts"; + +const HOME_PROJECT = ProjectId.make("project-1"); +const COORDINATOR = ThreadId.make("coordinator"); +const STANDING = ThreadId.make("standing"); + +const homeThread = (id: ThreadId, overrides: Partial = {}) => + makePeerThread(id, { projectId: HOME_PROJECT, ...overrides }); + +const homeProject = ( + assistant: OrchestrationProjectShell["assistant"] = { coordinatorThreadId: COORDINATOR }, +): OrchestrationProjectShell => ({ + id: HOME_PROJECT, + title: "Acme", + workspaceRoot: "/work/acme", + defaultModelSelection: null, + assistant, + scripts: [], + createdAt: TEST_NOW, + updatedAt: TEST_NOW, +}); + +const request = (overrides: Partial> = {}) => ({ + messageId: "cp-agent-message:coordinator:r1", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + ...overrides, +}); + +const completedTurn = (turnId = "turn-1") => ({ + turnId: turnId as never, + state: "completed" as const, + requestedAt: TEST_NOW, + startedAt: TEST_NOW, + completedAt: TEST_NOW, + assistantMessageId: null, +}); + +interface HarnessOptions { + readonly project?: OrchestrationProjectShell; + readonly threads?: ReadonlyArray; +} + +const harness = (options: HarnessOptions = {}) => { + const peer = makeFakePeer(); + const appended: Array = []; + const enqueued: Array = []; + const threads = new Map( + ( + options.threads ?? [homeThread(COORDINATOR), homeThread(STANDING, { pinnedAt: TEST_NOW })] + ).map((thread) => [thread.id, thread] as const), + ); + const project = options.project ?? homeProject(); + const bridgeDependencies = Layer.mergeAll( + Layer.mock(OrchestrationEngineService)({ + dispatch: (command) => + Effect.sync(() => { + appended.push(command); + return { sequence: appended.length }; + }), + }), + Layer.mock(ProjectionSnapshotQuery)({ + getProjectShellById: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + getThreadShellById: (threadId) => Effect.succeed(Option.fromNullishOr(threads.get(threadId))), + }), + Layer.mock(AgentCompletionReactor)({ + enqueueProject: (projectId) => Effect.sync(() => void enqueued.push(projectId)), + }), + ); + const services = RemoteAgentsLive.pipe( + Layer.provideMerge(peer.layer), + Layer.provideMerge(MemoryRemoteAgentStore), + Layer.provideMerge(bridgeDependencies), + ); + const layer = Layer.effect(RemoteAgentBridge, makeRemoteAgentBridge).pipe( + Layer.provideMerge(services), + ); + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer)); + const pushes = () => appended.filter((command) => command.type === "thread.message.user.append"); + return { peer, run, appended, enqueued, pushes, threads }; +}; + +/** Seeds one open agent with a request in flight and the peer thread it reads. */ +const seed = ( + h: ReturnType, + record: Partial, + peerThread: Partial = {}, + messages: ReadonlyArray<{ id: string; role: string; text: string; streaming?: boolean }> = [], +) => + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + yield* store.put(makeRecord({ inFlight: request(), ...record })); + h.peer.threads.set("remote-1", makePeerThread("remote-1", peerThread)); + h.peer.details.set("remote-1", { + messages: messages.map((message) => ({ streaming: false, ...message })) as never, + }); + }); + +const finishedMessages = [ + { id: "cp-agent-message:coordinator:r1", role: "user", text: "Compare plans." }, + { id: "a1", role: "assistant", text: "Plan B wins." }, +]; + +describe("RemoteAgentBridge delivery", () => { + it.effect( + "pushes a finished request into the coordinator, wakes the reactor, and settles the agent", + () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + const [push] = h.pushes(); + expect(push).toMatchObject({ + type: "thread.message.user.append", + commandId: "cp-push:remote-1:cp-agent-message:coordinator:r1", + threadId: "coordinator", + }); + if (push?.type === "thread.message.user.append") { + expect(push.message.messageId).toBe("cp-push:remote-1:cp-agent-message:coordinator:r1"); + expect(push.message.text).toContain('Result from agent "Pricing (on Mac Mini)"'); + expect(push.message.text).toContain("Plan B wins."); + expect(push.message.source).toMatchObject({ kind: "agent", threadId: "remote-1" }); + // A pushed turn never owes a result of its own. + expect(push.message.source).not.toHaveProperty("replyTo"); + } + expect(h.enqueued).toEqual([HOME_PROJECT]); + expect(h.peer.commands.map((command) => command.type)).toEqual(["thread.settle"]); + expect(record.state).toBe("settled"); + expect(record.inFlight).toBeNull(); + }), + ); + + it.effect("does not deliver the same request twice", () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + yield* bridge.pollOnce; + yield* bridge.pollOnce; + yield* bridge.pollOnce; + }), + ); + expect(h.pushes()).toHaveLength(1); + }), + ); + + it.effect( + "repeats the same push id after a restart lost the record update, so the engine dedupes it", + () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + yield* bridge.pollOnce; + // The crash: the append landed but the record still shows the request. + yield* store.put(makeRecord({ inFlight: request() })); + yield* bridge.pollOnce; + }), + ); + const ids = h.pushes().map((command) => command.commandId); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(1); + }), + ); + + it.effect("waits while the agent is still running, and reads its phase", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed( + h, + {}, + { + session: { + threadId: "remote-1" as never, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: TEST_NOW, + }, + }, + ); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(h.pushes()).toEqual([]); + expect(record.lastPhase).toBe("running"); + expect(record.inFlight).not.toBeNull(); + }), + ); + + it.effect("does not treat the turn from before the request as its result", () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + yield* seed( + h, + { inFlight: request({ baselineTurnId: "turn-1" }) }, + { latestTurn: completedTurn("turn-1") }, + finishedMessages, + ); + yield* bridge.pollOnce; + }), + ); + expect(h.pushes()).toEqual([]); + }), + ); + + it.effect("drops a stopped request's result without pushing it", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed( + h, + { inFlight: request({ suppressed: true }) }, + { latestTurn: { ...completedTurn(), state: "interrupted" } }, + finishedMessages, + ); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(h.pushes()).toEqual([]); + expect(record.inFlight).toBeNull(); + }), + ); + + it.effect("reports a request whose start failed, with the peer's detail", () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + yield* seed( + h, + {}, + { + session: { + threadId: "remote-1" as never, + status: "error", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: "provider missing" as never, + updatedAt: TEST_NOW, + }, + }, + finishedMessages.slice(0, 1), + ); + h.peer.details.set("remote-1", { + messages: [] as never, + activities: [ + { + kind: "provider.turn.start.failed", + payload: { + requestId: "cp-agent-message:coordinator:r1", + detail: "Model gpt-9 is not installed on this machine.", + }, + }, + ] as never, + }); + yield* bridge.pollOnce; + }), + ); + const [push] = h.pushes(); + expect(push?.type === "thread.message.user.append" && push.message.text).toContain( + "Model gpt-9 is not installed on this machine.", + ); + }), + ); + + it.effect("keeps a queued send going: the agent is not settled and the next request starts", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed( + h, + { + queuedSends: [ + { + messageId: "cp-remote-send:remote-1:q1", + text: "Then annual.", + replyTo: "coordinator", + queuedAt: TEST_NOW, + }, + ], + }, + { latestTurn: completedTurn() }, + finishedMessages, + ); + yield* bridge.pollOnce; + // The next poll sees the request off the record and the peer idle. + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(h.pushes()).toHaveLength(1); + expect(h.peer.commands.map((command) => command.type)).toEqual(["thread.turn.start"]); + expect(record.state).toBe("open"); + expect(record.inFlight?.messageId).toBe("cp-remote-send:remote-1:q1"); + }), + ); +}); + +describe("RemoteAgentBridge routing", () => { + it.effect("delivers to the standing agent that asked, when it may manage the agent", () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + yield* seed( + h, + { creatorThreadId: "standing", inFlight: request({ replyTo: "standing" }) }, + { latestTurn: completedTurn() }, + finishedMessages, + ); + yield* bridge.pollOnce; + }), + ); + expect(h.pushes()[0]).toMatchObject({ threadId: "standing" }); + }), + ); + + it.effect("falls back to the coordinator when the asker no longer manages the agent", () => + Effect.gen(function* () { + const h = harness(); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + // Another standing agent created it, so `standing` may not receive it. + yield* seed( + h, + { creatorThreadId: "someone-else", inFlight: request({ replyTo: "standing" }) }, + { latestTurn: completedTurn() }, + finishedMessages, + ); + yield* bridge.pollOnce; + }), + ); + expect(h.pushes()[0]).toMatchObject({ threadId: "coordinator" }); + }), + ); + + it.effect("falls back to the coordinator when the asker is archived", () => + Effect.gen(function* () { + const h = harness({ + threads: [ + homeThread(COORDINATOR), + homeThread(STANDING, { pinnedAt: TEST_NOW, archivedAt: TEST_NOW }), + ], + }); + yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + yield* seed( + h, + { creatorThreadId: "standing", inFlight: request({ replyTo: "standing" }) }, + { latestTurn: completedTurn() }, + finishedMessages, + ); + yield* bridge.pollOnce; + }), + ); + expect(h.pushes()[0]).toMatchObject({ threadId: "coordinator" }); + }), + ); + + it.effect("holds while the Project is archived and delivers after it is unarchived", () => + Effect.gen(function* () { + const h = harness({ + project: homeProject({ coordinatorThreadId: COORDINATOR, archivedAt: TEST_NOW }), + }); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(h.pushes()).toEqual([]); + expect(record.inFlight).not.toBeNull(); + expect(record.state).toBe("open"); + }), + ); +}); + +describe("RemoteAgentBridge machine health", () => { + it.effect("marks agents stale while the machine is down and delivers when it returns", () => + Effect.gen(function* () { + const h = harness(); + const results = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + h.peer.state.reachable = false; + yield* bridge.pollOnce; + const down = Option.getOrThrow(yield* store.get("remote-1")); + h.peer.state.reachable = true; + // Back off, then the machine answers on the next allowed poll. + yield* TestClock.adjust(Duration.minutes(2)); + yield* bridge.pollOnce; + return { down, up: Option.getOrThrow(yield* store.get("remote-1")) }; + }), + ); + expect(results.down.lastPhase).toBe("stale"); + expect(results.down.inFlight).not.toBeNull(); + expect(h.pushes()).toHaveLength(1); + expect(results.up.state).toBe("settled"); + }), + ); + + it.effect("holds without failing agents when the machine refuses the token", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, { latestTurn: completedTurn() }, finishedMessages); + h.peer.state.unauthorized = true; + yield* bridge.pollOnce; + yield* TestClock.adjust(Duration.hours(30)); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(h.pushes()).toEqual([]); + expect(record.state).toBe("open"); + expect(record.inFlight).not.toBeNull(); + }), + ); + + it.effect( + "loses the agent and pushes one failed result after the machine is gone for 24 hours", + () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* seed(h, {}, {}, finishedMessages); + h.peer.state.reachable = false; + yield* bridge.pollOnce; + yield* TestClock.adjust(Duration.millis(REMOTE_LOST_AFTER_MS + 120_000)); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(record.state).toBe("lost"); + const [push] = h.pushes(); + // Its own id, so a real result that arrives later is still delivered. + expect(push?.commandId).toBe("cp-push:remote-1:cp-agent-message:coordinator:r1:lost"); + expect(push?.type === "thread.message.user.append" && push.message.text).toContain( + "unreachable for 24 hours", + ); + }), + ); + + it.effect("loses an agent whose thread is gone from the machine", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* store.put(makeRecord({ inFlight: request() })); + yield* TestClock.adjust(Duration.minutes(10)); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(record.state).toBe("lost"); + expect(h.pushes()).toHaveLength(1); + }), + ); + + it.effect("drops a pending record whose start never landed", () => + Effect.gen(function* () { + const h = harness(); + const record = yield* h.run( + Effect.gen(function* () { + const bridge = yield* RemoteAgentBridge; + const store = yield* RemoteAgentStore; + yield* store.put(makeRecord({ state: "pending", inFlight: null })); + h.peer.threads.set("remote-1", makePeerThread("remote-1")); + yield* TestClock.adjust(Duration.minutes(5)); + yield* bridge.pollOnce; + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(record.state).toBe("lost"); + expect(h.pushes()).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/agentMachines/RemoteAgentBridge.ts b/apps/server/src/agentMachines/RemoteAgentBridge.ts new file mode 100644 index 0000000000000..ea8d0cbe36be3 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgentBridge.ts @@ -0,0 +1,376 @@ +/** + * Collects the results of agents running on linked machines. One poll per + * machine reads its shell snapshot; a finished request is appended into the + * home thread that asked for it, as a `cp-push:` message with the same id the + * local reactor would use, and the reactor is nudged to start it. Command + * receipts make each step idempotent across restarts. Fork-owned; see + * docs/internals/multi-machine-agents.md. + * + * @module RemoteAgentBridge + */ +import { + CommandId, + MessageId, + ThreadId, + canManageAgent, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import type * as Scope from "effect/Scope"; + +import { AgentCompletionReactor } from "../orchestration/AgentCompletionReactor.ts"; +import { isOrchestrationCommandRejection } from "../orchestration/Errors.ts"; +import { agentPushId, agentPushSettleId } from "../orchestration/agentProtocol.ts"; +import { formatAgentResult, type AgentResultOutcome } from "../orchestration/agentPushes.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { forkParked } from "../serverActivation.ts"; +import { AgentMachines } from "./AgentMachines.ts"; +import { PeerError } from "./AgentMachineClient.ts"; +import { RemoteAgents } from "./RemoteAgents.ts"; +import { + judgeRequest, + remotePhase, + remoteResultText, + remoteStartFailureDetail, + type PeerThreadView, +} from "./remoteAgentState.ts"; +import { RemoteAgentStore, type RemoteAgentRecord } from "./RemoteAgentStore.ts"; + +export const BRIDGE_ACTIVE_INTERVAL = Duration.seconds(3); +export const BRIDGE_IDLE_INTERVAL = Duration.seconds(15); +const BACKOFF_MAX_MS = 60_000; +/** A machine unreachable this long loses its agents. */ +export const REMOTE_LOST_AFTER_MS = 24 * 60 * 60 * 1000; +/** A record whose start never landed is dropped after this. */ +const PENDING_LIMIT_MS = 2 * 60 * 1000; + +export class RemoteAgentBridge extends Context.Service< + RemoteAgentBridge, + { + readonly start: () => Effect.Effect; + /** One pass over every machine with open agents. Exposed for tests. */ + readonly pollOnce: Effect.Effect; + } +>()("t3/agentMachines/RemoteAgentBridge") {} + +const isBusyRecord = (record: RemoteAgentRecord) => + record.state === "pending" || record.state === "open"; + +export const makeRemoteAgentBridge = Effect.gen(function* () { + const machines = yield* AgentMachines; + const remote = yield* RemoteAgents; + const store = yield* RemoteAgentStore; + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const reactor = yield* AgentCompletionReactor; + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const nowMs = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + const toMs = (iso: string) => DateTime.toEpochMillis(DateTime.makeUnsafe(iso)); + + // Per machine: when it may be polled again, and since when it has been down. + const nextPollAt = new Map(); + const backoffMs = new Map(); + const downSince = new Map(); + + const update = ( + record: RemoteAgentRecord, + change: (current: RemoteAgentRecord) => RemoteAgentRecord, + ) => store.update(record.threadId, change).pipe(Effect.ignore); + + const setPhase = ( + record: RemoteAgentRecord, + phase: RemoteAgentRecord["lastPhase"], + at: string | null, + ) => + phase === record.lastPhase && at === null + ? Effect.void + : update(record, (current) => ({ + ...current, + lastPhase: phase, + lastActivityAt: at ?? current.lastActivityAt, + })); + + /** The recipient the reactor would pick: the asker if live and allowed, else the coordinator. */ + const resolveRecipient = Effect.fn("RemoteAgentBridge.resolveRecipient")(function* ( + record: RemoteAgentRecord, + replyTo: string, + ) { + const project = Option.getOrUndefined( + yield* snapshots.getProjectShellById(record.homeProjectId as OrchestrationProjectShell["id"]), + ); + const assistant = project?.assistant; + // Archive holds everything; Unarchive delivers. + if (project === undefined || assistant == null || assistant.archivedAt != null) return null; + const coordinatorId = assistant.coordinatorThreadId; + const recipient = Option.getOrNull(yield* snapshots.getThreadShellById(ThreadId.make(replyTo))); + const usable = + recipient !== null && + recipient.archivedAt === null && + recipient.projectId === project.id && + (recipient.id === coordinatorId || + canManageAgent(project, recipient, { + id: ThreadId.make(record.threadId), + projectId: project.id, + pinnedAt: null, + createdByThreadId: ThreadId.make(record.creatorThreadId), + })); + return { project, target: usable ? recipient.id : coordinatorId }; + }); + + const deliver = Effect.fn("RemoteAgentBridge.deliver")(function* ( + record: RemoteAgentRecord, + request: NonNullable, + outcome: AgentResultOutcome, + text: string | null, + idSuffix = "", + ) { + const resolved = yield* resolveRecipient(record, request.replyTo); + // No live Project to deliver into: keep the request open and try again. + if (resolved === null) return false; + const createdAt = yield* nowIso; + const pushId = `${agentPushId(record.threadId, request.messageId)}${idSuffix}`; + const appended = yield* engine + .dispatch({ + type: "thread.message.user.append", + commandId: CommandId.make(pushId), + threadId: resolved.target, + message: { + messageId: MessageId.make(pushId), + text: formatAgentResult({ + agentTitle: `${record.title} (on ${record.machineLabel})`, + agentThreadId: record.threadId, + outcome, + text, + questions: [], + }), + attachments: [], + // No replyTo: a pushed turn never owes a result of its own. + source: { + kind: "agent", + threadId: ThreadId.make(record.threadId), + threadTitle: `${record.title} (on ${record.machineLabel})`, + }, + }, + createdAt, + }) + .pipe( + Effect.as(true), + Effect.catchIf(isOrchestrationCommandRejection, (error) => + Effect.logWarning("remote agent result append rejected", { + agentThreadId: record.threadId, + targetThreadId: resolved.target, + detail: error.message, + }).pipe(Effect.as(true)), + ), + ); + if (appended) yield* reactor.enqueueProject(resolved.project.id); + return appended; + }); + + const finishRequest = Effect.fn("RemoteAgentBridge.finishRequest")(function* ( + record: RemoteAgentRecord, + view: PeerThreadView, + verdict: Extract, { kind: "done" }>, + ) { + const request = record.inFlight; + if (request === null) return; + if (!request.suppressed) { + let outcome = verdict.outcome; + let text: string | null = null; + const detail = yield* machines + .thread(record.machineId, record.threadId, 2) + .pipe(Effect.catch(() => Effect.succeed(Option.none()))); + if (Option.isSome(detail)) { + text = remoteResultText({ + messages: detail.value.thread.messages, + requestMessageId: request.messageId, + }); + if (verdict.startFailure) { + const failure = remoteStartFailureDetail({ + activities: detail.value.thread.activities, + requestMessageId: request.messageId, + }); + outcome = { kind: "failed-to-start", detail: failure ?? view.session?.lastError ?? null }; + } + } else if (verdict.startFailure) { + outcome = { kind: "failed-to-start", detail: view.session?.lastError ?? null }; + } + const delivered = yield* deliver(record, request, outcome, text); + if (!delivered) return; + } + const latest = record.queuedSends.length === 0; + yield* update(record, (current) => ({ + ...current, + inFlight: null, + lastPhase: verdict.outcome.kind === "finished" ? "completed" : "failed", + })); + // The peer has no `replyTo` to settle a one-off agent, so the home does it, + // unless a later message is queued behind this one. + if (latest) { + const createdAt = yield* nowIso; + yield* machines + .dispatch(record.machineId, { + type: "thread.settle", + commandId: CommandId.make(agentPushSettleId(record.threadId, request.messageId)), + threadId: ThreadId.make(record.threadId), + }) + .pipe(Effect.ignore); + yield* update(record, (current) => ({ ...current, state: "settled", endedAt: createdAt })); + } + }); + + const markLost = Effect.fn("RemoteAgentBridge.markLost")(function* ( + record: RemoteAgentRecord, + reason: string, + ) { + const createdAt = yield* nowIso; + const request = record.inFlight; + if (request !== null && !request.suppressed) { + // Its own id: a real result that arrives if the machine returns still lands. + yield* deliver(record, request, { kind: "failed", lastError: reason }, null, ":lost"); + } + yield* update(record, (current) => ({ + ...current, + state: "lost", + endedAt: createdAt, + inFlight: null, + queuedSends: [], + lastPhase: "failed", + })); + }); + + const pollMachine = Effect.fn("RemoteAgentBridge.pollMachine")(function* ( + machineId: string, + records: ReadonlyArray, + ) { + const now = yield* nowMs; + if ((nextPollAt.get(machineId) ?? 0) > now) return; + const snapshot = yield* Effect.result(machines.shell(machineId, { fresh: true })); + if (snapshot._tag === "Failure") { + const error: PeerError = snapshot.failure; + if (error.kind === "unauthorized") { + // Hold: a machine that needs a fresh link does not fail healthy agents. + nextPollAt.set(machineId, now + BACKOFF_MAX_MS); + return; + } + const wait = Math.min((backoffMs.get(machineId) ?? 3_000) * 2, BACKOFF_MAX_MS); + backoffMs.set(machineId, wait); + nextPollAt.set(machineId, now + wait); + const since = downSince.get(machineId) ?? now; + downSince.set(machineId, since); + for (const record of records) { + yield* setPhase(record, "stale", null); + if (now - since >= REMOTE_LOST_AFTER_MS) { + yield* markLost(record, `${record.machineLabel} has been unreachable for 24 hours.`); + } + } + return; + } + backoffMs.delete(machineId); + downSince.delete(machineId); + nextPollAt.delete(machineId); + + for (const record of records) { + const thread: OrchestrationThreadShell | undefined = snapshot.success.threads.find( + (candidate) => candidate.id === record.threadId && candidate.archivedAt === null, + ); + const age = now - toMs(record.createdAt); + if (thread === undefined) { + if (record.state === "pending" && age < PENDING_LIMIT_MS) continue; + yield* markLost(record, `${record.title} is gone from ${record.machineLabel}.`); + continue; + } + if (record.state === "pending" && record.inFlight === null) { + // The start never landed and no retry finished it. + if (age >= PENDING_LIMIT_MS) yield* markLost(record, "The agent never started."); + continue; + } + const request = record.inFlight; + const verdict = + request === null + ? null + : judgeRequest({ + baselineTurnId: request.baselineTurnId, + thread, + sentAgeMs: now - toMs(request.sentAt), + }); + yield* setPhase( + record, + remotePhase({ + thread, + reachable: true, + requestPending: verdict !== null && verdict.kind === "waiting", + }), + thread.updatedAt, + ); + if (verdict?.kind === "done") { + yield* finishRequest(record, thread, verdict); + continue; + } + if (request === null && record.queuedSends.length > 0) { + yield* remote.startQueued(record, thread).pipe( + Effect.catch((error) => + Effect.logWarning("queued send to a remote agent was not started", { + agentThreadId: record.threadId, + detail: error.detail, + }), + ), + ); + } + } + }); + + const pollOnce = Effect.gen(function* () { + const open = (yield* store.list).filter(isBusyRecord); + const byMachine = Map.groupBy(open, (record) => record.machineId); + yield* Effect.forEach( + [...byMachine], + ([machineId, records]) => + pollMachine(machineId, records).pipe( + Effect.catchCause((cause) => + Effect.logWarning("remote agent poll failed", { machineId, cause: String(cause) }), + ), + ), + { concurrency: 4, discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("remote agent bridge pass failed", { cause: String(cause) }), + ), + ); + + const loop = Effect.gen(function* () { + while (true) { + yield* pollOnce; + const any = (yield* store.list.pipe(Effect.orElseSucceed(() => []))).some(isBusyRecord); + const base = any ? BRIDGE_ACTIVE_INTERVAL : BRIDGE_IDLE_INTERVAL; + // Jitter, so several homes do not poll a machine in step. + const jitter = yield* Random.next; + yield* Effect.sleep(Duration.millis(Duration.toMillis(base) * (0.8 + jitter * 0.4))); + } + }); + + return { + start: () => forkParked(loop), + pollOnce, + } satisfies Context.Service.Shape; +}); + +/** Starts polling when built, parked behind server activation like the reactors. */ +export const RemoteAgentBridgeLive = Layer.effect( + RemoteAgentBridge, + Effect.gen(function* () { + const bridge = yield* makeRemoteAgentBridge; + yield* bridge.start(); + return bridge; + }), +); diff --git a/apps/server/src/agentMachines/RemoteAgentStore.test.ts b/apps/server/src/agentMachines/RemoteAgentStore.test.ts new file mode 100644 index 0000000000000..20e9c090ff373 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgentStore.test.ts @@ -0,0 +1,245 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import { + REMOTE_AGENT_RETENTION_MS, + RemoteAgentRecord, + RemoteAgentStore, + RemoteAgentStoreLive, + type RemoteAgentStoreShape, +} from "./RemoteAgentStore.ts"; + +const NOW = Date.parse("2026-09-29T12:00:00.000Z"); +const iso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const DAY_MS = 24 * 60 * 60 * 1000; + +const record = ( + threadId: string, + overrides: Partial = {}, +): RemoteAgentRecord => ({ + threadId, + machineId: "env-mini", + machineLabel: "Mac Mini", + peerProjectId: "peer-project", + homeProjectId: "home-project", + title: `Agent ${threadId}`, + creatorThreadId: "coordinator", + runtimeMode: "approval-required", + createdAt: iso(NOW - DAY_MS), + state: "open", + endedAt: null, + inFlight: null, + queuedSends: [], + lastPhase: "idle", + lastActivityAt: null, + ...overrides, +}); + +/** A store over a fixed base dir, so a second instance re-reads the same file. */ +const withStore = ( + baseDir: string, + body: (store: RemoteAgentStoreShape) => Effect.Effect, +) => + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + return yield* body(store); + }).pipe( + Effect.provide( + RemoteAgentStoreLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))), + ), + ); + +/** A temp base dir, its state dir and the store file path. */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-remote-agent-store-test-" }); + const stateDir = yield* ServerConfig.ServerConfig.pipe( + Effect.map((config) => config.stateDir), + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + yield* TestClock.setTime(NOW); + return { fs, baseDir, stateDir, filePath: path.join(stateDir, "remote-agents.json") }; +}); + +const decodeFile = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ version: Schema.Literal(1), agents: Schema.Array(RemoteAgentRecord) }), + ), +); + +it.layer(NodeServices.layer)("RemoteAgentStore", (it) => { + it.effect("starts empty without a file", () => + Effect.gen(function* () { + const { fs, baseDir, filePath } = yield* setup; + const listed = yield* withStore(baseDir, (store) => store.list); + assert.deepEqual(listed, []); + assert.isFalse(yield* fs.exists(filePath)); + }), + ); + + it.effect("puts, gets, lists, updates and removes records", () => + Effect.gen(function* () { + const { baseDir } = yield* setup; + yield* withStore(baseDir, (store) => + Effect.gen(function* () { + yield* store.put(record("a")); + yield* store.put(record("b", { state: "pending" })); + assert.deepEqual( + (yield* store.list).map((r) => r.threadId), + ["a", "b"], + ); + assert.deepEqual(yield* store.get("a"), Option.some(record("a"))); + assert.isTrue(Option.isNone(yield* store.get("missing"))); + + // put replaces a record with the same thread id instead of adding one. + yield* store.put(record("a", { title: "Renamed" })); + assert.strictEqual((yield* store.list).length, 2); + assert.strictEqual(Option.getOrThrow(yield* store.get("a")).title, "Renamed"); + + const updated = yield* store.update("b", (r) => ({ + ...r, + state: "open", + lastPhase: "running", + })); + assert.deepEqual( + updated, + Option.some(record("b", { state: "open", lastPhase: "running" })), + ); + assert.strictEqual(Option.getOrThrow(yield* store.get("b")).lastPhase, "running"); + + yield* store.remove("a"); + yield* store.remove("never-there"); + assert.deepEqual( + (yield* store.list).map((r) => r.threadId), + ["b"], + ); + }), + ); + }), + ); + + it.effect("update on a missing record returns none and writes nothing", () => + Effect.gen(function* () { + const { fs, baseDir, filePath } = yield* setup; + let called = false; + const result = yield* withStore(baseDir, (store) => + store.update("missing", (r) => { + called = true; + return r; + }), + ); + assert.isTrue(Option.isNone(result)); + assert.isFalse(called); + assert.isFalse(yield* fs.exists(filePath)); + }), + ); + + it.effect("writes a decodable file that a fresh store instance reads back", () => + Effect.gen(function* () { + const { fs, baseDir, stateDir, filePath } = yield* setup; + const inFlight = { + messageId: "cp-remote:req-1", + replyTo: "coordinator", + sentAt: iso(NOW - 1_000), + baselineTurnId: null, + suppressed: false, + }; + const queued = { + messageId: "cp-remote:req-2", + text: "Then run the tests", + replyTo: "coordinator", + queuedAt: iso(NOW), + }; + const full = record("a", { inFlight, queuedSends: [queued], lastPhase: "running" }); + yield* withStore(baseDir, (store) => + Effect.gen(function* () { + yield* store.put(full); + yield* store.put(record("b")); + yield* store.remove("b"); + }), + ); + + const onDisk = decodeFile(yield* fs.readFileString(filePath)); + assert.deepEqual(onDisk, { version: 1, agents: [full] }); + // Atomic writes leave no temp files next to the store. + assert.deepEqual( + (yield* fs.readDirectory(stateDir)).filter((name) => name.startsWith("remote-agents.json")), + ["remote-agents.json"], + ); + + const reread = yield* withStore(baseDir, (store) => store.list); + assert.deepEqual(reread, [full]); + }), + ); + + it.effect("prunes settled and lost records older than the retention on the next write", () => + Effect.gen(function* () { + const { fs, baseDir, filePath } = yield* setup; + const expired = iso(NOW - REMOTE_AGENT_RETENTION_MS - 1); + const recent = iso(NOW - REMOTE_AGENT_RETENTION_MS + 60_000); + const seeded = [ + record("old-settled", { state: "settled", endedAt: expired }), + record("old-lost", { state: "lost", endedAt: iso(NOW - 30 * DAY_MS) }), + record("new-settled", { state: "settled", endedAt: recent }), + record("open", { createdAt: iso(NOW - 60 * DAY_MS) }), + ]; + yield* fs.writeFileString(filePath, encodeJson({ version: 1, agents: seeded })); + + yield* withStore(baseDir, (store) => + Effect.gen(function* () { + // Reading does not prune; the next write does. + assert.strictEqual((yield* store.list).length, 4); + yield* store.put(record("fresh")); + assert.deepEqual( + (yield* store.list).map((r) => r.threadId), + ["new-settled", "open", "fresh"], + ); + }), + ); + assert.deepEqual( + decodeFile(yield* fs.readFileString(filePath)).agents.map((r) => r.threadId), + ["new-settled", "open", "fresh"], + ); + }), + ); + + it.effect("sets an unreadable file aside and starts empty", () => + Effect.gen(function* () { + const { fs, baseDir, filePath } = yield* setup; + yield* fs.writeFileString(filePath, "{ not json"); + + const listed = yield* withStore(baseDir, (store) => store.list); + assert.deepEqual(listed, []); + assert.isFalse(yield* fs.exists(filePath)); + assert.strictEqual(yield* fs.readFileString(`${filePath}.unreadable`), "{ not json"); + + // A file from a newer build with an unknown version is set aside too. + yield* fs.writeFileString(filePath, encodeJson({ version: 2, agents: [] })); + yield* withStore(baseDir, (store) => + Effect.gen(function* () { + assert.deepEqual(yield* store.list, []); + yield* store.put(record("a")); + }), + ); + assert.deepEqual( + decodeFile(yield* fs.readFileString(filePath)).agents.map((r) => r.threadId), + ["a"], + ); + assert.strictEqual( + yield* fs.readFileString(`${filePath}.unreadable`), + encodeJson({ version: 2, agents: [] }), + ); + }), + ); +}); diff --git a/apps/server/src/agentMachines/RemoteAgentStore.ts b/apps/server/src/agentMachines/RemoteAgentStore.ts new file mode 100644 index 0000000000000..d0b9b7a531542 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgentStore.ts @@ -0,0 +1,199 @@ +/** + * The home's record of agents it started on linked machines. One JSON file in + * the state dir, written atomically: a fork migration would renumber on every + * upstream sync, and the file holds a few dozen small records at most. + * Fork-owned; see docs/internals/multi-machine-agents.md. + * + * @module RemoteAgentStore + */ +import { RuntimeMode } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import { AgentPhase } from "./agentPhase.ts"; + +/** Settled and lost records are kept this long, then pruned. */ +export const REMOTE_AGENT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +export const RemoteAgentRequest = Schema.Struct({ + /** `cp-remote:*`, the message id the peer stores. */ + messageId: Schema.String, + /** The home thread the result goes to (the manager that asked). */ + replyTo: Schema.String, + sentAt: Schema.String, + /** The peer thread's latest turn before the send, so its own turn can be told apart. */ + baselineTurnId: Schema.NullOr(Schema.String), + /** A stop cut the request off: its result is not pushed. */ + suppressed: Schema.Boolean, +}); +export type RemoteAgentRequest = typeof RemoteAgentRequest.Type; + +export const RemoteAgentQueuedSend = Schema.Struct({ + messageId: Schema.String, + text: Schema.String, + replyTo: Schema.String, + queuedAt: Schema.String, +}); +export type RemoteAgentQueuedSend = typeof RemoteAgentQueuedSend.Type; + +export const RemoteAgentRecord = Schema.Struct({ + /** The peer thread's id, derived from the create like a local agent's. */ + threadId: Schema.String, + machineId: Schema.String, + machineLabel: Schema.String, + peerProjectId: Schema.String, + homeProjectId: Schema.String, + title: Schema.String, + /** The home thread that started it; ownership follows this. */ + creatorThreadId: Schema.String, + runtimeMode: RuntimeMode, + createdAt: Schema.String, + /** `pending` until the peer accepted the create; `lost` when the peer thread is gone. */ + state: Schema.Literals(["pending", "open", "settled", "lost"]), + /** When it reached `settled` or `lost`, for pruning. */ + endedAt: Schema.NullOr(Schema.String), + inFlight: Schema.NullOr(RemoteAgentRequest), + queuedSends: Schema.Array(RemoteAgentQueuedSend), + lastPhase: AgentPhase, + lastActivityAt: Schema.NullOr(Schema.String), +}); +export type RemoteAgentRecord = typeof RemoteAgentRecord.Type; + +const StoreFile = Schema.Struct({ + version: Schema.Literal(1), + agents: Schema.Array(RemoteAgentRecord), +}); + +const StoreFileJson = Schema.fromJsonString(StoreFile); +const decodeStoreFile = Schema.decodeUnknownOption(StoreFileJson); +const encodeStoreFile = Schema.encodeSync(StoreFileJson); + +export class RemoteAgentStoreError extends Schema.TaggedError()( + "RemoteAgentStoreError", + { detail: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +export interface RemoteAgentStoreShape { + readonly list: Effect.Effect, RemoteAgentStoreError>; + readonly get: ( + threadId: string, + ) => Effect.Effect, RemoteAgentStoreError>; + readonly put: (record: RemoteAgentRecord) => Effect.Effect; + /** Applies `change` to one record under the write lock; none when it is missing. */ + readonly update: ( + threadId: string, + change: (record: RemoteAgentRecord) => RemoteAgentRecord, + ) => Effect.Effect, RemoteAgentStoreError>; + readonly remove: (threadId: string) => Effect.Effect; +} + +export class RemoteAgentStore extends Context.Service()( + "t3/agentMachines/RemoteAgentStore", +) {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const filePath = `${config.stateDir}/remote-agents.json`; + const lock = yield* Semaphore.make(1); + let cache: Array | null = null; + + const fail = (detail: string) => (cause: unknown) => new RemoteAgentStoreError({ detail, cause }); + + const load = Effect.gen(function* () { + if (cache !== null) return cache; + const exists = yield* fileSystem.exists(filePath).pipe(Effect.mapError(fail("read"))); + if (!exists) { + cache = []; + return cache; + } + const raw = yield* fileSystem.readFileString(filePath).pipe(Effect.mapError(fail("read"))); + const decoded = decodeStoreFile(raw); + if (Option.isNone(decoded)) { + // A file this build cannot read is set aside, not overwritten silently. + yield* Effect.logWarning("remote-agents.json could not be read; starting empty", { + filePath, + }); + yield* fileSystem + .rename(filePath, `${filePath}.unreadable`) + .pipe(Effect.catch(() => Effect.void)); + cache = []; + return cache; + } + cache = [...decoded.value.agents]; + return cache; + }); + + const prune = (records: ReadonlyArray, nowMs: number) => + records.filter( + (record) => + record.endedAt === null || + nowMs - DateTime.toEpochMillis(DateTime.makeUnsafe(record.endedAt)) < + REMOTE_AGENT_RETENTION_MS, + ); + + const persist = (next: ReadonlyArray) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const kept = prune(next, DateTime.toEpochMillis(now)); + yield* writeFileStringAtomically({ + filePath, + contents: encodeStoreFile({ version: 1, agents: kept }), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError(fail("write")), + ); + cache = [...kept]; + }); + + const list = lock.withPermits(1)(load.pipe(Effect.map((records) => [...records]))); + + const get = (threadId: string) => + list.pipe( + Effect.map((records) => Option.fromNullishOr(records.find((r) => r.threadId === threadId))), + ); + + const put = (record: RemoteAgentRecord) => + lock.withPermits(1)( + Effect.gen(function* () { + const records = yield* load; + yield* persist([...records.filter((r) => r.threadId !== record.threadId), record]); + }), + ); + + const update = (threadId: string, change: (record: RemoteAgentRecord) => RemoteAgentRecord) => + lock.withPermits(1)( + Effect.gen(function* () { + const records = yield* load; + const current = records.find((r) => r.threadId === threadId); + if (current === undefined) return Option.none(); + const next = change(current); + yield* persist(records.map((r) => (r.threadId === threadId ? next : r))); + return Option.some(next); + }), + ); + + const remove = (threadId: string) => + lock.withPermits(1)( + Effect.gen(function* () { + const records = yield* load; + if (!records.some((r) => r.threadId === threadId)) return; + yield* persist(records.filter((r) => r.threadId !== threadId)); + }), + ); + + return { list, get, put, update, remove } satisfies RemoteAgentStoreShape; +}); + +export const RemoteAgentStoreLive = Layer.effect(RemoteAgentStore, make); diff --git a/apps/server/src/agentMachines/RemoteAgents.test.ts b/apps/server/src/agentMachines/RemoteAgents.test.ts new file mode 100644 index 0000000000000..dbe6d9d2f7ea0 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgents.test.ts @@ -0,0 +1,565 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { agentCreateIds } from "../orchestration/agentProtocol.ts"; +import { RemoteAgents, RemoteAgentsLive, type CreateRemoteInput } from "./RemoteAgents.ts"; +import { RemoteAgentStore } from "./RemoteAgentStore.ts"; +import { + MemoryRemoteAgentStore, + TEST_NOW, + makeFakePeer, + makePeerThread, + makeRecord, + type FakePeerOptions, +} from "./testFixtures.ts"; + +const ids = agentCreateIds("coordinator", "request-1"); + +const createInput = (overrides: Partial = {}): CreateRemoteInput => ({ + machineId: "mini", + machineLabel: "Mac Mini", + peerProjectId: "peer-project", + home: { + project: { id: "project-1", title: "Acme", workspaceRoot: "/work/acme" }, + label: "This Mac", + }, + creator: { id: "coordinator", title: "Acme" }, + ids, + threadId: "remote-1", + title: "Pricing", + message: "Compare plans.", + runtimeMode: "approval-required", + baseModel: { instanceId: "codex" as never, model: "gpt-5.4" }, + requestedModel: undefined, + createdAt: TEST_NOW, + ...overrides, +}); + +const harness = (options?: FakePeerOptions) => { + const peer = makeFakePeer(options); + const layer = RemoteAgentsLive.pipe( + Layer.provideMerge(peer.layer), + Layer.provideMerge(MemoryRemoteAgentStore), + ); + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer)); + return { peer, run }; +}; + +describe("RemoteAgents.reserve and start", () => { + it.effect("creates the peer thread and sends the first message with no creator or replyTo", () => + Effect.gen(function* () { + const { peer, run } = harness(); + const record = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + const reserved = yield* remote.reserve(createInput()); + expect(reserved.existed).toBe(false); + expect(reserved.record.state).toBe("pending"); + return yield* remote.start(createInput(), reserved.record); + }), + ); + + expect(peer.commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.turn.start", + ]); + const [create, start] = peer.commands; + expect(create).toMatchObject({ + commandId: ids.createCommandId, + threadId: "remote-1", + projectId: "peer-project", + title: "Pricing", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + }); + expect(create).not.toHaveProperty("createdByThreadId"); + expect(start?.type === "thread.turn.start" && start.message.source).toEqual({ + kind: "agent", + threadId: "coordinator", + threadTitle: "Acme", + }); + if (start?.type === "thread.turn.start") { + expect(start.message.messageId).toBe(ids.messageId); + expect(start.message.text).toContain("You are running on Mac Mini"); + expect(start.message.text).toContain("Compare plans."); + expect(start.runtimeMode).toBe("approval-required"); + } + expect(record.state).toBe("open"); + expect(record.inFlight).toMatchObject({ + messageId: ids.messageId, + replyTo: "coordinator", + baselineTurnId: null, + suppressed: false, + }); + }), + ); + + it.effect( + "uses the peer Project's default model, else the base; an explicit model keeps the base instance", + () => + Effect.gen(function* () { + const { peer, run } = harness(); + yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + const reserved = yield* remote.reserve(createInput({ requestedModel: "gpt-5.4-mini" })); + yield* remote.start(createInput({ requestedModel: "gpt-5.4-mini" }), reserved.record); + }), + ); + expect(peer.commands[0]).toMatchObject({ + modelSelection: { instanceId: "codex", model: "gpt-5.4-mini" }, + }); + }), + ); + + it.effect("drops the record when the create never reached the peer", () => + Effect.gen(function* () { + const { peer, run } = harness({ failOn: ["thread.create"] }); + const outcome = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + const store = yield* RemoteAgentStore; + const reserved = yield* remote.reserve(createInput()); + const failure = yield* Effect.flip(remote.start(createInput(), reserved.record)); + return { failure, left: yield* store.list }; + }), + ); + expect(outcome.failure.stage).toBe("create"); + expect(outcome.left).toEqual([]); + expect(peer.commands).toEqual([]); + }), + ); + + it.effect( + "keeps the record pending when only the first message failed, and a retry finishes it", + () => + Effect.gen(function* () { + const { peer, run } = harness({ failOn: ["thread.turn.start"] }); + const result = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + const store = yield* RemoteAgentStore; + const reserved = yield* remote.reserve(createInput()); + const failure = yield* Effect.flip(remote.start(createInput(), reserved.record)); + const pending = yield* store.get("remote-1"); + return { failure, pending }; + }), + ); + expect(result.failure.stage).toBe("start"); + expect(Option.getOrThrow(result.pending).state).toBe("pending"); + + // The same ids on a healthy peer: reserve finds the record, start sends again. + const retry = harness(); + const finished = yield* retry.run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + const store = yield* RemoteAgentStore; + yield* store.put(Option.getOrThrow(result.pending)); + const again = yield* remote.reserve(createInput()); + expect(again.existed).toBe(true); + return yield* remote.start(createInput(), again.record); + }), + ); + expect(finished.state).toBe("open"); + expect(peer.commands.map((command) => command.type)).toEqual(["thread.create"]); + }), + ); + + it.effect( + "returns an open record with a request in flight as it is (a retry after a landed start)", + () => + Effect.gen(function* () { + const { peer, run } = harness(); + const record = makeRecord({ + state: "open", + inFlight: { + messageId: "cp-agent-message:x", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }, + }); + const result = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + return yield* remote.start(createInput(), record); + }), + ); + expect(result).toEqual(record); + expect(peer.commands).toEqual([]); + }), + ); +}); + +describe("RemoteAgents.openCount, find, list", () => { + it.effect("counts pending agents and open agents with a request in flight, per Project", () => + Effect.gen(function* () { + const { run } = harness(); + const inFlight = { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }; + const counts = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord({ threadId: "a", state: "pending" })); + yield* store.put(makeRecord({ threadId: "b", state: "open", inFlight })); + yield* store.put(makeRecord({ threadId: "c", state: "open", inFlight: null })); + yield* store.put(makeRecord({ threadId: "d", state: "settled" })); + yield* store.put(makeRecord({ threadId: "e", state: "lost", inFlight })); + yield* store.put( + makeRecord({ threadId: "f", state: "pending", homeProjectId: "project-2" }), + ); + return { + first: yield* remote.openCount("project-1"), + other: yield* remote.openCount("project-2"), + }; + }), + ); + expect(counts).toEqual({ first: 2, other: 1 }); + }), + ); + + it.effect("finds by id in any Project and by title only in the given Project", () => + Effect.gen(function* () { + const { run } = harness(); + const found = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord({ threadId: "a", title: "Pricing" })); + yield* store.put( + makeRecord({ threadId: "b", title: "Pricing", homeProjectId: "project-2" }), + ); + return { + byId: yield* remote.find({ projectId: "project-1", ref: "b" }), + byTitle: yield* remote.find({ projectId: "project-1", ref: " pricing " }), + }; + }), + ); + expect(found.byId.map((record) => record.threadId)).toEqual(["b"]); + expect(found.byTitle.map((record) => record.threadId)).toEqual(["a"]); + }), + ); + + it.effect("lists an open agent as stale while its machine is unreachable", () => + Effect.gen(function* () { + const { peer, run } = harness(); + peer.state.reachable = false; + const listed = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord({ threadId: "a", lastPhase: "running" })); + yield* store.put(makeRecord({ threadId: "b", state: "settled", lastPhase: "completed" })); + return yield* remote.list("project-1"); + }), + ); + expect(listed.map((entry) => [entry.record.threadId, entry.phase])).toEqual([ + ["a", "stale"], + ["b", "completed"], + ]); + }), + ); +}); + +describe("RemoteAgents.read", () => { + it.effect("maps the peer's messages to requests and results", () => + Effect.gen(function* () { + const { peer, run } = harness(); + peer.threads.set( + "remote-1", + makePeerThread("remote-1", { + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + requestedAt: TEST_NOW, + startedAt: TEST_NOW, + completedAt: TEST_NOW, + assistantMessageId: null, + }, + }), + ); + peer.details.set("remote-1", { + messages: [ + { id: "m1", role: "user", text: "Compare plans.", streaming: false }, + { id: "m2", role: "assistant", text: "Plan B wins.", streaming: false }, + ] as never, + }); + const read = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + return yield* remote.read(makeRecord(), 1); + }), + ); + expect(read.turns).toEqual([ + { state: "completed", request: "Compare plans.", result: "Plan B wins." }, + ]); + expect(read.phase).toBe("completed"); + }), + ); + + it.effect("says so when the peer thread is gone", () => + Effect.gen(function* () { + const { run } = harness(); + const failure = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + return yield* Effect.flip(remote.read(makeRecord(), 1)); + }), + ); + expect(failure.detail).toContain("no longer on Mac Mini"); + }), + ); + + it.effect("names the machine when it cannot be reached", () => + Effect.gen(function* () { + const { peer, run } = harness(); + peer.state.reachable = false; + const failure = yield* run( + Effect.gen(function* () { + const remote = yield* RemoteAgents; + return yield* Effect.flip(remote.read(makeRecord(), 1)); + }), + ); + expect(failure.detail).toContain("Could not reach Mac Mini"); + }), + ); +}); + +describe("RemoteAgents.stop", () => { + const inFlight = { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }; + + it.effect("suppresses the result first, interrupts a working agent, and stops its session", () => + Effect.gen(function* () { + const { peer, run } = harness(); + const outcome = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put( + makeRecord({ + inFlight, + queuedSends: [ + { messageId: "q", text: "later", replyTo: "coordinator", queuedAt: TEST_NOW }, + ], + }), + ); + const stopped = yield* remote.stop( + makeRecord({ + inFlight, + queuedSends: [ + { messageId: "q", text: "later", replyTo: "coordinator", queuedAt: TEST_NOW }, + ], + }), + { archive: false }, + ); + return { stopped, record: Option.getOrThrow(yield* store.get("remote-1")) }; + }), + ); + expect(outcome.stopped).toEqual({ stopped: true, archived: false }); + expect(peer.commands.map((command) => command.type)).toEqual([ + "thread.turn.interrupt", + "thread.session.stop", + ]); + expect(outcome.record.inFlight).toBeNull(); + expect(outcome.record.queuedSends).toEqual([]); + }), + ); + + it.effect("does not interrupt an idle agent, and reports nothing was stopped", () => + Effect.gen(function* () { + const { peer, run } = harness(); + const stopped = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord()); + return yield* remote.stop(makeRecord(), { archive: false }); + }), + ); + expect(stopped.stopped).toBe(false); + expect(peer.commands.map((command) => command.type)).toEqual(["thread.session.stop"]); + }), + ); + + it.effect("archives on request and settles the record", () => + Effect.gen(function* () { + const { peer, run } = harness(); + const record = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord()); + yield* remote.stop(makeRecord(), { archive: true }); + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + expect(peer.commands.map((command) => command.type)).toEqual([ + "thread.session.stop", + "thread.archive", + ]); + expect(record.state).toBe("settled"); + expect(record.endedAt).not.toBeNull(); + }), + ); +}); + +describe("RemoteAgents.send and startQueued", () => { + const sender = { id: "coordinator", title: "Acme" }; + + it.effect( + "starts a turn on an idle agent, recording the turn before the send as the baseline", + () => + Effect.gen(function* () { + const { peer, run } = harness(); + peer.threads.set( + "remote-1", + makePeerThread("remote-1", { + latestTurn: { + turnId: "turn-1" as never, + state: "completed", + requestedAt: TEST_NOW, + startedAt: TEST_NOW, + completedAt: TEST_NOW, + assistantMessageId: null, + }, + }), + ); + const outcome = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(makeRecord({ state: "settled", endedAt: TEST_NOW })); + const sent = yield* remote.send(makeRecord(), { + text: "Also compare annual pricing.", + sender, + replyTo: "coordinator", + runtimeMode: "approval-required", + }); + return { sent, record: Option.getOrThrow(yield* store.get("remote-1")) }; + }), + ); + expect(outcome.sent).toEqual({ queued: false }); + expect(peer.commands.map((command) => command.type)).toEqual(["thread.turn.start"]); + const start = peer.commands[0]; + expect(start?.type === "thread.turn.start" && start.message.messageId).toMatch( + /^cp-remote-send:remote-1:/, + ); + expect(outcome.record.state).toBe("open"); + expect(outcome.record.inFlight?.baselineTurnId).toBe("turn-1"); + }), + ); + + it.effect("queues a send while a request is in flight, and sends nothing", () => + Effect.gen(function* () { + const { peer, run } = harness(); + peer.threads.set("remote-1", makePeerThread("remote-1")); + const inFlightRecord = makeRecord({ + inFlight: { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }, + }); + const outcome = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(inFlightRecord); + const sent = yield* remote.send(inFlightRecord, { + text: "Then do this.", + sender, + replyTo: "coordinator", + runtimeMode: "approval-required", + }); + return { sent, record: Option.getOrThrow(yield* store.get("remote-1")) }; + }), + ); + expect(outcome.sent).toEqual({ queued: true }); + expect(peer.commands).toEqual([]); + expect(outcome.record.queuedSends.map((entry) => entry.text)).toEqual(["Then do this."]); + }), + ); + + it.effect( + "starts the oldest queued send once the peer is idle, and takes it off the queue first", + () => + Effect.gen(function* () { + const { peer, run } = harness(); + const queuedRecord = makeRecord({ + queuedSends: [ + { + messageId: "cp-remote-send:remote-1:q1", + text: "First.", + replyTo: "coordinator", + queuedAt: TEST_NOW, + }, + { + messageId: "cp-remote-send:remote-1:q2", + text: "Second.", + replyTo: "coordinator", + queuedAt: TEST_NOW, + }, + ], + }); + const view = makePeerThread("remote-1"); + const record = yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(queuedRecord); + yield* remote.startQueued(queuedRecord, view); + return Option.getOrThrow(yield* store.get("remote-1")); + }), + ); + const start = peer.commands[0]; + expect(start?.type === "thread.turn.start" && start.message.text).toBe("First."); + expect(record.queuedSends.map((entry) => entry.text)).toEqual(["Second."]); + expect(record.inFlight?.messageId).toBe("cp-remote-send:remote-1:q1"); + }), + ); + + it.effect("does not start a queued send while the peer is busy", () => + Effect.gen(function* () { + const { peer, run } = harness(); + const queuedRecord = makeRecord({ + queuedSends: [ + { + messageId: "cp-remote-send:remote-1:q1", + text: "First.", + replyTo: "coordinator", + queuedAt: TEST_NOW, + }, + ], + }); + const busy = makePeerThread("remote-1", { hasPendingApprovals: true }); + yield* run( + Effect.gen(function* () { + const store = yield* RemoteAgentStore; + const remote = yield* RemoteAgents; + yield* store.put(queuedRecord); + yield* remote.startQueued(queuedRecord, busy); + }), + ); + expect(peer.commands).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/agentMachines/RemoteAgents.ts b/apps/server/src/agentMachines/RemoteAgents.ts new file mode 100644 index 0000000000000..f47ed1fea3e89 --- /dev/null +++ b/apps/server/src/agentMachines/RemoteAgents.ts @@ -0,0 +1,544 @@ +/** + * Operations on agents that run on linked machines: start, read, stop, send. + * The MCP handlers call this for any agent the registry knows; the bridge + * calls it to start queued sends. Fork-owned; see + * docs/internals/multi-machine-agents.md. + * + * @module RemoteAgents + */ +import { + CommandId, + MessageId, + ProjectId, + ThreadId, + type ModelSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { AgentMachines, type HomeProject } from "./AgentMachines.ts"; +import { PeerError, type PeerCommand } from "./AgentMachineClient.ts"; +import { placeAgent, type PlacementDecision } from "./placement.ts"; +import { + isThreadBusy, + remotePhase, + remoteReadTurns, + type PeerThreadView, + type RemoteReadTurn, +} from "./remoteAgentState.ts"; +import { + RemoteAgentStore, + type RemoteAgentRecord, + type RemoteAgentRequest, +} from "./RemoteAgentStore.ts"; +import type { AgentPhase } from "./agentPhase.ts"; +import type { agentCreateIds } from "../orchestration/agentProtocol.ts"; + +export class RemoteAgentError extends Schema.TaggedError()("RemoteAgentError", { + detail: Schema.String, + /** + * `create`: nothing reached the peer and the record is gone, so the caller may + * place the agent elsewhere. `start`: the peer thread may exist; only a retry + * with the same ids is safe. + */ + stage: Schema.optional(Schema.Literals(["create", "start"])), +}) { + override get message(): string { + return this.detail; + } +} + +const REMOTE_SEND_PREFIX = "cp-remote-send:"; + +const unavailable = (label: string, error: PeerError) => + new RemoteAgentError({ + detail: + error.kind === "unauthorized" + ? `${label} refused this machine's token. Link it again in Settings > Connections > Agent machines.` + : `Could not reach ${label}: ${error.detail}`, + }); + +export interface PlanResult { + readonly decision: PlacementDecision; + readonly peerProjectId: string | null; + readonly allowLocalFallback: boolean; + /** The settings put every agent on this machine; nothing was probed. */ + readonly localOnly: boolean; +} + +export interface CreateRemoteInput { + readonly machineId: string; + readonly machineLabel: string; + readonly peerProjectId: string; + readonly home: { readonly project: HomeProject; readonly label: string }; + readonly creator: { readonly id: string; readonly title: string }; + readonly ids: ReturnType; + readonly threadId: string; + readonly title: string; + readonly message: string; + readonly runtimeMode: RuntimeMode; + /** The Project's default, else the coordinator's; the peer Project's default wins over it. */ + readonly baseModel: ModelSelection; + /** An explicit `model`: the slug on the base instance. */ + readonly requestedModel: string | undefined; + readonly createdAt: string; +} + +export interface RemoteAgentsShape { + /** Where the agent should run. Probes linked machines only when the settings could pick one. */ + readonly plan: (input: { + readonly project: HomeProject; + readonly requested: string | undefined; + }) => Effect.Effect; + /** + * Writes the `pending` record, or returns the one a retry with the same ids + * left. Cheap and local, so the caller does it under its create lock and + * the record counts toward the running cap at once. + */ + readonly reserve: ( + input: CreateRemoteInput, + ) => Effect.Effect< + { readonly record: RemoteAgentRecord; readonly existed: boolean }, + RemoteAgentError + >; + /** Sends the create and first message to the peer. Idempotent: a retry finishes what landed. */ + readonly start: ( + input: CreateRemoteInput, + record: RemoteAgentRecord, + ) => Effect.Effect; + readonly homeLabel: Effect.Effect; + readonly get: (threadId: string) => Effect.Effect>; + /** Records in a Project whose id or title matches `ref`. */ + readonly find: (input: { + readonly projectId: string; + readonly ref: string; + }) => Effect.Effect>; + readonly list: ( + projectId: string, + ) => Effect.Effect< + ReadonlyArray<{ readonly record: RemoteAgentRecord; readonly phase: AgentPhase }> + >; + /** Remote agents in the Project that count toward its running cap. */ + readonly openCount: (projectId: string) => Effect.Effect; + readonly read: ( + record: RemoteAgentRecord, + turns: number, + ) => Effect.Effect< + { readonly phase: AgentPhase; readonly turns: ReadonlyArray }, + RemoteAgentError + >; + readonly stop: ( + record: RemoteAgentRecord, + options: { readonly archive: boolean }, + ) => Effect.Effect<{ readonly stopped: boolean; readonly archived: boolean }, RemoteAgentError>; + /** Starts the message when the agent is idle, else queues it for the bridge. */ + readonly send: ( + record: RemoteAgentRecord, + input: { + readonly text: string; + readonly sender: { readonly id: string; readonly title: string }; + readonly replyTo: string | null; + readonly runtimeMode: RuntimeMode; + }, + ) => Effect.Effect<{ readonly queued: boolean }, RemoteAgentError>; + /** Starts the oldest queued send if the peer thread is idle. Used by the bridge. */ + readonly startQueued: ( + record: RemoteAgentRecord, + view: PeerThreadView, + ) => Effect.Effect; +} + +export class RemoteAgents extends Context.Service()( + "t3/agentMachines/RemoteAgents", +) {} + +const isCounted = (record: RemoteAgentRecord): boolean => + record.state === "pending" || (record.state === "open" && record.inFlight !== null); + +const make = Effect.gen(function* () { + const machines = yield* AgentMachines; + const store = yield* RemoteAgentStore; + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const storeFailed = () => new RemoteAgentError({ detail: "Could not read the remote agents." }); + const list = store.list.pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const send = (command: PeerCommand, record: RemoteAgentRecord) => + machines.dispatch(record.machineId, command).pipe( + Effect.tapError((error) => + error.kind === "unauthorized" ? machines.markNeedsRelink(record.machineId) : Effect.void, + ), + Effect.mapError((error) => unavailable(record.machineLabel, error)), + ); + + const startRequest = ( + record: RemoteAgentRecord, + input: { + readonly messageId: string; + readonly text: string; + readonly replyTo: string; + readonly sender: { readonly id: string; readonly title: string }; + readonly baselineTurnId: string | null; + readonly createdAt: string; + }, + ) => + Effect.gen(function* () { + yield* send( + { + type: "thread.turn.start", + commandId: CommandId.make(input.messageId), + threadId: ThreadId.make(record.threadId), + message: { + messageId: MessageId.make(input.messageId), + role: "user", + text: input.text, + attachments: [], + source: { + kind: "agent", + threadId: ThreadId.make(input.sender.id), + threadTitle: input.sender.title, + }, + }, + runtimeMode: record.runtimeMode, + interactionMode: "default", + createdAt: input.createdAt, + }, + record, + ); + const request: RemoteAgentRequest = { + messageId: input.messageId, + replyTo: input.replyTo, + sentAt: input.createdAt, + baselineTurnId: input.baselineTurnId, + suppressed: false, + }; + yield* store + .update(record.threadId, (current) => ({ + ...current, + state: "open", + endedAt: null, + inFlight: request, + lastPhase: "starting", + })) + .pipe(Effect.mapError(storeFailed)); + }); + + const plan: RemoteAgentsShape["plan"] = ({ project, requested }) => + Effect.gen(function* () { + const inputs = yield* machines + .placementInputs(project, { requested }) + .pipe(Effect.mapError((error) => new RemoteAgentError({ detail: error.detail }))); + const decision = placeAgent({ + settings: inputs.settings, + requested, + candidates: inputs.candidates, + }); + return { + decision, + peerProjectId: + decision.kind === "placed" + ? (inputs.peerProjectIds.get(decision.machineId) ?? null) + : null, + allowLocalFallback: inputs.settings.allowLocalFallback, + localOnly: inputs.localOnly, + } satisfies PlanResult; + }); + + const reserve: RemoteAgentsShape["reserve"] = (input) => + Effect.gen(function* () { + const existing = yield* store.get(input.threadId).pipe(Effect.mapError(storeFailed)); + if (Option.isSome(existing)) return { record: existing.value, existed: true }; + const record: RemoteAgentRecord = { + threadId: input.threadId, + machineId: input.machineId, + machineLabel: input.machineLabel, + peerProjectId: input.peerProjectId, + homeProjectId: input.home.project.id, + title: input.title, + creatorThreadId: input.creator.id, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + state: "pending", + endedAt: null, + inFlight: null, + queuedSends: [], + lastPhase: "starting", + lastActivityAt: null, + }; + yield* store.put(record).pipe(Effect.mapError(storeFailed)); + return { record, existed: false }; + }); + + const start: RemoteAgentsShape["start"] = (input, record) => + Effect.gen(function* () { + // A retry after a start that landed finds the request already in flight. + if (record.state === "open" && record.inFlight !== null) return record; + + const peerShell = yield* machines.shell(record.machineId).pipe( + Effect.mapError((error) => unavailable(record.machineLabel, error)), + Effect.tapError(() => store.remove(record.threadId).pipe(Effect.ignore)), + Effect.mapError((error) => new RemoteAgentError({ detail: error.detail, stage: "create" })), + ); + const peerProject = peerShell.projects.find((entry) => entry.id === record.peerProjectId); + const modelSelection: ModelSelection = + input.requestedModel !== undefined + ? { instanceId: input.baseModel.instanceId, model: input.requestedModel } + : (peerProject?.defaultModelSelection ?? input.baseModel); + + yield* send( + { + type: "thread.create", + commandId: CommandId.make(input.ids.createCommandId), + threadId: ThreadId.make(record.threadId), + projectId: ProjectId.make(record.peerProjectId), + title: record.title, + modelSelection, + runtimeMode: record.runtimeMode, + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: input.createdAt, + }, + record, + ).pipe( + // Nothing reached the peer: drop the record so a retry places afresh. + Effect.tapError(() => store.remove(record.threadId).pipe(Effect.ignore)), + Effect.mapError((error) => new RemoteAgentError({ detail: error.detail, stage: "create" })), + ); + + const preamble = `You are running on ${record.machineLabel} for the Project "${input.home.project.title}", started by ${input.creator.title} on ${input.home.label}. This machine has its own copy of the Project folder: read its AGENTS.md here, and your changes stay on this machine.\n\n`; + yield* startRequest(record, { + messageId: input.ids.messageId, + text: `${preamble}${input.message}`, + replyTo: input.creator.id, + sender: input.creator, + baselineTurnId: null, + createdAt: input.createdAt, + }).pipe( + Effect.mapError((error) => new RemoteAgentError({ detail: error.detail, stage: "start" })), + ); + return (yield* store.get(record.threadId).pipe(Effect.mapError(storeFailed))).pipe( + Option.getOrElse(() => record), + ); + }); + + const readView = (record: RemoteAgentRecord, turnLimit: number) => + machines.thread(record.machineId, record.threadId, turnLimit).pipe( + Effect.tapError((error) => + error.kind === "unauthorized" ? machines.markNeedsRelink(record.machineId) : Effect.void, + ), + Effect.mapError((error) => unavailable(record.machineLabel, error)), + ); + + const lostError = (record: RemoteAgentRecord) => + new RemoteAgentError({ + detail: `${record.title} is no longer on ${record.machineLabel}: the thread was deleted or archived there.`, + }); + + const viewOf = (thread: { + readonly latestTurn: PeerThreadView["latestTurn"]; + readonly session: PeerThreadView["session"]; + }): PeerThreadView => ({ + latestTurn: thread.latestTurn, + session: thread.session, + hasPendingApprovals: false, + hasPendingUserInput: false, + latestUserMessageAt: null, + }); + + const read: RemoteAgentsShape["read"] = (record, turns) => + Effect.gen(function* () { + const detail = yield* readView(record, turns); + if (Option.isNone(detail)) return yield* lostError(record); + const thread = detail.value.thread; + const view = viewOf(thread); + const waiting = + record.lastPhase === "waiting_for_approval" || record.lastPhase === "waiting_for_input"; + return { + phase: waiting + ? record.lastPhase + : remotePhase({ + thread: view, + reachable: true, + requestPending: + record.inFlight !== null && + view.latestTurn?.turnId === record.inFlight.baselineTurnId, + }), + turns: remoteReadTurns({ + messages: thread.messages, + latestTurnState: thread.latestTurn?.state ?? null, + limit: turns, + }), + }; + }); + + const stop: RemoteAgentsShape["stop"] = (record, options) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const working = record.inFlight !== null && !record.inFlight.suppressed; + const dropped = record.queuedSends.length; + // Before the interrupt: the bridge must not push the cut-off request's result. + yield* store + .update(record.threadId, (current) => ({ + ...current, + inFlight: current.inFlight === null ? null : { ...current.inFlight, suppressed: true }, + queuedSends: [], + })) + .pipe(Effect.mapError(storeFailed)); + const key = `${record.threadId}:${createdAt}`; + if (working) { + yield* send( + { + type: "thread.turn.interrupt", + commandId: CommandId.make(`cp-remote-stop:${key}`), + threadId: ThreadId.make(record.threadId), + createdAt, + }, + record, + ); + } + yield* send( + { + type: "thread.session.stop", + commandId: CommandId.make(`cp-remote-session-stop:${key}`), + threadId: ThreadId.make(record.threadId), + createdAt, + }, + record, + ); + if (options.archive) { + yield* send( + { + type: "thread.archive", + commandId: CommandId.make(`cp-remote-archive:${key}`), + threadId: ThreadId.make(record.threadId), + }, + record, + ); + yield* store + .update(record.threadId, (current) => ({ + ...current, + state: "settled", + endedAt: createdAt, + inFlight: null, + })) + .pipe(Effect.mapError(storeFailed)); + } else { + yield* store + .update(record.threadId, (current) => ({ ...current, inFlight: null })) + .pipe(Effect.mapError(storeFailed)); + } + return { stopped: working || dropped > 0, archived: options.archive }; + }); + + const sendMessage: RemoteAgentsShape["send"] = (record, input) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const messageId = `${REMOTE_SEND_PREFIX}${record.threadId}:${createdAt}:${record.queuedSends.length}`; + const detail = yield* readView(record, 1); + if (Option.isNone(detail)) return yield* lostError(record); + const view = viewOf(detail.value.thread); + const busy = record.inFlight !== null || isThreadBusy(view); + if (busy) { + yield* store + .update(record.threadId, (current) => ({ + ...current, + queuedSends: [ + ...current.queuedSends, + { + messageId, + text: input.text, + replyTo: input.replyTo ?? input.sender.id, + queuedAt: createdAt, + }, + ], + })) + .pipe(Effect.mapError(storeFailed)); + return { queued: true }; + } + yield* startRequest(record, { + messageId, + text: input.text, + replyTo: input.replyTo ?? input.sender.id, + sender: input.sender, + baselineTurnId: view.latestTurn?.turnId ?? null, + createdAt, + }); + return { queued: false }; + }); + + const startQueued: RemoteAgentsShape["startQueued"] = (record, view) => + Effect.gen(function* () { + const next = record.queuedSends[0]; + if (next === undefined || record.inFlight !== null || isThreadBusy(view)) return; + const createdAt = yield* nowIso; + // Off the queue first: a failed start is retried by the sender, never doubled. + yield* store + .update(record.threadId, (current) => ({ + ...current, + queuedSends: current.queuedSends.filter((entry) => entry.messageId !== next.messageId), + })) + .pipe(Effect.mapError(storeFailed)); + yield* startRequest(record, { + messageId: next.messageId, + text: next.text, + replyTo: next.replyTo, + sender: { id: next.replyTo, title: "Coordinator" }, + baselineTurnId: view.latestTurn?.turnId ?? null, + createdAt, + }); + }); + + return { + plan, + homeLabel: machines.homeLabel, + reserve, + start, + get: (threadId) => + store.get(threadId).pipe(Effect.orElseSucceed(() => Option.none())), + find: ({ projectId, ref }) => + list.pipe( + Effect.map((records) => { + const trimmed = ref.trim(); + const byId = records.find((record) => record.threadId === trimmed); + if (byId !== undefined) return [byId]; + return records.filter( + (record) => + record.homeProjectId === projectId && + record.title.localeCompare(trimmed, undefined, { sensitivity: "accent" }) === 0, + ); + }), + ), + list: (projectId) => + Effect.gen(function* () { + const records = (yield* list).filter((record) => record.homeProjectId === projectId); + return yield* Effect.forEach(records, (record) => + machines.isReachable(record.machineId).pipe( + Effect.map((reachable) => ({ + record, + phase: reachable || record.state !== "open" ? record.lastPhase : ("stale" as const), + })), + ), + ); + }), + openCount: (projectId) => + list.pipe( + Effect.map( + (records) => + records.filter((record) => record.homeProjectId === projectId && isCounted(record)) + .length, + ), + ), + read, + stop, + send: sendMessage, + startQueued, + } satisfies RemoteAgentsShape; +}); + +export const RemoteAgentsLive = Layer.effect(RemoteAgents, make); diff --git a/apps/server/src/agentMachines/agentPhase.ts b/apps/server/src/agentMachines/agentPhase.ts new file mode 100644 index 0000000000000..5f213aee45a66 --- /dev/null +++ b/apps/server/src/agentMachines/agentPhase.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +/** + * What an agent is doing, as `cp_agent_list` and `cp_agent_read` report it. + * Its own module so the linked-machine store can use it without importing the + * tool definitions, which import the linked-machine services. + */ +export const AgentPhase = Schema.Literals([ + "idle", + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", + "completed", + "failed", + "stale", +]); +export type AgentPhase = typeof AgentPhase.Type; diff --git a/apps/server/src/agentMachines/placement.test.ts b/apps/server/src/agentMachines/placement.test.ts new file mode 100644 index 0000000000000..583d618c71021 --- /dev/null +++ b/apps/server/src/agentMachines/placement.test.ts @@ -0,0 +1,289 @@ +import { LOCAL_AGENT_MACHINE, type AgentMachinePreference } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { placeAgent, placementScore, type PlacementCandidate } from "./placement.ts"; + +type Settings = Parameters[0]["settings"]; + +const machine = (label: string, preference: AgentMachinePreference = 50) => ({ + label, + baseUrl: `http://${label.toLowerCase().replaceAll(" ", "-")}.tailnet.ts.net:3773`, + enabled: true, + preference, +}); + +const settings = (overrides: Partial = {}): Settings => ({ + mode: "balanced", + singleMachineId: null, + localPreference: 50, + allowLocalFallback: true, + machines: { mini: machine("Mac Mini"), studio: machine("Studio") }, + ...overrides, +}); + +const local = (load = 0): PlacementCandidate => ({ + id: LOCAL_AGENT_MACHINE, + label: "This Mac", + ineligibleReason: null, + load, +}); + +const remote = ( + id: string, + label: string, + load = 0, + ineligibleReason: string | null = null, +): PlacementCandidate => ({ id, label, ineligibleReason, load }); + +const mini = (load = 0, reason: string | null = null) => remote("mini", "Mac Mini", load, reason); +const studio = (load = 0, reason: string | null = null) => remote("studio", "Studio", load, reason); + +const place = ( + overrides: Partial, + candidates: ReadonlyArray, + requested?: string, +) => placeAgent({ settings: settings(overrides), requested, candidates }); + +describe("placementScore", () => { + it("is load per unit of weight, counting the new agent", () => { + expect(placementScore(0, 50)).toBe(1 / 50); + expect(placementScore(3, 100)).toBe(4 / 100); + expect(placementScore(1, 25)).toBe(2 / 25); + }); +}); + +describe("placeAgent without an explicit machine", () => { + it("rejects when this machine is not a candidate", () => { + expect(place({ mode: "local" }, [mini()])).toEqual({ + kind: "rejected", + detail: "This machine is not a candidate.", + }); + }); + + it("local mode always places on this machine, however loaded", () => { + expect(place({ mode: "local" }, [local(9), mini(0)])).toEqual({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + label: "This Mac", + }); + }); + + it("single mode places on the chosen machine when it is eligible", () => { + expect( + place({ mode: "single", singleMachineId: "mini" }, [local(), mini(5), studio()]), + ).toEqual({ kind: "placed", machineId: "mini", label: "Mac Mini" }); + }); + + it("single mode with no choice or 'local' places on this machine", () => { + for (const singleMachineId of [null, LOCAL_AGENT_MACHINE]) { + expect(place({ mode: "single", singleMachineId }, [local(), mini()])).toEqual({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + label: "This Mac", + }); + } + }); + + it("single mode falls back to this machine with a note when the chosen one is ineligible", () => { + const decision = place({ mode: "single", singleMachineId: "mini" }, [ + local(), + mini(0, "offline"), + ]); + expect(decision).toEqual({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + label: "This Mac", + note: "Mac Mini is unavailable: offline. Started on this machine instead.", + }); + }); + + it("single mode falls back with a note when the chosen machine is no longer a candidate", () => { + const decision = place({ mode: "single", singleMachineId: "gone" }, [local(), mini()]); + expect(decision.kind).toBe("placed"); + if (decision.kind !== "placed") return; + expect(decision.machineId).toBe(LOCAL_AGENT_MACHINE); + expect(decision.note).toContain("no longer linked or enabled"); + }); + + it("single mode is rejected instead of falling back when fallback is off", () => { + expect( + place({ mode: "single", singleMachineId: "mini", allowLocalFallback: false }, [ + local(), + mini(0, "no matching Project"), + ]), + ).toEqual({ + kind: "rejected", + detail: "Mac Mini is unavailable: no matching Project. Fallback to this machine is off.", + }); + }); + + it("balanced picks the lowest (load + 1) / weight", () => { + // local: 2/50 = 0.04, mini: 3/100 = 0.03, studio: 1/25 = 0.04 + const decision = place( + { + localPreference: 50, + machines: { mini: machine("Mac Mini", 100), studio: machine("Studio", 25) }, + }, + [local(1), mini(2), studio(0)], + ); + expect(decision).toEqual({ kind: "placed", machineId: "mini", label: "Mac Mini" }); + }); + + it("balanced prefers an idle machine over a busier one at the same weight", () => { + expect(place({}, [local(2), mini(0), studio(1)])).toMatchObject({ machineId: "mini" }); + }); + + it("balanced reads a machine missing from settings as Normal (50)", () => { + // mini has no settings entry: 2/50 = 0.04 beats local 3/50 = 0.06. + expect(place({ machines: {} }, [local(2), mini(1)])).toMatchObject({ machineId: "mini" }); + }); + + it("balanced leaves Manual only (weight 0) machines out of automatic choice", () => { + const decision = place({ machines: { mini: machine("Mac Mini", 0) } }, [local(8), mini(0)]); + expect(decision).toEqual({ kind: "placed", machineId: LOCAL_AGENT_MACHINE, label: "This Mac" }); + }); + + it("balanced skips ineligible machines", () => { + expect(place({}, [local(4), mini(0, "offline"), studio(1)])).toMatchObject({ + machineId: "studio", + }); + }); + + it("a local preference of 0 sends every automatic agent to a linked machine", () => { + expect(place({ localPreference: 0 }, [local(0), mini(9)])).toMatchObject({ + machineId: "mini", + }); + }); + + it("a local preference of 0 with no eligible remote falls back to this machine with a note", () => { + const decision = place({ localPreference: 0 }, [ + local(0), + mini(0, "offline"), + studio(0, "offline"), + ]); + expect(decision).toEqual({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + label: "This Mac", + note: "No machine can take the agent: This Mac, Mac Mini (offline), Studio (offline). Started on this machine instead.", + }); + }); + + it("a local preference of 0 with no linked machine at all falls back with its own note", () => { + const decision = place({ localPreference: 0, machines: {} }, [local(0)]); + expect(decision).toEqual({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + label: "This Mac", + note: "No linked machine is available. Started on this machine instead.", + }); + }); + + it("balanced with nothing eligible is rejected when fallback is off", () => { + const decision = place({ localPreference: 0, allowLocalFallback: false }, [ + local(0), + mini(0, "needs re-link"), + ]); + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") return; + expect(decision.detail).toContain("Mac Mini (needs re-link)"); + expect(decision.detail).toContain("Fallback to this machine is off."); + }); +}); + +describe("placeAgent tie-breaks", () => { + it("equal scores go to the lower load", () => { + // local 1/50 = 0.02, mini 2/100 = 0.02: local has less load. + expect( + place({ machines: { mini: machine("Mac Mini", 100) } }, [local(0), mini(1)]), + ).toMatchObject({ machineId: LOCAL_AGENT_MACHINE }); + // local 2/50 = 0.04, mini 1/25 = 0.04: mini has less load. + expect( + place({ machines: { mini: machine("Mac Mini", 25) } }, [local(1), mini(0)]), + ).toMatchObject({ machineId: "mini" }); + }); + + it("equal score and load go to this machine, ahead of a lower id", () => { + const decision = place({ machines: { aaa: machine("Alpha") } }, [ + remote("aaa", "Alpha"), + local(0), + ]); + expect(decision).toMatchObject({ machineId: LOCAL_AGENT_MACHINE }); + }); + + it("equal linked machines are ordered by id, whatever the candidate order", () => { + const candidates = [studio(0), mini(0)]; + expect(place({ localPreference: 0 }, [local(), ...candidates])).toMatchObject({ + machineId: "mini", + }); + expect(place({ localPreference: 0 }, [local(), ...candidates.toReversed()])).toMatchObject({ + machineId: "mini", + }); + }); +}); + +describe("placeAgent with an explicit machine", () => { + it("names a machine by id or by label, ignoring case and spaces around it", () => { + for (const requested of ["mini", "MINI", "Mac Mini", " mac mini "]) { + expect(place({}, [local(0), mini(9)], requested)).toEqual({ + kind: "placed", + machineId: "mini", + label: "Mac Mini", + }); + } + }); + + it("'local' is always allowed, in every mode", () => { + for (const mode of ["local", "single", "balanced"] as const) { + expect(place({ mode, singleMachineId: "mini" }, [local(9), mini(0)], "local")).toMatchObject({ + kind: "placed", + machineId: LOCAL_AGENT_MACHINE, + }); + } + }); + + it("a linked machine is rejected in local mode", () => { + const decision = place({ mode: "local" }, [local(), mini()], "mini"); + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") return; + expect(decision.detail).toContain("Agents run on this machine only."); + }); + + it("single mode allows only the chosen machine", () => { + const candidates = [local(), mini(), studio()]; + expect(place({ mode: "single", singleMachineId: "mini" }, candidates, "Mac Mini")).toEqual({ + kind: "placed", + machineId: "mini", + label: "Mac Mini", + }); + const other = place({ mode: "single", singleMachineId: "mini" }, candidates, "studio"); + expect(other.kind).toBe("rejected"); + if (other.kind !== "rejected") return; + expect(other.detail).toContain("'Studio' is not that machine"); + }); + + it("balanced allows a Manual only machine when it is named", () => { + expect( + place({ machines: { mini: machine("Mac Mini", 0) } }, [local(0), mini(5)], "mini"), + ).toEqual({ kind: "placed", machineId: "mini", label: "Mac Mini" }); + }); + + it("an ineligible machine is rejected with its reason and never falls back", () => { + for (const mode of ["single", "balanced"] as const) { + expect( + place( + { mode, singleMachineId: "mini", allowLocalFallback: true }, + [local(), mini(0, "offline")], + "mini", + ), + ).toEqual({ kind: "rejected", detail: "Mac Mini cannot take the agent: offline." }); + } + }); + + it("an unknown machine is rejected and lists what is available", () => { + expect(place({}, [local(), mini(0, "offline")], "laptop")).toEqual({ + kind: "rejected", + detail: "No machine matches 'laptop'. Available: This Mac, Mac Mini (offline).", + }); + }); +}); diff --git a/apps/server/src/agentMachines/placement.ts b/apps/server/src/agentMachines/placement.ts new file mode 100644 index 0000000000000..cbbac9a91ed23 --- /dev/null +++ b/apps/server/src/agentMachines/placement.ts @@ -0,0 +1,164 @@ +/** + * Where a new agent runs. Pure: the caller gathers reachability, load and + * Project matches, this decides. Fork-owned; see + * docs/internals/multi-machine-agents.md. + * + * @module placement + */ +import { + LOCAL_AGENT_MACHINE, + type AgentMachinePreference, + type AgentPlacementSettings, +} from "@t3tools/contracts"; + +export interface PlacementCandidate { + /** `LOCAL_AGENT_MACHINE` for this machine, else the linked machine's id. */ + readonly id: string; + readonly label: string; + /** Null when the machine can take the agent; else why it cannot. */ + readonly ineligibleReason: string | null; + /** Running agents on the machine, plus placements it has not shown yet. */ + readonly load: number; +} + +export type PlacementDecision = + | { + readonly kind: "placed"; + readonly machineId: string; + readonly label: string; + /** Set when the choice is not what the settings asked for. */ + readonly note?: string; + } + | { readonly kind: "rejected"; readonly detail: string }; + +const weightOf = ( + settings: Pick, + candidate: PlacementCandidate, +): AgentMachinePreference => + candidate.id === LOCAL_AGENT_MACHINE + ? settings.localPreference + : (settings.machines[candidate.id]?.preference ?? 50); + +/** Load per unit of weight; a new agent adds one. Lower is better. */ +export function placementScore(load: number, weight: number): number { + return (load + 1) / weight; +} + +function findRequested( + requested: string, + candidates: ReadonlyArray, +): PlacementCandidate | null { + const wanted = requested.trim().toLowerCase(); + return ( + candidates.find((candidate) => candidate.id.toLowerCase() === wanted) ?? + candidates.find((candidate) => candidate.label.toLowerCase() === wanted) ?? + null + ); +} + +function describe(candidates: ReadonlyArray): string { + return candidates + .map((candidate) => + candidate.ineligibleReason === null + ? candidate.label + : `${candidate.label} (${candidate.ineligibleReason})`, + ) + .join(", "); +} + +/** + * `candidates` are this machine and every enabled linked machine, this + * machine first. `requested` is the tool's explicit `machine`, which the + * settings still bound and which never falls back. + */ +export function placeAgent(input: { + readonly settings: Pick< + AgentPlacementSettings, + "mode" | "singleMachineId" | "localPreference" | "allowLocalFallback" | "machines" + >; + readonly requested: string | undefined; + readonly candidates: ReadonlyArray; +}): PlacementDecision { + const { settings, requested, candidates } = input; + const local = candidates.find((candidate) => candidate.id === LOCAL_AGENT_MACHINE); + if (local === undefined) return { kind: "rejected", detail: "This machine is not a candidate." }; + const placed = (candidate: PlacementCandidate, note?: string): PlacementDecision => ({ + kind: "placed", + machineId: candidate.id, + label: candidate.label, + ...(note === undefined ? {} : { note }), + }); + + if (requested !== undefined) { + const target = findRequested(requested, candidates); + if (target === null) { + return { + kind: "rejected", + detail: `No machine matches '${requested}'. Available: ${describe(candidates)}.`, + }; + } + if (target.id === LOCAL_AGENT_MACHINE) return placed(target); + if (settings.mode === "local") { + return { + kind: "rejected", + detail: + "Agents run on this machine only. The user can change where new agents run in Settings > Connections > Agent machines.", + }; + } + if (settings.mode === "single" && target.id !== settings.singleMachineId) { + return { + kind: "rejected", + detail: `New agents are set to run on one machine. '${target.label}' is not that machine; omit machine or use "local".`, + }; + } + if (target.ineligibleReason !== null) { + return { + kind: "rejected", + detail: `${target.label} cannot take the agent: ${target.ineligibleReason}.`, + }; + } + return placed(target); + } + + if (settings.mode === "local") return placed(local); + + const fallback = (why: string): PlacementDecision => + settings.allowLocalFallback + ? placed(local, `${why} Started on this machine instead.`) + : { kind: "rejected", detail: `${why} Fallback to this machine is off.` }; + + if (settings.mode === "single") { + const singleId = settings.singleMachineId ?? LOCAL_AGENT_MACHINE; + if (singleId === LOCAL_AGENT_MACHINE) return placed(local); + const target = candidates.find((candidate) => candidate.id === singleId); + if (target === undefined) return fallback("The chosen machine is no longer linked or enabled."); + if (target.ineligibleReason === null) return placed(target); + return fallback(`${target.label} is unavailable: ${target.ineligibleReason}.`); + } + + // balanced: Manual only (weight 0) machines are left out, but this machine + // is never left out for the fallback below. + const eligible = candidates.filter( + (candidate) => candidate.ineligibleReason === null && weightOf(settings, candidate) > 0, + ); + if (eligible.length === 0) { + const skipped = candidates.filter((candidate) => candidate.id !== LOCAL_AGENT_MACHINE); + return fallback( + skipped.length === 0 + ? "No linked machine is available." + : `No machine can take the agent: ${describe(candidates)}.`, + ); + } + const best = [...eligible].sort((a, b) => { + const weightA = weightOf(settings, a); + const weightB = weightOf(settings, b); + return ( + placementScore(a.load, weightA) - placementScore(b.load, weightB) || + a.load - b.load || + weightB - weightA || + (a.id === LOCAL_AGENT_MACHINE ? -1 : 0) - (b.id === LOCAL_AGENT_MACHINE ? -1 : 0) || + a.id.localeCompare(b.id) + ); + })[0]!; + return placed(best); +} diff --git a/apps/server/src/agentMachines/projectMatch.test.ts b/apps/server/src/agentMachines/projectMatch.test.ts new file mode 100644 index 0000000000000..6cad976d9700d --- /dev/null +++ b/apps/server/src/agentMachines/projectMatch.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { matchPeerProject } from "./projectMatch.ts"; + +type Project = Parameters[0]; + +const REPO = "github.com/nick/control-plane"; + +const project = ( + id: string, + title: string, + workspaceRoot: string, + extra: Partial = {}, +): Project => ({ id, title, workspaceRoot, ...extra }); + +const withRepo = (canonicalKey: string) => ({ repositoryIdentity: { canonicalKey } }); +const archived = { assistant: { archivedAt: "2026-09-01T00:00:00.000Z" } }; + +const home = project("home", "Control Plane", "/Users/nick/Code/control-plane", withRepo(REPO)); +const homeNoRepo = project("home", "Control Plane", "/Users/nick/Code/control-plane"); + +describe("matchPeerProject by repository", () => { + it("matches the one peer Project with the same canonicalKey, whatever its title or folder", () => { + const peers = [ + project("other", "Other", "/srv/other", withRepo("github.com/nick/other")), + project("peer", "CP on the Mini", "/Volumes/work/cp", withRepo(REPO)), + ]; + expect(matchPeerProject(home, peers)).toEqual({ kind: "matched", projectId: "peer" }); + }); + + it("narrows by title when several peer Projects share the repository", () => { + const peers = [ + project("scratch", "Scratch", "/srv/cp-scratch", withRepo(REPO)), + project("main", "control plane", "/srv/cp", withRepo(REPO)), + ]; + expect(matchPeerProject(home, peers)).toEqual({ kind: "matched", projectId: "main" }); + }); + + it("is ambiguous when several share the repository and the title does not single one out", () => { + expect( + matchPeerProject(home, [ + project("a", "Scratch", "/srv/a", withRepo(REPO)), + project("b", "Experiments", "/srv/b", withRepo(REPO)), + ]), + ).toEqual({ kind: "ambiguous", count: 2 }); + expect( + matchPeerProject(home, [ + project("a", "Control Plane", "/srv/a", withRepo(REPO)), + project("b", "Control Plane", "/srv/b", withRepo(REPO)), + project("c", "Scratch", "/srv/c", withRepo(REPO)), + ]), + ).toEqual({ kind: "ambiguous", count: 2 }); + }); + + it("falls back to title and folder when no peer shares the repository", () => { + const peers = [project("peer", "Control Plane", "/Volumes/work/control-plane")]; + expect(matchPeerProject(home, peers)).toEqual({ kind: "matched", projectId: "peer" }); + }); +}); + +describe("matchPeerProject by title and folder", () => { + it("matches the same title (ignoring case) and the same folder basename", () => { + const peers = [ + project("wrong-folder", "Control Plane", "/srv/cp"), + project("peer", "control plane", "/home/nick/src/control-plane"), + ]; + expect(matchPeerProject(homeNoRepo, peers)).toEqual({ kind: "matched", projectId: "peer" }); + }); + + it("ignores trailing slashes and reads backslash paths", () => { + for (const workspaceRoot of [ + "/srv/control-plane/", + "/srv/control-plane//", + "C:\\Users\\nick\\control-plane", + "C:\\Users\\nick\\control-plane\\", + ]) { + expect( + matchPeerProject(homeNoRepo, [project("peer", "Control Plane", workspaceRoot)]), + ).toEqual({ kind: "matched", projectId: "peer" }); + } + const windowsHome = project("home", "Control Plane", "D:\\code\\control-plane\\"); + expect( + matchPeerProject(windowsHome, [project("peer", "Control Plane", "/srv/control-plane")]), + ).toEqual({ kind: "matched", projectId: "peer" }); + }); + + it("is missing when the title matches but the folder does not, or the other way round", () => { + expect( + matchPeerProject(homeNoRepo, [ + project("a", "Control Plane", "/srv/control-plane-2"), + project("b", "Control Plane Fork", "/srv/control-plane"), + ]), + ).toEqual({ kind: "missing" }); + }); + + it("is ambiguous when several peers share the title and folder", () => { + expect( + matchPeerProject(homeNoRepo, [ + project("a", "Control Plane", "/srv/control-plane"), + project("b", "Control Plane", "/Volumes/x/control-plane"), + ]), + ).toEqual({ kind: "ambiguous", count: 2 }); + }); + + it("is missing with no peer Projects", () => { + expect(matchPeerProject(home, [])).toEqual({ kind: "missing" }); + }); +}); + +describe("matchPeerProject and archived Projects", () => { + it("never matches an archived peer Project, by repository or by title", () => { + expect( + matchPeerProject(home, [ + project("gone", "Control Plane", "/srv/control-plane", { + ...withRepo(REPO), + ...archived, + }), + ]), + ).toEqual({ kind: "missing" }); + expect( + matchPeerProject(homeNoRepo, [ + project("gone", "Control Plane", "/srv/control-plane", archived), + ]), + ).toEqual({ kind: "missing" }); + }); + + it("an archived twin does not make a live match ambiguous", () => { + expect( + matchPeerProject(home, [ + project("gone", "Control Plane", "/srv/old", { ...withRepo(REPO), ...archived }), + project("live", "Control Plane", "/srv/new", withRepo(REPO)), + ]), + ).toEqual({ kind: "matched", projectId: "live" }); + }); + + it("a live assistant marker with a null archivedAt still matches", () => { + expect( + matchPeerProject(home, [ + project("live", "CP", "/srv/cp", { ...withRepo(REPO), assistant: { archivedAt: null } }), + ]), + ).toEqual({ kind: "matched", projectId: "live" }); + }); +}); diff --git a/apps/server/src/agentMachines/projectMatch.ts b/apps/server/src/agentMachines/projectMatch.ts new file mode 100644 index 0000000000000..f56e4db6de2d8 --- /dev/null +++ b/apps/server/src/agentMachines/projectMatch.ts @@ -0,0 +1,66 @@ +/** + * Finds the Project on a linked machine that matches a Project on this one. + * Fork-owned; see docs/internals/multi-machine-agents.md. + * + * @module projectMatch + */ + +interface MatchableProject { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly repositoryIdentity?: { readonly canonicalKey: string } | null | undefined; + readonly assistant?: { readonly archivedAt?: string | null | undefined } | null | undefined; +} + +export type ProjectMatch = + | { readonly kind: "matched"; readonly projectId: string } + | { readonly kind: "missing" } + | { readonly kind: "ambiguous"; readonly count: number }; + +const basename = (path: string): string => { + const trimmed = path.replace(/[\\/]+$/, ""); + const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return trimmed.slice(index + 1); +}; + +const sameText = (a: string, b: string): boolean => + a.localeCompare(b, undefined, { sensitivity: "accent" }) === 0; + +const resolve = (matches: ReadonlyArray): ProjectMatch | null => + matches.length === 0 + ? null + : matches.length === 1 + ? { kind: "matched", projectId: matches[0]!.id } + : { kind: "ambiguous", count: matches.length }; + +/** + * The same repository (`canonicalKey`) wins when the home Project has one, + * narrowed by title if several peers share it. Otherwise the same title and + * the same folder name. Archived Projects never match. Nothing is created. + */ +export function matchPeerProject( + home: MatchableProject, + peers: ReadonlyArray, +): ProjectMatch { + const live = peers.filter((peer) => peer.assistant?.archivedAt == null); + const key = home.repositoryIdentity?.canonicalKey; + if (key !== undefined) { + const sameRepo = live.filter((peer) => peer.repositoryIdentity?.canonicalKey === key); + const byRepo = + sameRepo.length > 1 + ? (resolve(sameRepo.filter((peer) => sameText(peer.title, home.title))) ?? + ({ kind: "ambiguous", count: sameRepo.length } as const)) + : resolve(sameRepo); + if (byRepo !== null) return byRepo; + } + const folder = basename(home.workspaceRoot); + return ( + resolve( + live.filter( + (peer) => + sameText(peer.title, home.title) && sameText(basename(peer.workspaceRoot), folder), + ), + ) ?? { kind: "missing" } + ); +} diff --git a/apps/server/src/agentMachines/remoteAgentState.test.ts b/apps/server/src/agentMachines/remoteAgentState.test.ts new file mode 100644 index 0000000000000..83ed9b9eba50e --- /dev/null +++ b/apps/server/src/agentMachines/remoteAgentState.test.ts @@ -0,0 +1,375 @@ +import { + ThreadId, + TurnId, + type OrchestrationLatestTurn, + type OrchestrationSession, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import { + REMOTE_START_GRACE_MS, + judgeRequest, + machineLoad, + remotePhase, + remoteReadTurns, + remoteResultText, + remoteStartFailureDetail, + type PeerThreadView, +} from "./remoteAgentState.ts"; + +const T0 = "2026-01-01T12:00:00.000Z"; +const at = (offsetMs: number) => DateTime.formatIso(DateTime.makeUnsafe(Date.parse(T0) + offsetMs)); + +const session = ( + status: OrchestrationSession["status"], + lastError: string | null = null, +): OrchestrationSession => ({ + threadId: ThreadId.make("agent-1"), + status, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError, + updatedAt: T0, +}); + +const turn = ( + turnId: string, + state: OrchestrationLatestTurn["state"], + requestedAt = T0, +): OrchestrationLatestTurn => ({ + turnId: TurnId.make(turnId), + state, + requestedAt, + startedAt: requestedAt, + completedAt: state === "running" ? null : requestedAt, + assistantMessageId: null, +}); + +const view = (overrides: Partial = {}): PeerThreadView => ({ + latestTurn: null, + session: session("ready"), + hasPendingApprovals: false, + hasPendingUserInput: false, + latestUserMessageAt: null, + ...overrides, +}); + +const judge = (thread: PeerThreadView, baselineTurnId: string | null = "turn-0", sentAgeMs = 0) => + judgeRequest({ baselineTurnId, thread, sentAgeMs }); + +describe("judgeRequest", () => { + it("waits while the agent is busy or blocked on the user, even after a new turn ended", () => { + const ended = turn("turn-1", "completed"); + for (const thread of [ + view({ latestTurn: ended, hasPendingApprovals: true }), + view({ latestTurn: ended, hasPendingUserInput: true }), + view({ latestTurn: ended, session: session("starting") }), + view({ latestTurn: ended, session: session("running") }), + ]) { + expect(judge(thread, "turn-0", 60_000)).toEqual({ kind: "waiting" }); + } + }); + + it("is done when a turn newer than the baseline finished, failed or was interrupted", () => { + expect(judge(view({ latestTurn: turn("turn-1", "completed") }))).toEqual({ + kind: "done", + outcome: { kind: "finished" }, + startFailure: false, + }); + expect( + judge(view({ latestTurn: turn("turn-1", "error"), session: session("ready", "Overloaded") })), + ).toEqual({ + kind: "done", + outcome: { kind: "failed", lastError: "Overloaded" }, + startFailure: false, + }); + expect(judge(view({ latestTurn: turn("turn-1", "error"), session: null }))).toEqual({ + kind: "done", + outcome: { kind: "failed", lastError: null }, + startFailure: false, + }); + expect(judge(view({ latestTurn: turn("turn-1", "interrupted") }))).toEqual({ + kind: "done", + outcome: { kind: "stopped" }, + startFailure: false, + }); + }); + + it("treats any turn as new when there was no baseline", () => { + expect(judge(view({ latestTurn: turn("turn-1", "completed") }), null)).toMatchObject({ + kind: "done", + outcome: { kind: "finished" }, + }); + }); + + it("is not done when the latest turn is still the baseline", () => { + for (const state of ["completed", "error", "interrupted"] as const) { + expect(judge(view({ latestTurn: turn("turn-0", state) }), "turn-0", 10 * 60_000)).toEqual({ + kind: "waiting", + }); + } + }); + + it("waits while the new turn is running", () => { + expect(judge(view({ latestTurn: turn("turn-1", "running") }))).toEqual({ kind: "waiting" }); + }); + + it("reads a session error with no new turn as a start failure at once", () => { + expect( + judge( + view({ latestTurn: turn("turn-0", "completed"), session: session("error", "No model") }), + ), + ).toEqual({ + kind: "done", + outcome: { kind: "failed-to-start", detail: "No model" }, + startFailure: true, + }); + }); + + it("reads a stopped or interrupted session with no new turn as a start failure only after the grace", () => { + for (const status of ["stopped", "interrupted"] as const) { + const thread = view({ latestTurn: turn("turn-0", "completed"), session: session(status) }); + expect(judge(thread, "turn-0", 1_000)).toEqual({ kind: "waiting" }); + expect(judge(thread, "turn-0", REMOTE_START_GRACE_MS)).toEqual({ kind: "waiting" }); + expect(judge(thread, "turn-0", REMOTE_START_GRACE_MS + 1)).toEqual({ + kind: "done", + outcome: { kind: "failed-to-start", detail: null }, + startFailure: true, + }); + } + }); + + it("waits with no session and no turn yet", () => { + expect(judge(view({ session: null }), null, 10 * 60_000)).toEqual({ kind: "waiting" }); + expect(judge(view({ session: session("idle") }), null, 10 * 60_000)).toEqual({ + kind: "waiting", + }); + }); +}); + +describe("remotePhase", () => { + const phase = (thread: PeerThreadView | null, reachable = true, requestPending = false) => + remotePhase({ thread, reachable, requestPending }); + + it("is stale when the machine is unreachable or the thread is unknown", () => { + expect(phase(view({ session: session("running") }), false)).toBe("stale"); + expect(phase(null)).toBe("stale"); + }); + + it("maps pending approvals, input and live sessions first", () => { + expect(phase(view({ hasPendingApprovals: true, hasPendingUserInput: true }))).toBe( + "waiting_for_approval", + ); + expect(phase(view({ hasPendingUserInput: true }))).toBe("waiting_for_input"); + expect(phase(view({ session: session("starting") }))).toBe("starting"); + expect(phase(view({ session: session("running"), latestTurn: turn("t", "error") }))).toBe( + "running", + ); + }); + + it("reads a request the peer has not started as starting", () => { + expect(phase(view({ latestTurn: turn("t", "completed") }), true, true)).toBe("starting"); + }); + + it("maps the latest turn when idle", () => { + expect(phase(view({ latestTurn: turn("t", "error") }))).toBe("failed"); + expect(phase(view({ latestTurn: turn("t", "completed") }))).toBe("completed"); + expect(phase(view({ latestTurn: turn("t", "interrupted") }))).toBe("idle"); + expect(phase(view({ session: null }))).toBe("idle"); + }); +}); + +const shellThread = ( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell => + ({ + id: ThreadId.make(id), + archivedAt: null, + latestTurn: null, + session: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + latestUserMessageAt: null, + ...overrides, + }) as OrchestrationThreadShell; + +describe("machineLoad", () => { + const snapshot = { + updatedAt: T0, + threads: [ + shellThread("running", { session: session("running") }), + shellThread("approval", { hasPendingApprovals: true }), + // A first message 30 seconds before the snapshot, no turn yet: counted as starting. + shellThread("queued", { latestUserMessageAt: at(-30_000) }), + // The same shape ten minutes before the snapshot: not counted. + shellThread("old-queued", { latestUserMessageAt: at(-10 * 60_000) }), + shellThread("idle", { + session: session("ready"), + latestTurn: turn("t", "completed", at(-60_000)), + latestUserMessageAt: at(-61_000), + }), + shellThread("archived", { + session: session("running"), + archivedAt: at(-1_000), + }), + ], + }; + + it("counts running threads judged at the snapshot's own time, ignoring archived ones", () => { + // The home clock (today) is months after T0, far past any grace; the + // queued thread still counts because it is judged at the snapshot's time. + expect(machineLoad({ snapshot, recentPlacements: [] })).toBe(3); + }); + + it("adds recent placements the snapshot does not show running yet", () => { + expect( + machineLoad({ + snapshot, + recentPlacements: [{ threadId: "new-1" }, { threadId: "new-2" }, { threadId: "idle" }], + }), + ).toBe(6); + }); + + it("does not double count a placement the snapshot already shows running", () => { + expect( + machineLoad({ + snapshot, + recentPlacements: [{ threadId: "running" }, { threadId: "queued" }, { threadId: "new" }], + }), + ).toBe(4); + }); + + it("is zero for an empty machine", () => { + expect(machineLoad({ snapshot: { updatedAt: T0, threads: [] }, recentPlacements: [] })).toBe(0); + }); +}); + +const user = (text: string) => ({ role: "user", text, streaming: false }); +const assistant = (text: string, streaming = false) => ({ role: "assistant", text, streaming }); + +describe("remoteReadTurns", () => { + const messages = [ + assistant("greeting before any request"), + user("A"), + assistant("draft a"), + assistant("a1"), + user("B"), + assistant(" "), + user("C"), + assistant("c so far", true), + ]; + + it("groups replies under requests; earlier turns read completed or interrupted", () => { + expect(remoteReadTurns({ messages, latestTurnState: "running", limit: 10 })).toEqual([ + { state: "completed", request: "A", result: "a1" }, + { state: "interrupted", request: "B", result: null }, + { state: "running", request: "C", result: null }, + ]); + }); + + it("maps the latest turn's state", () => { + const last = (latestTurnState: Parameters[0]["latestTurnState"]) => + remoteReadTurns({ messages, latestTurnState, limit: 10 }).at(-1)?.state; + expect(last(null)).toBe("queued"); + expect(last("error")).toBe("failed"); + expect(last("completed")).toBe("completed"); + expect(last("interrupted")).toBe("interrupted"); + }); + + it("does not count a streaming reply as a result", () => { + expect( + remoteReadTurns({ + messages: [user("A"), assistant("done"), assistant("more", true)], + latestTurnState: "running", + limit: 5, + }), + ).toEqual([{ state: "running", request: "A", result: "done" }]); + }); + + it("keeps the latest `limit` requests, oldest first", () => { + expect( + remoteReadTurns({ messages, latestTurnState: "completed", limit: 2 }).map( + (entry) => entry.request, + ), + ).toEqual(["B", "C"]); + }); + + it("is empty with no requests", () => { + expect( + remoteReadTurns({ messages: [assistant("hi")], latestTurnState: null, limit: 5 }), + ).toEqual([]); + }); +}); + +describe("remoteResultText", () => { + const messages = [ + { id: "u1", ...user("first") }, + { id: "a1", ...assistant("old reply") }, + { id: "req", ...user("the request") }, + { id: "a2", ...assistant("partial") }, + { id: "a3", ...assistant("final") }, + { id: "a4", ...assistant(" ") }, + { id: "a5", ...assistant("still writing", true) }, + ]; + + it("is the last complete reply after the request", () => { + expect(remoteResultText({ messages, requestMessageId: "req" })).toBe("final"); + }); + + it("is null when nothing after the request is a complete reply", () => { + expect( + remoteResultText({ messages: messages.slice(0, 3), requestMessageId: "req" }), + ).toBeNull(); + }); + + it("falls back to the last complete reply when the request is outside the window", () => { + expect(remoteResultText({ messages: messages.slice(3), requestMessageId: "req" })).toBe( + "final", + ); + expect(remoteResultText({ messages: messages.slice(0, 2), requestMessageId: "req" })).toBe( + "old reply", + ); + }); +}); + +describe("remoteStartFailureDetail", () => { + const failed = (payload: unknown) => ({ kind: "provider.turn.start.failed", payload }); + + it("returns the detail of the start failure for this request", () => { + expect( + remoteStartFailureDetail({ + activities: [ + failed({ requestId: "other", detail: "Wrong request" }), + { kind: "provider.turn.started", payload: { requestId: "req", detail: "Not a failure" } }, + failed({ requestId: "req", detail: "Model gpt-9 is not available" }), + ], + requestMessageId: "req", + }), + ).toBe("Model gpt-9 is not available"); + }); + + it("prefers the latest matching failure", () => { + expect( + remoteStartFailureDetail({ + activities: [ + failed({ requestId: "req", detail: "first" }), + failed({ requestId: "req", detail: "second" }), + ], + requestMessageId: "req", + }), + ).toBe("second"); + }); + + it("is null without a matching failure or a usable detail", () => { + const detail = (activities: ReadonlyArray<{ kind: string; payload: unknown }>) => + remoteStartFailureDetail({ activities, requestMessageId: "req" }); + expect(detail([])).toBeNull(); + expect(detail([failed(null), failed("req"), failed({ requestId: "other" })])).toBeNull(); + expect(detail([failed({ requestId: "req", detail: "" })])).toBeNull(); + expect(detail([failed({ requestId: "req", detail: 42 })])).toBeNull(); + }); +}); diff --git a/apps/server/src/agentMachines/remoteAgentState.ts b/apps/server/src/agentMachines/remoteAgentState.ts new file mode 100644 index 0000000000000..c808fdcc48415 --- /dev/null +++ b/apps/server/src/agentMachines/remoteAgentState.ts @@ -0,0 +1,234 @@ +/** + * Pure rules for agents that run on a linked machine: when a request is done, + * what phase an agent reads as, and how busy a machine is. Everything reads + * the peer's own timestamps, never the home clock. Fork-owned; see + * docs/internals/multi-machine-agents.md. + * + * @module remoteAgentState + */ +import { + isRunningAgent, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import { isAgentCountedRunning } from "../mcp/toolkits/agents/agentScope.ts"; +import type { AgentResultOutcome } from "../orchestration/agentPushes.ts"; +import type { AgentPhase } from "./agentPhase.ts"; + +/** The slice of a peer thread's shell the bridge reads. */ +export type PeerThreadView = Pick< + OrchestrationThreadShell, + "latestTurn" | "session" | "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" +>; + +export type RequestVerdict = + | { readonly kind: "waiting" } + | { + readonly kind: "done"; + readonly outcome: AgentResultOutcome; + /** The request never became a turn: the caller looks for the start failure's detail. */ + readonly startFailure: boolean; + }; + +/** A stopped session with no turn this soon after the send may still be starting. */ +export const REMOTE_START_GRACE_MS = 15_000; + +/** + * Whether the request in flight has finished. A request is matched to its + * turn by `baselineTurnId`, the peer thread's latest turn before the request + * was sent, because the peer stores its own message times and does not report + * which message started a turn. `sentAgeMs` is measured on the home clock and + * only bounds how long a stopped session may still be starting. + */ +export function judgeRequest(input: { + readonly baselineTurnId: string | null; + readonly thread: PeerThreadView; + readonly sentAgeMs: number; +}): RequestVerdict { + const { thread, baselineTurnId } = input; + const status = thread.session?.status; + if ( + thread.hasPendingApprovals || + thread.hasPendingUserInput || + status === "starting" || + status === "running" + ) { + return { kind: "waiting" }; + } + const turn = thread.latestTurn; + if (turn !== null && turn.turnId !== baselineTurnId) { + switch (turn.state) { + case "running": + return { kind: "waiting" }; + case "completed": + return { kind: "done", outcome: { kind: "finished" }, startFailure: false }; + case "error": + return { + kind: "done", + outcome: { kind: "failed", lastError: thread.session?.lastError ?? null }, + startFailure: false, + }; + case "interrupted": + return { kind: "done", outcome: { kind: "stopped" }, startFailure: false }; + } + } + // No turn took the request: the start failed, or the agent was stopped first. + if ( + status === "error" || + ((status === "stopped" || status === "interrupted") && input.sentAgeMs > REMOTE_START_GRACE_MS) + ) { + return { + kind: "done", + outcome: { kind: "failed-to-start", detail: thread.session?.lastError ?? null }, + startFailure: true, + }; + } + return { kind: "waiting" }; +} + +/** + * A remote agent's phase from its peer thread; `stale` when the machine is + * unreachable. `requestPending` is a request in flight that `judgeRequest` + * still reads as waiting, so a send the peer has not started yet reads as + * starting. + */ +export function remotePhase(input: { + readonly thread: PeerThreadView | null; + readonly reachable: boolean; + readonly requestPending: boolean; +}): AgentPhase { + const { thread } = input; + if (!input.reachable || thread === null) return "stale"; + if (thread.hasPendingApprovals) return "waiting_for_approval"; + if (thread.hasPendingUserInput) return "waiting_for_input"; + const status = thread.session?.status; + if (status === "starting") return "starting"; + if (status === "running") return "running"; + if (input.requestPending) return "starting"; + if (thread.latestTurn?.state === "error") return "failed"; + if (thread.latestTurn?.state === "completed") return "completed"; + return "idle"; +} + +/** How long a placement counts toward a machine's load before its snapshot must show it. */ +export const PLACEMENT_GRACE_MS = 120_000; + +/** + * Running agents on a machine: every non-archived thread the snapshot counts + * as running, judged at the snapshot's own time, plus this home's recent + * placements the snapshot does not show running yet (so a burst of creates + * spreads before the peer catches up). + */ +export function machineLoad(input: { + readonly snapshot: Pick; + readonly recentPlacements: ReadonlyArray<{ readonly threadId: string }>; +}): number { + const { snapshot } = input; + const running = new Set(); + for (const thread of snapshot.threads) { + if (thread.archivedAt === null && isAgentCountedRunning(thread, snapshot.updatedAt)) { + running.add(thread.id); + } + } + const unseen = input.recentPlacements.filter((placement) => !running.has(placement.threadId)); + return running.size + unseen.length; +} + +/** For the local machine, whose threads carry the home clock. */ +export function isThreadBusy(thread: PeerThreadView): boolean { + return isRunningAgent(thread); +} + +export type RemoteReadState = "queued" | "running" | "completed" | "failed" | "interrupted"; + +export interface RemoteReadTurn { + readonly state: RemoteReadState; + readonly request: string; + readonly result: string | null; +} + +/** + * The latest `limit` requests of a peer thread, oldest first, for + * `cp_agent_read`. Over HTTP only the latest turn has a state, so earlier + * requests read as completed when they got a reply and interrupted when not. + * A streaming reply is still being written and does not count as a result. + */ +export function remoteReadTurns(input: { + readonly messages: ReadonlyArray<{ + readonly role: string; + readonly text: string; + readonly streaming: boolean; + }>; + readonly latestTurnState: "running" | "completed" | "error" | "interrupted" | null; + readonly limit: number; +}): ReadonlyArray { + const groups: Array<{ request: string; result: string | null }> = []; + for (const message of input.messages) { + if (message.role === "user") { + groups.push({ request: message.text, result: null }); + } else if ( + message.role === "assistant" && + groups.length > 0 && + !message.streaming && + message.text.trim().length > 0 + ) { + groups[groups.length - 1]!.result = message.text; + } + } + const latest: RemoteReadState = + input.latestTurnState === null + ? "queued" + : input.latestTurnState === "error" + ? "failed" + : input.latestTurnState; + return groups + .map((group, index): RemoteReadTurn => ({ + state: + index === groups.length - 1 ? latest : group.result === null ? "interrupted" : "completed", + request: group.request, + result: group.result, + })) + .slice(-input.limit); +} + +/** + * The agent's last complete reply after the request, from a peer thread's + * messages. Falls back to the last complete reply when the request is outside + * the window the peer returned. + */ +export function remoteResultText(input: { + readonly messages: ReadonlyArray<{ + readonly id: string; + readonly role: string; + readonly text: string; + readonly streaming: boolean; + }>; + readonly requestMessageId: string; +}): string | null { + const at = input.messages.findIndex((message) => message.id === input.requestMessageId); + const after = at === -1 ? input.messages : input.messages.slice(at + 1); + const reply = after.findLast( + (message) => + message.role === "assistant" && !message.streaming && message.text.trim().length > 0, + ); + return reply?.text ?? null; +} + +/** The detail the peer recorded when the provider could not start the request's turn. */ +export function remoteStartFailureDetail(input: { + readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; + readonly requestMessageId: string; +}): string | null { + const failure = input.activities.findLast((activity) => { + const payload = activity.payload as { readonly requestId?: unknown } | null; + return ( + activity.kind === "provider.turn.start.failed" && + payload !== null && + typeof payload === "object" && + payload.requestId === input.requestMessageId + ); + }); + const detail = (failure?.payload as { readonly detail?: unknown } | undefined)?.detail; + return typeof detail === "string" && detail.length > 0 ? detail : null; +} diff --git a/apps/server/src/agentMachines/testFixtures.ts b/apps/server/src/agentMachines/testFixtures.ts new file mode 100644 index 0000000000000..6e836e25a5f67 --- /dev/null +++ b/apps/server/src/agentMachines/testFixtures.ts @@ -0,0 +1,230 @@ +/** + * Test doubles for the linked-machine services. Fork-owned. + */ +import { + ProjectId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { AgentMachines, type AgentMachinesShape } from "./AgentMachines.ts"; +import { PeerError, type PeerCommand } from "./AgentMachineClient.ts"; +import { + RemoteAgentStore, + type RemoteAgentRecord, + type RemoteAgentStoreShape, +} from "./RemoteAgentStore.ts"; +import { RemoteAgents, type RemoteAgentsShape } from "./RemoteAgents.ts"; + +/** Every agent stays on this machine and no remote agent exists. */ +export const localOnlyRemoteAgents = { + plan: () => + Effect.succeed({ + decision: { kind: "placed" as const, machineId: "local", label: "This Mac" }, + peerProjectId: null, + allowLocalFallback: true, + localOnly: true, + }), + homeLabel: Effect.succeed("This Mac"), + get: () => Effect.succeed(Option.none()), + find: () => Effect.succeed([]), + list: () => Effect.succeed([]), + openCount: () => Effect.succeed(0), +} satisfies Partial; + +export const RemoteAgentsLocalOnly = Layer.mock(RemoteAgents)(localOnlyRemoteAgents); + +// ── In-memory store and a fake linked machine ────────────────────── + +export const TEST_NOW = "1970-01-01T00:00:00.000Z"; +const LONG_AGO = "2026-08-20T00:00:00.000Z"; + +/** A store that keeps records in a Map, for tests that do not need the file. */ +export const makeMemoryRemoteAgentStore = Effect.sync(() => { + const records = new Map(); + return { + list: Effect.sync(() => [...records.values()]), + get: (threadId) => Effect.sync(() => Option.fromNullishOr(records.get(threadId))), + put: (record) => Effect.sync(() => void records.set(record.threadId, record)), + update: (threadId, change) => + Effect.sync(() => { + const current = records.get(threadId); + if (current === undefined) return Option.none(); + const next = change(current); + records.set(threadId, next); + return Option.some(next); + }), + remove: (threadId) => Effect.sync(() => void records.delete(threadId)), + } satisfies RemoteAgentStoreShape; +}); + +export const MemoryRemoteAgentStore = Layer.effect(RemoteAgentStore, makeMemoryRemoteAgentStore); + +export function makeRecord(overrides: Partial = {}): RemoteAgentRecord { + return { + threadId: "remote-1", + machineId: "mini", + machineLabel: "Mac Mini", + peerProjectId: "peer-project", + homeProjectId: "project-1", + title: "Pricing", + creatorThreadId: "coordinator", + runtimeMode: "approval-required", + createdAt: TEST_NOW, + state: "open", + endedAt: null, + inFlight: null, + queuedSends: [], + lastPhase: "idle", + lastActivityAt: null, + ...overrides, + }; +} + +export function makePeerThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: ProjectId.make("peer-project"), + title: id, + modelSelection: { instanceId: "codex" as never, model: "gpt-5.4" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: LONG_AGO, + updatedAt: LONG_AGO, + archivedAt: null, + settledOverride: null, + settledAt: null, + pinnedAt: null, + session: null, + latestUserMessageAt: LONG_AGO, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +export const peerProject: OrchestrationProjectShell = { + id: ProjectId.make("peer-project"), + title: "Acme", + workspaceRoot: "/peer/acme", + defaultModelSelection: null, + assistant: { coordinatorThreadId: ThreadId.make("peer-coordinator") }, + scripts: [], + createdAt: LONG_AGO, + updatedAt: LONG_AGO, +}; + +export interface FakePeerOptions { + /** Fails these command types with an unreachable error. */ + readonly failOn?: ReadonlyArray; + readonly machineId?: string; +} + +/** + * A linked machine in memory: its threads are whatever the test sets, and the + * commands the home sends are recorded. `detail` builds the thread snapshot a + * read returns. + */ +export function makeFakePeer(options: FakePeerOptions = {}) { + const machineId = options.machineId ?? "mini"; + const threads = new Map(); + const details = new Map>(); + const commands: Array = []; + const failOn = new Set(options.failOn ?? []); + const state = { reachable: true, unauthorized: false, needsRelink: false }; + + const guard = (effect: Effect.Effect): Effect.Effect => + state.unauthorized + ? Effect.fail( + new PeerError({ kind: "unauthorized", detail: "The linked machine rejected the token." }), + ) + : state.reachable + ? effect + : Effect.fail( + new PeerError({ kind: "unreachable", detail: "Could not reach the linked machine." }), + ); + + const snapshot = (): OrchestrationShellSnapshot => ({ + snapshotSequence: 1, + projects: [peerProject], + threads: [...threads.values()], + updatedAt: TEST_NOW, + }); + + const shellApi: AgentMachinesShape["shell"] = () => guard(Effect.sync(snapshot)); + const threadApi: AgentMachinesShape["thread"] = (_machine, threadId) => + guard( + Effect.sync(() => { + const shell = threads.get(threadId); + if (shell === undefined) return Option.none(); + return Option.some({ + snapshotSequence: 1, + thread: { + ...shell, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: shell.session, + ...details.get(threadId), + } as unknown as OrchestrationThread, + } as OrchestrationThreadDetailSnapshot); + }), + ); + const dispatchApi: AgentMachinesShape["dispatch"] = (_machine, command) => + guard( + failOn.has(command.type) + ? Effect.fail(new PeerError({ kind: "unreachable", detail: `${command.type} failed` })) + : Effect.sync(() => { + commands.push(command); + if (command.type === "thread.create") { + threads.set( + command.threadId, + makePeerThread(command.threadId, { + projectId: command.projectId, + title: command.title, + runtimeMode: command.runtimeMode, + modelSelection: command.modelSelection, + }), + ); + } + }), + ); + + const service: AgentMachinesShape = { + manage: () => Effect.die("not stubbed"), + placementInputs: () => Effect.die("not stubbed"), + shell: shellApi, + thread: threadApi, + dispatch: dispatchApi, + markNeedsRelink: () => Effect.sync(() => void (state.needsRelink = true)), + isReachable: () => Effect.sync(() => state.reachable && !state.unauthorized), + homeLabel: Effect.succeed("This Mac"), + }; + + return { + machineId, + threads, + details, + commands, + state, + layer: Layer.succeed(AgentMachines, service), + service, + }; +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 81fed9eb384eb..5f3079d96f9e3 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, + [WS_METHODS.serverAgentMachines]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 77c7ec79f0bdf..44bd969a1f650 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -19,6 +19,7 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; +import { RemoteAgentsLocalOnly } from "../agentMachines/testFixtures.ts"; import { AgentsToolkitRegistrationLive } from "./toolkits/agents/handlers.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -62,6 +63,7 @@ const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive. getThreadShellById: () => Effect.succeed(Option.none()), }), Layer.mock(OrchestrationEngineService)({}), + RemoteAgentsLocalOnly, NodeServices.layer, ), ), @@ -81,6 +83,7 @@ const ThreadsTestLayer = McpHttpServer.ThreadsToolkitRegistrationLive.pipe( }), }), Layer.mock(OrchestrationEngineService)({}), + RemoteAgentsLocalOnly, NodeServices.layer, SqlitePersistenceMemory, ), @@ -92,6 +95,7 @@ const AgentsTestLayer = AgentsToolkitRegistrationLive.pipe( Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({}), Layer.mock(OrchestrationEngineService)({}), + RemoteAgentsLocalOnly, makeProviderRegistryLayer(), SqlitePersistenceMemory, NodeServices.layer, diff --git a/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts new file mode 100644 index 0000000000000..298338fe43c9f --- /dev/null +++ b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts @@ -0,0 +1,667 @@ +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentPlacementSettings, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ServerProvider, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + AgentMachines, + type AgentMachinesShape, + type PlacementInputs, +} from "../../../agentMachines/AgentMachines.ts"; +import { RemoteAgents, RemoteAgentsLive } from "../../../agentMachines/RemoteAgents.ts"; +import { RemoteAgentStore } from "../../../agentMachines/RemoteAgentStore.ts"; +import { + MemoryRemoteAgentStore, + TEST_NOW, + makeFakePeer, + makePeerThread, + makeRecord, +} from "../../../agentMachines/testFixtures.ts"; +import { AgentLineage } from "../../../orchestration/agentLineage.ts"; +import { AGENT_RUNNING_CAP } from "../../../orchestration/agentProtocol.ts"; +import { OrchestrationEngineService } from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProjectionProjectRepository } from "../../../persistence/Services/ProjectionProjects.ts"; +import { ProjectionThreadMessageRepository } from "../../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../../persistence/Services/ProjectionTurns.ts"; +import { makeProviderRegistryLayer } from "../../../provider/testUtils/providerRegistryMock.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { AgentsToolkitHandlers } from "./handlers.ts"; +import { + AgentCreateResult, + AgentListResult, + AgentReadResult, + AgentStopResult, + AgentsToolkit, +} from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const COORDINATOR_ID = ThreadId.make("coordinator"); +const STANDING_ID = ThreadId.make("standing"); +const CODEX = ProviderInstanceId.make("codex"); +const LONG_AGO = "2026-08-20T00:00:00.000Z"; + +const homeThread = ( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell => + makePeerThread(id, { projectId: PROJECT_ID, runtimeMode: "full-access", ...overrides }); + +const running = (id: string) => + homeThread(id, { + session: { + threadId: ThreadId.make(id), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: LONG_AGO, + }, + }); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Acme", + workspaceRoot: "/work/acme", + defaultModelSelection: null, + assistant: { coordinatorThreadId: COORDINATOR_ID }, + scripts: [], + createdAt: LONG_AGO, + updatedAt: LONG_AGO, +}; + +const provider: ServerProvider = { + instanceId: CODEX, + driver: ProviderDriverKind.make(CODEX), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: LONG_AGO, + models: [{ slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], +}; + +const settings = (overrides: Partial = {}): AgentPlacementSettings => ({ + mode: "local", + singleMachineId: null, + localPreference: 50, + allowLocalFallback: true, + machines: {}, + ...overrides, +}); + +const mini = { + label: "Mac Mini", + baseUrl: "https://mini.tail1234.ts.net", + enabled: true, + preference: 50, +} as const; + +interface HarnessInput { + readonly caller: ThreadId; + readonly threads?: ReadonlyArray; + readonly placement: PlacementInputs; + readonly failPeerOn?: ReadonlyArray<"thread.create" | "thread.turn.start">; +} + +const makeHarness = Effect.fn("makeRemoteAgentsHarness")(function* (input: HarnessInput) { + const threads = new Map( + (input.threads ?? [homeThread("coordinator", { title: "Acme" })]).map( + (thread) => [thread.id, thread] as const, + ), + ); + const commands: Array = []; + const creators = new Map(); + const peer = makeFakePeer({ ...(input.failPeerOn ? { failOn: input.failPeerOn } : {}) }); + const machines = Layer.succeed(AgentMachines, { + ...peer.service, + placementInputs: () => Effect.succeed(input.placement), + } satisfies AgentMachinesShape); + // Built once and shared: layers rebuild on every `provide`, and the store + // must be the same instance for the handlers and the test. + const built = yield* Layer.build( + RemoteAgentsLive.pipe(Layer.provideMerge(machines), Layer.provideMerge(MemoryRemoteAgentStore)), + ); + const store = Context.get(built, RemoteAgentStore); + const remoteLayer = Layer.mergeAll( + Layer.succeed(RemoteAgents, Context.get(built, RemoteAgents)), + Layer.succeed(RemoteAgentStore, store), + ); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (threadId) => Effect.succeed(Option.fromNullishOr(threads.get(threadId))), + getProjectShellById: (projectId) => + Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [project], + threads: [...threads.values()], + updatedAt: LONG_AGO, + }), + }), + Layer.mock(ProjectionTurnRepository)({ listByThreadId: () => Effect.succeed([]) }), + Layer.mock(ProjectionProjectRepository)({}), + Layer.mock(ProjectionThreadMessageRepository)({ + getByMessageId: () => Effect.succeed(Option.none()), + listByThreadId: () => Effect.succeed([]), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + if (command.type === "thread.create") { + threads.set( + command.threadId, + homeThread(command.threadId, { + title: command.title, + runtimeMode: command.runtimeMode, + latestUserMessageAt: null, + }), + ); + if (command.createdByThreadId) + creators.set(command.threadId, command.createdByThreadId); + } + return { sequence: commands.length }; + }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(AgentLineage, { + creatorOf: (threadId) => Effect.succeed(creators.get(threadId) ?? null), + }), + makeProviderRegistryLayer([provider]), + remoteLayer, + NodeServices.layer, + ); + const toolkit = yield* AgentsToolkit.pipe( + Effect.provide(AgentsToolkitHandlers.pipe(Layer.provide(dependencies))), + ); + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: input.caller, + providerSessionId: "provider-session-1", + providerInstanceId: CODEX, + capabilities: new Set(["agents"]), + issuedAt: 1, + }; + const run = , E, R>( + handled: Effect.Effect, E, R>, + success: S, + ) => + handled.pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((chunk) => Schema.decodeUnknownSync(success)(chunk.at(-1)!.result)), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provide(dependencies), + ); + const seedRecord = (overrides: Parameters[0] = {}) => + store.put(makeRecord({ homeProjectId: PROJECT_ID, ...overrides })); + return { + commands, + peer, + threads, + seedRecord, + records: store.list, + create: (params: Parameters>[1]) => + run(toolkit.handle("cp_agent_create", params), AgentCreateResult), + list: (params: Parameters>[1] = {}) => + run(toolkit.handle("cp_agent_list", params), AgentListResult), + read: (params: Parameters>[1]) => + run(toolkit.handle("cp_agent_read", params), AgentReadResult), + stop: (params: Parameters>[1]) => + run(toolkit.handle("cp_agent_stop", params), AgentStopResult), + }; +}); + +const localCandidate = (load = 0) => ({ + id: "local", + label: "This Mac", + ineligibleReason: null, + load, +}); +const miniCandidate = (load = 0, ineligibleReason: string | null = null) => ({ + id: "mini", + label: "Mac Mini", + ineligibleReason, + load, +}); + +const placement = ( + overrides: Partial, + candidates: PlacementInputs["candidates"], +): PlacementInputs => ({ + settings: settings(overrides), + localOnly: false, + candidates, + peerProjectIds: new Map([["mini", "peer-project"]]), +}); + +const types = (commands: ReadonlyArray<{ readonly type: string }>) => + commands.map((command) => command.type); + +describe("cp_agent_create placement", () => { + it.effect("sends every agent to the one chosen machine and records it", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const result = yield* h.create({ title: "Pricing", message: "Compare plans." }); + + expect(result).toMatchObject({ title: "Pricing", created: true, machineLabel: "Mac Mini" }); + expect(types(h.peer.commands)).toEqual(["thread.create", "thread.turn.start"]); + expect(types(h.commands)).toEqual([]); + const records = yield* h.records; + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + threadId: result.threadId, + machineId: "mini", + homeProjectId: PROJECT_ID, + creatorThreadId: "coordinator", + state: "open", + }); + expect(result.placement).toBeUndefined(); + }), + ); + + it.effect("balances by load across this machine and a linked one", () => + Effect.gen(function* () { + const busyHome = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "balanced", machines: { mini } }, [ + localCandidate(3), + miniCandidate(0), + ]), + }); + expect((yield* busyHome.create({ title: "A", message: "go" })).machineLabel).toBe("Mac Mini"); + + const busyPeer = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "balanced", machines: { mini } }, [ + localCandidate(0), + miniCandidate(3), + ]), + }); + const local = yield* busyPeer.create({ title: "B", message: "go" }); + expect(local.machineLabel).toBeNull(); + expect(types(busyPeer.commands)).toContain("thread.create"); + expect(busyPeer.peer.commands).toEqual([]); + }), + ); + + it.effect("never asks for looser runtime mode than the caller, on a linked machine either", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + threads: [homeThread("coordinator", { title: "Acme", runtimeMode: "approval-required" })], + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const result = yield* h.create({ + title: "Pricing", + message: "go", + runtimeMode: "full-access", + }); + expect(result.runtimeMode).toBe("approval-required"); + expect(h.peer.commands[0]).toMatchObject({ runtimeMode: "approval-required" }); + }), + ); + + it.effect("falls back to this machine when the linked one cannot be reached, and says so", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + failPeerOn: ["thread.create"], + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const result = yield* h.create({ title: "Pricing", message: "go" }); + expect(result.machineLabel).toBeNull(); + expect(result.placement).toContain("Started on this machine instead"); + expect(types(h.commands)).toContain("thread.create"); + expect(yield* h.records).toEqual([]); + }), + ); + + it.effect("does not fall back when fallback is off", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + failPeerOn: ["thread.create"], + placement: placement( + { + mode: "single", + singleMachineId: "mini", + allowLocalFallback: false, + machines: { mini }, + }, + [localCandidate(), miniCandidate()], + ), + }); + const error = yield* Effect.flip(h.create({ title: "Pricing", message: "go" })); + expect(error.message).toContain("Could not reach"); + expect(types(h.commands)).toEqual([]); + }), + ); + + it.effect("never falls back for an explicit machine", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + failPeerOn: ["thread.create"], + placement: placement({ mode: "balanced", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const error = yield* Effect.flip( + h.create({ title: "Pricing", message: "go", machine: "Mac Mini" }), + ); + expect(error.message).toContain("Could not reach"); + expect(types(h.commands)).toEqual([]); + }), + ); + + it.effect("rejects a named machine when the settings keep agents on this one", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "local", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const error = yield* Effect.flip( + h.create({ title: "Pricing", message: "go", machine: "mini" }), + ); + expect(error.message).toContain("Agents run on this machine only"); + }), + ); + + it.effect("starts on this machine when the named machine is 'local'", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const result = yield* h.create({ title: "Pricing", message: "go", machine: "local" }); + expect(result.machineLabel).toBeNull(); + expect(h.peer.commands).toEqual([]); + }), + ); + + it.effect("keeps standing agents on this machine, and refuses a remote one", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const local = yield* h.create({ title: "Sales", message: "go", standing: true }); + expect(local.machineLabel).toBeNull(); + expect(h.peer.commands).toEqual([]); + const error = yield* Effect.flip( + h.create({ title: "Ops", message: "go", standing: true, machine: "Mac Mini" }), + ); + expect(error.message).toContain("standing agent runs on this machine"); + }), + ); + + it.effect("counts remote agents toward the Project's cap", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + threads: [ + homeThread("coordinator", { title: "Acme" }), + ...Array.from({ length: AGENT_RUNNING_CAP - 1 }, (_, index) => running(`agent-${index}`)), + ], + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + // The last free slot goes to a remote agent... + yield* h.create({ title: "One", message: "go" }); + // ...so the next one, anywhere, is over the cap. + const error = yield* Effect.flip(h.create({ title: "Two", message: "go" })); + expect(error._tag).toBe("ConcurrencyLimitError"); + }), + ); + + it.effect( + "a retry with the same clientRequestId returns the agent it started and sends nothing more", + () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + const first = yield* h.create({ + title: "Pricing", + message: "go", + clientRequestId: "req-1", + }); + const sent = h.peer.commands.length; + const again = yield* h.create({ + title: "Pricing", + message: "go", + clientRequestId: "req-1", + }); + expect(again.threadId).toBe(first.threadId); + expect(again.created).toBe(false); + expect(h.peer.commands).toHaveLength(sent); + expect(yield* h.records).toHaveLength(1); + }), + ); + + it.effect("stays on the machine it started on when the settings change before a retry", () => + Effect.gen(function* () { + const remoteSettings = placement( + { mode: "single", singleMachineId: "mini", machines: { mini } }, + [localCandidate(), miniCandidate()], + ); + const h = yield* makeHarness({ caller: COORDINATOR_ID, placement: remoteSettings }); + const first = yield* h.create({ title: "Pricing", message: "go", clientRequestId: "req-2" }); + expect(first.machineLabel).toBe("Mac Mini"); + // A retry after the user switched to "this machine only" must not start a second agent here. + const h2 = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "local" }, [localCandidate()]), + }); + yield* h2.seedRecord({ + threadId: first.threadId, + title: "Pricing", + state: "open", + inFlight: { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }, + }); + const again = yield* h2.create({ title: "Pricing", message: "go", clientRequestId: "req-2" }); + expect(again.threadId).toBe(first.threadId); + expect(types(h2.commands)).toEqual([]); + }), + ); + + it.effect("refuses a clientRequestId reused with a different title", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + caller: COORDINATOR_ID, + placement: placement({ mode: "single", singleMachineId: "mini", machines: { mini } }, [ + localCandidate(), + miniCandidate(), + ]), + }); + yield* h.create({ title: "Pricing", message: "go", clientRequestId: "req-3" }); + const error = yield* Effect.flip( + h.create({ title: "Other", message: "go", clientRequestId: "req-3" }), + ); + expect(error._tag).toBe("ClientRequestIdConflictError"); + }), + ); +}); + +describe("cp_agent_list, cp_agent_read and cp_agent_stop with remote agents", () => { + const seededHarness = (caller: ThreadId = COORDINATOR_ID) => + makeHarness({ + caller, + threads: [ + homeThread("coordinator", { title: "Acme" }), + homeThread("standing", { title: "Sales", pinnedAt: LONG_AGO }), + homeThread("local-agent", { title: "Local agent" }), + ], + placement: placement({ mode: "local" }, [localCandidate()]), + }); + + it.effect("lists remote agents next to local ones, with their machine", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ lastPhase: "running" }); + const { agents } = yield* h.list(); + expect(agents.map((agent) => [agent.title, agent.machine])).toEqual( + expect.arrayContaining([ + ["Pricing", "Mac Mini"], + ["Local agent", null], + ]), + ); + expect(agents.find((agent) => agent.title === "Pricing")).toMatchObject({ + threadId: "remote-1", + phase: "running", + standing: false, + settled: false, + }); + }), + ); + + it.effect("hides settled remote agents unless asked", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ state: "settled", endedAt: TEST_NOW }); + expect((yield* h.list()).agents.some((agent) => agent.title === "Pricing")).toBe(false); + expect( + (yield* h.list({ includeSettled: true })).agents.find((agent) => agent.title === "Pricing") + ?.settled, + ).toBe(true); + }), + ); + + it.effect("a standing agent lists only the remote agents it started", () => + Effect.gen(function* () { + const h = yield* seededHarness(STANDING_ID); + yield* h.seedRecord({ threadId: "mine", title: "Mine", creatorThreadId: "standing" }); + yield* h.seedRecord({ threadId: "theirs", title: "Theirs", creatorThreadId: "coordinator" }); + const titles = (yield* h.list()).agents.map((agent) => agent.title); + expect(titles).toContain("Mine"); + expect(titles).not.toContain("Theirs"); + }), + ); + + it.effect("reads a remote agent by title", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord(); + h.peer.threads.set("remote-1", makePeerThread("remote-1")); + h.peer.details.set("remote-1", { + messages: [ + { id: "m1", role: "user", text: "Compare plans.", streaming: false }, + { id: "m2", role: "assistant", text: "Plan B wins.", streaming: false }, + ] as never, + }); + const read = yield* h.read({ agent: "Pricing" }); + expect(read.threadId).toBe("remote-1"); + expect(read.turns.at(-1)).toMatchObject({ + request: "Compare plans.", + result: "Plan B wins.", + }); + }), + ); + + it.effect("refuses a remote agent that another manager started", () => + Effect.gen(function* () { + const h = yield* seededHarness(STANDING_ID); + yield* h.seedRecord({ threadId: "theirs", title: "Theirs", creatorThreadId: "coordinator" }); + const byId = yield* Effect.flip(h.read({ agent: "theirs" })); + const byTitle = yield* Effect.flip(h.read({ agent: "Theirs" })); + expect(byId._tag).toBe("NotYourAgentError"); + expect(byTitle._tag).toBe("NotYourAgentError"); + }), + ); + + it.effect("reports several agents with one title, local and remote, as ambiguous", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ title: "Local agent" }); + const error = yield* Effect.flip(h.read({ agent: "Local agent" })); + expect(error._tag).toBe("AgentAmbiguousError"); + }), + ); + + it.effect("stops a remote agent on its machine, and only touches the peer", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ + inFlight: { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }, + }); + const stopped = yield* h.stop({ agent: "Pricing" }); + expect(stopped).toEqual({ threadId: "remote-1", stopped: true, archived: false }); + expect(types(h.peer.commands)).toEqual(["thread.turn.interrupt", "thread.session.stop"]); + expect(types(h.commands)).toEqual([]); + }), + ); + + it.effect("finds nothing for an unknown agent", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + const error = yield* Effect.flip(h.read({ agent: "Nobody" })); + expect(error._tag).toBe("AgentNotFoundError"); + }), + ); +}); diff --git a/apps/server/src/mcp/toolkits/agents/handlers.test.ts b/apps/server/src/mcp/toolkits/agents/handlers.test.ts index 4af979f91c045..0d3c826b5b05a 100644 --- a/apps/server/src/mcp/toolkits/agents/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/agents/handlers.test.ts @@ -26,6 +26,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { RemoteAgentsLocalOnly } from "../../../agentMachines/testFixtures.ts"; import { AgentLineage } from "../../../orchestration/agentLineage.ts"; import { OrchestrationCommandInvariantError, @@ -366,6 +367,7 @@ const makeHarness = Effect.fn("makeAgentsToolkitHarness")(function* (input: Harn creatorOf: (threadId) => Effect.succeed(creators.get(threadId) ?? null), }), makeProviderRegistryLayer(providers), + RemoteAgentsLocalOnly, NodeServices.layer, ); const toolkit = yield* AgentsToolkit.pipe( diff --git a/apps/server/src/mcp/toolkits/agents/handlers.ts b/apps/server/src/mcp/toolkits/agents/handlers.ts index e159d5c7a5639..09b6d3a3e1a60 100644 --- a/apps/server/src/mcp/toolkits/agents/handlers.ts +++ b/apps/server/src/mcp/toolkits/agents/handlers.ts @@ -5,6 +5,7 @@ import { EventId, isRunningAgent, isStandingAgent, + LOCAL_AGENT_MACHINE, MessageId, type AgentManagerRole, type OrchestrationCommand, @@ -24,6 +25,8 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { McpServer } from "effect/unstable/ai"; +import { type RemoteAgentRecord } from "../../../agentMachines/RemoteAgentStore.ts"; +import { RemoteAgentError, RemoteAgents } from "../../../agentMachines/RemoteAgents.ts"; import { AgentLineage } from "../../../orchestration/agentLineage.ts"; import { AGENT_RUNNING_CAP, @@ -108,6 +111,7 @@ const make = Effect.gen(function* () { // Serializes creates so two calls cannot both pass the running cap. const createLock = yield* Semaphore.make(1); const scheduleHandlers = yield* makeScheduleHandlers; + const remote = yield* RemoteAgents; const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const randomUuid = crypto.randomUUIDv4.pipe(Effect.orDie); @@ -146,8 +150,10 @@ const make = Effect.gen(function* () { thread.id !== manager.project.assistant?.coordinatorThreadId && isAgentCountedRunning(thread, now), ).length; - if (running >= AGENT_RUNNING_CAP) { - return yield* new ConcurrencyLimitError({ limit: AGENT_RUNNING_CAP, running }); + // Agents on linked machines share the Project's cap. + const total = running + (yield* remote.openCount(manager.project.id)); + if (total >= AGENT_RUNNING_CAP) { + return yield* new ConcurrencyLimitError({ limit: AGENT_RUNNING_CAP, running: total }); } }); @@ -202,15 +208,39 @@ const make = Effect.gen(function* () { }); }); - const requireAgent = Effect.fn("AgentsToolkit.requireAgent")(function* ( + const remoteAsManaged = (manager: Manager, record: RemoteAgentRecord) => ({ + id: ThreadId.make(record.threadId), + projectId: manager.project.id, + pinnedAt: null, + createdByThreadId: ThreadId.make(record.creatorThreadId), + }); + + /** + * An agent a tool names: a local thread, or one the registry knows on a + * linked machine. Local wins a title unless a remote agent the caller manages + * has the same title too, which is ambiguous like two local ones. + */ + const requireTarget = Effect.fn("AgentsToolkit.requireTarget")(function* ( manager: Manager, ref: string, ) { + const found = yield* remote.find({ projectId: manager.project.id, ref }); + const managed = found.filter( + (record) => + record.homeProjectId === manager.project.id && + canManageAgent(manager.project, manager.caller, remoteAsManaged(manager, record)), + ); const snapshot = yield* snapshots.getShellSnapshot().pipe(Effect.mapError(readFailed)); const candidates = agentRefCandidates(ref, { threads: snapshot.threads, projectId: manager.project.id, }); + const byRemoteId = found.find((record) => record.threadId === ref.trim()); + if (byRemoteId !== undefined) { + return managed.includes(byRemoteId) + ? ({ kind: "remote", record: byRemoteId } as const) + : yield* new NotYourAgentError({ agent: ref }); + } const canManage = yield* managesThread(manager, candidates); const resolved = resolveAgentRef(ref, { threads: candidates, @@ -219,16 +249,37 @@ const make = Effect.gen(function* () { }); switch (resolved.kind) { case "found": - return resolved.thread; - case "not-yours": - return yield* new NotYourAgentError({ agent: ref }); + return managed.length > 0 + ? yield* new AgentAmbiguousError({ agent: ref }) + : ({ kind: "local", thread: resolved.thread } as const); case "ambiguous": return yield* new AgentAmbiguousError({ agent: ref }); + case "not-yours": case "not-found": + if (managed.length === 1) return { kind: "remote", record: managed[0]! } as const; + if (managed.length > 1) return yield* new AgentAmbiguousError({ agent: ref }); + if (resolved.kind === "not-yours" || found.length > 0) { + return yield* new NotYourAgentError({ agent: ref }); + } return yield* new AgentNotFoundError({ agent: ref }); } }); + const toolFailed = (error: RemoteAgentError) => + new AgentToolFailedError({ detail: error.detail }); + + /** The Project's coordinator, or the caller when it is the coordinator or the thread is gone. */ + const resolveCoordinator = Effect.fn("AgentsToolkit.resolveCoordinator")(function* ( + manager: Manager, + ) { + const coordinatorId = manager.project.assistant?.coordinatorThreadId; + if (manager.role === "coordinator" || coordinatorId === undefined) return manager.caller; + return Option.getOrElse( + yield* snapshots.getThreadShellById(coordinatorId).pipe(Effect.mapError(readFailed)), + () => manager.caller, + ); + }); + /** A first message no turn has adopted yet reads as starting: the cap already counts it. */ const phaseOf = (manager: Manager, thread: OrchestrationThreadShell, now: string): AgentPhase => { const phase = projectThreadAwareness({ @@ -310,115 +361,253 @@ const make = Effect.gen(function* () { ), ); - return yield* createLock.withPermits(1)( - Effect.gen(function* () { - if (input.clientRequestId !== undefined) { - const existing = yield* snapshots - .getThreadShellById(threadId) - .pipe(Effect.mapError(readFailed)); - if (Option.isSome(existing)) { - if (existing.value.title !== input.title) { - return yield* new ClientRequestIdConflictError({ - clientRequestId: input.clientRequestId, - threadId, + const createLocal = (note: string | undefined) => + createLock + .withPermits(1)( + Effect.gen(function* () { + if (input.clientRequestId !== undefined) { + const existing = yield* snapshots + .getThreadShellById(threadId) + .pipe(Effect.mapError(readFailed)); + if (Option.isSome(existing)) { + if (existing.value.title !== input.title) { + return yield* new ClientRequestIdConflictError({ + clientRequestId: input.clientRequestId, + threadId, + }); + } + // The earlier call stopped after creating the thread (a cancel + // or a crash): finish it, or the agent never gets its message. + // Its first message starts it running, so the cap applies. + if (existing.value.latestUserMessageAt === null) { + const createdAt = yield* nowIso; + yield* requireFreeSlot(manager, createdAt); + yield* Effect.uninterruptible( + pinAndStart({ + manager, + ids, + threadId, + pin: input.standing === true && existing.value.pinnedAt == null, + message: input.message, + runtimeMode: existing.value.runtimeMode, + createdAt, + }), + ); + } + return { + threadId, + title: existing.value.title, + runtimeMode: existing.value.runtimeMode, + created: false, + }; + } + // Created before but no longer live: archived or deleted. + if ( + (yield* lineage.creatorOf(threadId).pipe(Effect.mapError(readFailed))) !== null + ) { + return yield* new AgentToolFailedError({ + detail: + "The agent this clientRequestId started was archived or deleted. Use a new clientRequestId.", + }); + } + } + + const coordinator = yield* resolveCoordinator(manager); + const model = resolveAgentModel({ + requested: input.model, + base: project.defaultModelSelection ?? coordinator.modelSelection, + providers: yield* registry.getProviders, + }); + if (model.kind === "unknown") { + return yield* new UnknownModelError({ + model: input.model ?? "", + available: model.available, }); } - // The earlier call stopped after creating the thread (a cancel - // or a crash): finish it, or the agent never gets its message. - // Its first message starts it running, so the cap applies. - if (existing.value.latestUserMessageAt === null) { - const createdAt = yield* nowIso; - yield* requireFreeSlot(manager, createdAt); - yield* Effect.uninterruptible( - pinAndStart({ + // Never looser than the caller, whatever it asks for. + const runtimeMode = stricterRuntimeMode( + input.runtimeMode ?? + stricterRuntimeMode(coordinator.runtimeMode, caller.runtimeMode), + caller.runtimeMode, + ); + + const createdAt = yield* nowIso; + yield* requireFreeSlot(manager, createdAt); + + // A cancelled call must not leave an agent created without its + // first message, so the three dispatches run to the end. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* dispatchCreateStep({ + type: "thread.create", + commandId: CommandId.make(ids.createCommandId), + threadId, + projectId: project.id, + title: input.title, + modelSelection: model.modelSelection, + runtimeMode, + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + createdByThreadId: caller.id, + }); + yield* pinAndStart({ manager, ids, threadId, - pin: input.standing === true && existing.value.pinnedAt == null, + pin: input.standing === true, message: input.message, - runtimeMode: existing.value.runtimeMode, + runtimeMode, createdAt, - }), - ); - } - return { - threadId, - title: existing.value.title, - runtimeMode: existing.value.runtimeMode, - created: false, - }; - } - // Created before but no longer live: archived or deleted. - if ((yield* lineage.creatorOf(threadId).pipe(Effect.mapError(readFailed))) !== null) { - return yield* new AgentToolFailedError({ - detail: - "The agent this clientRequestId started was archived or deleted. Use a new clientRequestId.", - }); - } - } + }); + }), + ); + // Returns at once: the result arrives later as a message. + return { threadId, title: input.title, runtimeMode, created: true }; + }), + ) + .pipe( + Effect.map((result) => ({ + ...result, + machineLabel: null, + ...(note === undefined ? {} : { placement: note }), + })), + ); - const coordinatorId = project.assistant?.coordinatorThreadId; - const coordinator = - manager.role === "coordinator" || coordinatorId === undefined - ? caller - : Option.getOrElse( - yield* snapshots - .getThreadShellById(coordinatorId) - .pipe(Effect.mapError(readFailed)), - () => caller, - ); - const model = resolveAgentModel({ - requested: input.model, - base: project.defaultModelSelection ?? coordinator.modelSelection, - providers: yield* registry.getProviders, - }); - if (model.kind === "unknown") { - return yield* new UnknownModelError({ - model: input.model ?? "", - available: model.available, + /** Where this agent runs: here, or a linked machine the settings allow. */ + const route = Effect.gen(function* () { + const named = input.machine?.trim(); + const asksForLocal = named !== undefined && named.toLowerCase() === LOCAL_AGENT_MACHINE; + // A retry, or an agent this call already made, stays where it started. + if (input.clientRequestId !== undefined) { + if (Option.isSome(yield* remote.get(threadId))) + return { kind: "remote-retry" } as const; + const local = yield* snapshots + .getThreadShellById(threadId) + .pipe(Effect.mapError(readFailed)); + if (Option.isSome(local)) return { kind: "local", note: undefined } as const; + } + if (input.standing === true) { + if (named !== undefined && !asksForLocal) { + return yield* new AgentToolFailedError({ + detail: 'A standing agent runs on this machine. Omit machine, or use "local".', }); } - // Never looser than the caller, whatever it asks for. + return { kind: "local", note: undefined } as const; + } + const plan = yield* remote + .plan({ + project: { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity, + }, + requested: named, + }) + .pipe(Effect.mapError((error) => new AgentToolFailedError({ detail: error.detail }))); + if (plan.decision.kind === "rejected") { + return yield* new AgentToolFailedError({ detail: plan.decision.detail }); + } + if (plan.decision.machineId === LOCAL_AGENT_MACHINE) { + return { kind: "local", note: plan.decision.note } as const; + } + if (plan.peerProjectId === null) { + return yield* new AgentToolFailedError({ + detail: `${plan.decision.label} has no matching Project.`, + }); + } + return { + kind: "remote", + machineId: plan.decision.machineId, + machineLabel: plan.decision.label, + peerProjectId: plan.peerProjectId, + // An explicit machine never falls back. + fallbackAllowed: named === undefined && plan.allowLocalFallback, + } as const; + }); + + const createRemote = (target: { + readonly machineId: string; + readonly machineLabel: string; + readonly peerProjectId: string; + }) => + Effect.gen(function* () { + const homeLabel = yield* remote.homeLabel; + const coordinator = yield* resolveCoordinator(manager); const runtimeMode = stricterRuntimeMode( input.runtimeMode ?? stricterRuntimeMode(coordinator.runtimeMode, caller.runtimeMode), caller.runtimeMode, ); - const createdAt = yield* nowIso; - yield* requireFreeSlot(manager, createdAt); - - // A cancelled call must not leave an agent created without its - // first message, so the three dispatches run to the end. - yield* Effect.uninterruptible( + const request = { + machineId: target.machineId, + machineLabel: target.machineLabel, + peerProjectId: target.peerProjectId, + home: { + project: { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + }, + label: homeLabel, + }, + creator: { id: caller.id, title: caller.title }, + ids, + threadId, + title: input.title, + message: input.message, + runtimeMode, + baseModel: project.defaultModelSelection ?? coordinator.modelSelection, + requestedModel: input.model, + createdAt, + }; + // Only the cap check and the record are under the lock: the peer calls are slow. + const reserved = yield* createLock.withPermits(1)( Effect.gen(function* () { - yield* dispatchCreateStep({ - type: "thread.create", - commandId: CommandId.make(ids.createCommandId), - threadId, - projectId: project.id, - title: input.title, - modelSelection: model.modelSelection, - runtimeMode, - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt, - createdByThreadId: caller.id, - }); - yield* pinAndStart({ - manager, - ids, - threadId, - pin: input.standing === true, - message: input.message, - runtimeMode, - createdAt, - }); + const found = yield* remote.get(threadId); + if (Option.isSome(found) && found.value.title !== input.title) { + return yield* new ClientRequestIdConflictError({ + clientRequestId: input.clientRequestId ?? "", + threadId, + }); + } + if (Option.isNone(found)) yield* requireFreeSlot(manager, createdAt); + return yield* remote + .reserve(request) + .pipe( + Effect.mapError((error) => new AgentToolFailedError({ detail: error.detail })), + ); }), ); - // Returns at once: the result arrives later as a message. - return { threadId, title: input.title, runtimeMode, created: true }; - }), + const record = yield* Effect.uninterruptible(remote.start(request, reserved.record)); + return { + threadId, + title: input.title, + runtimeMode: record.runtimeMode, + created: !reserved.existed, + machineLabel: record.machineLabel, + }; + }); + + const routed = yield* route; + if (routed.kind === "local") return yield* createLocal(routed.note); + if (routed.kind === "remote-retry") { + const record = yield* remote.get(threadId); + if (Option.isNone(record)) return yield* createLocal(undefined); + return yield* createRemote(record.value).pipe( + Effect.catchTag("RemoteAgentError", (error) => Effect.fail(toolFailed(error))), + ); + } + return yield* createRemote(routed).pipe( + Effect.catchTag("RemoteAgentError", (error) => + error.stage === "create" && routed.fallbackAllowed + ? createLocal( + `Could not start on ${routed.machineLabel}: ${error.detail} Started on this machine instead.`, + ) + : Effect.fail(toolFailed(error)), + ), ); }), @@ -433,7 +622,7 @@ const make = Effect.gen(function* () { (input.includeSettled === true || thread.settledAt === null), ); const canManage = yield* managesThread(manager, listed); - const agents = listed + const local = listed .filter((thread) => canManage(thread)) .map((thread) => ({ threadId: thread.id, @@ -442,14 +631,44 @@ const make = Effect.gen(function* () { phase: phaseOf(manager, thread, now), settled: thread.settledAt !== null, lastActivityAt: thread.updatedAt, + machine: null, + })); + const onMachines = (yield* remote.list(manager.project.id)) + .filter( + ({ record }) => + (input.includeSettled === true || + record.state === "open" || + record.state === "pending") && + canManageAgent(manager.project, manager.caller, remoteAsManaged(manager, record)), + ) + .map(({ record, phase }) => ({ + threadId: ThreadId.make(record.threadId), + title: record.title, + standing: false, + phase, + settled: record.state === "settled" || record.state === "lost", + lastActivityAt: record.lastActivityAt ?? record.createdAt, + machine: record.machineLabel, })); - return { agents }; + return { agents: [...local, ...onMachines] }; }), cp_agent_read: (input) => Effect.gen(function* () { const manager = yield* requireManager(); - const agent = yield* requireAgent(manager, input.agent); + const target = yield* requireTarget(manager, input.agent); + if (target.kind === "remote") { + const read = yield* remote + .read(target.record, input.turns ?? 1) + .pipe(Effect.mapError(toolFailed)); + return { + threadId: ThreadId.make(target.record.threadId), + title: target.record.title, + phase: read.phase, + turns: capAgentReadTurns(read.turns), + }; + } + const agent = target.thread; const turns = yield* turnRows .listByThreadId({ threadId: agent.id }) .pipe(Effect.mapError(readFailed)); @@ -494,7 +713,14 @@ const make = Effect.gen(function* () { cp_agent_stop: (input) => Effect.gen(function* () { const manager = yield* requireManager(); - const agent = yield* requireAgent(manager, input.agent); + const target = yield* requireTarget(manager, input.agent); + if (target.kind === "remote") { + const stopped = yield* remote + .stop(target.record, { archive: input.archive === true }) + .pipe(Effect.mapError(toolFailed)); + return { threadId: ThreadId.make(target.record.threadId), ...stopped }; + } + const agent = target.thread; const createdAt = yield* nowIso; const turns = yield* turnRows .listByThreadId({ threadId: agent.id }) diff --git a/apps/server/src/mcp/toolkits/agents/scheduleHandlers.test.ts b/apps/server/src/mcp/toolkits/agents/scheduleHandlers.test.ts index 7ccaaf0c10354..6792e803a6f08 100644 --- a/apps/server/src/mcp/toolkits/agents/scheduleHandlers.test.ts +++ b/apps/server/src/mcp/toolkits/agents/scheduleHandlers.test.ts @@ -1,3 +1,4 @@ +import { RemoteAgentsLocalOnly } from "../../../agentMachines/testFixtures.ts"; import { CommandId, EnvironmentId, @@ -51,6 +52,7 @@ const SEEDED_AT = "2026-01-01T00:00:00.000Z"; /** The real engine, projections and SQLite, so writes go through the decider as they do live. */ const TestLayer = AgentsToolkitHandlersLive.pipe( Layer.provideMerge(makeProviderRegistryLayer()), + Layer.provideMerge(RemoteAgentsLocalOnly), Layer.provideMerge( OrchestrationEngineLive.pipe( Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), diff --git a/apps/server/src/mcp/toolkits/agents/tools.ts b/apps/server/src/mcp/toolkits/agents/tools.ts index 5c74da9d36c42..ac0da83bd042f 100644 --- a/apps/server/src/mcp/toolkits/agents/tools.ts +++ b/apps/server/src/mcp/toolkits/agents/tools.ts @@ -17,6 +17,8 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts"; +import { AgentPhase } from "../../../agentMachines/agentPhase.ts"; +import * as RemoteAgents from "../../../agentMachines/RemoteAgents.ts"; // Fork-owned. Parameters stay flat primitives: OpenCode stringifies nested unions. @@ -25,6 +27,7 @@ const dependencies = [ OrchestrationEngine.OrchestrationEngineService, ProjectionSnapshotQuery.ProjectionSnapshotQuery, ProviderRegistry.ProviderRegistry, + RemoteAgents.RemoteAgents, ]; export class AgentsUnavailableError extends Schema.TaggedError()( @@ -176,17 +179,7 @@ const AgentRef = TrimmedNonEmptyString.annotate({ description: "The agent's threadId (preferred) or its exact title.", }); -export const AgentPhase = Schema.Literals([ - "idle", - "starting", - "running", - "waiting_for_approval", - "waiting_for_input", - "completed", - "failed", - "stale", -]); -export type AgentPhase = typeof AgentPhase.Type; +export { AgentPhase }; export const AgentCreateInput = Schema.Struct({ title: TrimmedNonEmptyString.annotate({ description: "A short name for the agent." }), @@ -211,6 +204,12 @@ export const AgentCreateInput = Schema.Struct({ description: "Makes a retried call return the agent it already started.", }), ), + machine: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + 'Run the agent on a linked machine (its name), or "local" for this one. Omit to follow the user\'s Agent machines setting, which also limits what you may choose. One-off agents only.', + }), + ), }); export type AgentCreateInput = typeof AgentCreateInput.Type; @@ -221,6 +220,14 @@ export const AgentCreateResult = Schema.Struct({ created: Schema.Boolean.annotate({ description: "False when clientRequestId matched an agent started earlier.", }), + machineLabel: Schema.NullOr(Schema.String).annotate({ + description: "The machine the agent runs on, or null for this one.", + }), + placement: Schema.optional( + Schema.String.annotate({ + description: "Present when the agent did not start where the settings asked, and why.", + }), + ), }); export type AgentCreateResult = typeof AgentCreateResult.Type; @@ -238,6 +245,9 @@ export const AgentListEntry = Schema.Struct({ phase: AgentPhase, settled: Schema.Boolean, lastActivityAt: Schema.String, + machine: Schema.NullOr(Schema.String).annotate({ + description: "The machine the agent runs on, or null for this one.", + }), }); export type AgentListEntry = typeof AgentListEntry.Type; @@ -379,7 +389,7 @@ export type ScheduleDeleteResult = typeof ScheduleDeleteResult.Type; const AgentCreateTool = Tool.make("cp_agent_create", { description: - "Start an agent in this Project with a first message. Its final message comes back to you, and it stays available for follow-ups. Only the coordinator settles a one-off agent with cp_agent_settle once its work is complete and no follow-ups remain.", + "Start an agent in this Project with a first message. Its final message comes back to you, and it stays available for follow-ups. Only the coordinator settles a one-off agent with cp_agent_settle once its work is complete and no follow-ups remain. It may run on another linked machine, per the user's settings.", parameters: AgentCreateInput, success: AgentCreateResult, failure: AgentToolError, diff --git a/apps/server/src/mcp/toolkits/threads/handlers.test.ts b/apps/server/src/mcp/toolkits/threads/handlers.test.ts index a5373adb64296..6282bac4b3239 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.test.ts @@ -18,6 +18,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import { RemoteAgents, type RemoteAgentsShape } from "../../../agentMachines/RemoteAgents.ts"; +import { localOnlyRemoteAgents, makeRecord } from "../../../agentMachines/testFixtures.ts"; import { AgentLineage } from "../../../orchestration/agentLineage.ts"; import { OrchestrationEngineService, @@ -150,6 +152,8 @@ const makeHarness = Effect.fn("makeThreadsToolkitHarness")(function* ( readonly projects?: ReadonlyArray; /** requestedAt of a sent message no turn has adopted yet, per thread. */ readonly pendingStarts?: ReadonlyMap; + /** Overrides for the linked-machine agents service; by default there are none. */ + readonly remote?: Partial; } = {}, ) { yield* TestClock.setTime(Date.parse(NOW)); @@ -182,6 +186,7 @@ const makeHarness = Effect.fn("makeThreadsToolkitHarness")(function* ( latestSequence: Effect.succeed(0), }), Layer.succeed(Crypto.Crypto, testCrypto), + Layer.mock(RemoteAgents)({ ...localOnlyRemoteAgents, ...options.remote }), ); const repositories = Layer.mergeAll( Layer.mock(AgentLineage)({ @@ -234,6 +239,7 @@ const projectHarness = ( options: { readonly threads?: ReadonlyArray; readonly pendingStarts?: ReadonlyMap; + readonly remote?: Partial; } = {}, ) => makeHarness({ threads: projectThreads, projects, ...options }); @@ -384,6 +390,92 @@ describe("threads toolkit handlers", () => { ); }); +describe("cp_thread_send to an agent on a linked machine", () => { + const remoteRecord = makeRecord({ + threadId: "remote-agent-1", + title: "Pricing", + homeProjectId: PROJECT_ID, + creatorThreadId: COORDINATOR, + }); + const found = (records = [remoteRecord]) => ({ + find: () => Effect.succeed(records), + }); + + it.effect("sends to the remote agent the coordinator started, without a local dispatch", () => + Effect.gen(function* () { + const sent: Array<{ text: string; replyTo: string | null }> = []; + const harness = yield* projectHarness({ + remote: { + ...found(), + send: (_record, input) => + Effect.sync(() => { + sent.push({ text: input.text, replyTo: input.replyTo }); + return { queued: true }; + }), + }, + }); + const result = yield* harness.send( + { message: "Also annual.", threadId: "remote-agent-1" }, + COORDINATOR, + ); + expect(result).toEqual({ threadId: "remote-agent-1", threadTitle: "Pricing", queued: true }); + expect(sent).toEqual([{ text: "Also annual.", replyTo: COORDINATOR }]); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("finds the remote agent by title when no local thread has it", () => + Effect.gen(function* () { + const harness = yield* projectHarness({ + remote: { ...found(), send: () => Effect.succeed({ queued: false }) }, + }); + const result = yield* harness.send({ message: "Hi", threadTitle: "Pricing" }, COORDINATOR); + expect(result).toEqual({ threadId: "remote-agent-1", threadTitle: "Pricing", queued: false }); + }), + ); + + it.effect("prefers a local thread with the same title", () => + Effect.gen(function* () { + const sentRemote: Array = []; + const harness = yield* projectHarness({ + remote: { + ...found([{ ...remoteRecord, title: "Research" }]), + send: () => Effect.sync(() => (sentRemote.push("sent"), { queued: false })), + }, + }); + const result = yield* harness.send({ message: "Hi", threadTitle: "Research" }, COORDINATOR); + expect(result.threadId).toBe(STANDING); + expect(sentRemote).toEqual([]); + }), + ); + + it.effect("reads a remote agent another manager started as not found", () => + Effect.gen(function* () { + const harness = yield* projectHarness({ remote: found() }); + const error = yield* harness + .send({ message: "Hi", threadId: "remote-agent-1" }, STANDING) + .pipe(Effect.flip); + expect(error._tag).toBe("ThreadSendTargetNotFoundError"); + }), + ); + + it.effect("turns a peer failure into a send failure", () => + Effect.gen(function* () { + const harness = yield* projectHarness({ + remote: { + ...found(), + send: () => + Effect.fail({ _tag: "RemoteAgentError", detail: "Could not reach Mac Mini" } as never), + }, + }); + const error = yield* harness + .send({ message: "Hi", threadId: "remote-agent-1" }, COORDINATOR) + .pipe(Effect.flip); + expect(error._tag).toBe("ThreadSendFailedError"); + }), + ); +}); + function assertTrue(value: unknown): asserts value is true { expect(value).toBe(true); } diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 7dd4ba54e6d5e..a5c40a60156b5 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -5,7 +5,7 @@ import { canManageAgent, type OrchestrationAgentMessageSource, type OrchestrationThreadShell, - type ThreadId, + ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { RemoteAgents } from "../../../agentMachines/RemoteAgents.ts"; import { AgentLineage } from "../../../orchestration/agentLineage.ts"; import { agentSendId } from "../../../orchestration/agentProtocol.ts"; import { isDeliveryIdle } from "../../../orchestration/agentPushes.ts"; @@ -38,6 +39,7 @@ const make = Effect.gen(function* () { const turns = yield* ProjectionTurnRepository; const lineage = yield* AgentLineage; const crypto = yield* Crypto.Crypto; + const remote = yield* RemoteAgents; const randomUuid = crypto.randomUUIDv4.pipe(Effect.orDie); const mintId = (tag: string, threadId: ThreadId) => @@ -103,6 +105,51 @@ const make = Effect.gen(function* () { }; }); + /** + * A send to an agent the sender started on a linked machine. Null when the + * target is not one of those, so the local path runs. An agent the sender + * does not manage reads as not found, as a thread in another Project would. + */ + const sendToRemoteAgent = Effect.fn("ThreadsToolkit.sendToRemoteAgent")(function* ( + input: ThreadSendInput, + sender: OrchestrationThreadShell, + localMatchExists: boolean, + ) { + const ref = input.threadId ?? input.threadTitle?.trim(); + if (ref === undefined || ref.length === 0) return null; + if (input.threadId === undefined && localMatchExists) return null; + const found = yield* remote.find({ projectId: sender.projectId, ref }); + if (found.length === 0) return null; + const project = Option.getOrUndefined( + yield* snapshots + .getProjectShellById(sender.projectId) + .pipe(Effect.mapError((cause) => new ThreadSendFailedError({ cause }))), + ); + const managed = found.filter( + (record) => + project !== undefined && + record.homeProjectId === project.id && + canManageAgent(project, sender, { + id: ThreadId.make(record.threadId), + projectId: project.id, + pinnedAt: null, + createdByThreadId: ThreadId.make(record.creatorThreadId), + }), + ); + if (managed.length === 0) return yield* new ThreadSendTargetNotFoundError({ query: ref }); + if (managed.length > 1) return yield* new ThreadSendTargetAmbiguousError({ title: ref }); + const record = managed[0]!; + const { queued } = yield* remote + .send(record, { + text: input.message, + sender: { id: sender.id, title: sender.title }, + replyTo: sender.id, + runtimeMode: record.runtimeMode, + }) + .pipe(Effect.mapError((cause) => new ThreadSendFailedError({ cause }))); + return { threadId: ThreadId.make(record.threadId), threadTitle: record.title, queued }; + }); + const toSendFailure = (effect: Effect.Effect) => effect.pipe( Effect.catchCause((cause) => @@ -124,6 +171,22 @@ const make = Effect.gen(function* () { cause: new Error("Sender thread was not found."), }); } + const localTitleMatch = + input.threadId === undefined && input.threadTitle !== undefined + ? (yield* snapshots + .getShellSnapshot() + .pipe( + Effect.mapError((cause) => new ThreadSendFailedError({ cause })), + )).threads.some( + (thread) => + thread.projectId === sender.value.projectId && + thread.title.localeCompare(input.threadTitle!.trim(), undefined, { + sensitivity: "accent", + }) === 0, + ) + : false; + const remoteSent = yield* sendToRemoteAgent(input, sender.value, localTitleMatch); + if (remoteSent !== null) return remoteSent; const target: OrchestrationThreadShell = yield* resolveTarget(input, sender.value); if (target.id === sender.value.id) { return yield* new ThreadSendSelfError({}); diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index 4d4b426d63fa7..1b012e67fac45 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -4,6 +4,7 @@ import * as Tool from "effect/unstable/ai/Tool"; import * as Toolkit from "effect/unstable/ai/Toolkit"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as RemoteAgents from "../../../agentMachines/RemoteAgents.ts"; import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -11,6 +12,7 @@ const dependencies = [ McpInvocationContext.McpInvocationContext, OrchestrationEngine.OrchestrationEngineService, ProjectionSnapshotQuery.ProjectionSnapshotQuery, + RemoteAgents.RemoteAgents, ]; export class ThreadSendTargetMissingError extends Schema.TaggedError()( diff --git a/apps/server/src/orchestration/AgentCompletionReactor.ts b/apps/server/src/orchestration/AgentCompletionReactor.ts index 47e94ae9bf395..b1d084b45290a 100644 --- a/apps/server/src/orchestration/AgentCompletionReactor.ts +++ b/apps/server/src/orchestration/AgentCompletionReactor.ts @@ -84,6 +84,12 @@ export class AgentCompletionReactor extends Context.Service< readonly start: () => Effect.Effect; /** Resolves once every event committed so far has been handled. */ readonly drain: Effect.Effect; + /** + * Queues a pass for a Project. The remote agent bridge calls this after it + * appends a result of its own, since that append is not an event the + * reactor watches. + */ + readonly enqueueProject: (projectId: ProjectId) => Effect.Effect; } >()("t3/orchestration/AgentCompletionReactor") {} @@ -492,7 +498,7 @@ export const make = Effect.gen(function* () { } }); - return { start, drain } satisfies AgentCompletionReactor["Service"]; + return { start, drain, enqueueProject: enqueue } satisfies AgentCompletionReactor["Service"]; }); const RepositoriesLive = Layer.mergeAll( diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 101af57f6b4bf..903fbafd2d401 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -105,6 +105,7 @@ describe("OrchestrationReactor", () => { return Effect.void; }, drain: Effect.void, + enqueueProject: () => Effect.void, }), ), Layer.provideMerge( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3205dfb186549..d521076aef86e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -135,6 +135,8 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; +import { AgentMachines } from "./agentMachines/AgentMachines.ts"; +import { RemoteAgents } from "./agentMachines/RemoteAgents.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; @@ -814,6 +816,8 @@ const buildAppUnderTest = (options?: { streamChanges: Stream.empty, ...options?.layers?.providerRegistry, }), + Layer.mock(AgentMachines)({}), + Layer.mock(RemoteAgents)({}), Layer.mock(ProviderService.ProviderService)({ uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), ...options?.layers?.providerService, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 87eaf415ca1fc..2938cf5458110 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -85,6 +85,10 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import { AgentMachinesLive } from "./agentMachines/AgentMachines.ts"; +import { RemoteAgentBridgeLive } from "./agentMachines/RemoteAgentBridge.ts"; +import { RemoteAgentsLive } from "./agentMachines/RemoteAgents.ts"; +import { RemoteAgentStoreLive } from "./agentMachines/RemoteAgentStore.ts"; import * as AgentCompletionReactor from "./orchestration/AgentCompletionReactor.ts"; import * as ScheduleHost from "./schedules/ScheduleHost.ts"; import * as ScheduleRunner from "./schedules/ScheduleRunner.ts"; @@ -257,6 +261,11 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(StorageCleanup.layer), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + // Agents on linked machines: the bridge needs the reactor below it. + Layer.provideMerge(RemoteAgentBridgeLive), + Layer.provideMerge(RemoteAgentsLive), + Layer.provideMerge(AgentMachinesLive.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge(RemoteAgentStoreLive), Layer.provideMerge(AgentCompletionReactor.layer), Layer.provideMerge(ScheduleService.layer), Layer.provideMerge(ScheduleHost.layer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 09c2fe1f8b3c4..5d54ffeb597c9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -151,6 +151,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; +import { AgentMachines } from "./agentMachines/AgentMachines.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -667,6 +668,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const agentMachines = yield* AgentMachines; const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; @@ -2647,6 +2649,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverAgentMachines]: (input) => + observeRpcEffect(WS_METHODS.serverAgentMachines, agentMachines.manage(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverReportClientActivity]: (input, metadata) => Ref.update(rpcClientIds, (clientIds) => { const next = new Set(clientIds); diff --git a/apps/web/src/components/settings/AgentMachinesSettings.logic.test.ts b/apps/web/src/components/settings/AgentMachinesSettings.logic.test.ts new file mode 100644 index 0000000000000..baea5687760e6 --- /dev/null +++ b/apps/web/src/components/settings/AgentMachinesSettings.logic.test.ts @@ -0,0 +1,266 @@ +import { + AgentMachinesError, + type AgentMachineStatus, + type AgentPlacementSettings, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + agentMachineEntryPatch, + agentMachineLinkInput, + agentMachineStatusPresentation, + agentMachinesErrorMessage, + balancedAgentMachineCount, + linkedAgentMachines, + projectMatchText, + singleAgentMachineOptions, + singleAgentMachineValue, + summarizeAgentPlacement, + tokenExpiryText, +} from "./AgentMachinesSettings.logic"; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.parse("2026-09-29T12:00:00.000Z"); + +function placement(overrides: Partial = {}): AgentPlacementSettings { + return { + mode: "local", + singleMachineId: null, + localPreference: 50, + allowLocalFallback: true, + machines: { + "env-mini": { + label: "Mac Mini", + baseUrl: "http://mini.tail.ts.net:3773", + enabled: true, + preference: 50, + }, + "env-box": { + label: "Build box", + baseUrl: "https://box.example.com", + enabled: true, + preference: 100, + }, + }, + ...overrides, + }; +} + +function status(overrides: Partial = {}): AgentMachineStatus { + return { + id: "env-mini", + status: "connected", + matchedProjectCount: 3, + projectCount: 4, + ...overrides, + }; +} + +describe("summarizeAgentPlacement", () => { + it("names this machine for local mode", () => { + expect(summarizeAgentPlacement(placement())).toBe("This machine"); + }); + + it("names the chosen machine for one machine", () => { + expect( + summarizeAgentPlacement(placement({ mode: "single", singleMachineId: "env-mini" })), + ).toBe("One machine: Mac Mini"); + }); + + it("reads a missing or unlinked choice as this machine", () => { + expect(summarizeAgentPlacement(placement({ mode: "single" }))).toBe("This machine"); + expect(summarizeAgentPlacement(placement({ mode: "single", singleMachineId: "gone" }))).toBe( + "This machine", + ); + }); + + it("counts machines Balance can pick", () => { + expect(summarizeAgentPlacement(placement({ mode: "balanced" }))).toBe( + "Balanced across 3 machines", + ); + expect( + summarizeAgentPlacement( + placement({ + mode: "balanced", + localPreference: 0, + machines: { + "env-mini": { + label: "Mac Mini", + baseUrl: "http://mini.tail.ts.net:3773", + enabled: true, + preference: 50, + }, + }, + }), + ), + ).toBe("Balanced across 1 machine"); + }); +}); + +describe("balancedAgentMachineCount", () => { + it("skips switched-off and Manual only machines", () => { + const settings = placement({ + localPreference: 0, + machines: { + a: { label: "A", baseUrl: "https://a", enabled: false, preference: 100 }, + b: { label: "B", baseUrl: "https://b", enabled: true, preference: 0 }, + c: { label: "C", baseUrl: "https://c", enabled: true, preference: 25 }, + }, + }); + expect(balancedAgentMachineCount(settings)).toBe(1); + }); +}); + +describe("machine options", () => { + it("lists linked machines by label", () => { + expect(linkedAgentMachines(placement()).map((machine) => machine.id)).toEqual([ + "env-box", + "env-mini", + ]); + }); + + it("offers this machine first for one machine", () => { + expect(singleAgentMachineOptions(placement())).toEqual([ + { value: "local", label: "This machine" }, + { value: "env-box", label: "Build box" }, + { value: "env-mini", label: "Mac Mini" }, + ]); + }); + + it("selects this machine when the choice is gone", () => { + expect(singleAgentMachineValue(placement({ singleMachineId: "env-box" }))).toBe("env-box"); + expect(singleAgentMachineValue(placement({ singleMachineId: "gone" }))).toBe("local"); + expect(singleAgentMachineValue(placement({ singleMachineId: null }))).toBe("local"); + }); +}); + +describe("agentMachineStatusPresentation", () => { + it("is muted before the first check", () => { + expect(agentMachineStatusPresentation(undefined, NOW)).toEqual({ + label: "Not checked", + tone: "muted", + }); + }); + + it("warns when a connected token expires within a week", () => { + expect( + agentMachineStatusPresentation( + status({ tokenExpiresAt: new Date(NOW + 20 * DAY).toISOString() }), + NOW, + ), + ).toEqual({ label: "Connected", tone: "success" }); + expect( + agentMachineStatusPresentation( + status({ tokenExpiresAt: new Date(NOW + 3 * DAY + 60_000).toISOString() }), + NOW, + ), + ).toEqual({ label: "Connected, token expires in 3 days", tone: "warning" }); + }); + + it("shows the expiry on a machine that needs re-linking", () => { + expect( + agentMachineStatusPresentation( + status({ + status: "needs-relink", + tokenExpiresAt: new Date(NOW - 2 * DAY - 60_000).toISOString(), + }), + NOW, + ), + ).toEqual({ label: "Needs re-link (token expired 2 days ago)", tone: "destructive" }); + expect(agentMachineStatusPresentation(status({ status: "needs-relink" }), NOW).label).toBe( + "Needs re-link", + ); + }); + + it("labels the remaining kinds", () => { + expect(agentMachineStatusPresentation(status({ status: "offline" }), NOW)).toEqual({ + label: "Offline", + tone: "muted", + }); + expect(agentMachineStatusPresentation(status({ status: "incompatible" }), NOW).label).toBe( + "Incompatible version", + ); + expect(agentMachineStatusPresentation(status({ status: "wrong-environment" }), NOW).label).toBe( + "Wrong environment", + ); + }); +}); + +describe("tokenExpiryText", () => { + it("handles today, one day and bad dates", () => { + expect(tokenExpiryText(new Date(NOW + 60_000).toISOString(), NOW)).toBe("token expires today"); + expect(tokenExpiryText(new Date(NOW + DAY + 60_000).toISOString(), NOW)).toBe( + "token expires in 1 day", + ); + expect(tokenExpiryText("not a date", NOW)).toBeNull(); + expect(tokenExpiryText(undefined, NOW)).toBeNull(); + }); +}); + +describe("projectMatchText", () => { + it("counts matching Projects", () => { + expect(projectMatchText(status())).toBe("Matches 3 of 4 Projects"); + expect(projectMatchText(status({ matchedProjectCount: 0, projectCount: 1 }))).toBe( + "Matches 0 of 1 Project", + ); + }); + + it("is empty with nothing to match", () => { + expect(projectMatchText(status({ projectCount: 0, matchedProjectCount: 0 }))).toBeNull(); + expect(projectMatchText(undefined)).toBeNull(); + }); +}); + +describe("agentMachineEntryPatch", () => { + it("sends the whole entry with the change applied", () => { + expect(agentMachineEntryPatch(placement(), "env-mini", { enabled: false })).toEqual({ + machines: { + "env-mini": { + label: "Mac Mini", + baseUrl: "http://mini.tail.ts.net:3773", + enabled: false, + preference: 50, + }, + }, + }); + expect(agentMachineEntryPatch(placement(), "env-box", { preference: 0 })).toEqual({ + machines: { + "env-box": { + label: "Build box", + baseUrl: "https://box.example.com", + enabled: true, + preference: 0, + }, + }, + }); + }); + + it("never writes back an unlinked machine", () => { + expect(agentMachineEntryPatch(placement(), "gone", { enabled: true })).toBeNull(); + }); +}); + +describe("agentMachineLinkInput", () => { + it("trims the link and drops a blank address", () => { + expect(agentMachineLinkInput(" https://mini/pair#token=abc ", " ")).toEqual({ + action: "link", + pairingUrl: "https://mini/pair#token=abc", + }); + expect(agentMachineLinkInput("https://mini/pair", " http://100.64.0.2:3773 ")).toEqual({ + action: "link", + pairingUrl: "https://mini/pair", + baseUrl: "http://100.64.0.2:3773", + }); + }); +}); + +describe("agentMachinesErrorMessage", () => { + it("prefers the server's detail", () => { + expect(agentMachinesErrorMessage(new AgentMachinesError({ detail: "Link expired" }))).toBe( + "Link expired", + ); + expect(agentMachinesErrorMessage(new Error("socket closed"))).toBe("socket closed"); + expect(agentMachinesErrorMessage({ message: "Forbidden" })).toBe("Forbidden"); + expect(agentMachinesErrorMessage(null)).toMatch(/did not answer/); + }); +}); diff --git a/apps/web/src/components/settings/AgentMachinesSettings.logic.ts b/apps/web/src/components/settings/AgentMachinesSettings.logic.ts new file mode 100644 index 0000000000000..9cbdcd1973f22 --- /dev/null +++ b/apps/web/src/components/settings/AgentMachinesSettings.logic.ts @@ -0,0 +1,235 @@ +import { + AgentMachinesError, + AgentPlacementMode as AgentPlacementModeSchema, + LOCAL_AGENT_MACHINE, + type AgentMachineConfig, + type AgentMachinePreference, + type AgentMachineStatus, + type AgentMachinesInput, + type AgentPlacementMode, + type AgentPlacementSettings, + type AgentPlacementSettingsPatch, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const isAgentPlacementMode = Schema.is(AgentPlacementModeSchema); + +export const agentPlacementModeOptions: ReadonlyArray<{ + readonly value: AgentPlacementMode; + readonly label: string; +}> = [ + { value: "local", label: "This machine" }, + { value: "single", label: "One machine" }, + { value: "balanced", label: "Balance across machines" }, +]; + +export interface LinkedAgentMachine extends AgentMachineConfig { + readonly id: string; +} + +/** Linked machines in label order, so rows never reorder when one is toggled. */ +export function linkedAgentMachines( + placement: AgentPlacementSettings, +): ReadonlyArray { + return Object.entries(placement.machines) + .map(([id, config]) => ({ id, ...config })) + .toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); +} + +/** + * The machine "One machine" points at. Null, `local` and a machine that has + * since been unlinked all read as this machine, like the server's placement. + */ +export function singleAgentMachineValue(placement: AgentPlacementSettings): string { + const id = placement.singleMachineId; + return id !== null && id in placement.machines ? id : LOCAL_AGENT_MACHINE; +} + +export function singleAgentMachineOptions( + placement: AgentPlacementSettings, +): ReadonlyArray<{ readonly value: string; readonly label: string }> { + return [ + { value: LOCAL_AGENT_MACHINE, label: "This machine" }, + ...linkedAgentMachines(placement).map((machine) => ({ + value: machine.id, + label: machine.label, + })), + ]; +} + +/** + * How many machines Balance can pick without being named: this machine and + * every switched-on linked machine, minus those set to Manual only. + */ +export function balancedAgentMachineCount(placement: AgentPlacementSettings): number { + const linked = Object.values(placement.machines).filter( + (machine) => machine.enabled && machine.preference > 0, + ).length; + return linked + (placement.localPreference > 0 ? 1 : 0); +} + +/** Closed-header summary of where new agents run. */ +export function summarizeAgentPlacement(placement: AgentPlacementSettings): string { + switch (placement.mode) { + case "local": + return "This machine"; + case "single": { + const id = singleAgentMachineValue(placement); + return id === LOCAL_AGENT_MACHINE + ? "This machine" + : `One machine: ${placement.machines[id]!.label}`; + } + case "balanced": { + const count = balancedAgentMachineCount(placement); + return `Balanced across ${count} ${count === 1 ? "machine" : "machines"}`; + } + } +} + +export type AgentMachineStatusTone = "success" | "warning" | "destructive" | "muted"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "expires in 5 days", "expired 2 days ago". Null when the date is missing or unreadable. */ +export function tokenExpiryText(tokenExpiresAt: string | undefined, now: number): string | null { + if (tokenExpiresAt === undefined) return null; + const expiresAt = Date.parse(tokenExpiresAt); + if (Number.isNaN(expiresAt)) return null; + const remaining = expiresAt - now; + const days = Math.floor(Math.abs(remaining) / DAY_MS); + const span = days === 1 ? "1 day" : `${days} days`; + if (remaining >= 0) return days === 0 ? "token expires today" : `token expires in ${span}`; + return days === 0 ? "token expired today" : `token expired ${span} ago`; +} + +/** Tokens last 30 days and are not refreshed, so warn a week ahead. */ +const EXPIRY_WARNING_MS = 7 * DAY_MS; + +export function agentMachineStatusPresentation( + status: AgentMachineStatus | undefined, + now: number, +): { readonly label: string; readonly tone: AgentMachineStatusTone } { + if (status === undefined) return { label: "Not checked", tone: "muted" }; + const expiry = tokenExpiryText(status.tokenExpiresAt, now); + switch (status.status) { + case "connected": { + const expiresAt = + status.tokenExpiresAt === undefined ? Number.NaN : Date.parse(status.tokenExpiresAt); + const expiringSoon = !Number.isNaN(expiresAt) && expiresAt - now <= EXPIRY_WARNING_MS; + return expiringSoon && expiry !== null + ? { label: `Connected, ${expiry}`, tone: "warning" } + : { label: "Connected", tone: "success" }; + } + case "offline": + return { label: "Offline", tone: "muted" }; + case "needs-relink": + return { + label: expiry === null ? "Needs re-link" : `Needs re-link (${expiry})`, + tone: "destructive", + }; + case "incompatible": + return { label: "Incompatible version", tone: "destructive" }; + case "wrong-environment": + return { label: "Wrong environment", tone: "destructive" }; + } +} + +export function agentMachineStatusDotClassName(tone: AgentMachineStatusTone): string { + switch (tone) { + case "success": + return "bg-success"; + case "warning": + return "bg-warning"; + case "destructive": + return "bg-destructive"; + case "muted": + return "bg-muted-foreground/40"; + } +} + +/** "Matches 3 of 4 Projects". Null when this machine has no Projects to match. */ +export function projectMatchText(status: AgentMachineStatus | undefined): string | null { + if (status === undefined || status.projectCount === 0) return null; + const noun = status.projectCount === 1 ? "Project" : "Projects"; + return `Matches ${status.matchedProjectCount} of ${status.projectCount} ${noun}`; +} + +export function agentMachineStatusesById( + statuses: ReadonlyArray, +): ReadonlyMap { + return new Map(statuses.map((status) => [status.id, status])); +} + +// ── Patches ───────────────────────────────────────────────────────── + +export function agentPlacementModePatch(mode: AgentPlacementMode): AgentPlacementSettingsPatch { + return { mode }; +} + +export function singleAgentMachinePatch(id: string): AgentPlacementSettingsPatch { + return { singleMachineId: id }; +} + +export function localPreferencePatch( + localPreference: AgentMachinePreference, +): AgentPlacementSettingsPatch { + return { localPreference }; +} + +export function localFallbackPatch(allowLocalFallback: boolean): AgentPlacementSettingsPatch { + return { allowLocalFallback }; +} + +/** + * The server merges `machines` per entry and replaces the entry it names, so + * the patch carries the whole entry. Null when the machine is no longer linked, + * because writing it back would resurrect an entry without a token. + */ +export function agentMachineEntryPatch( + placement: AgentPlacementSettings, + id: string, + changes: Partial>, +): AgentPlacementSettingsPatch | null { + const current = placement.machines[id]; + if (current === undefined) return null; + return { + machines: { + [id]: { + label: current.label, + baseUrl: current.baseUrl, + enabled: changes.enabled ?? current.enabled, + preference: changes.preference ?? current.preference, + }, + }, + }; +} + +// ── RPC ───────────────────────────────────────────────────────────── + +/** A link request; a blank address override is left out so the link's own address is used. */ +export function agentMachineLinkInput(pairingUrl: string, baseUrl: string): AgentMachinesInput { + const override = baseUrl.trim(); + return { + action: "link", + pairingUrl: pairingUrl.trim(), + ...(override.length > 0 ? { baseUrl: override } : {}), + }; +} + +const isAgentMachinesError = Schema.is(AgentMachinesError); + +/** Toast text for a failed agent machines call. The server's detail wins when it sent one. */ +export function agentMachinesErrorMessage(error: unknown): string { + if (isAgentMachinesError(error)) return error.detail; + if (error instanceof Error && error.message.trim().length > 0) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message; + } + return "The server did not answer. Check that it is up to date and try again."; +} diff --git a/apps/web/src/components/settings/AgentMachinesSettings.tsx b/apps/web/src/components/settings/AgentMachinesSettings.tsx new file mode 100644 index 0000000000000..d2610c22e916c --- /dev/null +++ b/apps/web/src/components/settings/AgentMachinesSettings.tsx @@ -0,0 +1,591 @@ +import { + type AgentMachinePreference, + type AgentMachineStatus, + type AgentPlacementSettings, + type AgentPlacementSettingsPatch, + type EnvironmentId, + type EnvironmentMachineKind, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { MoreVertical, PlusIcon } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; + +import { useUpdateEnvironmentSettings } from "~/hooks/useSettings"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironment, +} from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { + agentMachineEntryPatch, + agentMachineLinkInput, + agentMachineStatusDotClassName, + agentMachineStatusPresentation, + agentMachinesErrorMessage, + agentMachineStatusesById, + agentPlacementModeOptions, + isAgentPlacementMode, + linkedAgentMachines, + projectMatchText, + singleAgentMachineOptions, + singleAgentMachineValue, + summarizeAgentPlacement, +} from "./AgentMachinesSettings.logic"; +import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { loadPreferences } from "./LoadBalancingSettings"; +import { useOptionalSettingsScope } from "./SettingsScopeContext"; +import { searchableSetting } from "./settingsSearch"; + +/** + * Folded section under Load balancing: where coordinators on the environment + * being viewed start new agents, and the machines linked to it. Unlike Load + * balancing this is a server setting, so it edits that environment only and + * is hidden until the environment is connected. + */ +export function AgentMachinesSettings() { + const scope = useOptionalSettingsScope(); + const primaryEnvironment = usePrimaryEnvironment(); + const environment = scope ? scope.environment : primaryEnvironment; + if ( + environment === null || + environment.connection.phase !== "connected" || + environment.serverConfig === null + ) { + return null; + } + return ( + + ); +} + +function AgentMachinesSection({ + environment, + placement, +}: { + readonly environment: EnvironmentPresentation; + readonly placement: AgentPlacementSettings; +}) { + const updateSettings = useUpdateEnvironmentSettings(environment.environmentId); + const update = useCallback( + (patch: AgentPlacementSettingsPatch | null) => { + if (patch !== null) updateSettings({ agentPlacement: patch }); + }, + [updateSettings], + ); + const { id, title } = searchableSetting("agent-machines"); + return ( + + + + ); +} + +export interface AgentMachinesCheckState { + readonly phase: "checking" | "done" | "failed"; + readonly statuses: ReadonlyMap; + /** When `statuses` arrived; token expiry is measured from here, not from render. */ + readonly checkedAt: number; + readonly error?: string; +} + +function checkedState(statuses: ReadonlyArray): AgentMachinesCheckState { + return { phase: "done", statuses: agentMachineStatusesById(statuses), checkedAt: Date.now() }; +} + +/** Mounts when the fold opens, so each opening checks the machines once. */ +function AgentMachinesBody({ + environment, + placement, + update, +}: { + readonly environment: EnvironmentPresentation; + readonly placement: AgentPlacementSettings; + readonly update: (patch: AgentPlacementSettingsPatch | null) => void; +}) { + const environmentId = environment.environmentId; + const agentMachines = useAtomCommand(serverEnvironment.agentMachines, { reportFailure: false }); + const { environments } = useEnvironments(); + const [check, setCheck] = useState({ + phase: "checking", + statuses: new Map(), + checkedAt: 0, + }); + const [addOpen, setAddOpen] = useState(false); + const [unlinking, setUnlinking] = useState(null); + + // Starts in "checking"; the caller sets that phase for a manual refresh. + const requestCheck = useCallback(async () => { + const result = await agentMachines({ environmentId, input: { action: "check" } }); + if (result._tag === "Success") { + setCheck(checkedState(result.value.machines)); + } else if (!isAtomCommandInterrupted(result)) { + setCheck((previous) => ({ + ...previous, + phase: "failed", + error: agentMachinesErrorMessage(squashAtomCommandFailure(result)), + })); + } + }, [agentMachines, environmentId]); + + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- The check RPC is the external system; its state lands only after the await. + void requestCheck(); + }, [requestCheck]); + + const refresh = () => { + setCheck((previous) => ({ ...previous, phase: "checking" })); + void requestCheck(); + }; + + const unlink = async (machineId: string, label: string) => { + setUnlinking(machineId); + const result = await agentMachines({ + environmentId, + input: { action: "unlink", id: machineId }, + }); + setUnlinking(null); + if (result._tag === "Success") { + setCheck(checkedState(result.value.machines)); + toastManager.add({ + type: "success", + title: `Unlinked ${label}`, + description: `To revoke its access fully, remove ${environment.label} from the clients list in Settings → Connections on ${label}.`, + }); + } else if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: `Could not unlink ${label}`, + description: agentMachinesErrorMessage(squashAtomCommandFailure(result)), + }); + } + }; + + return ( + <> + + resolveEnvironmentMachineKind( + environments.find((candidate) => candidate.environmentId === machineId)?.serverConfig ?? + null, + ) + } + placement={placement} + check={check} + unlinking={unlinking} + update={update} + onRefresh={refresh} + onAdd={() => setAddOpen(true)} + onUnlink={(machineId, label) => void unlink(machineId, label)} + /> + setCheck(checkedState(statuses))} + /> + + ); +} + +/** + * The open section's rows, from props only so it can be rendered against a + * fixture. `AgentMachinesBody` owns the RPC state. + */ +export function AgentMachinesView({ + environmentLabel, + localKind, + localSubtitle, + machineKind, + placement, + check, + unlinking, + update, + onRefresh, + onAdd, + onUnlink, +}: { + readonly environmentLabel: string; + readonly localKind: EnvironmentMachineKind; + readonly localSubtitle: ReactNode; + readonly machineKind: (machineId: string) => EnvironmentMachineKind; + readonly placement: AgentPlacementSettings; + readonly check: AgentMachinesCheckState; + readonly unlinking: string | null; + readonly update: (patch: AgentPlacementSettingsPatch | null) => void; + readonly onRefresh: () => void; + readonly onAdd: () => void; + readonly onUnlink: (machineId: string, label: string) => void; +}) { + const balanced = placement.mode === "balanced"; + const machines = linkedAgentMachines(placement); + const checking = check.phase === "checking"; + + return ( + <> +

+ {check.phase === "failed" ? ( +

+ Could not check machines: {check.error} +

+ ) : null} + + + + {placement.mode === "single" ? ( + + + + ) : null} + + update({ allowLocalFallback })} + /> + + + update({ localPreference })} + /> + {/* Keeps the preference column aligned with the linked rows' menu button. */} + + + {machines.map((machine) => { + const status = check.statuses.get(machine.id); + const presentation = checking + ? { label: "Checking…", tone: "muted" as const } + : agentMachineStatusPresentation(status, check.checkedAt); + const matches = projectMatchText(status); + return ( + + + + {presentation.label} + {matches ? ` · ${matches}` : ""} + + + } + below={ + status?.detail && status.status !== "connected" ? ( +

{status.detail}

+ ) : null + } + > + + update(agentMachineEntryPatch(placement, machine.id, { enabled })) + } + /> + + update(agentMachineEntryPatch(placement, machine.id, { preference })) + } + /> + + + } + > + + + + onUnlink(machine.id, machine.label)}> + Unlink + + + +
+ ); + })} + {machines.length === 0 ? ( +

+ No linked machines. Add one to start agents on it. +

+ ) : null} + + ); +} + +function SettingLine({ + label, + description, + children, +}: { + readonly label: string; + readonly description?: string; + readonly children: ReactNode; +}) { + return ( +
+
+

{label}

+ {description ?

{description}

: null} +
+
{children}
+
+ ); +} + +function PreferenceSelect({ + label, + value, + disabled, + onChange, +}: { + readonly label: string; + readonly value: AgentMachinePreference; + readonly disabled: boolean; + readonly onChange: (value: AgentMachinePreference) => void; +}) { + return ( + + ); +} + +function AddAgentMachineDialog({ + open, + onOpenChange, + environmentId, + environmentLabel, + onLinked, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly onLinked: (statuses: ReadonlyArray) => void; +}) { + const agentMachines = useAtomCommand(serverEnvironment.agentMachines, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [busy, setBusy] = useState(false); + const canSubmit = pairingUrl.trim().length > 0 && !busy; + + const close = () => { + setPairingUrl(""); + setBaseUrl(""); + onOpenChange(false); + }; + + const submit = async () => { + if (!canSubmit) return; + setBusy(true); + const result = await agentMachines({ + environmentId, + input: agentMachineLinkInput(pairingUrl, baseUrl), + }); + setBusy(false); + if (result._tag === "Success") { + onLinked(result.value.machines); + toastManager.add({ + type: "success", + title: result.value.linked ? `Linked ${result.value.linked.label}` : "Machine linked", + description: `Coordinators on ${environmentLabel} can now start agents there.`, + }); + close(); + } else if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Could not link machine", + description: agentMachinesErrorMessage(squashAtomCommandFailure(result)), + }); + } + }; + + return ( + { + if (!next) close(); + else onOpenChange(true); + }} + > + + + Add a machine for agents + + On the other machine, create a pairing link in Settings → Connections, or run{" "} + t3 pair --tailscale, and paste it here. + + + +
{ + event.preventDefault(); + void submit(); + }} + > +
+ + setPairingUrl(event.target.value)} + autoFocus + /> +
+
+ + setBaseUrl(event.target.value)} + /> +
+

+ Linking lets {environmentLabel} run agents on that machine with its full access. + Access lasts 30 days, then the machine needs linking again. +

+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 171a6659eff88..8463699a4cf4a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -76,6 +76,7 @@ import { formatDesktopSshTarget, } from "./EnvironmentRow"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { AgentMachinesSettings } from "./AgentMachinesSettings"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; import { Input } from "../ui/input"; @@ -3784,6 +3785,7 @@ export function ConnectionsSettings() { /> + ); diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index 005ee99fc8911..f54fbaa40ef21 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -12,12 +12,14 @@ import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { searchableSetting } from "./settingsSearch"; -const preferences = [ +/** Shared with Agent machines, whose server-side preferences use the same four values. */ +export const loadPreferences = [ { value: 100, label: "Prefer" }, { value: 50, label: "Normal" }, { value: 25, label: "Less often" }, { value: 0, label: "Manual only" }, ] as const; +const preferences = loadPreferences; type LoadPreference = (typeof preferences)[number]["value"]; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 35874c37a2bd3..cf0819bb5bfa1 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -798,6 +798,14 @@ export const SETTINGS_SEARCH_ITEMS = [ "automatic machine environment resources cpu memory capacity preference weight shared workspaces projects", ], }, + { + id: "agent-machines", + title: "Agent machines", + to: "/settings/connections", + searchTerms: [ + "coordinator agents other machine remote link pair mac mini balance placement fallback where new agents run", + ], + }, { id: "github-routing", title: "GitHub sharing", diff --git a/docs/internals/multi-machine-agents.md b/docs/internals/multi-machine-agents.md new file mode 100644 index 0000000000000..ca11373ca0296 --- /dev/null +++ b/docs/internals/multi-machine-agents.md @@ -0,0 +1,266 @@ +# Multi-machine agents + +A coordinator (or a standing agent) can start one-off agents on another machine linked to its own +server, such as a Mac Mini on the same tailnet. Where a new agent runs follows a server-owned +setting. Code: `apps/server/src/agentMachines/` and `apps/server/src/mcp/toolkits/agents/`. + +## What exists today + +- **One server, one event log.** A Project and its threads belong to one environment + ([remote](./remote.md)). Servers never call each other; only clients hold connections to several. + So `cp_agent_create` can only dispatch to the local engine (`handlers.ts`). +- **Client load balancing is invisible to the server.** `loadBalancingEnabled` and + `loadBalancingWeights` are client settings that pick an environment for a composer draft + (`ChatView.tsx`, `load-balancing.ts`). An MCP call has no client, so agent placement needs + server-owned settings. +- **Every server already exposes an authenticated HTTP surface:** `GET /api/orchestration/shell`, + `GET /api/orchestration/threads/:id`, `POST /api/orchestration/dispatch` + (`orchestration/http.ts`), scoped `orchestration:read` and `orchestration:operate`. The `t3 +project` CLI already drives a server this way. +- **Results are a local ledger.** `AgentCompletionReactor` reads finished requests from the local + projection, appends `cp-push::` into the recipient, and starts it when idle. The + per-Project cap (`AGENT_RUNNING_CAP`, 10) counts local threads only. +- **T3 Connect is off** (`forkFeatures.ts`); machines reach each other over Tailscale and pairing + links. + +## Model + +The coordinator's server (the **home**) is an HTTP client of a **peer** server. A remote agent is +a normal thread in a matching Project on the peer, so it runs and shows in that machine's UI like +any thread. The home keeps a **remote agent registry** so it can list, read, stop, message and +collect results. Nothing is added to the peer, so any peer that serves the three routes works. + +## Settings + +Server-owned, on `ServerSettings.agentPlacement` (schema in a fork-owned contracts module so the +upstream settings file gains one line). Defaults keep today's behaviour. + +```ts +agentPlacement: { + mode: "local" | "single" | "balanced"; // default "local" + singleMachineId: string | null; // "single": a machine id, or "local" + localPreference: 0 | 25 | 50 | 100; // this machine's weight in "balanced" + allowLocalFallback: boolean; // default true + machines: Record< + string, + { + // keyed by the peer's environmentId + label: string; + baseUrl: string; // https, or http to a loopback/private/tailnet host + enabled: boolean; + preference: 0 | 25 | 50 | 100; + } + >; +} +``` + +- **`machines` is a per-entry map in the patch,** like `usageLimitSources`: `null` removes an + entry. A whole array would be replaced by a patch and race with link and unlink. The token is + bound to the machine id, and it is sent only after the peer's descriptor proves that id, so a + patched `baseUrl` cannot redirect it to another server. +- **Preferences reuse the existing vocabulary:** Prefer 100, Normal 50, Less often 25, Manual only 0. Manual only excludes a machine from automatic choice; it can still be named explicitly. +- **The bearer token is never in settings.** It lives in `ServerSecretStore` under + `agent-machine-token-` (the id comes from the peer, so it is encoded like + `usageLimitSourceSecretName`) and is never returned over RPC. Unlink and failed link delete it. +- **`agentMachines` RPC** (`link`, `unlink`, `check`; scope `orchestration:operate`). `enabled` + and `preference` are ordinary settings patches. + `link` takes a pairing link and an optional `baseUrl` override (the link's address may not be + reachable from the home). It exchanges the credential at `/oauth/token` with + `scope=orchestration:read orchestration:operate` and a `client_label` naming the home, reads + `/.well-known/t3/environment` for the id, label and versions, refuses a peer whose + `orchestrationProtocolVersion` differs, stores the token and adds the entry (keeping an existing + entry's `enabled`, `preference` and name). `unlink` removes the entry and token and marks that + machine's agents lost. `check` probes each machine and returns status, token expiry and, per + local Project, whether the peer has a matching Project. +- **The client Load balancing section is untouched.** It still governs composer drafts. + +**UI.** A folded **Agent machines** section under Settings > Connections, built from +`FoldedSettingsSection`, `EnvironmentRow` and the preference `Select` used by Load balancing. +"Where new agents run" (This machine, One machine, Balance across machines), a machine picker for +One machine, a row per machine (this machine plus linked ones) with status, enabled switch and +preference, a fallback switch, and "Add machine" (pairing link). It edits the environment being +viewed, like Device hosts. + +## Placement + +Pure `placeAgent` in `agentMachines/placement.ts`. Inputs: settings, optional explicit `machine`, +per-candidate `{ reachable, load, projectMatch }`. + +1. **Candidates:** this machine plus enabled linked machines. +2. **Eligible:** reachable, matching Project present (this machine always), and in `balanced` + preference above 0 unless named explicitly. +3. **`local`:** this machine. **`single`:** the chosen machine if eligible. +4. **`balanced`:** minimize `(load + 1) / weight`. Ties: lower load, higher weight, this machine, + then id. The order is total, so placement is deterministic. +5. **Nothing eligible:** fall back to this machine when `allowLocalFallback` and no explicit + `machine` was given (the result carries a `placement` note); otherwise fail with each machine's + reason. An explicit `machine` never falls back. + +**Load** is running agents on the whole machine (`isAgentCountedRunning` over every non-archived +thread in the peer's shell snapshot) plus this home's placements from the last 2 minutes that the +snapshot does not yet show running, which spreads a burst of creates. Peer timestamps are +compared against the peer's own `snapshot` time, never the home clock, so skew does not matter. +Shell snapshots are cached 5 seconds per machine. **Cap:** one Project cap across machines: local +running agents in the Project plus remote agents whose polled phase is busy (or in the 2-minute +start window) stay under `AGENT_RUNNING_CAP`. **Offline:** probes time out at 3 seconds; a failed +probe makes the machine ineligible for 30 seconds and shows offline in settings. + +Probing and placement run **outside** `createLock`, so a slow peer never blocks local creates. +Only the cap check and the registry write are inside it. + +## The remote agent's Project, folder and context + +The peer must already have the Project. Match by the same `repositoryIdentity.canonicalKey` when +the home Project has one, else the same title and folder basename. Zero or several matches make +the machine ineligible for that Project, with the reason in `check`. Nothing is created remotely. + +The agent runs in the peer's folder and reads that machine's `AGENTS.md` and `CLAUDE.md`. Copies +can drift; the first message says which machine it is on and that edits stay there. Not built: +creating the Project on the peer and copying `AGENTS.md`, pushing a branch, a shared filesystem. + +The peer thread starts Local with the peer Project's default model, else the home's selection; an +explicit `model` passes through. The home cannot list a peer's providers over HTTP, so an +unavailable model surfaces as a failed remote turn (see results). Because the peer's own +assistant rules apply, the create sends **no `createdByThreadId` and no `replyTo`** (both are +rejected on the peer for a thread the peer does not know), and the peer Project's coordinator, if +any, will see the thread as one of its agents and count it toward the peer's cap. Message ids sent +to the peer use a `cp-remote:` prefix, never `cp-send:`, `cp-push:` or `cp-schedule:`, so the +peer's own reactor never mistakes them for deliveries. + +## Tool API + +- `cp_agent_create` gains `machine?: string` (id, label or `"local"`). Bounded by settings: + rejected when mode is `local`; in `single` only the chosen machine or `"local"`; in `balanced` + any enabled, reachable, matched machine including Manual only ones. +- Result gains `machineLabel` (null for this machine) and `placement` (a note only when a fallback + happened). +- `standing: true` stays on this machine; asking for a remote standing agent is an error. +- `cp_agent_list` entries gain `machine` (label or null). +- `cp_agent_read`, `cp_agent_stop`, `cp_thread_send` resolve a threadId or exact title across + local and remote agents the caller manages; their parameters do not change. +- **Ownership** of a remote agent comes from the registry, as a managed-agent shape + `{ id, projectId: home Project, pinnedAt: null, createdByThreadId: registry creator }` fed to the + existing `canManageAgent`, because `lineage.creatorOf` reads the local event log. +- **Runtime mode** is `stricterRuntimeMode(...)` exactly as today, computed before anything leaves + the home. + +## Results, read, stop, send + +**Client allowlist.** The home's peer client can dispatch only `thread.create`, +`thread.turn.start`, `thread.turn.interrupt`, `thread.session.stop`, `thread.archive` and +`thread.settle`. Peer dispatch errors are opaque (every failure is a 500), so any non-2xx or +timeout is treated as unknown and resolved by reading the thread; retries reuse the same command +ids, which the peer dedupes. + +**RemoteAgentBridge.** One poll per machine with open records (every 3 seconds, jittered), reading +the shell snapshot, which carries each thread's `latestTurn`, `session`, pending flags and +`latestUserMessageAt`. Thread detail is fetched only when a record's state changes or for a read. + +- **A request is done** (`judgeRequest`) when the session is not busy and either + (a) the peer's `latestTurn` differs from the `baselineTurnId` recorded before the send and is + terminal (the peer stores its own message times and does not say which message started a turn, + so the turn is matched by identity, not by time); or + (c) the session is `error`, or `stopped` for more than 15 seconds, with no newer turn. Case (c) + reads the peer thread for its `provider.turn.start.failed` activity (matched on the request id) + to report why, so a start failure such as an unavailable model comes back as a failed result. +- **Delivery.** The bridge appends `cp-push::` into the recipient with + `formatAgentResult` and no `replyTo`; the command id is the message id, so receipts make it + idempotent across restarts. Routing copies the reactor: the recipient if it is live in the same + Project and may manage the agent, else the coordinator, and it holds while the Project's + assistant is archived or unmarked or the recipient is archived. Then the bridge calls + `AgentCompletionReactor.enqueue(projectId)`; without it an idle recipient would never start, + because the reactor enqueues only for `cp-send:` and schedule appends. The peer thread is settled + only when it is a one-off and the request is still its latest. +- **Read.** Maps the peer's messages and `latestTurn` to `AgentReadResult` with the same caps. + Older turns have no state over HTTP, so their state is approximated as `completed`. +- **Stop.** Dispatches interrupt, session stop and optionally archive on the peer and marks the + request suppressed so its result is not pushed. +- **Send.** An idle remote agent gets `thread.turn.start`; a busy one queues in the registry and + the bridge starts it when the peer goes idle (`queued: true`, same meaning as today). +- **Registry** `remote-agents.json` in the state dir, atomic writes: machine id, peer thread and + Project ids, creator, title, state (`pending`, `open`, `settled`, `lost`), request in flight, + queued sends, last phase. Create order: look up the registry by the derived thread id first + (before the local thread check and before placement, so a retry cannot land on another machine), + place, write `pending`, dispatch, then `open`. A failed peer create removes the `pending` record. + Records are pruned 7 days after settling. + +## Failure modes + +| Failure | Behaviour | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Peer offline at create | Ineligible; fall back or fail per setting. No record left. | +| Peer offline mid-run | Agent keeps running there; phase `stale`; polling backs off; results arrive on return. | +| Peer gone 24 hours | Record `lost`; one failed result pushed under its own id, so a real result that arrives later is still delivered. | +| Home restarts | Registry and push receipts recompute what is owed. | +| 401 or 403 (tokens last 30 days, no refresh) | Machine marked needs re-link and ineligible; the bridge holds and pushes nothing; resumes after re-link. Settings shows the expiry. | +| Create partly applied | Retry with the same `clientRequestId` finishes it via the registry. | +| Peer thread deleted or archived | Record `lost`; failed result pushed once. | +| Model unavailable on the peer | Case (b) or (c) delivers a failed result; the coordinator can retry with `machine: "local"`. | +| Peer protocol version differs | Link refused; a later mismatch marks the machine ineligible. | +| Unlink with agents running | Allowed; those records become `lost` and are reported. | + +## Security + +- **Linking is trust.** `orchestration:operate` lets the home run any client command on the peer, + including code execution in `full-access`. Link only machines you would let the coordinator's + machine act on. The runtime-mode rule constrains the MCP path only. +- **Allowlist.** The peer client refuses any command outside the six above, so a bug or a + prompt-injected tool call cannot reach `project.delete` or `thread.delete`. +- **Least privilege token.** Read and operate only; never `access:write` or relay scopes. A + session cannot revoke itself, so unlink deletes the local token and tells you to revoke it from + the peer's client list (the exchange sets `client_label` so it is recognizable). +- **Endpoint validation.** https, or http to a loopback, private-range or tailnet host + (`isPrivateNetworkHost` covers `100.64/10` and `.ts.net`). Redirects are not followed, responses + are size-bounded, and the descriptor's environment id must match on every probe. +- **Trust in results** matches a local agent's: a labelled agent message, never a user instruction. + +## Test plan + +- `placement.test.ts`: modes, weights, ties, Manual only, explicit override bounds, fallback, + offline, missing or ambiguous Project, in-flight load term, skew. +- `projectMatch.test.ts`, `RemoteAgentStore.test.ts` (atomic write, prune, pending cleanup, + retry finds record), `AgentMachineClient.test.ts` (exchange, scope, id pinning, 401, timeout, + redirects, endpoint validation, allowlist, secret name encoding). +- `RemoteAgentBridge.test.ts`: done rules (a) to (c), idempotence across restart, routing and + archived hold, reactor enqueue, suppress on stop, queued send, 401 hold, lost peer. +- `handlers.test.ts`: local and remote create, registry-first retry, cap across machines, + standing stays local, runtime mode never looser, list, read, stop and send on remote agents. +- Settings schema and patch tests; web logic tests for the section; visual check of the UI. + +## Decisions for Nick + +1. **This machine is a candidate in Balance** with its own preference. Manual only makes it + coordination-only. +2. **One shared cap** of 10 per Project across machines, not per machine. +3. **Fallback to this machine** is on by default when the chosen machine is unavailable, but never + for an explicit `machine`. Turn it off to fail instead. +4. **Projects are not auto-created on the peer.** A machine without a matching Project is skipped. +5. **Standing agents stay local.** Remote agents are one-off only. +6. **Remote agents show in the peer's sidebar, not the coordinator's Project.** They appear in + `cp_agent_list` and in your client through the peer environment. A local mirror row is a + follow-up. +7. **Load is running agents on the machine,** not CPU or memory. The host-resource sampler is + RPC-only and would need a WebSocket peer client. +8. **Server-owned settings, separate from the client Load balancing switch.** They do not sync. +9. **Older servers show a failed check** in the Agent machines section instead of hiding it: the + client fills in the settings default, so it cannot tell the server lacks the feature. Hiding it + needs a capability flag on the environment descriptor (follow-up). +10. **Linking grants operate scope,** not read-only, and tokens expire after 30 days and need + re-linking. Refresh is a follow-up. + +## Review changes + +A reviewer checked this spec against the code. Changes made: the bridge now enqueues the reactor +(an appended push never started an idle recipient); result readiness handles start failures and +matches requests by peer timestamps; opaque peer errors are resolved by reading; 401 holds instead +of failing agents; unlink no longer claims to revoke the peer session; the trust statement and a +command allowlist replace "least privilege"; secret names are encoded; `machines` moved out of the +settings patch; create order is registry first, placement outside the lock, explicit machine never +falls back; ownership and routing use the registry and the reactor's rules; polling is per machine; +peer version is checked; the peer create omits `createdByThreadId` and `replyTo` and avoids +`cp-` message ids. + +Built differently from the review's suggestions: a request is matched to its turn by +`baselineTurnId` (simpler than comparing peer timestamps and equivalent for one request in flight +per agent); `machines` is a per-entry map rather than an array kept out of the patch; the reactor +gets an `enqueueProject` method rather than a new event trigger, so local behaviour is unchanged. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b38cf1c2b9562..5369375e44615 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -57,6 +57,40 @@ on that machine. Existing threads stay where they started. If resource checks ar unavailable or all eligible machines are full, choose a machine manually to continue. Mobile keeps its manual environment selection. +### Start agents on other machines + +A coordinator can start its agents on another machine, such as a Mac Mini on the +same tailnet. Set this up in **Settings → Connections → Agent machines** while viewing +the machine the coordinator runs on. + +To link a machine, create a pairing link on it (in **Settings → Connections** there, or +with `t3 pair --tailscale`), then choose **Add machine** and paste the link. If the +link's address is not reachable from the coordinator's machine, enter one that is under +**Address**. Linking lets the coordinator's machine run agents there with full access, +so link only machines you trust it with. + +**Where new agents run** has three modes: + +- **This machine** starts every agent on the coordinator's machine. This is the default. +- **One machine** sends every agent to the machine you pick. +- **Balance across machines** sends each agent to the machine with the fewest running + agents, weighted by each machine's preference. **Manual only** keeps a machine out of + automatic choice; a coordinator can still name it. + +An agent runs in the matching Project on the other machine, so the Project must exist on +both. Machines are matched by repository, or by Project title and folder name. A machine +without the Project is skipped, and each row shows how many of your Projects it matches. +The agent uses that machine's folder, files and `AGENTS.md`, and its edits stay there. +The thread appears in that machine's sidebar. + +When the chosen machine is offline or has no matching Project, the agent starts on this +machine instead. Turn off **Use this machine if no other is available** to fail instead. +Standing agents always stay on the coordinator's machine. + +A link lasts 30 days. After that the row shows **Needs re-link**; add the machine again +with a new pairing link. Unlinking removes the saved access here; to revoke it fully, +remove the client from **Settings → Connections** on the other machine. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 18127076ff19f..7b1610f9b438d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1102,6 +1102,13 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, }), + // Link, unlink and check machines this environment may start agents on. + // Kept off the config lane: check probes peers for seconds and must not + // hold back settings writes. The server serializes machine edits. + agentMachines: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:agent-machines", + tag: WS_METHODS.serverAgentMachines, + }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, diff --git a/packages/contracts/src/agentPlacement.ts b/packages/contracts/src/agentPlacement.ts new file mode 100644 index 0000000000000..30c1ddea84fe3 --- /dev/null +++ b/packages/contracts/src/agentPlacement.ts @@ -0,0 +1,120 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +// Fork-owned. Where a coordinator's new agents run. See +// docs/internals/multi-machine-agents.md. Imports only baseSchemas so +// settings.ts and rpc.ts can import this module without a cycle. + +/** + * `local` starts every agent on this machine (today's behaviour), `single` + * sends them all to one machine, `balanced` spreads them by load. + */ +export const AgentPlacementMode = Schema.Literals(["local", "single", "balanced"]); +export type AgentPlacementMode = typeof AgentPlacementMode.Type; +export const DEFAULT_AGENT_PLACEMENT_MODE: AgentPlacementMode = "local"; + +/** The same vocabulary as the client's Load balancing: Prefer, Normal, Less often, Manual only. */ +export const AgentMachinePreference = Schema.Literals([100, 50, 25, 0]); +export type AgentMachinePreference = typeof AgentMachinePreference.Type; +export const DEFAULT_AGENT_MACHINE_PREFERENCE: AgentMachinePreference = 50; + +/** The linked machine's environment id, as its own descriptor reports it. */ +export const AgentMachineId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); +export type AgentMachineId = typeof AgentMachineId.Type; + +/** What the user picks for "this machine" in `singleMachineId`. */ +export const LOCAL_AGENT_MACHINE = "local"; + +/** A linked machine. Its bearer token lives in the server secret store, never here. */ +export const AgentMachineConfig = Schema.Struct({ + label: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + /** https, or http to a loopback, private-range or tailnet host. */ + baseUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(2048)), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + preference: AgentMachinePreference.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_AGENT_MACHINE_PREFERENCE)), + ), +}); +export type AgentMachineConfig = typeof AgentMachineConfig.Type; + +export const AgentPlacementSettings = Schema.Struct({ + mode: AgentPlacementMode.pipe(Schema.withDecodingDefault(Effect.succeed("local" as const))), + /** For `single`: a machine id, or `local`. Null reads as this machine. */ + singleMachineId: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + /** This machine's weight in `balanced`. */ + localPreference: AgentMachinePreference.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_AGENT_MACHINE_PREFERENCE)), + ), + /** Use this machine when no other is eligible. An explicit machine never falls back. */ + allowLocalFallback: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + machines: Schema.Record(AgentMachineId, AgentMachineConfig).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type AgentPlacementSettings = typeof AgentPlacementSettings.Type; + +/** Per-entry `machines`, like `usageLimitSources`: `null` removes an entry. */ +export const AgentPlacementSettingsPatch = Schema.Struct({ + mode: Schema.optionalKey(AgentPlacementMode), + singleMachineId: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + localPreference: Schema.optionalKey(AgentMachinePreference), + allowLocalFallback: Schema.optionalKey(Schema.Boolean), + machines: Schema.optionalKey(Schema.Record(AgentMachineId, Schema.NullOr(AgentMachineConfig))), +}); +export type AgentPlacementSettingsPatch = typeof AgentPlacementSettingsPatch.Type; + +// ── RPC ──────────────────────────────────────────────────────────── + +export const AgentMachinesInput = Schema.Union([ + Schema.Struct({ + action: Schema.Literal("link"), + /** The pairing link created on the other machine. */ + pairingUrl: TrimmedNonEmptyString, + /** Overrides the link's address when it is not reachable from this machine. */ + baseUrl: Schema.optionalKey(TrimmedNonEmptyString), + }), + Schema.Struct({ action: Schema.Literal("unlink"), id: AgentMachineId }), + Schema.Struct({ action: Schema.Literal("check") }), +]); +export type AgentMachinesInput = typeof AgentMachinesInput.Type; + +export const AgentMachineStatusKind = Schema.Literals([ + "connected", + "offline", + "needs-relink", + "incompatible", + "wrong-environment", +]); +export type AgentMachineStatusKind = typeof AgentMachineStatusKind.Type; + +export const AgentMachineStatus = Schema.Struct({ + id: AgentMachineId, + status: AgentMachineStatusKind, + detail: Schema.optionalKey(Schema.String), + /** When the stored token stops working. Tokens last 30 days and are not refreshed. */ + tokenExpiresAt: Schema.optionalKey(Schema.String), + /** Projects on this machine with a matching Project on the linked one. */ + matchedProjectCount: Schema.Int, + projectCount: Schema.Int, +}); +export type AgentMachineStatus = typeof AgentMachineStatus.Type; + +export const AgentMachinesResult = Schema.Struct({ + /** The entry `link` added. */ + linked: Schema.optionalKey(Schema.Struct({ id: AgentMachineId, label: Schema.String })), + machines: Schema.Array(AgentMachineStatus), +}); +export type AgentMachinesResult = typeof AgentMachinesResult.Type; + +export class AgentMachinesError extends Schema.TaggedError()( + "AgentMachinesError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d67b18f37d4d4..02c052ea72027 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -39,6 +39,7 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; export * from "./browserProfile.ts"; +export * from "./agentPlacement.ts"; export * from "./device.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b43c87653086a..0dc25eb22bde3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { AgentMachinesError, AgentMachinesInput, AgentMachinesResult } from "./agentPlacement.ts"; import { ProviderAuthCancelInput, ProviderAuthCompleteInput, @@ -387,6 +388,7 @@ export const WS_METHODS = { serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", serverSignalProcess: "server.signalProcess", + serverAgentMachines: "server.agentMachines", serverReportClientActivity: "server.reportClientActivity", serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", @@ -661,6 +663,12 @@ const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { error: EnvironmentAuthorizationError, }); +const WsServerAgentMachinesRpc = Rpc.make(WS_METHODS.serverAgentMachines, { + payload: AgentMachinesInput, + success: AgentMachinesResult, + error: Schema.Union([EnvironmentAuthorizationError, AgentMachinesError]), +}); + const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -1429,6 +1437,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetUsageSummaryRpc, WsServerRefreshUsageRatesRpc, WsServerSignalProcessRpc, + WsServerAgentMachinesRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, WsServerGetBackgroundPolicyRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a9994a84b3afa..0e7ecefe7de38 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -965,3 +965,86 @@ it("validates remote device hosts and rejects ambiguous host ids", () => { ).toThrow(); expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); }); + +describe("ServerSettings.agentPlacement", () => { + it("defaults to this machine only for existing installations", () => { + const expected = { + mode: "local", + singleMachineId: null, + localPreference: 50, + allowLocalFallback: true, + machines: {}, + }; + expect(DEFAULT_SERVER_SETTINGS.agentPlacement).toEqual(expected); + expect(decodeServerSettings({}).agentPlacement).toEqual(expected); + expect(decodeServerSettings({ agentPlacement: {} }).agentPlacement).toEqual(expected); + }); + + it("decodes a machine entry with enabled and Normal preference by default", () => { + const settings = decodeServerSettings({ + agentPlacement: { + mode: "balanced", + machines: { + "env-mini": { label: " Mac Mini ", baseUrl: "http://mini.tailnet.ts.net:3773" }, + }, + }, + }); + expect(settings.agentPlacement.mode).toBe("balanced"); + expect(settings.agentPlacement.machines).toEqual({ + "env-mini": { + label: "Mac Mini", + baseUrl: "http://mini.tailnet.ts.net:3773", + enabled: true, + preference: 50, + }, + }); + }); + + it("round-trips through encode", () => { + const settings = decodeServerSettings({ + agentPlacement: { + mode: "single", + singleMachineId: "env-mini", + localPreference: 0, + machines: { + "env-mini": { + label: "Mac Mini", + baseUrl: "http://mini.tailnet.ts.net:3773", + enabled: false, + preference: 25, + }, + }, + }, + }); + expect(decodeServerSettings(encodeServerSettings(settings)).agentPlacement).toEqual( + settings.agentPlacement, + ); + }); + + it("rejects preferences outside the Load balancing vocabulary and unknown modes", () => { + expect(() => decodeServerSettings({ agentPlacement: { localPreference: 30 } })).toThrow(); + expect(() => decodeServerSettings({ agentPlacement: { mode: "random" } })).toThrow(); + expect(() => + decodeServerSettings({ + agentPlacement: { + machines: { "env-mini": { label: "Mini", baseUrl: "http://m", preference: 75 } }, + }, + }), + ).toThrow(); + expect(() => + decodeServerSettings({ + agentPlacement: { machines: { "env-mini": { label: " ", baseUrl: "http://m" } } }, + }), + ).toThrow(); + expect(() => decodeServerSettingsPatch({ agentPlacement: { localPreference: 10 } })).toThrow(); + }); + + it("patches decode partially, with null removing a machine entry", () => { + expect(decodeServerSettingsPatch({ agentPlacement: { mode: "balanced" } })).toEqual({ + agentPlacement: { mode: "balanced" }, + }); + expect( + decodeServerSettingsPatch({ agentPlacement: { machines: { "env-mini": null } } }), + ).toEqual({ agentPlacement: { machines: { "env-mini": null } } }); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a8da184f34be3..f07de4f5463f8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,3 +1,4 @@ +import { AgentPlacementSettings, AgentPlacementSettingsPatch } from "./agentPlacement.ts"; import { SshDeviceHostConfigs } from "./device.ts"; import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; @@ -1248,6 +1249,8 @@ export const ServerSettings = Schema.Struct({ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** Where this environment's coordinators start new agents. Fork-owned. */ + agentPlacement: AgentPlacementSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1519,6 +1522,7 @@ export const ServerSettingsPatch = Schema.Struct({ usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), ), + agentPlacement: Schema.optionalKey(AgentPlacementSettingsPatch), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 267e7a7b3442a..277756a8c5add 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -753,3 +753,91 @@ describe("serverSettings helpers", () => { expect(resolved.pauseWhenOnBattery).toBe(false); }); }); + +describe("applyServerSettingsPatch agentPlacement", () => { + const mini = { + label: "Mac Mini", + baseUrl: "http://mini.tailnet.ts.net:3773", + enabled: true, + preference: 50 as const, + }; + const studio = { + label: "Studio", + baseUrl: "https://studio.example.com", + enabled: true, + preference: 100 as const, + }; + const linked = { + ...DEFAULT_SERVER_SETTINGS, + agentPlacement: { ...DEFAULT_SERVER_SETTINGS.agentPlacement, machines: { "env-mini": mini } }, + }; + + it("defaults to this machine only, with fallback on and no linked machines", () => { + expect(DEFAULT_SERVER_SETTINGS.agentPlacement).toEqual({ + mode: "local", + singleMachineId: null, + localPreference: 50, + allowLocalFallback: true, + machines: {}, + }); + }); + + it("patches scalar fields without dropping linked machines", () => { + const next = applyServerSettingsPatch(linked, { + agentPlacement: { + mode: "single", + singleMachineId: "env-mini", + localPreference: 0, + allowLocalFallback: false, + }, + }); + expect(next.agentPlacement).toEqual({ + mode: "single", + singleMachineId: "env-mini", + localPreference: 0, + allowLocalFallback: false, + machines: { "env-mini": mini }, + }); + const onlyMode = applyServerSettingsPatch(next, { agentPlacement: { mode: "balanced" } }); + expect(onlyMode.agentPlacement).toEqual({ ...next.agentPlacement, mode: "balanced" }); + }); + + it("adds, updates and removes machines per entry", () => { + const added = applyServerSettingsPatch(linked, { + agentPlacement: { machines: { "env-studio": studio } }, + }); + expect(added.agentPlacement.machines).toEqual({ "env-mini": mini, "env-studio": studio }); + + const updated = applyServerSettingsPatch(added, { + agentPlacement: { machines: { "env-mini": { ...mini, enabled: false, preference: 0 } } }, + }); + expect(updated.agentPlacement.machines).toEqual({ + "env-mini": { ...mini, enabled: false, preference: 0 }, + "env-studio": studio, + }); + + const removed = applyServerSettingsPatch(updated, { + agentPlacement: { machines: { "env-mini": null } }, + }); + expect(removed.agentPlacement.machines).toEqual({ "env-studio": studio }); + expect(linked.agentPlacement.machines).toEqual({ "env-mini": mini }); + }); + + it("a patch with only machines keeps the mode and other fields", () => { + const balanced = applyServerSettingsPatch(linked, { + agentPlacement: { mode: "balanced", localPreference: 25, allowLocalFallback: false }, + }); + const next = applyServerSettingsPatch(balanced, { + agentPlacement: { machines: { "env-studio": studio } }, + }); + expect(next.agentPlacement).toEqual({ + ...balanced.agentPlacement, + machines: { "env-mini": mini, "env-studio": studio }, + }); + }); + + it("leaves agentPlacement untouched when the patch does not mention it", () => { + const next = applyServerSettingsPatch(linked, { environmentIcon: "mac-mini" }); + expect(next.agentPlacement).toEqual(linked.agentPlacement); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 61103b77babd1..d506cd3d7b609 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -274,6 +274,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + // `machines` is merged per entry below, with the same `null` removals. + agentPlacement: agentPlacementPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, // Already translated into `projectSettingsOverrides` above; the legacy @@ -380,6 +382,20 @@ export function applyServerSettingsPatch( ), } : {}), + ...(agentPlacementPatch !== undefined + ? { + agentPlacement: (() => { + const { machines, ...rest } = agentPlacementPatch; + return { + ...current.agentPlacement, + ...rest, + ...(machines !== undefined + ? { machines: mergeSettingsEntries(current.agentPlacement.machines, machines) } + : {}), + }; + })(), + } + : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( From 259c9bc6a18d5e5e7b9844a37d3bedd9070f5893 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:34:38 -0600 Subject: [PATCH 06/28] test: fix lint in agent machine tests Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/agentMachines/AgentMachineClient.ts | 4 +++- apps/server/src/agentMachines/AgentMachines.test.ts | 10 ++++++---- .../src/mcp/toolkits/agents/handlers.remote.test.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/server/src/agentMachines/AgentMachineClient.ts b/apps/server/src/agentMachines/AgentMachineClient.ts index dcb12e8182b45..72bf91798a08f 100644 --- a/apps/server/src/agentMachines/AgentMachineClient.ts +++ b/apps/server/src/agentMachines/AgentMachineClient.ts @@ -100,9 +100,11 @@ const noRedirects = (effect: Effect.Effect) => const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); +const isPeerError = Schema.is(PeerError); + /** Maps any transport or HTTP failure to a `PeerError`. Peer dispatch errors are opaque 500s. */ export function classifyPeerFailure(error: unknown): PeerError { - if (Schema.is(PeerError)(error)) return error; + if (isPeerError(error)) return error; const tag = (error as { readonly _tag?: string } | null)?._tag ?? ""; const status = (error as { readonly response?: { readonly status?: number } } | null)?.response ?.status; diff --git a/apps/server/src/agentMachines/AgentMachines.test.ts b/apps/server/src/agentMachines/AgentMachines.test.ts index cfc4fa22c2589..2108702e0f077 100644 --- a/apps/server/src/agentMachines/AgentMachines.test.ts +++ b/apps/server/src/agentMachines/AgentMachines.test.ts @@ -24,7 +24,8 @@ import { peerProject, } from "./testFixtures.ts"; -const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString); +const JsonString = Schema.fromJsonString(Schema.Unknown); +const encodeJson = Schema.encodeSync(JsonString); const MINI_URL = "http://100.101.102.103:4000"; const HOME_PROJECT_ID = ProjectId.make("home-project"); @@ -172,7 +173,7 @@ const storeToken = (machineId = "env-mini", expiresAt = "2026-10-29T00:00:00.000 const secrets = yield* ServerSecretStore.ServerSecretStore; yield* secrets.set( agentMachineSecretName(machineId), - new TextEncoder().encode(JSON.stringify({ token: "peer-token", expiresAt })), + new TextEncoder().encode(encodeJson({ token: "peer-token", expiresAt })), ); }); @@ -212,8 +213,9 @@ describe("AgentMachines.manage link", () => { expect(Option.isSome(stored)).toBe(true); expect(new TextDecoder().decode(Option.getOrThrow(stored))).toContain("peer-token"); // The credential never reaches settings. - expect(JSON.stringify(yield* settings.getSettings)).not.toContain("peer-token"); - expect(JSON.stringify(yield* settings.getSettings)).not.toContain("ABC123"); + const persisted = encodeJson(yield* settings.getSettings); + expect(persisted).not.toContain("peer-token"); + expect(persisted).not.toContain("ABC123"); }), ); }); diff --git a/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts index 298338fe43c9f..6157ef758be1f 100644 --- a/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts +++ b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts @@ -132,7 +132,7 @@ const makeHarness = Effect.fn("makeRemoteAgentsHarness")(function* (input: Harne ); const commands: Array = []; const creators = new Map(); - const peer = makeFakePeer({ ...(input.failPeerOn ? { failOn: input.failPeerOn } : {}) }); + const peer = makeFakePeer(input.failPeerOn ? { failOn: input.failPeerOn } : {}); const machines = Layer.succeed(AgentMachines, { ...peer.service, placementInputs: () => Effect.succeed(input.placement), From d7e07fd410f09de059ed66b44b62fa728875af14 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:42:22 -0600 Subject: [PATCH 07/28] feat: settle agents on linked machines cp_agent_settle now works on a remote agent: it settles the peer thread when no request is in flight, archives on request, and marks the record settled. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/agentMachines/RemoteAgents.ts | 40 +++++++++++++++++ .../toolkits/agents/handlers.remote.test.ts | 43 +++++++++++++++++++ .../src/mcp/toolkits/agents/handlers.ts | 18 +++++++- 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/server/src/agentMachines/RemoteAgents.ts b/apps/server/src/agentMachines/RemoteAgents.ts index f47ed1fea3e89..cdbdaf07e20d7 100644 --- a/apps/server/src/agentMachines/RemoteAgents.ts +++ b/apps/server/src/agentMachines/RemoteAgents.ts @@ -136,6 +136,11 @@ export interface RemoteAgentsShape { record: RemoteAgentRecord, options: { readonly archive: boolean }, ) => Effect.Effect<{ readonly stopped: boolean; readonly archived: boolean }, RemoteAgentError>; + /** Settles an idle remote agent on its machine, and archives it on request. */ + readonly settle: ( + record: RemoteAgentRecord, + options: { readonly archive: boolean }, + ) => Effect.Effect<{ readonly archived: boolean }, RemoteAgentError>; /** Starts the message when the agent is idle, else queues it for the bridge. */ readonly send: ( record: RemoteAgentRecord, @@ -436,6 +441,40 @@ const make = Effect.gen(function* () { return { stopped: working || dropped > 0, archived: options.archive }; }); + const settle: RemoteAgentsShape["settle"] = (record, options) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const key = `${record.threadId}:${createdAt}`; + yield* send( + { + type: "thread.settle", + commandId: CommandId.make(`cp-remote-settle:${key}`), + threadId: ThreadId.make(record.threadId), + }, + record, + ); + if (options.archive) { + yield* send( + { + type: "thread.archive", + commandId: CommandId.make(`cp-remote-archive:${key}`), + threadId: ThreadId.make(record.threadId), + }, + record, + ); + } + yield* store + .update(record.threadId, (current) => ({ + ...current, + state: "settled", + endedAt: createdAt, + inFlight: null, + queuedSends: [], + })) + .pipe(Effect.mapError(storeFailed)); + return { archived: options.archive }; + }); + const sendMessage: RemoteAgentsShape["send"] = (record, input) => Effect.gen(function* () { const createdAt = yield* nowIso; @@ -536,6 +575,7 @@ const make = Effect.gen(function* () { ), read, stop, + settle, send: sendMessage, startQueued, } satisfies RemoteAgentsShape; diff --git a/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts index 6157ef758be1f..390b8e4a19556 100644 --- a/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts +++ b/apps/server/src/mcp/toolkits/agents/handlers.remote.test.ts @@ -47,6 +47,7 @@ import { AgentCreateResult, AgentListResult, AgentReadResult, + AgentSettleResult, AgentStopResult, AgentsToolkit, } from "./tools.ts"; @@ -234,6 +235,8 @@ const makeHarness = Effect.fn("makeRemoteAgentsHarness")(function* (input: Harne run(toolkit.handle("cp_agent_read", params), AgentReadResult), stop: (params: Parameters>[1]) => run(toolkit.handle("cp_agent_stop", params), AgentStopResult), + settle: (params: Parameters>[1]) => + run(toolkit.handle("cp_agent_settle", params), AgentSettleResult), }; }); @@ -657,6 +660,46 @@ describe("cp_agent_list, cp_agent_read and cp_agent_stop with remote agents", () }), ); + it.effect("settles an idle remote agent on its machine and archives on request", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ lastPhase: "completed" }); + const settled = yield* h.settle({ agent: "Pricing", archive: true }); + expect(settled).toEqual({ threadId: "remote-1", settled: true, archived: true }); + expect(types(h.peer.commands)).toEqual(["thread.settle", "thread.archive"]); + expect(types(h.commands)).toEqual([]); + expect((yield* h.records)[0]?.state).toBe("settled"); + }), + ); + + it.effect("refuses to settle a remote agent with a request in flight", () => + Effect.gen(function* () { + const h = yield* seededHarness(); + yield* h.seedRecord({ + lastPhase: "running", + inFlight: { + messageId: "m", + replyTo: "coordinator", + sentAt: TEST_NOW, + baselineTurnId: null, + suppressed: false, + }, + }); + const error = yield* Effect.flip(h.settle({ agent: "Pricing" })); + expect(error._tag).toBe("AgentBusyError"); + expect(h.peer.commands).toEqual([]); + }), + ); + + it.effect("leaves settling to the coordinator", () => + Effect.gen(function* () { + const h = yield* seededHarness(STANDING_ID); + yield* h.seedRecord({ creatorThreadId: "standing" }); + const error = yield* Effect.flip(h.settle({ agent: "Pricing" })); + expect(error._tag).toBe("SettleCoordinatorOnlyError"); + }), + ); + it.effect("finds nothing for an unknown agent", () => Effect.gen(function* () { const h = yield* seededHarness(); diff --git a/apps/server/src/mcp/toolkits/agents/handlers.ts b/apps/server/src/mcp/toolkits/agents/handlers.ts index 09b6d3a3e1a60..a8712d7fb98d2 100644 --- a/apps/server/src/mcp/toolkits/agents/handlers.ts +++ b/apps/server/src/mcp/toolkits/agents/handlers.ts @@ -825,7 +825,23 @@ const make = Effect.gen(function* () { // Settling is the coordinator's call: a standing agent manages one-offs // it started, but does not decide when their work is done. if (manager.role !== "coordinator") return yield* new SettleCoordinatorOnlyError(); - const agent = yield* requireAgent(manager, input.agent); + const target = yield* requireTarget(manager, input.agent); + if (target.kind === "remote") { + const { record } = target; + // A request in flight is the peer's agent still working. + if (record.inFlight !== null && !record.inFlight.suppressed) { + return yield* new AgentBusyError({ agent: input.agent, phase: record.lastPhase }); + } + const settled = yield* remote + .settle(record, { archive: input.archive === true }) + .pipe(Effect.mapError(toolFailed)); + return { + threadId: ThreadId.make(record.threadId), + settled: true as const, + archived: settled.archived, + }; + } + const agent = target.thread; if (isStandingAgent(manager.project, agent)) { return yield* new AgentToolFailedError({ detail: `'${input.agent}' is a standing agent, and standing agents do not settle.`, From c057681dc15078cb9a3f11244c566bf6be040c41 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:25:29 -0600 Subject: [PATCH 08/28] fix: leave remote agents open for follow-ups after they report Follows the coordinator-owned settling rule: the bridge no longer settles a remote agent after its result. cp_agent_settle does it, on the peer. Co-Authored-By: Claude Sonnet 5.5 --- .../agentMachines/RemoteAgentBridge.test.ts | 10 ++++++---- .../src/agentMachines/RemoteAgentBridge.ts | 18 +++--------------- docs/internals/multi-machine-agents.md | 4 ++-- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/server/src/agentMachines/RemoteAgentBridge.test.ts b/apps/server/src/agentMachines/RemoteAgentBridge.test.ts index aee28fcde4f45..7101cb69a46b9 100644 --- a/apps/server/src/agentMachines/RemoteAgentBridge.test.ts +++ b/apps/server/src/agentMachines/RemoteAgentBridge.test.ts @@ -137,7 +137,7 @@ const finishedMessages = [ describe("RemoteAgentBridge delivery", () => { it.effect( - "pushes a finished request into the coordinator, wakes the reactor, and settles the agent", + "pushes a finished request into the coordinator, wakes the reactor, and leaves the agent open for follow-ups", () => Effect.gen(function* () { const h = harness(); @@ -165,8 +165,9 @@ describe("RemoteAgentBridge delivery", () => { expect(push.message.source).not.toHaveProperty("replyTo"); } expect(h.enqueued).toEqual([HOME_PROJECT]); - expect(h.peer.commands.map((command) => command.type)).toEqual(["thread.settle"]); - expect(record.state).toBe("settled"); + // Only the coordinator settles it: the bridge sends the peer no settle. + expect(h.peer.commands).toEqual([]); + expect(record.state).toBe("open"); expect(record.inFlight).toBeNull(); }), ); @@ -469,7 +470,8 @@ describe("RemoteAgentBridge machine health", () => { expect(results.down.lastPhase).toBe("stale"); expect(results.down.inFlight).not.toBeNull(); expect(h.pushes()).toHaveLength(1); - expect(results.up.state).toBe("settled"); + expect(results.up.state).toBe("open"); + expect(results.up.inFlight).toBeNull(); }), ); diff --git a/apps/server/src/agentMachines/RemoteAgentBridge.ts b/apps/server/src/agentMachines/RemoteAgentBridge.ts index ea8d0cbe36be3..9ded59a27ad50 100644 --- a/apps/server/src/agentMachines/RemoteAgentBridge.ts +++ b/apps/server/src/agentMachines/RemoteAgentBridge.ts @@ -27,7 +27,7 @@ import type * as Scope from "effect/Scope"; import { AgentCompletionReactor } from "../orchestration/AgentCompletionReactor.ts"; import { isOrchestrationCommandRejection } from "../orchestration/Errors.ts"; -import { agentPushId, agentPushSettleId } from "../orchestration/agentProtocol.ts"; +import { agentPushId } from "../orchestration/agentProtocol.ts"; import { formatAgentResult, type AgentResultOutcome } from "../orchestration/agentPushes.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -207,25 +207,13 @@ export const makeRemoteAgentBridge = Effect.gen(function* () { const delivered = yield* deliver(record, request, outcome, text); if (!delivered) return; } - const latest = record.queuedSends.length === 0; + // The agent stays open for follow-ups, as a local one does: only the + // coordinator settles it, with cp_agent_settle. yield* update(record, (current) => ({ ...current, inFlight: null, lastPhase: verdict.outcome.kind === "finished" ? "completed" : "failed", })); - // The peer has no `replyTo` to settle a one-off agent, so the home does it, - // unless a later message is queued behind this one. - if (latest) { - const createdAt = yield* nowIso; - yield* machines - .dispatch(record.machineId, { - type: "thread.settle", - commandId: CommandId.make(agentPushSettleId(record.threadId, request.messageId)), - threadId: ThreadId.make(record.threadId), - }) - .pipe(Effect.ignore); - yield* update(record, (current) => ({ ...current, state: "settled", endedAt: createdAt })); - } }); const markLost = Effect.fn("RemoteAgentBridge.markLost")(function* ( diff --git a/docs/internals/multi-machine-agents.md b/docs/internals/multi-machine-agents.md index ca11373ca0296..0ad57092d0e70 100644 --- a/docs/internals/multi-machine-agents.md +++ b/docs/internals/multi-machine-agents.md @@ -169,8 +169,8 @@ the shell snapshot, which carries each thread's `latestTurn`, `session`, pending Project and may manage the agent, else the coordinator, and it holds while the Project's assistant is archived or unmarked or the recipient is archived. Then the bridge calls `AgentCompletionReactor.enqueue(projectId)`; without it an idle recipient would never start, - because the reactor enqueues only for `cp-send:` and schedule appends. The peer thread is settled - only when it is a one-off and the request is still its latest. + because the reactor enqueues only for `cp-send:` and schedule appends. The agent stays open for follow-ups, as a local one does: only the coordinator settles it, + with `cp_agent_settle`, which also works on remote agents (idle only; it settles the peer thread). - **Read.** Maps the peer's messages and `latestTurn` to `AgentReadResult` with the same caps. Older turns have no state over HTTP, so their state is approximated as `completed`. - **Stop.** Dispatches interrupt, session stop and optionally archive on the peer and marks the From 3d3613d1814b31661719d7e3157b5a0c9bfcbce4 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:28:18 -0600 Subject: [PATCH 09/28] chore(release): prepare Control Plane 0.0.58 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3fb4448cbabe2..0cbf7b2f4f54e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.57", + "version": "0.0.58", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 575d010df67ba..7ee85628e4695 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.57", + "version": "0.0.58", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index bcbc836fcd075..943b5a519bd9a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.57", + "version": "0.0.58", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 89a29a8eed687..b890840055afa 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.57", + "version": "0.0.58", "private": true, "files": [ "dist" From 9296d3de1c36b1da19faf20c7d9b829876021c48 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:17:45 -0600 Subject: [PATCH 10/28] fix(mobile): list New Project with cached Projects on cold launch Home only offered New Project once an environment's socket reached connected, even when the cached server config already advertised Projects. The cached Projects rendered on the first frame and New Project popped in about half a second later, shifting the list. Projects support now comes from the cached or live config. Only a connected server's config can mark Projects unsupported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeSectionList.tsx | 2 +- .../src/features/home/homeSections.test.ts | 49 +++++++++++++++++++ apps/mobile/src/features/home/homeSections.ts | 26 +++++++++- .../src/features/home/useHomeSections.ts | 30 +++++------- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/home/HomeSectionList.tsx b/apps/mobile/src/features/home/HomeSectionList.tsx index 51114b3ece18e..d8a54ee675c96 100644 --- a/apps/mobile/src/features/home/HomeSectionList.tsx +++ b/apps/mobile/src/features/home/HomeSectionList.tsx @@ -369,7 +369,7 @@ export function HomeSectionList( const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const projectActions = useProjectActions(); const { openNewProject, openConvertToProject } = useOpenNewProject(); - // New Project needs a connected environment that supports Projects. + // New Project needs an environment whose cached or live config supports Projects. const canCreateProject = capabilities.assistants.size > 0; // The iPhone closes each open section with its action row; search shows hits only. const searching = searchQuery.trim().length > 0; diff --git a/apps/mobile/src/features/home/homeSections.test.ts b/apps/mobile/src/features/home/homeSections.test.ts index fe5d46625037b..393f95fae3498 100644 --- a/apps/mobile/src/features/home/homeSections.test.ts +++ b/apps/mobile/src/features/home/homeSections.test.ts @@ -12,11 +12,13 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type ServerConfig, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { PendingQueuedTask } from "../../state/use-pending-new-tasks"; import { + assistantEnvironmentIds, buildHomeSections, folderMoveDestination, HOME_PROJECTS_SECTION_KEY, @@ -860,6 +862,53 @@ describe("withSectionActions", () => { }); }); +describe("cold launch from the cached shell", () => { + const config = (assistants: boolean) => + ({ environment: { capabilities: { assistants } } }) as unknown as ServerConfig; + const disconnected = new Set(); + + it("takes Projects support from a cached config before the socket connects", () => { + expect(assistantEnvironmentIds(new Map([[env, config(true)]]), disconnected)).toEqual({ + assistants: new Set([env]), + assistantsUnsupported: new Set(), + }); + // Only a connected server's config can say it predates Projects. + expect(assistantEnvironmentIds(new Map([[env, config(false)]]), disconnected)).toEqual({ + assistants: new Set(), + assistantsUnsupported: new Set(), + }); + expect(assistantEnvironmentIds(new Map([[env, config(false)]]), new Set([env]))).toEqual({ + assistants: new Set(), + assistantsUnsupported: new Set([env]), + }); + }); + + it("lists the Projects and New Project in the same first pass", () => { + const capabilities = assistantEnvironmentIds(new Map([[env, config(true)]]), disconnected); + const { items } = buildHomeSections( + input({ + assistantsEnvironmentIds: capabilities.assistants, + assistantsUnsupportedEnvironmentIds: capabilities.assistantsUnsupported, + }), + ); + const listed = withSectionActions(items, { + newProject: capabilities.assistants.size > 0, + addWorkspace: true, + }); + expect(trace(listed).slice(0, 4)).toEqual([ + "section:projects", + "project:personal", + "section-action", + "section:tasks", + ]); + expect(listed[2]).toEqual({ + type: "section-action", + key: "section-action:projects", + action: "new-project", + }); + }); +}); + describe("Settled off Home", () => { it("drops the Settled shelf and its rows", () => { const { items } = buildHomeSections(input({ settledShelf: false })); diff --git a/apps/mobile/src/features/home/homeSections.ts b/apps/mobile/src/features/home/homeSections.ts index 24815b5c8a79a..459963b1d11d9 100644 --- a/apps/mobile/src/features/home/homeSections.ts +++ b/apps/mobile/src/features/home/homeSections.ts @@ -18,7 +18,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { getThreadSortTimestamp } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { EnvironmentId, ServerConfig, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -194,7 +194,7 @@ export interface HomeSectionsInput { readonly matchedThreadKeys?: ReadonlySet; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly projectSortOrder: HomeProjectSortOrder; - /** Connected environments whose server advertises `assistants`. */ + /** Environments whose cached or live server config advertises `assistants`. */ readonly assistantsEnvironmentIds: ReadonlySet; /** Connected environments whose loaded server config lacks `assistants`. */ readonly assistantsUnsupportedEnvironmentIds?: ReadonlySet; @@ -948,6 +948,28 @@ export function homeSectionItemsAreEqual( } } +/** + * Which environments offer Projects. Support comes from the config the device + * already has, cached or live, so Home lists New Project with the cached + * Projects on the first frame instead of popping it in once the socket + * connects. Only a connected server's config can say it predates Projects. + */ +export function assistantEnvironmentIds( + serverConfigs: ReadonlyMap, + connectedEnvironmentIds: ReadonlySet, +): { + readonly assistants: ReadonlySet; + readonly assistantsUnsupported: ReadonlySet; +} { + const assistants = new Set(); + const assistantsUnsupported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.assistants === true) assistants.add(environmentId); + else if (connectedEnvironmentIds.has(environmentId)) assistantsUnsupported.add(environmentId); + } + return { assistants, assistantsUnsupported }; +} + /** Which trailing action rows a list shows, decided by its caller. */ export interface HomeSectionActions { /** New Project, unless the server predates Projects. */ diff --git a/apps/mobile/src/features/home/useHomeSections.ts b/apps/mobile/src/features/home/useHomeSections.ts index 3b4fdfe201547..2d3c88d01350f 100644 --- a/apps/mobile/src/features/home/useHomeSections.ts +++ b/apps/mobile/src/features/home/useHomeSections.ts @@ -28,7 +28,11 @@ import { } from "../threads/threadListV2"; import { createThreadMovePlanner } from "../threads/threadOrder"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; -import { buildHomeSections, type HomeSelectionReveal } from "./homeSections"; +import { + assistantEnvironmentIds, + buildHomeSections, + type HomeSelectionReveal, +} from "./homeSections"; import type { HomeProjectSortOrder } from "./homeThreadList"; // Fork-owned. The state both Home panes feed into `buildHomeSections`: server @@ -81,22 +85,14 @@ export function useHomeCapabilities() { }), [serverConfigs], ); - // Connected servers split by whether their config advertises Projects. One - // with a config but no flag predates Projects, so Home can say so. - const { assistants, assistantsUnsupported } = useMemo(() => { - const connected = new Set(connectedKey.length === 0 ? [] : connectedKey.split("\0")); - const supported = new Set(); - const unsupported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (!connected.has(environmentId)) continue; - if (config.environment.capabilities.assistants === true) supported.add(environmentId); - else unsupported.add(environmentId); - } - return { - assistants: supported as ReadonlySet, - assistantsUnsupported: unsupported as ReadonlySet, - }; - }, [connectedKey, serverConfigs]); + const { assistants, assistantsUnsupported } = useMemo( + () => + assistantEnvironmentIds( + serverConfigs, + new Set(connectedKey.length === 0 ? [] : (connectedKey.split("\0") as EnvironmentId[])), + ), + [connectedKey, serverConfigs], + ); // One identity per change: rows and the list's extraData key off it. return useMemo( () => ({ serverConfigs, ...capabilities, assistants, assistantsUnsupported }), From b4a74f6572f37f2360fad6c7153bcd8c2e1c0462 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:26:58 -0600 Subject: [PATCH 11/28] Show settled Project agents only on the mobile Settled screen Drop the per-Project settled toggle and rows from the Home list and iPad sidebar. The Settled screen gets a Projects area above the Tasks groups, one group per Project, newest settled first. View Settled is now in the Android and iPad sidebar menus too, and the unused settled toggle helper in client-runtime is removed. Co-Authored-By: Claude Sonnet 5.5 --- apps/mobile/src/features/home/HomeHeader.tsx | 16 +++- .../src/features/home/HomeRouteScreen.tsx | 6 +- .../src/features/home/HomeSectionList.tsx | 56 +------------- .../home/SettledThreadsRouteScreen.tsx | 72 ++++++++++++++---- .../src/features/home/homeSections.test.ts | 76 +++++++++++++------ apps/mobile/src/features/home/homeSections.ts | 64 +++++----------- .../src/features/home/settledThreads.ts | 41 +++++++++- .../src/features/home/useHomeSections.ts | 16 ---- .../layout/AdaptiveWorkspaceLayout.tsx | 5 ++ .../threads/ThreadNavigationSidebar.tsx | 9 ++- .../sidebar/sidebarAssistants.logic.ts | 2 - .../src/state/assistantLists.test.ts | 54 +------------ .../src/state/assistantLists.ts | 28 ------- 13 files changed, 201 insertions(+), 244 deletions(-) diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 944f19ada089c..8ed4d45d59cd2 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -32,7 +32,7 @@ export function HomeHeader(props: { readonly onProjectChange: (projectKey: string | null) => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; - /** Adds View Settled to the iOS overflow menu. */ + /** Adds View Settled to the overflow menu. */ readonly onOpenSettled?: () => void; readonly onStartNewTask: () => void; readonly onStartSearch: () => void; @@ -99,12 +99,24 @@ function AndroidHomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), + ...(props.onOpenSettled ? [{ id: "settled", title: "View Settled" }] : []), + ], + [ + props.environments, + props.onOpenSettled, + props.projects, + props.selectedEnvironmentId, + props.selectedProjectKey, ], - [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; + if (id === "settled") { + props.onOpenSettled?.(); + return; + } + if (id === "environment:all") { props.onEnvironmentChange(null); return; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 15ea58fe3b36c..bcb6365cd1699 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -200,10 +200,8 @@ export function HomeRouteScreen() { params: { screen: "Settings" }, }) } - // Android keeps the Settled shelf on Home; iOS moves it to its own screen. - {...(Platform.OS === "ios" - ? { onOpenSettled: () => navigation.navigate("Settled") } - : {})} + // Android also keeps the Tasks Settled shelf on Home; Project agents list only on the Settled screen. + onOpenSettled={() => navigation.navigate("Settled")} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft" })} onStartSearch={() => setSearchOpen(true)} diff --git a/apps/mobile/src/features/home/HomeSectionList.tsx b/apps/mobile/src/features/home/HomeSectionList.tsx index d8a54ee675c96..6092bfe6d6a98 100644 --- a/apps/mobile/src/features/home/HomeSectionList.tsx +++ b/apps/mobile/src/features/home/HomeSectionList.tsx @@ -267,48 +267,6 @@ function AgentIndent(props: { readonly grouped: boolean; readonly children: Reac return props.grouped ? props.children : {props.children}; } -const AgentSettledToggleRow = memo(function AgentSettledToggleRow(props: { - readonly pane: "screen" | "sidebar"; - readonly grouped: boolean; - readonly expansionKey: string; - readonly label: string; - readonly nextSettledCount: number; - readonly onPress: (expansionKey: string, count: number) => void; -}) { - if (props.grouped) { - const showsMore = props.nextSettledCount > 0; - return ( - props.onPress(props.expansionKey, props.nextSettledCount)} - style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, ...CHILD_INSET_STYLE })} - > - {props.label} - - - ); - } - return ( - - props.onPress(props.expansionKey, props.nextSettledCount)} - style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} - > - {props.label} - - - ); -}); - export function HomeSectionList( props: HomeSectionListProps & { readonly pane: "screen" | "sidebar"; @@ -588,7 +546,7 @@ export function HomeSectionList( ], ); - const { setAssistantSettledCount, showMoreSettled, threadMovePlanners } = model; + const { showMoreSettled, threadMovePlanners } = model; const renderRow = useCallback( (item: HomeSectionItem) => { switch (item.type) { @@ -680,17 +638,6 @@ export function HomeSectionList( /> ); - case "agent-settled-toggle": - return ( - - ); case "folder": return ( ; + readonly project?: EnvironmentProject; +} + const NO_ENVIRONMENTS: ReadonlySet = new Set(); const SEPARATOR_STYLE = { marginLeft: GROUPED_CHILD_INSET, marginRight: 20 } as const; const itemType = (item: SettledItem) => item.type; @@ -60,14 +74,20 @@ export function SettledThreadsRouteScreen() { const items = useMemo(() => { const partition = partitionAssistants(projects, threads, null); - const groups = buildSettledWorkspaceGroups({ + const projectGroups: SettledGroup[] = buildSettledProjectGroups({ + assistants: partition.assistants, + snoozeEnvironmentIds: capabilities.snooze, + settlementEnvironmentIds: capabilities.settlement, + now: `${nowMinute}:00.000Z`, + }).map((group) => ({ ...group, title: group.project.title })); + const workspaceGroups: SettledGroup[] = buildSettledWorkspaceGroups({ projects: partition.workspaceProjects, threads: partition.workspaceThreads, projectGroupingMode, settlementEnvironmentIds: capabilities.settlement, now: `${nowMinute}:00.000Z`, }); - return groups.flatMap((group, index): SettledItem[] => { + return [...projectGroups, ...workspaceGroups].flatMap((group, index): SettledItem[] => { const key = `group:${group.key}`; const expanded = expandedGroups.has(key); return [ @@ -78,6 +98,7 @@ export function SettledThreadsRouteScreen() { first: index === 0, count: group.threads.length, expanded, + ...(group.project ? { project: group.project } : {}), }, ...(expanded ? group.threads.map((thread): SettledItem => ({ @@ -88,7 +109,15 @@ export function SettledThreadsRouteScreen() { : []), ]; }); - }, [capabilities.settlement, expandedGroups, nowMinute, projectGroupingMode, projects, threads]); + }, [ + capabilities.settlement, + capabilities.snooze, + expandedGroups, + nowMinute, + projectGroupingMode, + projects, + threads, + ]); const openSwipeableRef = useRef(null); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { @@ -122,12 +151,25 @@ export function SettledThreadsRouteScreen() { className="min-h-[54px] flex-row items-center gap-3 px-5 py-2" style={item.first ? undefined : { marginTop: 16 }} > - + {item.project ? ( + + ) : ( + + )} {item.title} @@ -208,7 +250,7 @@ export function SettledThreadsRouteScreen() { ListEmptyComponent={ } diff --git a/apps/mobile/src/features/home/homeSections.test.ts b/apps/mobile/src/features/home/homeSections.test.ts index 393f95fae3498..988844e681a1f 100644 --- a/apps/mobile/src/features/home/homeSections.test.ts +++ b/apps/mobile/src/features/home/homeSections.test.ts @@ -28,7 +28,7 @@ import { type HomeSectionItem, type HomeSectionsInput, } from "./homeSections"; -import { buildSettledWorkspaceGroups } from "./settledThreads"; +import { buildSettledProjectGroups, buildSettledWorkspaceGroups } from "./settledThreads"; const env = EnvironmentId.make("env-1"); const otherEnv = EnvironmentId.make("env-2"); @@ -177,7 +177,6 @@ function input( settledShelfExpanded: true, collapsedKeys: new Set(), expandedAssistantKeys: new Set(), - assistantSettledCounts: new Map(), selectedThreadKey: null, lastVisitedAtById: {}, ...rest, @@ -203,8 +202,6 @@ function trace(items: readonly HomeSectionItem[]): string[] { return `agent:${item.item.thread.id}`; case "agent-pending": return `agent-pending:${item.pendingTask.title}`; - case "agent-settled-toggle": - return `agent-settled:${item.label}`; case "folder": return `folder:${item.title}${item.collapsed ? " (collapsed)" : ""}`; case "v2-thread": @@ -226,7 +223,6 @@ describe("buildHomeSections partition", () => { "agent:sales", "agent:flights", "agent:later", - "agent-settled:1 settled", "section:tasks", "folder:api", "task:api-a", @@ -405,34 +401,43 @@ describe("buildHomeSections Project rows", () => { expect(jumpThreads[0]?.id).toBe("sales"); }); - it("puts unsent agents after the active agents and pages settled agents by ten", () => { + it("puts unsent agents after the active agents and lists no settled agent under the Project", () => { const settledAgents = Array.from({ length: 12 }, (_, index) => thread(`settled-${index}`, "personal", { ...settled, settledAt: `2026-09-2${index < 10 ? 0 : 1}T0${index % 10}:00:00.000Z`, }), ); - const base = input({ - threads: [...fixtureThreads, ...settledAgents], - pendingTasks: [pendingTask("draft", "personal", "Book hotel")], - expandedAssistantKeys: new Set([personalKey]), - }); - const collapsedPage = trace(buildHomeSections(base).items); - expect(collapsedPage.slice(1, 7)).toEqual([ + const traced = trace( + buildHomeSections( + input({ + threads: [...fixtureThreads, ...settledAgents], + pendingTasks: [pendingTask("draft", "personal", "Book hotel")], + expandedAssistantKeys: new Set([personalKey]), + }), + ).items, + ); + expect(traced.slice(1, 6)).toEqual([ "project:personal (open)", "agent:sales", "agent:flights", "agent-pending:Book hotel", "agent:later", - "agent-settled:13 settled", ]); - const firstPage = trace( - buildHomeSections({ ...base, assistantSettledCounts: new Map([[personalKey, 10]]) }).items, - ); expect( - firstPage.filter((entry) => entry.startsWith("agent:settled-") || entry === "agent:done"), - ).toHaveLength(10); - expect(firstPage).toContain("agent-settled:3 more settled"); + traced.some((entry) => entry.startsWith("agent:settled-") || entry === "agent:done"), + ).toBe(false); + expect(traced.some((entry) => entry.startsWith("agent-settled"))).toBe(false); + }); + + it("keeps the open settled agent out of the Project list and leaves the Project closed", () => { + const { items, selectionReveal } = buildHomeSections( + input({ + selectedThreadKey: "env-1:done", + }), + ); + expect(trace(items)).not.toContain("agent:done"); + expect(selectionReveal).toBeNull(); }); it("marks standing agents and gives snoozed agents a wake label", () => { @@ -647,9 +652,10 @@ describe("buildHomeSections search", () => { ).toBe(true); }); - it("shows matching settled agents without a settled toggle that search would ignore", () => { + it("never shows a matching settled agent under its Project", () => { const { items } = buildHomeSections(input({ searchQuery: "done" })); - expect(trace(items)).toEqual(["section:projects", "project:personal (open)", "agent:done"]); + expect(trace(items)).not.toContain("agent:done"); + expect(trace(items)).not.toContain("project:personal (open)"); }); it("matches a Project by its name or its coordinator, never as a thread row", () => { @@ -937,4 +943,30 @@ describe("Settled off Home", () => { ["website", ["site-new", "site-old"]], ]); }); + + it("groups settled Project agents by Project, newest first, skipping Projects with none", () => { + const partition = partitionAssistants( + [personal, website, api], + [ + ...fixtureThreads, + thread("done-new", "personal", { settledOverride: "settled", settledAt: LATER }), + ], + null, + ); + const groups = buildSettledProjectGroups({ assistants: partition.assistants, now: LATER }); + expect( + groups.map((group) => [group.project.title, group.threads.map((item) => String(item.id))]), + ).toEqual([[personal.title, ["done-new", "done"]]]); + }); + + it("leaves snoozed, active and unsupported agents out of the Project groups", () => { + const partition = partitionAssistants([personal], fixtureThreads, null); + expect( + buildSettledProjectGroups({ + assistants: partition.assistants, + settlementEnvironmentIds: new Set(), + now: NOW, + }), + ).toEqual([]); + }); }); diff --git a/apps/mobile/src/features/home/homeSections.ts b/apps/mobile/src/features/home/homeSections.ts index 459963b1d11d9..6bb42d1b5cd9e 100644 --- a/apps/mobile/src/features/home/homeSections.ts +++ b/apps/mobile/src/features/home/homeSections.ts @@ -1,9 +1,7 @@ import { assistantExpansionKey, - assistantSettledToggle, rollupAssistantsStatus, rollupThreadGroupStatus, - visibleAssistantAgentRows, type SidebarRollupStatus, } from "@t3tools/client-runtime/state/assistant-lists"; import { @@ -49,7 +47,7 @@ export const HOME_TASKS_SECTION_KEY = "home-section:tasks"; export type HomeSectionKind = "projects" | "tasks"; -type AgentSectionRow = "pinned" | "active" | "snoozed" | "settled"; +type AgentSectionRow = "pinned" | "active" | "snoozed"; /** Where Move up/down puts a Tasks row: next to its folder neighbor in the same block. */ export interface FolderMoveDestination { @@ -113,14 +111,6 @@ export interface HomeAgentPendingItem { readonly pendingTask: PendingNewTask; } -export interface HomeAgentSettledToggleItem { - readonly type: "agent-settled-toggle"; - readonly key: string; - readonly expansionKey: string; - readonly label: string; - readonly nextSettledCount: number; -} - export interface HomeFolderItem { readonly type: "folder"; readonly key: string; @@ -172,7 +162,6 @@ export type HomeSectionItem = | HomeProjectItem | HomeAgentItem | HomeAgentPendingItem - | HomeAgentSettledToggleItem | HomeFolderItem | HomeTaskThreadItem | ThreadListV2PendingListItem @@ -216,8 +205,6 @@ export interface HomeSectionsInput { /** Collapsed section and folder keys. */ readonly collapsedKeys: ReadonlySet; readonly expandedAssistantKeys: ReadonlySet; - /** Settled agents paged in per Project, by expansion key. */ - readonly assistantSettledCounts: ReadonlyMap; /** The thread open in the detail pane (iPad); null on the phone. */ readonly selectedThreadKey: string | null; readonly lastVisitedAtById: Readonly>; @@ -302,6 +289,17 @@ function agentListItem( }; } +/** A Project's agents in rendered order: standing, active, snoozed. Settled agents never list here. */ +function agentRows( + sections: AssistantAgentSections, +): { readonly thread: EnvironmentThreadShell; readonly section: AgentSectionRow }[] { + return [ + ...sections.standing.map((thread) => ({ thread, section: "pinned" as const })), + ...sections.active.map((thread) => ({ thread, section: "active" as const })), + ...sections.snoozed.map((thread) => ({ thread, section: "snoozed" as const })), + ]; +} + function filterSections( sections: AssistantAgentSections, keep: (thread: EnvironmentThreadShell) => boolean, @@ -441,7 +439,8 @@ export function buildHomeSections(input: HomeSectionsInput): HomeSections { rollupEntries.push(rollupEntry); const coordinatorKey = scopedThreadKey(environmentId, coordinatorThreadId); const selectedAgent = - selectedKey !== null && entry.agents.some((agent) => threadKeyOf(agent) === selectedKey); + selectedKey !== null && + agentRows(sections).some((row) => threadKeyOf(row.thread) === selectedKey); if (selectedKey === coordinatorKey || selectedAgent) { selectionReveal = { collapsedKeys: collapsed ? [HOME_PROJECTS_SECTION_KEY] : [], @@ -459,7 +458,6 @@ export function buildHomeSections(input: HomeSectionsInput): HomeSections { shownSections.standing.length + shownSections.active.length + shownSections.snoozed.length + - shownSections.settled.length + pending.length > 0; if (searching && !projectMatches && !hasHits) continue; @@ -480,13 +478,8 @@ export function buildHomeSections(input: HomeSectionsInput): HomeSections { if (entry.coordinator !== null) projectJumpThreads.push(entry.coordinator); if (!expanded) continue; - const settledCount = searching - ? shownSections.settled.length - : (input.assistantSettledCounts.get(expansionKey) ?? 0); - const { rows, hiddenSettledCount } = visibleAssistantAgentRows(shownSections, { - settledCount, - routeThreadKey: selectedKey, - }); + // Settled Project agents live on the Settled screen, never under their Project. + const rows = agentRows(shownSections); let pendingPlaced = false; const placePending = () => { if (pendingPlaced) return; @@ -501,28 +494,11 @@ export function buildHomeSections(input: HomeSectionsInput): HomeSections { }; for (const row of rows) { // Unsent agents sit after the active agents, before the parked ones. - if (row.section === "snoozed" || row.section === "settled") placePending(); + if (row.section === "snoozed") placePending(); projectItems.push(agentListItem(row.thread, row.section, input.snoozeLabelNow)); projectJumpThreads.push(row.thread); } placePending(); - // Search shows every matching settled agent, so there is nothing to page. - const toggle = searching - ? null - : assistantSettledToggle({ - settledCount, - settledTotal: shownSections.settled.length, - hiddenSettledCount, - }); - if (toggle !== null) { - projectItems.push({ - type: "agent-settled-toggle", - key: `agent-settled:${expansionKey}`, - expansionKey, - label: toggle.label, - nextSettledCount: toggle.nextSettledCount, - }); - } } if (!searching || projectItems.length > 0) { @@ -901,12 +877,6 @@ export function homeSectionItemsAreEqual( ); case "agent-pending": return item.type === "agent-pending" && previous.pendingTask === item.pendingTask; - case "agent-settled-toggle": - return ( - item.type === "agent-settled-toggle" && - previous.label === item.label && - previous.nextSettledCount === item.nextSettledCount - ); case "folder": return ( item.type === "folder" && diff --git a/apps/mobile/src/features/home/settledThreads.ts b/apps/mobile/src/features/home/settledThreads.ts index e14bc786e3ed5..e6c6e05f3acd7 100644 --- a/apps/mobile/src/features/home/settledThreads.ts +++ b/apps/mobile/src/features/home/settledThreads.ts @@ -1,3 +1,7 @@ +import { + sectionAssistantAgents, + type AssistantPartition, +} from "@t3tools/client-runtime/state/assistants"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -8,8 +12,41 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; import { buildThreadListV2Items } from "../threads/threadListV2"; import { buildHomeProjectScopes } from "./homeThreadList"; -// Fork-owned. The Settled screen's model: settled Tasks threads, the same set -// the Home Settled shelf held, grouped under their workspace folder. +// Fork-owned. The Settled screen's model: settled Project agents grouped by +// Project, then settled Tasks threads, the same set the Home Settled shelf held, +// grouped under their workspace folder. + +export interface SettledProjectGroup { + readonly key: string; + readonly project: EnvironmentProject; + /** Most recently settled first. */ + readonly threads: ReadonlyArray; +} + +/** Projects that hold settled agents, in the Home's Project order. Settled agents show nowhere else. */ +export function buildSettledProjectGroups(input: { + readonly assistants: AssistantPartition["assistants"]; + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly now: string; +}): SettledProjectGroup[] { + const groups: SettledProjectGroup[] = []; + for (const entry of input.assistants) { + const { environmentId, id } = entry.project; + const { settled } = sectionAssistantAgents(entry.agents, { + now: input.now, + supportsSnooze: input.snoozeEnvironmentIds?.has(environmentId) ?? true, + supportsSettlement: input.settlementEnvironmentIds?.has(environmentId) ?? true, + }); + if (settled.length === 0) continue; + groups.push({ + key: `project:${scopedProjectKey(environmentId, id)}`, + project: entry.project, + threads: settled, + }); + } + return groups; +} export interface SettledWorkspaceGroup { readonly key: string; diff --git a/apps/mobile/src/features/home/useHomeSections.ts b/apps/mobile/src/features/home/useHomeSections.ts index 2d3c88d01350f..5a215036116ae 100644 --- a/apps/mobile/src/features/home/useHomeSections.ts +++ b/apps/mobile/src/features/home/useHomeSections.ts @@ -236,19 +236,6 @@ export function useHomeSections(input: { })), [], ); - // Settled agents page per Project for the session, like the settled tail. - const [assistantSettledCounts, setAssistantSettledCounts] = useState>( - () => new Map(), - ); - const setAssistantSettledCount = useCallback((key: string, count: number) => { - setAssistantSettledCounts((current) => { - const next = new Map(current); - if (count > 0) next.set(key, count); - else next.delete(key); - return next; - }); - }, []); - const partition = useMemo( () => partitionAssistants(input.projects, input.threads, null), [input.projects, input.threads], @@ -281,12 +268,10 @@ export function useHomeSections(input: { settledShelf: input.settledShelf ?? true, collapsedKeys: sectionPreferences.collapsedKeys, expandedAssistantKeys: sectionPreferences.expandedAssistantKeys, - assistantSettledCounts, selectedThreadKey: input.selectedThreadKey, lastVisitedAtById, }); }, [ - assistantSettledCounts, capabilities.assistants, capabilities.assistantsUnsupported, capabilities.settlement, @@ -375,7 +360,6 @@ export function useHomeSections(input: { shelf, sectionPreferences, showMoreSettled, - setAssistantSettledCount, }; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 974160783f8c8..c9131f5ff5eea 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -422,6 +422,10 @@ function AdaptiveWorkspaceLayoutContent( }); }, [navigation]); + const handleOpenSettled = useCallback(() => { + navigation.navigate("Settled"); + }, [navigation]); + const handleStartNewTask = useCallback(() => { navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft" }); }, [navigation]); @@ -594,6 +598,7 @@ function AdaptiveWorkspaceLayoutContent( onRequestVisibility={revealPrimarySidebar} selectedThreadKey={selectedThreadKey} onOpenSettings={handleOpenSettings} + onOpenSettled={handleOpenSettled} onOpenEnvironmentSettings={handleOpenEnvironmentSettings} onNewThreadInProject={handleNewThreadInProject} onAddWorkspace={handleAddWorkspace} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 15e7c33ec6aad..176892de00c78 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -52,6 +52,8 @@ interface ThreadNavigationSidebarProps { readonly visible: boolean; readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; + /** Opens the Settled screen, the only place settled Project agents list. */ + readonly onOpenSettled: () => void; readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; /** A Tasks folder's "+" and a Project's New agent: the composer on that folder. */ @@ -247,12 +249,17 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), + { id: "settled", title: "View Settled" }, ], [environments, options, projectFilterOptions, selectedProjectKey], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; + if (event === "settled") { + props.onOpenSettled(); + return; + } if (event === "environment:all") { setSelectedEnvironmentId(null); return; @@ -276,7 +283,7 @@ function ThreadNavigationSidebarPane( return; } }, - [environments, projectFilterOptions, setSelectedEnvironmentId], + [environments, projectFilterOptions, props, setSelectedEnvironmentId], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); diff --git a/apps/web/src/components/sidebar/sidebarAssistants.logic.ts b/apps/web/src/components/sidebar/sidebarAssistants.logic.ts index f1fcddaa9d38f..39fad5f2a6a2d 100644 --- a/apps/web/src/components/sidebar/sidebarAssistants.logic.ts +++ b/apps/web/src/components/sidebar/sidebarAssistants.logic.ts @@ -13,9 +13,7 @@ import { orderItemsByPreferredIds } from "../Sidebar.logic"; // helpers shared with mobile live in client-runtime and are re-exported here. export { - ASSISTANT_SETTLED_PAGE_SIZE, assistantExpansionKey, - assistantSettledToggle, isAssistantExpanded, rollupAssistantsStatus, rollupThreadGroupStatus, diff --git a/packages/client-runtime/src/state/assistantLists.test.ts b/packages/client-runtime/src/state/assistantLists.test.ts index 7ffa46cf1b576..84f180e39b952 100644 --- a/packages/client-runtime/src/state/assistantLists.test.ts +++ b/packages/client-runtime/src/state/assistantLists.test.ts @@ -3,9 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { scopedThreadKey, scopeThreadRef } from "../environment/scoped.ts"; import { - ASSISTANT_SETTLED_PAGE_SIZE, assistantExpansionKey, - assistantSettledToggle, isAssistantExpanded, rollupAssistantsStatus, rollupThreadGroupStatus, @@ -60,48 +58,27 @@ const settledAgents = Array.from({ length: 13 }, (_, index) => thread(`settled-$ describe("settled paging under a Project", () => { const agentSections = sections({ settled: settledAgents }); - it("hides settled agents until asked, then pages by ten", () => { + it("hides settled agents until paged in", () => { const hidden = visibleAssistantAgentRows(agentSections, { settledCount: 0, routeThreadKey: null, }); expect(hidden.rows).toEqual([]); - expect( - assistantSettledToggle({ - settledCount: 0, - settledTotal: 13, - hiddenSettledCount: hidden.hiddenSettledCount, - }), - ).toEqual({ label: "13 settled", nextSettledCount: ASSISTANT_SETTLED_PAGE_SIZE }); + expect(hidden.hiddenSettledCount).toBe(13); const firstPage = visibleAssistantAgentRows(agentSections, { - settledCount: ASSISTANT_SETTLED_PAGE_SIZE, + settledCount: 10, routeThreadKey: null, }); expect(firstPage.rows).toHaveLength(10); expect(firstPage.rows.every((row) => row.section === "settled")).toBe(true); - expect( - assistantSettledToggle({ - settledCount: ASSISTANT_SETTLED_PAGE_SIZE, - settledTotal: 13, - hiddenSettledCount: firstPage.hiddenSettledCount, - }), - ).toEqual({ label: "3 more settled", nextSettledCount: 20 }); + expect(firstPage.hiddenSettledCount).toBe(3); const all = visibleAssistantAgentRows(agentSections, { settledCount: 20, routeThreadKey: null, }); expect(all.hiddenSettledCount).toBe(0); - expect( - assistantSettledToggle({ settledCount: 20, settledTotal: 13, hiddenSettledCount: 0 }), - ).toEqual({ label: "Hide settled", nextSettledCount: 0 }); - }); - - it("shows no button without settled agents", () => { - expect( - assistantSettledToggle({ settledCount: 0, settledTotal: 0, hiddenSettledCount: 0 }), - ).toBeNull(); }); it("never hides the open thread behind the settled button", () => { @@ -112,29 +89,6 @@ describe("settled paging under a Project", () => { }); expect(visible.rows.map((row) => row.thread.id)).toEqual([routeThread.id]); expect(visible.hiddenSettledCount).toBe(12); - expect( - assistantSettledToggle({ - settledCount: 0, - settledTotal: 13, - hiddenSettledCount: visible.hiddenSettledCount, - }), - ).toEqual({ label: "12 settled", nextSettledCount: ASSISTANT_SETTLED_PAGE_SIZE }); - }); - - it("shows no dead button when the open thread is the only settled agent", () => { - const only = thread("only-settled"); - const visible = visibleAssistantAgentRows(sections({ settled: [only] }), { - settledCount: 0, - routeThreadKey: keyOf(only), - }); - expect(visible.rows.map((row) => row.thread.id)).toEqual([only.id]); - expect( - assistantSettledToggle({ - settledCount: 0, - settledTotal: 1, - hiddenSettledCount: visible.hiddenSettledCount, - }), - ).toBeNull(); }); }); diff --git a/packages/client-runtime/src/state/assistantLists.ts b/packages/client-runtime/src/state/assistantLists.ts index 889237bd03c0a..7d31413460083 100644 --- a/packages/client-runtime/src/state/assistantLists.ts +++ b/packages/client-runtime/src/state/assistantLists.ts @@ -12,9 +12,6 @@ import { // Fork-owned. Pure list helpers behind the Projects section, shared by the web // sidebar and the mobile home and iPad sidebar. -/** Settled agents under a Project page in like the settled tail. */ -export const ASSISTANT_SETTLED_PAGE_SIZE = 10; - /** The collapsed dot of a section, folder or Project. Null shows no dot. */ export type SidebarRollupStatus = SidebarThreadStatus | "unread" | null; @@ -74,31 +71,6 @@ export function visibleAssistantAgentRows( return { rows, hiddenSettledCount: sections.settled.length - settled.length }; } -/** - * The settled button under a Project: "3 settled", then "N more settled", - * then "Hide settled" once a page is open and nothing is left. No button when - * nothing is hidden and no page is open (the open thread can pull the only - * settled agent in). - */ -export function assistantSettledToggle(input: { - readonly settledCount: number; - readonly settledTotal: number; - readonly hiddenSettledCount: number; -}): { readonly label: string; readonly nextSettledCount: number } | null { - const settledCount = Math.max(0, input.settledCount); - if (input.settledTotal === 0) return null; - if (input.hiddenSettledCount === 0) { - return settledCount > 0 ? { label: "Hide settled", nextSettledCount: 0 } : null; - } - return { - label: - settledCount === 0 - ? `${input.hiddenSettledCount} settled` - : `${input.hiddenSettledCount} more settled`, - nextSettledCount: settledCount + ASSISTANT_SETTLED_PAGE_SIZE, - }; -} - const isUnreadThread = ( thread: RollupThread, lastVisitedAtById: Readonly>, From 9d9d2191e06c58f40e85b53e34d1b40b2a67f869 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:01:28 -0600 Subject: [PATCH 12/28] feat(mobile): add demo mode for App Store review A "Try demo" button on the first-run screens (Home empty state, iPad empty detail, Add Environment sheet, New Task empty state) opens a local "Demo Mac" environment with sample Projects, Tasks folders and threads. The demo environment is a platform registration served by an in-process DemoServer through a new ConnectionDriverOverride hook, so the real connection supervisor, shell and thread state, and screens run unchanged with no network calls. Sends and approvals get canned local replies; unsupported requests fail quietly with a demo message. A persistent Demo banner offers Exit demo, which clears the sample data and opens the connect flow. Also fixes the Add Environment sheet showing an error on open: it read an app-wide error that failed sends and other flows also write, so it could open already showing a stale or unrelated failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/App.tsx | 9 +- apps/mobile/src/connection/platform.ts | 11 +- apps/mobile/src/connection/runtime.ts | 9 +- .../connection/ConnectionsNewRouteScreen.tsx | 73 ++- .../src/features/demo/DemoModeFrame.tsx | 102 +++ .../src/features/demo/TryDemoButton.tsx | 57 ++ .../src/features/demo/demoFixtures.test.ts | 80 +++ apps/mobile/src/features/demo/demoFixtures.ts | 570 +++++++++++++++++ .../mobile/src/features/demo/demoMode.test.ts | 108 ++++ apps/mobile/src/features/demo/demoMode.ts | 166 +++++ .../src/features/demo/demoServer.test.ts | 244 ++++++++ apps/mobile/src/features/demo/demoServer.ts | 581 ++++++++++++++++++ .../src/features/home/HomeRouteScreen.tsx | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 4 + .../features/layout/WorkspaceEmptyDetail.tsx | 24 +- .../features/threads/NewTaskRouteScreen.tsx | 20 +- .../client-runtime/src/connection/driver.ts | 21 + .../client-runtime/src/connection/index.ts | 6 +- 18 files changed, 2026 insertions(+), 61 deletions(-) create mode 100644 apps/mobile/src/features/demo/DemoModeFrame.tsx create mode 100644 apps/mobile/src/features/demo/TryDemoButton.tsx create mode 100644 apps/mobile/src/features/demo/demoFixtures.test.ts create mode 100644 apps/mobile/src/features/demo/demoFixtures.ts create mode 100644 apps/mobile/src/features/demo/demoMode.test.ts create mode 100644 apps/mobile/src/features/demo/demoMode.ts create mode 100644 apps/mobile/src/features/demo/demoServer.test.ts create mode 100644 apps/mobile/src/features/demo/demoServer.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 34a742f8f11b7..8bce702b16ee8 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ import { RegistryContext } from "@effect/atom-react"; import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { appNavigationRef, DemoModeFrame } from "./features/demo/DemoModeFrame"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { @@ -86,9 +87,11 @@ function AppContent() { header (glass buttons, title, materials) is forced light even when the system is in dark mode. */} - - - + + + + + diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index f7fa30c8f2309..955761fd0611d 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -2,7 +2,6 @@ import { ClientPresentation, CloudSession, EnvironmentOwnedDataCleanup, - PlatformConnectionSource, PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, @@ -25,6 +24,7 @@ import Constants from "expo-constants"; import * as Network from "expo-network"; import { AppState } from "react-native"; +import { demoPlatformConnectionSourceLayer } from "../features/demo/demoMode"; import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; import * as MobileStorage from "../persistence/mobile-storage"; @@ -198,11 +198,10 @@ const capabilitiesLayer = Layer.effectContext( }), ); -const platformConnectionSourceLayer = Layer.succeed( - PlatformConnectionSource, - PlatformConnectionSource.of({ - registrations: Stream.empty, - }), +// The phone has no local backend; its only platform environment is the +// in-process demo environment while demo mode is on. +const platformConnectionSourceLayer = demoPlatformConnectionSourceLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), ); const providedConnectionStorageLayer = connectionStorageLayer.pipe( diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index ce478962b8b52..d85b6fce54205 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -1,9 +1,11 @@ import { Connection } from "@t3tools/client-runtime/connection"; -import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell"; -import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads"; import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; +import { + demoAwareSnapshotLoaderLayer, + demoConnectionDriverOverrideLayer, +} from "../features/demo/demoMode"; import type { FoundationHotModule } from "../lib/foundation-fast-refresh"; import { hotSwappableAtomRuntime } from "../lib/hot-swappable-atom-runtime"; import { runtimeContextLayer } from "../lib/runtime"; @@ -20,7 +22,7 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( Layer.provide(runtimeContextLayer), ); -const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer); +const snapshotLoaderLayer = demoAwareSnapshotLoaderLayer; type ConnectionLayerSource = | typeof Connection.layer @@ -39,6 +41,7 @@ const providedClientConnectionLayer = snapshotLoaderLayer.pipe( runtimeContextLayer, providedConnectionPlatformLayer, mobileBackgroundActivityObserverLayer, + demoConnectionDriverOverrideLayer, ), ), ); diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 6c0d60beb5f59..f23f8e193bd28 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -18,7 +18,9 @@ import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { useEnvironments } from "../../state/environments"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { TryDemoButton } from "../demo/TryDemoButton"; type ConnectionsNewRouteParams = { readonly mode?: string; @@ -49,6 +51,11 @@ export function ConnectionsNewRouteScreen({ const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + // The connection error is app-wide state that sends and other flows also + // write. Only show it here after this sheet tried to pair, so the sheet + // never opens already showing an unrelated or stale failure. + const [attemptedPairing, setAttemptedPairing] = useState(false); + const pairingError = attemptedPairing ? pairingConnectionError : null; const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); @@ -57,6 +64,8 @@ export function ConnectionsNewRouteScreen({ const headerIconColor = useUniwindTheme()["--color-icon"]; const connectDisabled = isSubmitting || hostInput.trim().length === 0; + // Someone without a computer yet (an App Store reviewer, say) can explore sample data. + const showDemoEntry = useEnvironments().environments.length === 0; useEffect(() => { const { host, code } = parsePairingUrl(connectionPairingUrl); @@ -157,6 +166,7 @@ export function ConnectionsNewRouteScreen({ const connectAndClose = useCallback( async (pairingUrl: string, replaceWithHome: boolean) => { setIsSubmitting(true); + setAttemptedPairing(true); onChangeConnectionPairingUrl(pairingUrl); try { const result = await onConnectPress(pairingUrl); @@ -262,40 +272,43 @@ export function ConnectionsNewRouteScreen({ ) ) : ( - - + <> + {showDemoEntry ? : null} + + - + - {pairingConnectionError ? : null} + {pairingError ? : null} - - { - void handleSubmit(); - }} - /> + + { + void handleSubmit(); + }} + /> + - + )} diff --git a/apps/mobile/src/features/demo/DemoModeFrame.tsx b/apps/mobile/src/features/demo/DemoModeFrame.tsx new file mode 100644 index 0000000000000..8d6fffdfa7014 --- /dev/null +++ b/apps/mobile/src/features/demo/DemoModeFrame.tsx @@ -0,0 +1,102 @@ +import { createNavigationContainerRef } from "@react-navigation/native"; +import type { ReactNode } from "react"; +import { Alert, Pressable, View } from "react-native"; +import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { exitDemoMode, useDemoModeActive } from "./demoMode"; + +export const appNavigationRef = createNavigationContainerRef(); + +export const DEMO_BANNER_TITLE = "Demo"; +export const DEMO_BANNER_DETAIL = "Sample data"; +export const EXIT_DEMO_LABEL = "Exit demo"; + +function openConnectFlow() { + if (!appNavigationRef.isReady()) return; + appNavigationRef.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }); +} + +// Long enough for the rebuilt navigation tree to mount. +const REMOUNT_MS = 600; + +function leaveDemoForConnectFlow() { + exitDemoMode(); + setTimeout(openConnectFlow, REMOUNT_MS); +} + +export function confirmExitDemoMode() { + Alert.alert( + "Exit demo?", + "The sample data is removed from this device. Next, you can pair Control Plane on your computer.", + [ + { text: "Stay in demo", style: "cancel" }, + { + text: EXIT_DEMO_LABEL, + style: "destructive", + onPress: leaveDemoForConnectFlow, + }, + ], + ); +} + +function DemoModeBanner() { + const insets = useSafeAreaInsets(); + return ( + + + + + + {DEMO_BANNER_TITLE} + + + + {DEMO_BANNER_DETAIL} + + + + {EXIT_DEMO_LABEL} + + + + ); +} + +/** + * Hosts the app below a persistent demo banner while demo mode is on. The + * nested safe-area provider measures from below the banner, so screens and + * native headers lay out as if the banner were the top of the screen. + * + * Entering or leaving demo mode remounts navigation (the `key`). That starts + * both sides on Home, never leaves a demo screen open without its data, and + * gives UIKit a fresh hierarchy: resizing the frame under a sheet that is + * being dismissed in code leaves the presenting view stuck in its shrunken + * card state on iOS 26 and later. + */ +export function DemoModeFrame(props: { readonly children: ReactNode }) { + const active = useDemoModeActive(); + return ( + + {active ? : null} + + {props.children} + + + ); +} diff --git a/apps/mobile/src/features/demo/TryDemoButton.tsx b/apps/mobile/src/features/demo/TryDemoButton.tsx new file mode 100644 index 0000000000000..b844e046757a0 --- /dev/null +++ b/apps/mobile/src/features/demo/TryDemoButton.tsx @@ -0,0 +1,57 @@ +import { Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { enterDemoMode } from "./demoMode"; + +export const TRY_DEMO_LABEL = "Try demo"; +export const TRY_DEMO_DETAIL = "No computer? Explore the app with sample data."; + +/** + * The demo entry point on first-run screens. Starting the demo adds the + * "Demo Mac" environment; `DemoModeFrame` then rebuilds navigation on Home, + * which also closes the sheet this button may sit in. + */ +export function TryDemoButton(props: { + /** `row` puts the explanation beside the button, for sheets with little height. */ + readonly layout?: "stack" | "row"; + readonly className?: string; +}) { + const row = props.layout === "row"; + return ( + + {row ? ( + + {TRY_DEMO_DETAIL} + + ) : null} + enterDemoMode()} + testID="try-demo-button" + > + + {TRY_DEMO_LABEL} + + {row ? null : ( + {TRY_DEMO_DETAIL} + )} + + ); +} diff --git a/apps/mobile/src/features/demo/demoFixtures.test.ts b/apps/mobile/src/features/demo/demoFixtures.test.ts new file mode 100644 index 0000000000000..385409f793d28 --- /dev/null +++ b/apps/mobile/src/features/demo/demoFixtures.test.ts @@ -0,0 +1,80 @@ +import { partitionAssistants } from "@t3tools/client-runtime/state/assistants"; +import { OrchestrationShellSnapshot, ServerConfig } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DEMO_ENVIRONMENT_ID, + DEMO_ENVIRONMENT_LABEL, + DEMO_THREAD_IDS, + makeDemoData, +} from "./demoFixtures"; +import { DemoServer } from "./demoServer"; + +const NOW = Date.parse("2026-09-29T12:00:00.000Z"); +const encodeServerConfig = Schema.encodeSync(ServerConfig); +const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); +const encodeShellSnapshot = Schema.encodeSync(OrchestrationShellSnapshot); +const decodeShellSnapshot = Schema.decodeUnknownSync(OrchestrationShellSnapshot); + +describe("demo fixtures", () => { + it("describes the Demo Mac environment with Projects enabled", () => { + const { serverConfig } = makeDemoData(NOW); + expect(serverConfig.environment.environmentId).toBe(DEMO_ENVIRONMENT_ID); + expect(serverConfig.environment.label).toBe(DEMO_ENVIRONMENT_LABEL); + expect(serverConfig.environment.capabilities.assistants).toBe(true); + expect(serverConfig.providers.length).toBeGreaterThan(0); + // Round-trips through the wire schema, like a config from a real computer. + const encoded = encodeServerConfig(serverConfig); + expect(decodeServerConfig(encoded).environment.environmentId).toBe(DEMO_ENVIRONMENT_ID); + }); + + it("builds a shell snapshot that survives the wire schema", () => { + const snapshot = new DemoServer({ now: () => NOW }).shellSnapshot(); + const encoded = encodeShellSnapshot(snapshot); + const decoded = decodeShellSnapshot(encoded); + expect(decoded.threads).toHaveLength(snapshot.threads.length); + expect(decoded.projects).toHaveLength(snapshot.projects.length); + }); + + it("covers Projects, Tasks folders and every thread state the reviewer should see", () => { + const snapshot = new DemoServer({ now: () => NOW }).shellSnapshot(); + const projects = snapshot.projects.map((project) => ({ + ...project, + environmentId: DEMO_ENVIRONMENT_ID, + })); + const threads = snapshot.threads.map((thread) => ({ + ...thread, + environmentId: DEMO_ENVIRONMENT_ID, + })); + const partition = partitionAssistants(projects, threads, null); + + expect(partition.assistants.map((entry) => entry.project.title)).toEqual([ + "Mobile App", + "Website Launch", + ]); + for (const entry of partition.assistants) { + expect(entry.coordinator).not.toBeNull(); + expect(entry.agents.length).toBeGreaterThan(0); + } + expect(partition.workspaceProjects.map((project) => project.title).sort()).toEqual([ + "acme-web", + "api-server", + ]); + expect(partition.workspaceThreads.length).toBeGreaterThanOrEqual(4); + + const byId = new Map(snapshot.threads.map((thread) => [thread.id as string, thread])); + expect(byId.get(DEMO_THREAD_IDS.heroLayout)?.session?.status).toBe("running"); + expect(byId.get(DEMO_THREAD_IDS.heroLayout)?.planProgress?.step).toBeTruthy(); + expect(byId.get(DEMO_THREAD_IDS.blogPost)?.settledAt).not.toBeNull(); + expect(byId.get(DEMO_THREAD_IDS.darkMode)?.hasPendingApprovals).toBe(true); + expect(byId.get(DEMO_THREAD_IDS.pricingCopy)?.hasPendingUserInput).toBe(true); + }); + + it("gives every thread a conversation", () => { + for (const thread of makeDemoData(NOW).threads) { + expect(thread.messages.some((message) => message.role === "user")).toBe(true); + expect(thread.messages.some((message) => message.role === "assistant")).toBe(true); + } + }); +}); diff --git a/apps/mobile/src/features/demo/demoFixtures.ts b/apps/mobile/src/features/demo/demoFixtures.ts new file mode 100644 index 0000000000000..ecca87a815fe2 --- /dev/null +++ b/apps/mobile/src/features/demo/demoFixtures.ts @@ -0,0 +1,570 @@ +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; +import { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThread, + type OrchestrationThreadShell, + ServerConfig, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +/** + * Sample data for demo mode. Everything is decoded through the real contract + * schemas, so the demo environment feeds the same state layer and screens as a + * paired computer. + */ + +export const DEMO_ENVIRONMENT_ID = EnvironmentId.make("demo-mac"); +export const DEMO_ENVIRONMENT_LABEL = "Demo Mac"; +/** Never contacted: the demo session is served in-process. */ +export const DEMO_HTTP_BASE_URL = "https://demo.controlplane.invalid"; +export const DEMO_WS_BASE_URL = "wss://demo.controlplane.invalid"; + +export const DEMO_UNAVAILABLE_MESSAGE = "Demo mode: connect a computer to do this."; + +const MODEL_SELECTION = { instanceId: "claudeAgent", model: "claude-opus-4-6" } as const; +const CODEX_SELECTION = { instanceId: "codex", model: "gpt-5.4" } as const; + +export const DEMO_PROJECT_IDS = { + launch: "demo-project-launch", + mobile: "demo-project-mobile", + web: "demo-tasks-acme-web", + api: "demo-tasks-api-server", +} as const; + +export const DEMO_THREAD_IDS = { + launchCoordinator: "demo-thread-launch-coordinator", + heroLayout: "demo-thread-hero-layout", + blogPost: "demo-thread-blog-post", + mobileCoordinator: "demo-thread-mobile-coordinator", + darkMode: "demo-thread-dark-mode", + pricingCopy: "demo-thread-pricing-copy", + reactUpgrade: "demo-thread-react-upgrade", + rateLimit: "demo-thread-rate-limit", + slowSearch: "demo-thread-slow-search", +} as const; + +export const DEMO_APPROVAL_REQUEST_ID = "demo-approval-run-tests"; +export const DEMO_QUESTION_REQUEST_ID = "demo-question-pricing-tone"; + +const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); +const decodeThread = Schema.decodeUnknownSync(OrchestrationThread); +const decodeShellSnapshot = Schema.decodeUnknownSync(OrchestrationShellSnapshot); + +function minutesAgo(now: number, minutes: number): string { + return new Date(now - minutes * 60_000).toISOString(); +} + +export function makeDemoServerConfig(now: number): ServerConfig { + const checkedAt = new Date(now).toISOString(); + const provider = (input: { + readonly instanceId: string; + readonly driver: string; + readonly displayName: string; + readonly models: ReadonlyArray<{ readonly slug: string; readonly name: string }>; + }) => ({ + instanceId: input.instanceId, + driver: input.driver, + displayName: input.displayName, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: input.models.map((model, index) => ({ + slug: model.slug, + name: model.name, + isCustom: false, + isDefault: index === 0, + capabilities: null, + })), + }); + return decodeServerConfig({ + environment: { + environmentId: DEMO_ENVIRONMENT_ID, + label: DEMO_ENVIRONMENT_LABEL, + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "demo", + capabilities: { + repositoryIdentity: false, + connectionProbe: true, + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + assistants: true, + }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/Users/demo", + keybindingsConfigPath: "/Users/demo/.config/control-plane/keybindings.json", + keybindings: [], + issues: [], + providers: [ + provider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + displayName: "Claude", + models: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + }), + provider({ + instanceId: "codex", + driver: "codex", + displayName: "Codex", + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }), + ], + availableEditors: [], + observability: { + logsDirectoryPath: "/Users/demo/.control-plane/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + // Decodes to the server defaults, like a fresh install. + settings: {}, + shellResumeCompletionMarker: true, + threadResumeCompletionMarker: true, + }); +} + +interface DemoProjectInput { + readonly id: string; + readonly title: string; + readonly workspaceRoot: string; + readonly coordinatorThreadId?: string; + readonly icon: { readonly kind: "emoji"; readonly emoji: string } | null; + readonly createdMinutesAgo: number; +} + +const DEMO_PROJECTS: ReadonlyArray = [ + { + id: DEMO_PROJECT_IDS.launch, + title: "Website Launch", + workspaceRoot: "/Users/demo/Projects/website-launch", + coordinatorThreadId: DEMO_THREAD_IDS.launchCoordinator, + icon: { kind: "emoji", emoji: "🚀" }, + createdMinutesAgo: 60 * 24 * 6, + }, + { + id: DEMO_PROJECT_IDS.mobile, + title: "Mobile App", + workspaceRoot: "/Users/demo/Projects/mobile-app", + coordinatorThreadId: DEMO_THREAD_IDS.mobileCoordinator, + icon: { kind: "emoji", emoji: "📱" }, + createdMinutesAgo: 60 * 24 * 4, + }, + { + id: DEMO_PROJECT_IDS.web, + title: "acme-web", + workspaceRoot: "/Users/demo/Code/acme-web", + icon: null, + createdMinutesAgo: 60 * 24 * 20, + }, + { + id: DEMO_PROJECT_IDS.api, + title: "api-server", + workspaceRoot: "/Users/demo/Code/api-server", + icon: null, + createdMinutesAgo: 60 * 24 * 30, + }, +]; + +type DemoMessage = readonly [role: "user" | "assistant", text: string, minutesAgo: number]; + +interface DemoThreadInput { + readonly id: string; + readonly projectId: string; + readonly title: string; + readonly branch: string | null; + readonly codex?: boolean; + readonly state: "running" | "completed"; + readonly settledMinutesAgo?: number; + readonly messages: ReadonlyArray; + readonly activities?: (now: number, turnId: string) => ReadonlyArray; + readonly planProgress?: { + readonly step: string; + readonly completedSteps: number; + readonly totalSteps: number; + }; +} + +const DEMO_THREADS: ReadonlyArray = [ + { + id: DEMO_THREAD_IDS.launchCoordinator, + projectId: DEMO_PROJECT_IDS.launch, + title: "Launch coordinator", + branch: null, + state: "completed", + messages: [ + ["user", "Plan the launch for Friday. Split it into agents and keep me posted.", 190], + [ + "assistant", + [ + "Here is the plan for Friday's launch. I started two agents:", + "", + "1. **Fix hero image layout on mobile**: the hero crops badly under 400px wide. Running now.", + "2. **Write launch blog post**: draft is done and ready for your review.", + "", + "I will check back when the layout fix lands and run the full test suite before we ship.", + ].join("\n"), + 185, + ], + ["user", "Great. Make sure the blog post links to the pricing page.", 40], + [ + "assistant", + "Done. The post now links to `/pricing` in the intro and the closing call to action.", + 38, + ], + ], + }, + { + id: DEMO_THREAD_IDS.heroLayout, + projectId: DEMO_PROJECT_IDS.launch, + title: "Fix hero image layout on mobile", + branch: "fix/hero-mobile-layout", + state: "running", + planProgress: { step: "Running visual checks", completedSteps: 2, totalSteps: 4 }, + messages: [ + [ + "user", + "The hero image crops the headline on small phones. Fix it and check 320, 375 and 430 widths.", + 30, + ], + [ + "assistant", + [ + "Found it: `Hero.tsx` sets a fixed `height: 520px` and `object-fit: cover`, so narrow screens lose the top of the image.", + "", + "- Switched to `aspect-ratio: 4 / 3` below 480px", + "- Moved the headline above the image on small screens", + "", + "Running visual checks at 320, 375 and 430 now.", + ].join("\n"), + 12, + ], + ], + }, + { + id: DEMO_THREAD_IDS.blogPost, + projectId: DEMO_PROJECT_IDS.launch, + title: "Write launch blog post", + branch: "docs/launch-post", + state: "completed", + settledMinutesAgo: 90, + messages: [ + ["user", "Draft a short launch post: what it is, who it is for, one screenshot.", 180], + [ + "assistant", + [ + "Draft saved to `content/blog/launch.md` (640 words).", + "", + "**Outline**", + "- What we built and why", + "- Three things you can do on day one", + "- Pricing and how to get started", + "", + "I used `hero-dashboard.png` as the screenshot. Want a shorter version for social?", + ].join("\n"), + 150, + ], + ], + }, + { + id: DEMO_THREAD_IDS.mobileCoordinator, + projectId: DEMO_PROJECT_IDS.mobile, + title: "Mobile app coordinator", + branch: null, + state: "completed", + messages: [ + ["user", "Next up is dark mode. Get an agent on it.", 70], + [ + "assistant", + "Started **Add dark mode toggle**. It is waiting on your approval to run the settings tests.", + 55, + ], + ], + }, + { + id: DEMO_THREAD_IDS.darkMode, + projectId: DEMO_PROJECT_IDS.mobile, + title: "Add dark mode toggle", + branch: "feat/dark-mode-toggle", + codex: true, + state: "running", + messages: [ + [ + "user", + "Add a dark mode toggle to Settings > Appearance. Follow the system by default.", + 65, + ], + [ + "assistant", + [ + "Added `ThemeToggle` with three options: System, Light and Dark. The choice is saved per device.", + "", + "Next I want to run the settings test suite to make sure nothing else changed.", + ].join("\n"), + 52, + ], + ], + activities: (now, turnId) => [ + { + id: "demo-activity-approval-requested", + tone: "approval", + kind: "approval.requested", + summary: "Run tests", + payload: { + requestId: DEMO_APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "pnpm test --filter settings", + }, + turnId, + createdAt: minutesAgo(now, 51), + }, + ], + }, + { + id: DEMO_THREAD_IDS.pricingCopy, + projectId: DEMO_PROJECT_IDS.web, + title: "Update pricing page copy", + branch: "copy/pricing-refresh", + state: "running", + messages: [ + ["user", "Refresh the pricing page copy. Keep it short.", 25], + ["assistant", "I have two directions for the headline. Which tone do you want?", 22], + ], + activities: (now, turnId) => [ + { + id: "demo-activity-question-requested", + tone: "info", + kind: "user-input.requested", + summary: "Question", + payload: { + requestId: DEMO_QUESTION_REQUEST_ID, + questions: [ + { + id: "tone", + header: "Headline tone", + question: "Which tone should the pricing headline use?", + options: [ + { label: "Friendly", description: "Simple pricing that grows with you." }, + { label: "Direct", description: "One plan. Every feature. No surprises." }, + ], + multiSelect: false, + }, + ], + }, + turnId, + createdAt: minutesAgo(now, 22), + }, + ], + }, + { + id: DEMO_THREAD_IDS.reactUpgrade, + projectId: DEMO_PROJECT_IDS.web, + title: "Upgrade React to 19", + branch: "chore/react-19", + state: "completed", + messages: [ + ["user", "Upgrade React to 19 and fix anything that breaks.", 60 * 26], + [ + "assistant", + [ + "Upgraded `react` and `react-dom` to 19.1. Two fixes were needed:", + "", + "- Replaced `ReactDOM.render` with `createRoot` in `main.tsx`", + "- Removed a `defaultProps` usage in `Button.tsx`", + "", + "All 214 tests pass.", + ].join("\n"), + 60 * 25, + ], + ], + }, + { + id: DEMO_THREAD_IDS.rateLimit, + projectId: DEMO_PROJECT_IDS.api, + title: "Add rate limiting to /login", + branch: "feat/login-rate-limit", + codex: true, + state: "completed", + messages: [ + ["user", "Rate limit /login to 5 attempts per minute per IP.", 60 * 5], + [ + "assistant", + [ + "Added a sliding window limiter in `middleware/rateLimit.ts`:", + "", + "```ts", + 'app.post("/login", rateLimit({ windowMs: 60_000, max: 5 }), login);', + "```", + "", + "Returns `429` with a `Retry-After` header. Added 3 tests.", + ].join("\n"), + 60 * 5 - 8, + ], + ], + }, + { + id: DEMO_THREAD_IDS.slowSearch, + projectId: DEMO_PROJECT_IDS.api, + title: "Investigate slow search query", + branch: null, + state: "completed", + messages: [ + ["user", "Search takes 3 seconds on large accounts. Why?", 60 * 8], + [ + "assistant", + "The `ILIKE '%term%'` filter on `documents.title` scans the whole table. A trigram index brings it to about 40ms on the staging copy. Want me to add the migration?", + 60 * 8 - 15, + ], + ], + }, +]; + +function buildThread(input: DemoThreadInput, now: number): OrchestrationThread { + const turnId = `${input.id}-turn-1`; + const first = input.messages[0]; + const last = input.messages[input.messages.length - 1]; + const createdAt = minutesAgo(now, (first?.[2] ?? 0) + 1); + const updatedAt = minutesAgo(now, last?.[2] ?? 0); + const running = input.state === "running"; + const lastAssistant = input.messages.findLast(([role]) => role === "assistant"); + const selection = input.codex ? CODEX_SELECTION : MODEL_SELECTION; + return decodeThread({ + id: input.id, + projectId: input.projectId, + title: input.title, + modelSelection: selection, + runtimeMode: "approval-required", + interactionMode: "default", + branch: input.branch, + worktreePath: null, + latestTurn: { + turnId, + state: running ? "running" : "completed", + requestedAt: createdAt, + startedAt: createdAt, + completedAt: running ? null : updatedAt, + assistantMessageId: + lastAssistant === undefined ? null : `${input.id}-message-${input.messages.length - 1}`, + }, + createdAt, + updatedAt, + archivedAt: null, + settledOverride: input.settledMinutesAgo === undefined ? null : "settled", + settledAt: + input.settledMinutesAgo === undefined ? null : minutesAgo(now, input.settledMinutesAgo), + deletedAt: null, + messages: input.messages.map(([role, text, age], index) => ({ + id: `${input.id}-message-${index}`, + role, + text, + turnId, + streaming: false, + createdAt: minutesAgo(now, age), + updatedAt: minutesAgo(now, age), + })), + proposedPlans: [], + activities: input.activities?.(now, turnId) ?? [], + checkpoints: [], + session: { + threadId: input.id, + status: running ? "running" : "ready", + providerName: input.codex ? "Codex" : "Claude", + providerInstanceId: selection.instanceId, + runtimeMode: "approval-required", + activeTurnId: running ? turnId : null, + lastError: null, + updatedAt, + }, + }); +} + +/** Projects the full thread onto the shell row the Home list renders. */ +export function demoThreadShell( + thread: OrchestrationThread, + planProgress: DemoThreadInput["planProgress"] | null = null, +): OrchestrationThreadShell { + const pending = derivePendingRequests(thread.activities); + const latestUserMessage = thread.messages.findLast((m) => m.role === "user"); + const running = thread.latestTurn?.state === "running"; + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + pullRequests: thread.pullRequests, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt ?? null, + snoozedUntil: thread.snoozedUntil ?? null, + snoozedAt: thread.snoozedAt ?? null, + pinnedAt: thread.pinnedAt ?? null, + session: thread.session, + latestUserMessageAt: latestUserMessage?.createdAt ?? null, + hasPendingApprovals: pending.approvals.length > 0, + hasPendingUserInput: pending.userInputs.length > 0, + hasActionableProposedPlan: false, + planProgress: running ? planProgress : null, + }; +} + +export interface DemoData { + readonly serverConfig: ServerConfig; + readonly projects: OrchestrationShellSnapshot["projects"]; + readonly threads: ReadonlyArray; + readonly planProgressByThreadId: ReadonlyMap< + string, + NonNullable + >; +} + +export function makeDemoData(now: number = Date.now()): DemoData { + const threads = DEMO_THREADS.map((input) => buildThread(input, now)); + const snapshot = decodeShellSnapshot({ + snapshotSequence: 1, + updatedAt: new Date(now).toISOString(), + threads: [], + projects: DEMO_PROJECTS.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModelSelection: MODEL_SELECTION, + ...(project.icon === null ? {} : { projectIcon: project.icon }), + ...(project.coordinatorThreadId === undefined + ? {} + : { assistant: { coordinatorThreadId: project.coordinatorThreadId } }), + scripts: [], + createdAt: minutesAgo(now, project.createdMinutesAgo), + updatedAt: minutesAgo(now, project.createdMinutesAgo), + })), + }); + const planProgressByThreadId = new Map>(); + for (const input of DEMO_THREADS) { + if (input.planProgress !== undefined) planProgressByThreadId.set(input.id, input.planProgress); + } + return { + serverConfig: makeDemoServerConfig(now), + projects: snapshot.projects, + threads, + planProgressByThreadId, + }; +} diff --git a/apps/mobile/src/features/demo/demoMode.test.ts b/apps/mobile/src/features/demo/demoMode.test.ts new file mode 100644 index 0000000000000..e124670c2bdd9 --- /dev/null +++ b/apps/mobile/src/features/demo/demoMode.test.ts @@ -0,0 +1,108 @@ +import { assert, it } from "@effect/vitest"; +import { + BearerConnectionTarget, + ConnectionDriverOverride, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { afterEach, describe, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { DEMO_ENVIRONMENT_ID } from "./demoFixtures"; +import { + DEMO_CONNECTION_TARGET, + currentDemoServer, + demoConnectionDriverOverrideLayer, + demoModeActiveAtom, + demoRegistrations, + enterDemoMode, + exitDemoMode, + isDemoModeActive, +} from "./demoMode"; +import { DemoServer } from "./demoServer"; + +vi.mock("../../state/atom-registry", async () => { + const { AtomRegistry } = await import("effect/unstable/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); + +const demoEntry: ConnectionCatalogEntry = { + target: DEMO_CONNECTION_TARGET, + profile: Option.none(), + enabled: true, +}; + +const realEntry: ConnectionCatalogEntry = { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("real-mac"), + label: "Real Mac", + connectionId: "real-connection", + }), + profile: Option.none(), + enabled: true, +}; + +afterEach(() => exitDemoMode()); + +describe("demo mode state", () => { + it("registers the demo environment only while active", () => { + assert.deepStrictEqual(demoRegistrations(false), []); + const [registration] = demoRegistrations(true); + assert.strictEqual(registration?.target.environmentId, DEMO_ENVIRONMENT_ID); + assert.strictEqual(registration?.target.label, "Demo Mac"); + }); + + it("enters and exits, disposing the sample data", () => { + assert.isFalse(isDemoModeActive()); + const server = new DemoServer(); + const dispose = vi.spyOn(server, "dispose"); + + enterDemoMode({ server }); + assert.isTrue(appAtomRegistry.get(demoModeActiveAtom)); + assert.strictEqual(currentDemoServer(), server); + + exitDemoMode(); + assert.isFalse(appAtomRegistry.get(demoModeActiveAtom)); + assert.isNull(currentDemoServer()); + assert.strictEqual(dispose.mock.calls.length, 1); + }); + + it("starts fresh sample data on each entry", () => { + enterDemoMode(); + const first = currentDemoServer(); + exitDemoMode(); + enterDemoMode(); + assert.notStrictEqual(currentDemoServer(), first); + }); +}); + +describe("demo connection override", () => { + it.effect("serves the demo environment in-process and leaves real ones alone", () => + Effect.gen(function* () { + const override = yield* ConnectionDriverOverride; + assert.isTrue(Option.isNone(override.connect(realEntry))); + + enterDemoMode(); + const lease = override.connect(demoEntry); + assert.isTrue(Option.isSome(lease)); + if (Option.isNone(lease)) return; + const { prepared, session } = yield* Effect.scoped(lease.value); + assert.strictEqual(prepared.environmentId, DEMO_ENVIRONMENT_ID); + const config = yield* session.initialConfig; + assert.strictEqual(config.environment.environmentId, DEMO_ENVIRONMENT_ID); + }).pipe(Effect.provide(demoConnectionDriverOverrideLayer)), + ); + + it.effect("blocks the demo connection after exit instead of reaching the network", () => + Effect.gen(function* () { + const override = yield* ConnectionDriverOverride; + const lease = override.connect(demoEntry); + assert.isTrue(Option.isSome(lease)); + if (Option.isNone(lease)) return; + const error = yield* Effect.scoped(lease.value).pipe(Effect.flip); + assert.strictEqual(error._tag, "ConnectionBlockedError"); + }).pipe(Effect.provide(demoConnectionDriverOverrideLayer)), + ); +}); diff --git a/apps/mobile/src/features/demo/demoMode.ts b/apps/mobile/src/features/demo/demoMode.ts new file mode 100644 index 0000000000000..15a517de58d78 --- /dev/null +++ b/apps/mobile/src/features/demo/demoMode.ts @@ -0,0 +1,166 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + ConnectionBlockedError, + ConnectionDriverOverride, + type EnvironmentConnectionLease, + type PlatformConnectionRegistration, + PrimaryConnectionRegistration, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentCacheStore, PlatformConnectionSource } from "@t3tools/client-runtime/platform"; +import { ShellSnapshotLoader, shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell"; +import { + ThreadSnapshotLoader, + threadSnapshotLoaderLayer, +} from "@t3tools/client-runtime/state/threads"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { + DEMO_ENVIRONMENT_ID, + DEMO_ENVIRONMENT_LABEL, + DEMO_HTTP_BASE_URL, + DEMO_WS_BASE_URL, +} from "./demoFixtures"; +import { DemoServer } from "./demoServer"; + +/** + * Demo mode lets someone without a computer (an App Store reviewer, say) + * explore the app on sample data. It registers one platform environment, + * "Demo Mac", whose connection is served by an in-process `DemoServer`, so + * the real connection supervisor, shell and thread state, and screens all run + * unchanged. Nothing is persisted: relaunching the app or exiting demo mode + * removes the environment and its cached data, and real environments are + * never touched. + */ + +export const demoModeActiveAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:demo-mode-active"), +); + +let demoServer: DemoServer | null = null; + +export function currentDemoServer(): DemoServer | null { + return demoServer; +} + +export function isDemoModeActive(): boolean { + return appAtomRegistry.get(demoModeActiveAtom); +} + +export function useDemoModeActive(): boolean { + return useAtomValue(demoModeActiveAtom); +} + +export function isDemoEnvironmentId(environmentId: string | null | undefined): boolean { + return environmentId === DEMO_ENVIRONMENT_ID; +} + +export function enterDemoMode(options?: { readonly server?: DemoServer }): void { + if (appAtomRegistry.get(demoModeActiveAtom)) return; + demoServer = options?.server ?? new DemoServer(); + appAtomRegistry.set(demoModeActiveAtom, true); +} + +export function exitDemoMode(): void { + if (!appAtomRegistry.get(demoModeActiveAtom) && demoServer === null) return; + appAtomRegistry.set(demoModeActiveAtom, false); + demoServer?.dispose(); + demoServer = null; +} + +export const DEMO_CONNECTION_TARGET = new PrimaryConnectionTarget({ + environmentId: DEMO_ENVIRONMENT_ID, + label: DEMO_ENVIRONMENT_LABEL, + httpBaseUrl: DEMO_HTTP_BASE_URL, + wsBaseUrl: DEMO_WS_BASE_URL, +}); + +const DEMO_REGISTRATION = new PrimaryConnectionRegistration({ target: DEMO_CONNECTION_TARGET }); + +export function demoRegistrations(active: boolean): ReadonlyArray { + return active ? [DEMO_REGISTRATION] : []; +} + +/** + * Platform environments for the connection registry: the demo environment + * while demo mode is on, nothing otherwise. Leftover demo cache from a run + * that ended without exiting is cleared first. + */ +export const demoPlatformConnectionSourceLayer = Layer.effect( + PlatformConnectionSource, + Effect.gen(function* () { + const cache = yield* EnvironmentCacheStore; + const clearStaleDemoCache = cache.clear(DEMO_ENVIRONMENT_ID).pipe(Effect.ignore); + return PlatformConnectionSource.of({ + registrations: Stream.concat( + Stream.fromEffect(clearStaleDemoCache).pipe(Stream.drain), + AtomRegistry.toStream(appAtomRegistry, demoModeActiveAtom).pipe( + Stream.changes, + Stream.map(demoRegistrations), + ), + ), + }); + }), +); + +function demoLease(): Effect.Effect { + return Effect.suspend(() => { + const server = demoServer; + if (server === null) { + return Effect.fail( + new ConnectionBlockedError({ reason: "configuration", detail: "Demo mode has ended." }), + ); + } + return Effect.succeed({ + prepared: { + environmentId: DEMO_ENVIRONMENT_ID, + label: DEMO_ENVIRONMENT_LABEL, + httpBaseUrl: DEMO_HTTP_BASE_URL, + socketUrl: DEMO_WS_BASE_URL, + httpAuthorization: null, + target: DEMO_CONNECTION_TARGET, + }, + session: server.makeSession(), + }); + }); +} + +/** Serves the demo environment in-process; every other environment connects normally. */ +export const demoConnectionDriverOverrideLayer = Layer.succeed(ConnectionDriverOverride, { + connect: (entry) => + isDemoEnvironmentId(entry.target.environmentId) ? Option.some(demoLease()) : Option.none(), +}); + +/** HTTP snapshot loaders that answer the demo environment from memory, without a request. */ +export const demoAwareSnapshotLoaderLayer = Layer.merge( + Layer.effect( + ShellSnapshotLoader, + Effect.gen(function* () { + const base = yield* ShellSnapshotLoader; + return ShellSnapshotLoader.of({ + load: (prepared) => + isDemoEnvironmentId(prepared.environmentId) + ? Effect.sync(() => Option.fromNullishOr(demoServer?.shellSnapshot())) + : base.load(prepared), + }); + }), + ).pipe(Layer.provide(shellSnapshotLoaderLayer)), + Layer.effect( + ThreadSnapshotLoader, + Effect.gen(function* () { + const base = yield* ThreadSnapshotLoader; + return ThreadSnapshotLoader.of({ + load: (prepared, threadId, window, reasoningMessages) => + isDemoEnvironmentId(prepared.environmentId) + ? Effect.sync(() => Option.fromNullishOr(demoServer?.threadSnapshot(threadId))) + : base.load(prepared, threadId, window, reasoningMessages), + }); + }), + ).pipe(Layer.provide(threadSnapshotLoaderLayer)), +); diff --git a/apps/mobile/src/features/demo/demoServer.test.ts b/apps/mobile/src/features/demo/demoServer.test.ts new file mode 100644 index 0000000000000..ae312bead9989 --- /dev/null +++ b/apps/mobile/src/features/demo/demoServer.test.ts @@ -0,0 +1,244 @@ +import { assert, it } from "@effect/vitest"; +import { + ApprovalRequestId, + CommandId, + MessageId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; +import { describe } from "vite-plus/test"; + +import { + DEMO_APPROVAL_REQUEST_ID, + DEMO_PROJECT_IDS, + DEMO_QUESTION_REQUEST_ID, + DEMO_THREAD_IDS, + DEMO_UNAVAILABLE_MESSAGE, +} from "./demoFixtures"; +import { + DEMO_APPROVAL_REPLY_TEXT, + DEMO_REPLY_TEXT, + DemoModeUnavailableError, + DemoServer, +} from "./demoServer"; + +const NOW = Date.parse("2026-09-29T12:00:00.000Z"); + +function makeServer() { + const timers: Array<() => void> = []; + const server = new DemoServer({ + now: () => NOW, + setTimer: (callback) => { + timers.push(callback); + return callback; + }, + clearTimer: (handle) => { + const index = timers.indexOf(handle as () => void); + if (index >= 0) timers.splice(index, 1); + }, + }); + const flushReplies = () => { + for (const callback of timers.splice(0)) callback(); + }; + return { server, timers, flushReplies }; +} + +function shellRow(server: DemoServer, threadId: string) { + return server.shellSnapshot().threads.find((thread) => thread.id === threadId); +} + +describe("DemoServer commands", () => { + it("answers a sent message with a canned reply", () => { + const { server, flushReplies } = makeServer(); + const threadId = ThreadId.make(DEMO_THREAD_IDS.rateLimit); + const before = server.thread(threadId)!.messages.length; + + const receipt = server.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("demo-command-1"), + threadId, + message: { + messageId: MessageId.make("demo-user-message"), + role: "user", + text: "Can you also cover /signup?", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: new Date(NOW).toISOString(), + }); + + assert.isAbove(receipt.sequence, 1); + assert.strictEqual(server.thread(threadId)!.messages.length, before + 1); + assert.strictEqual(server.thread(threadId)!.latestTurn?.state, "running"); + + flushReplies(); + const thread = server.thread(threadId)!; + assert.strictEqual(thread.messages.length, before + 2); + assert.strictEqual(thread.messages.at(-1)?.text, DEMO_REPLY_TEXT); + assert.strictEqual(thread.latestTurn?.state, "completed"); + assert.strictEqual(thread.session?.status, "ready"); + }); + + it("creates a new task's thread from the bootstrap and replies", () => { + const { server, flushReplies } = makeServer(); + const threadId = ThreadId.make("demo-new-task"); + server.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("demo-command-new"), + threadId, + message: { + messageId: MessageId.make("demo-new-message"), + role: "user", + text: "Add a changelog page", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: ProjectId.make(DEMO_PROJECT_IDS.web), + title: "Add a changelog page", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: new Date(NOW).toISOString(), + }, + }, + createdAt: new Date(NOW).toISOString(), + }); + + assert.strictEqual(shellRow(server, threadId)?.title, "Add a changelog page"); + flushReplies(); + assert.strictEqual(server.thread(threadId)!.messages.at(-1)?.text, DEMO_REPLY_TEXT); + }); + + it("resolves the pending approval locally", () => { + const { server, flushReplies } = makeServer(); + const threadId = ThreadId.make(DEMO_THREAD_IDS.darkMode); + assert.isTrue(shellRow(server, threadId)?.hasPendingApprovals); + + server.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("demo-command-2"), + threadId, + requestId: ApprovalRequestId.make(DEMO_APPROVAL_REQUEST_ID), + decision: "accept", + createdAt: new Date(NOW).toISOString(), + }); + + assert.isFalse(shellRow(server, threadId)?.hasPendingApprovals); + flushReplies(); + assert.strictEqual(server.thread(threadId)!.messages.at(-1)?.text, DEMO_APPROVAL_REPLY_TEXT); + }); + + it("resolves the pending question locally", () => { + const { server } = makeServer(); + const threadId = ThreadId.make(DEMO_THREAD_IDS.pricingCopy); + assert.isTrue(shellRow(server, threadId)?.hasPendingUserInput); + + server.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.make("demo-command-3"), + threadId, + requestId: ApprovalRequestId.make(DEMO_QUESTION_REQUEST_ID), + answers: { tone: "Direct" }, + createdAt: new Date(NOW).toISOString(), + }); + + assert.isFalse(shellRow(server, threadId)?.hasPendingUserInput); + }); + + it("settles and pins threads", () => { + const { server } = makeServer(); + const threadId = ThreadId.make(DEMO_THREAD_IDS.slowSearch); + server.dispatch({ type: "thread.pin", commandId: CommandId.make("c-pin"), threadId }); + assert.isNotNull(shellRow(server, threadId)?.pinnedAt); + server.dispatch({ type: "thread.settle", commandId: CommandId.make("c-settle"), threadId }); + assert.isNotNull(shellRow(server, threadId)?.settledAt); + }); + + it("stops pending replies on dispose", () => { + const { server, timers } = makeServer(); + server.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("demo-command-4"), + threadId: ThreadId.make(DEMO_THREAD_IDS.darkMode), + requestId: ApprovalRequestId.make(DEMO_APPROVAL_REQUEST_ID), + decision: "accept", + createdAt: new Date(NOW).toISOString(), + }); + assert.strictEqual(timers.length, 1); + server.dispose(); + assert.strictEqual(timers.length, 0); + }); +}); + +describe("DemoServer session", () => { + it.effect("streams the shell snapshot, then the completion marker", () => + Effect.gen(function* () { + const { server } = makeServer(); + const items = yield* server + .makeSession() + .client[ORCHESTRATION_WS_METHODS.subscribeShell]({ requestCompletionMarker: true }) + .pipe(Stream.take(2), Stream.runCollect); + const [first, second] = Array.from(items); + assert.strictEqual(first?.kind, "snapshot"); + assert.strictEqual(second?.kind, "synchronized"); + }), + ); + + it.effect("pushes live thread updates to subscribers", () => + Effect.gen(function* () { + const { server } = makeServer(); + const threadId = ThreadId.make(DEMO_THREAD_IDS.pricingCopy); + const session = server.makeSession(); + const fiber = yield* session.client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId, + }).pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + // Let the subscription register before the command lands. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + server.dispatch({ type: "thread.settle", commandId: CommandId.make("c-live"), threadId }); + const [initial, update] = Array.from(yield* Fiber.join(fiber)); + assert.strictEqual(initial?.kind, "snapshot"); + assert.strictEqual(update?.kind, "snapshot"); + if (update?.kind === "snapshot") assert.isNotNull(update.snapshot.thread.settledAt); + }), + ); + + it.effect("serves the server config without a network", () => + Effect.gen(function* () { + const { server } = makeServer(); + const session = server.makeSession(); + const config = yield* session.initialConfig; + assert.strictEqual(config.environment.label, "Demo Mac"); + const events = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(1), Stream.runCollect); + assert.strictEqual(Array.from(events)[0]?.type, "snapshot"); + yield* session.client[WS_METHODS.serverProbe]({}); + }), + ); + + it.effect("fails unsupported requests with a friendly demo message", () => + Effect.gen(function* () { + const { server } = makeServer(); + // The demo fails requests the real schema declares infallible. + const error: unknown = yield* server + .makeSession() + .client[WS_METHODS.vcsListRefs]({ cwd: "/Users/demo/Code/acme-web" }) + .pipe(Effect.flip); + assert.instanceOf(error, DemoModeUnavailableError); + assert.strictEqual((error as DemoModeUnavailableError).message, DEMO_UNAVAILABLE_MESSAGE); + }), + ); +}); diff --git a/apps/mobile/src/features/demo/demoServer.ts b/apps/mobile/src/features/demo/demoServer.ts new file mode 100644 index 0000000000000..de6ea793e47c5 --- /dev/null +++ b/apps/mobile/src/features/demo/demoServer.ts @@ -0,0 +1,581 @@ +import type { RpcSession, WsRpcProtocolClient } from "@t3tools/client-runtime/rpc"; +import { + EventId, + MessageId, + ORCHESTRATION_WS_METHODS, + type OrchestrationMessage, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, + type OrchestrationThread, + type OrchestrationThreadActivity, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, + type ServerConfig, + type ServerConfigStreamEvent, + TurnId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as RpcSchema from "effect/unstable/rpc/RpcSchema"; + +import { + DEMO_UNAVAILABLE_MESSAGE, + type DemoData, + demoThreadShell, + makeDemoData, +} from "./demoFixtures"; + +/** What an unsupported demo request fails with. The message is safe to show as-is. */ +export class DemoModeUnavailableError extends Schema.TaggedError()( + "DemoModeUnavailableError", + { message: Schema.String }, +) {} + +export const DEMO_REPLY_TEXT = [ + "This is demo mode, so no agent is running on a real computer.", + "", + "To put agents to work on your own code, install Control Plane on your Mac, then pair this phone in **Settings > Connections > Environments**.", +].join("\n"); + +export const DEMO_APPROVAL_REPLY_TEXT = + "Approved. In a real session the agent would run the command on your computer and report back here."; +export const DEMO_DECLINE_REPLY_TEXT = + "Declined. The agent skips the command and asks how you want to continue."; +export const DEMO_ANSWER_REPLY_TEXT = + "Thanks, I will use that. In demo mode nothing changes on a real computer."; + +type Listener
= (item: A) => void; + +export interface DemoServerOptions { + readonly now?: () => number; + /** How long the canned agent reply takes to arrive. */ + readonly replyDelayMs?: number; + readonly setTimer?: (callback: () => void, delayMs: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; +} + +type DispatchCommand = Parameters< + WsRpcProtocolClient[typeof ORCHESTRATION_WS_METHODS.dispatchCommand] +>[0]; + +function isStreamTag(tag: string): boolean { + const rpc = WsRpcGroup.requests.get(tag); + return rpc !== undefined && RpcSchema.isStreamSchema(rpc.successSchema); +} + +/** + * An in-memory stand-in for a paired computer. It owns the demo projects and + * threads, answers the subscriptions the app opens, and applies the commands a + * reviewer can reasonably try (send, approve, answer, settle, pin, rename). + * Anything else fails with a friendly `DemoModeUnavailableError` instead of + * touching the network. + */ +export class DemoServer { + private readonly now: () => number; + private readonly replyDelayMs: number; + private readonly setTimer: (callback: () => void, delayMs: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + private readonly timers = new Set(); + private readonly shellListeners = new Set>(); + private readonly threadListeners = new Map< + string, + Set> + >(); + private readonly data: DemoData; + private readonly threads = new Map(); + private sequence = 1; + private idCounter = 0; + private disposed = false; + + constructor(options: DemoServerOptions = {}) { + this.now = options.now ?? Date.now; + this.replyDelayMs = options.replyDelayMs ?? 1_200; + this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + this.clearTimer = + options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType)); + this.data = makeDemoData(this.now()); + for (const thread of this.data.threads) this.threads.set(thread.id, thread); + } + + get serverConfig(): ServerConfig { + return this.data.serverConfig; + } + + shellSnapshot(): OrchestrationShellSnapshot { + return { + snapshotSequence: this.sequence, + projects: this.data.projects, + threads: [...this.threads.values()] + .filter((thread) => thread.deletedAt === null) + .map((thread) => this.shellFor(thread)), + updatedAt: new Date(this.now()).toISOString(), + }; + } + + threadSnapshot(threadId: string): OrchestrationThreadDetailSnapshot | null { + const thread = this.threads.get(threadId); + return thread === undefined ? null : { snapshotSequence: this.sequence, thread }; + } + + thread(threadId: string): OrchestrationThread | null { + return this.threads.get(threadId) ?? null; + } + + /** Stops pending replies and drops listeners. The instance is unusable afterwards. */ + dispose(): void { + this.disposed = true; + for (const handle of this.timers) this.clearTimer(handle); + this.timers.clear(); + this.shellListeners.clear(); + this.threadListeners.clear(); + } + + subscribeShell(listener: Listener): () => void { + this.shellListeners.add(listener); + return () => this.shellListeners.delete(listener); + } + + subscribeThread(threadId: string, listener: Listener): () => void { + const listeners = this.threadListeners.get(threadId) ?? new Set(); + listeners.add(listener); + this.threadListeners.set(threadId, listeners); + return () => listeners.delete(listener); + } + + dispatch(command: DispatchCommand): { readonly sequence: number } { + if (this.disposed) return { sequence: this.sequence }; + const at = new Date(this.now()).toISOString(); + switch (command.type) { + case "thread.create": + this.createThread(command.threadId, command); + break; + case "thread.turn.start": { + // New tasks create their thread in the same command. + const created = command.bootstrap?.createThread; + if (created !== undefined) { + this.createThread(command.threadId, { + ...created, + branch: created.branch ?? command.bootstrap?.prepareWorktree?.branch ?? null, + }); + } + const turnId = TurnId.make(this.nextId("demo-turn")); + const message: OrchestrationMessage = { + id: command.message.messageId, + role: "user", + text: command.message.text, + turnId, + streaming: false, + createdAt: at, + updatedAt: at, + }; + this.update(command.threadId, (thread) => ({ + ...thread, + ...(command.titleSeed !== undefined && thread.messages.length === 0 + ? { title: command.titleSeed } + : {}), + messages: [...thread.messages, message], + latestTurn: { + turnId, + state: "running", + requestedAt: at, + startedAt: at, + completedAt: null, + assistantMessageId: null, + }, + session: this.sessionFor(thread, "running", turnId, at), + settledAt: null, + settledOverride: thread.settledAt === null ? thread.settledOverride : "active", + updatedAt: at, + })); + this.scheduleReply(command.threadId, turnId, DEMO_REPLY_TEXT); + break; + } + case "thread.approval.respond": { + const accepted = command.decision !== "decline" && command.decision !== "cancel"; + this.resolveRequest(command.threadId, { + kind: "approval.resolved", + summary: accepted ? "Approved" : "Declined", + requestId: command.requestId, + at, + }); + this.scheduleReply( + command.threadId, + TurnId.make(this.nextId("demo-turn")), + accepted ? DEMO_APPROVAL_REPLY_TEXT : DEMO_DECLINE_REPLY_TEXT, + ); + break; + } + case "thread.user-input.respond": { + this.resolveRequest(command.threadId, { + kind: "user-input.resolved", + summary: "Answered", + requestId: command.requestId, + answers: command.answers, + at, + }); + this.scheduleReply( + command.threadId, + TurnId.make(this.nextId("demo-turn")), + DEMO_ANSWER_REPLY_TEXT, + ); + break; + } + case "thread.user-input.dismiss": { + this.resolveRequest(command.threadId, { + kind: "user-input.resolved", + summary: "Dismissed", + requestId: command.requestId, + at, + }); + break; + } + case "thread.turn.interrupt": + case "thread.session.stop": { + this.update(command.threadId, (thread) => this.completeTurn(thread, at, "interrupted")); + break; + } + case "thread.settle": + this.update(command.threadId, (thread) => ({ + ...thread, + settledAt: at, + settledOverride: "settled", + pinnedAt: null, + })); + break; + case "thread.unsettle": + this.update(command.threadId, (thread) => ({ + ...thread, + settledAt: null, + settledOverride: "active", + unsettledAt: at, + })); + break; + case "thread.pin": + this.update(command.threadId, (thread) => ({ ...thread, pinnedAt: at })); + break; + case "thread.unpin": + this.update(command.threadId, (thread) => ({ ...thread, pinnedAt: null })); + break; + case "thread.snooze": + this.update(command.threadId, (thread) => ({ + ...thread, + snoozedUntil: command.snoozedUntil, + snoozedAt: at, + })); + break; + case "thread.unsnooze": + this.update(command.threadId, (thread) => ({ + ...thread, + snoozedUntil: null, + snoozedAt: null, + })); + break; + case "thread.archive": + this.update(command.threadId, (thread) => ({ ...thread, archivedAt: at })); + break; + case "thread.unarchive": + this.update(command.threadId, (thread) => ({ ...thread, archivedAt: null })); + break; + case "thread.meta.update": + this.update(command.threadId, (thread) => ({ + ...thread, + ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.modelSelection !== undefined + ? { modelSelection: command.modelSelection } + : {}), + })); + break; + case "thread.runtime-mode.set": + this.update(command.threadId, (thread) => ({ + ...thread, + runtimeMode: command.runtimeMode, + })); + break; + case "thread.interaction-mode.set": + this.update(command.threadId, (thread) => ({ + ...thread, + interactionMode: command.interactionMode, + })); + break; + default: + // Accepted but ignored: the demo has no computer to act on. + break; + } + return { sequence: this.sequence }; + } + + /** The RPC session the connection supervisor sees for the demo environment. */ + makeSession(): RpcSession { + const handlers: Record unknown> = { + [WS_METHODS.subscribeServerConfig]: () => + Stream.concat( + Stream.succeed({ + version: 1, + type: "snapshot", + config: this.serverConfig, + }), + Stream.never, + ), + [WS_METHODS.serverGetConfig]: () => Effect.succeed(this.serverConfig), + [WS_METHODS.serverProbe]: () => Effect.succeed({}), + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { + readonly requestCompletionMarker?: boolean; + }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const offer = (item: OrchestrationShellStreamItem) => { + Queue.offerUnsafe(queue, item); + }; + offer({ kind: "snapshot", snapshot: this.shellSnapshot() }); + if (input.requestCompletionMarker === true) offer({ kind: "synchronized" }); + return this.subscribeShell(offer); + }), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { + readonly threadId: string; + readonly requestCompletionMarker?: boolean; + }) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const offer = (item: OrchestrationThreadStreamItem) => { + Queue.offerUnsafe(queue, item); + }; + const snapshot = this.threadSnapshot(input.threadId); + if (snapshot !== null) offer({ kind: "snapshot", snapshot }); + if (input.requestCompletionMarker === true) offer({ kind: "synchronized" }); + return this.subscribeThread(input.threadId, offer); + }), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: DispatchCommand) => + Effect.sync(() => this.dispatch(command)), + [ORCHESTRATION_WS_METHODS.searchThreads]: () => Effect.succeed({ matches: [] }), + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: () => + Effect.sync(() => { + const snapshot = this.shellSnapshot(); + return { + ...snapshot, + threads: snapshot.threads.filter((thread) => thread.archivedAt !== null), + }; + }), + }; + const unavailable = () => + Effect.fail(new DemoModeUnavailableError({ message: DEMO_UNAVAILABLE_MESSAGE })); + const client = new Proxy( + {}, + { + get: (_target, tag) => { + if (typeof tag !== "string") return undefined; + const handler = handlers[tag]; + if (handler !== undefined) return handler; + // Streams the demo cannot feed stay quiet rather than erroring. + return isStreamTag(tag) ? () => Stream.never : unavailable; + }, + }, + ) as WsRpcProtocolClient; + const initialConfig = Effect.succeed(this.serverConfig); + return { + client, + initialConfig, + subscribeServerConfig: (input) => + ( + handlers[WS_METHODS.subscribeServerConfig] as ( + value: typeof input, + ) => ReturnType + )(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + } + + private createThread( + threadId: OrchestrationThread["id"], + input: Pick< + OrchestrationThread, + | "projectId" + | "title" + | "modelSelection" + | "runtimeMode" + | "interactionMode" + | "branch" + | "worktreePath" + | "createdAt" + >, + ): void { + if (this.threads.has(threadId)) return; + this.threads.set(threadId, { + ...input, + id: threadId, + pullRequests: [], + latestTurn: null, + updatedAt: input.createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }); + this.publish(threadId); + } + + private shellFor(thread: OrchestrationThread) { + return demoThreadShell(thread, this.data.planProgressByThreadId.get(thread.id) ?? null); + } + + private nextId(prefix: string): string { + this.idCounter += 1; + return `${prefix}-${this.now()}-${this.idCounter}`; + } + + private sessionFor( + thread: OrchestrationThread, + status: "running" | "ready" | "interrupted", + activeTurnId: TurnId | null, + at: string, + ): OrchestrationThread["session"] { + return { + threadId: thread.id, + status, + providerName: thread.session?.providerName ?? null, + ...(thread.session?.providerInstanceId !== undefined + ? { providerInstanceId: thread.session.providerInstanceId } + : {}), + runtimeMode: thread.runtimeMode, + activeTurnId, + lastError: null, + updatedAt: at, + }; + } + + private completeTurn( + thread: OrchestrationThread, + at: string, + state: "completed" | "interrupted", + assistantMessageId: MessageId | null = null, + ): OrchestrationThread { + return { + ...thread, + latestTurn: + thread.latestTurn === null + ? null + : { + ...thread.latestTurn, + state, + completedAt: at, + ...(assistantMessageId !== null ? { assistantMessageId } : {}), + }, + session: this.sessionFor(thread, state === "completed" ? "ready" : "interrupted", null, at), + updatedAt: at, + }; + } + + private resolveRequest( + threadId: string, + input: { + readonly kind: "approval.resolved" | "user-input.resolved"; + readonly summary: string; + readonly requestId: string; + readonly answers?: Readonly>; + readonly at: string; + }, + ): void { + this.update(threadId, (thread) => { + const activity: OrchestrationThreadActivity = { + id: EventId.make(this.nextId("demo-activity")), + tone: input.kind === "approval.resolved" ? "approval" : "info", + kind: input.kind, + summary: input.summary, + payload: { + requestId: input.requestId, + ...(input.answers === undefined ? {} : { answers: input.answers }), + }, + turnId: thread.latestTurn?.turnId ?? null, + createdAt: input.at, + }; + return { ...thread, activities: [...thread.activities, activity], updatedAt: input.at }; + }); + } + + private scheduleReply(threadId: string, turnId: TurnId, text: string): void { + const handle = this.setTimer(() => { + this.timers.delete(handle); + if (this.disposed) return; + const at = new Date(this.now()).toISOString(); + const messageId = MessageId.make(this.nextId("demo-message")); + this.update(threadId, (thread) => { + const withTurn: OrchestrationThread = + thread.latestTurn?.state === "running" + ? thread + : { + ...thread, + latestTurn: { + turnId, + state: "running", + requestedAt: at, + startedAt: at, + completedAt: null, + assistantMessageId: null, + }, + }; + const activeTurnId = withTurn.latestTurn?.turnId ?? turnId; + const reply: OrchestrationMessage = { + id: messageId, + role: "assistant", + text, + turnId: activeTurnId, + streaming: false, + createdAt: at, + updatedAt: at, + }; + return this.completeTurn( + { ...withTurn, messages: [...withTurn.messages, reply] }, + at, + "completed", + messageId, + ); + }); + }, this.replyDelayMs); + this.timers.add(handle); + } + + private update( + threadId: string, + transform: (thread: OrchestrationThread) => OrchestrationThread, + ): void { + const current = this.threads.get(threadId); + if (current === undefined) return; + this.threads.set(threadId, transform(current)); + this.publish(threadId); + } + + private publish(threadId: string): void { + const thread = this.threads.get(threadId); + if (thread === undefined) return; + this.sequence += 1; + const sequence = this.sequence; + const snapshot: OrchestrationThreadDetailSnapshot = { snapshotSequence: sequence, thread }; + for (const listener of this.threadListeners.get(threadId) ?? []) { + listener({ kind: "snapshot", snapshot }); + } + const item: OrchestrationShellStreamItem = { + kind: "thread-upserted", + sequence, + thread: this.shellFor(thread), + }; + for (const listener of this.shellListeners) listener(item); + } +} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index bcb6365cd1699..8ddd5cb97dbe5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -147,7 +147,7 @@ export function HomeRouteScreen() { ) : null} navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3dc7694c41ad7..140c66c7faf72 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -16,6 +16,7 @@ import { cn } from "../../lib/cn"; import { AppText as Text } from "../../components/AppText"; import { AppTextInput } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { TryDemoButton } from "../demo/TryDemoButton"; import { HomeComposerBar } from "./HomeComposerBar"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -237,6 +238,9 @@ export function HomeScreen(props: HomeScreenProps) { } variant="plain" /> + {!props.catalogState.isLoadingConnections && !props.catalogState.hasConnections ? ( + + ) : null} {emptyState.loading ? ( diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 9fd7b802fdebf..09987928e7f3c 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -5,6 +5,7 @@ import { AppText as Text } from "../../components/AppText"; import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import { EmptyState } from "../../components/EmptyState"; +import { TryDemoButton } from "../demo/TryDemoButton"; export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void; @@ -24,16 +25,21 @@ export function WorkspaceEmptyDetail(props: { title="No environments connected" detail="Add an environment to load workspaces and start coding sessions." variant="plain" - action={ - - } + {...(Platform.OS === "android" + ? { + action: ( + + ), + } + : { actionLabel: "Add environment", onAction: props.onAddConnection })} /> + ) : ( diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index a519f173510ca..d60eb0fe17b2d 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -17,6 +17,7 @@ import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; +import { TryDemoButton } from "../demo/TryDemoButton"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; @@ -275,14 +276,17 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps ) : !catalogState.hasReadyEnvironment ? ( - navigation.navigate("ConnectionsNew")} - > - - Add environment - - + <> + navigation.navigate("ConnectionsNew")} + > + + Add environment + + + {!catalogState.hasConnections ? : null} + ) : ( ()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} +/** + * Lets a platform serve an environment from an in-process session instead of + * the network, such as the mobile app's local demo environment. Returning + * `None` falls through to the normal resolver and socket connection. + */ +export class ConnectionDriverOverride extends Context.Reference<{ + readonly connect: ( + entry: ConnectionCatalogEntry, + ) => Option.Option< + Effect.Effect + >; +}>("@t3tools/client-runtime/connection/driver/ConnectionDriverOverride", { + defaultValue: () => ({ connect: () => Option.none() }), +}) {} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const resolver = yield* ConnectionResolver.ConnectionResolver; const sessions = yield* RpcSession.RpcSessionFactory; + const override = yield* ConnectionDriverOverride; const connect = Effect.fn("ConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, @@ -50,6 +67,10 @@ export const make = Effect.gen(function* () { "connection.environment.id": target.environmentId, "connection.target.kind": target._tag, }); + const overridden = override.connect(entry); + if (Option.isSome(overridden)) { + return yield* overridden.value; + } yield* reportProgress({ stage: "preparing" }); const prepared = yield* resolver.prepare(entry); yield* reportProgress({ stage: "opening", prepared }); diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index cf8385aa132ba..b6a592ee43305 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -1,7 +1,11 @@ export * from "./catalog.ts"; export * as Connectivity from "./connectivity.ts"; export * as CredentialStore from "./credentialStore.ts"; -export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; +export { + type ConnectionDriverProgress, + ConnectionDriverOverride, + type EnvironmentConnectionLease, +} from "./driver.ts"; export * from "./errors.ts"; export * from "./githubRoutingPermissions.ts"; export * as Connection from "./layer.ts"; From 766c399c930e3d70cff6035e1f3dc6f189860c35 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:22:39 -0600 Subject: [PATCH 13/28] fix(mobile): friendly pairing error for unreachable or failing hosts Pairing with a host that is offline or answers with a server error (the stopped App Review server returned 503) showed raw transport or status text. The Add Environment screen now says the computer could not be reached, what to check, and that Try demo works without one. Code, permission and compatibility failures keep their specific message. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/connection/pairing.test.ts | 30 +++++++++++++++++++ .../mobile/src/features/connection/pairing.ts | 18 +++++++++++ .../state/use-remote-environment-registry.ts | 4 +-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 1939276847949..5a1e92e70dde9 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,10 +1,16 @@ +import { + ConnectionBlockedError, + ConnectionTransientError, +} from "@t3tools/client-runtime/connection"; import { describe, expect, it } from "vite-plus/test"; import { buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, + pairingErrorMessage, parsePairingUrl, + UNREACHABLE_PAIRING_MESSAGE, } from "./pairing"; describe("buildPairingUrl", () => { @@ -62,3 +68,27 @@ describe("parsePairingUrl", () => { }); }); }); + +describe("pairingErrorMessage", () => { + it("explains an unreachable host or a server error", () => { + for (const reason of ["network", "timeout", "remote-unavailable"] as const) { + expect( + pairingErrorMessage( + new ConnectionTransientError({ reason, detail: "Request failed with status 503" }), + ), + ).toBe(UNREACHABLE_PAIRING_MESSAGE); + } + }); + + it("keeps specific pairing failures", () => { + expect( + pairingErrorMessage( + new ConnectionBlockedError({ + reason: "authentication", + detail: "The environment credential is invalid.", + }), + ), + ).toBe("The environment credential is invalid."); + expect(pairingErrorMessage("nope")).toBe("Failed to pair with the environment."); + }); +}); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index ee9e4b9ec8f3c..20fa20b26eb31 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -90,3 +90,21 @@ export function extractPairingUrlFromQrPayload(payload: string): string { return trimmed; } + +export const UNREACHABLE_PAIRING_MESSAGE = + "Could not reach that computer. Check that Control Plane is running on your Mac and that this phone can reach it, then try again. No computer? Tap Try demo to explore with sample data."; + +/** + * What the Add Environment screen shows when pairing fails. An unreachable + * host or a server error (a 503 from a stopped server, say) gets a message the + * user can act on instead of the raw transport or status text. Pairing-code, + * permission and compatibility failures keep their specific message. + */ +export function pairingErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "_tag" in error) { + if (error._tag === "ConnectionTransientError") return UNREACHABLE_PAIRING_MESSAGE; + } + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Failed to pair with the environment."; +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 1664dc7461aad..97b5f62194d0e 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -5,6 +5,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; +import { pairingErrorMessage } from "../features/connection/pairing"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; import { useWorkspaceState } from "../state/workspace"; @@ -126,8 +127,7 @@ export function useRemoteConnections() { const result = await controller.connectPairingUrl(nextPairingUrl); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); - const message = - error instanceof Error ? error.message : "Failed to pair with the environment."; + const message = pairingErrorMessage(error); if ( error !== null && typeof error === "object" && From 644115fec033afda85cccbb70f93dfde9bf3192e Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:54:55 -0600 Subject: [PATCH 14/28] fix(connection): retry and explain failed pairing requests Build 14's review server returned 503 from /.well-known/t3/environment, which surfaced as "returned undeclared status 503" with no way forward. - Undeclared statuses keep their transient class (saved environments still reconnect on web and mobile) but split by reason: 5xx is remote-unavailable, 4xx is endpoint-unavailable. - The status error records Retry-After. - Pairing retries 502/503/504 twice over about 20s, honoring Retry-After (capped at 12s). - Mobile maps each reason, and a rejected pairing code, to plain text, with Try again and Try demo buttons under the banner. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../connection/ConnectionsNewRouteScreen.tsx | 19 ++- .../src/features/connection/pairing.test.ts | 41 +++++- .../mobile/src/features/connection/pairing.ts | 33 ++++- .../src/features/demo/TryDemoButton.tsx | 9 +- .../client-runtime/src/connection/errors.ts | 9 +- .../src/connection/onboarding.test.ts | 125 +++++++++++++++++- .../src/connection/onboarding.ts | 41 +++++- packages/client-runtime/src/rpc/http.ts | 30 ++++- .../client-runtime/src/rpc/retryAfter.test.ts | 18 +++ 9 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 packages/client-runtime/src/rpc/retryAfter.test.ts diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index f23f8e193bd28..76f20bf35d3bd 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -294,7 +294,24 @@ export function ConnectionsNewRouteScreen({ onChangeText={handleCodeChange} /> - {pairingError ? : null} + {pairingError ? ( + + + + { + void handleSubmit(); + }} + /> + + + + ) : null} { @@ -70,17 +75,31 @@ describe("parsePairingUrl", () => { }); describe("pairingErrorMessage", () => { - it("explains an unreachable host or a server error", () => { - for (const reason of ["network", "timeout", "remote-unavailable"] as const) { + const requestUrl = "https://review.example.test/.well-known/t3/environment"; + + it("explains a server that returns 503", () => { + const error = mapRemoteEnvironmentError( + new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, 503), + ); + expect(pairingErrorMessage(error)).toBe(SERVER_UNAVAILABLE_PAIRING_MESSAGE); + }); + + it("points at the host and code for a 4xx", () => { + const error = mapRemoteEnvironmentError( + new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, 404), + ); + expect(pairingErrorMessage(error)).toBe(WRONG_HOST_PAIRING_MESSAGE); + }); + + it("explains an unreachable host", () => { + for (const reason of ["network", "timeout", "transport"] as const) { expect( - pairingErrorMessage( - new ConnectionTransientError({ reason, detail: "Request failed with status 503" }), - ), + pairingErrorMessage(new ConnectionTransientError({ reason, detail: "fetch failed" })), ).toBe(UNREACHABLE_PAIRING_MESSAGE); } }); - it("keeps specific pairing failures", () => { + it("explains a rejected pairing code", () => { expect( pairingErrorMessage( new ConnectionBlockedError({ @@ -88,7 +107,15 @@ describe("pairingErrorMessage", () => { detail: "The environment credential is invalid.", }), ), - ).toBe("The environment credential is invalid."); + ).toBe(WRONG_CODE_PAIRING_MESSAGE); + }); + + it("keeps specific pairing failures", () => { + expect( + pairingErrorMessage( + new ConnectionBlockedError({ reason: "unsupported", detail: "Update the desktop app." }), + ), + ).toBe("Update the desktop app."); expect(pairingErrorMessage("nope")).toBe("Failed to pair with the environment."); }); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 20fa20b26eb31..9651e225392da 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -92,17 +92,36 @@ export function extractPairingUrlFromQrPayload(payload: string): string { } export const UNREACHABLE_PAIRING_MESSAGE = - "Could not reach that computer. Check that Control Plane is running on your Mac and that this phone can reach it, then try again. No computer? Tap Try demo to explore with sample data."; + "Could not reach that computer. Check that Control Plane is running on your Mac and that this phone can reach it, then tap Try again."; +export const SERVER_UNAVAILABLE_PAIRING_MESSAGE = + "That computer's server is not responding right now. Wait a minute, then tap Try again."; +export const WRONG_CODE_PAIRING_MESSAGE = + "That pairing code was not accepted. Codes expire, so create a new one on your Mac, then tap Try again."; +export const WRONG_HOST_PAIRING_MESSAGE = + "That host did not answer as Control Plane. Check the host and pairing code shown on your Mac, then tap Try again."; /** - * What the Add Environment screen shows when pairing fails. An unreachable - * host or a server error (a 503 from a stopped server, say) gets a message the - * user can act on instead of the raw transport or status text. Pairing-code, - * permission and compatibility failures keep their specific message. + * What the Add Environment screen shows when pairing fails. Transport and + * status failures (a 503 from a stopped server, a wrong host, no route) get + * a plain explanation instead of the raw request text, and so does a + * rejected pairing code. Permission and compatibility failures keep their + * specific message. */ export function pairingErrorMessage(error: unknown): string { - if (typeof error === "object" && error !== null && "_tag" in error) { - if (error._tag === "ConnectionTransientError") return UNREACHABLE_PAIRING_MESSAGE; + if (typeof error === "object" && error !== null && "_tag" in error && "reason" in error) { + if (error._tag === "ConnectionTransientError") { + switch (error.reason) { + case "remote-unavailable": + return SERVER_UNAVAILABLE_PAIRING_MESSAGE; + case "endpoint-unavailable": + return WRONG_HOST_PAIRING_MESSAGE; + default: + return UNREACHABLE_PAIRING_MESSAGE; + } + } + if (error._tag === "ConnectionBlockedError" && error.reason === "authentication") { + return WRONG_CODE_PAIRING_MESSAGE; + } } return error instanceof Error && error.message.trim().length > 0 ? error.message diff --git a/apps/mobile/src/features/demo/TryDemoButton.tsx b/apps/mobile/src/features/demo/TryDemoButton.tsx index b844e046757a0..baabe8d7f3e39 100644 --- a/apps/mobile/src/features/demo/TryDemoButton.tsx +++ b/apps/mobile/src/features/demo/TryDemoButton.tsx @@ -14,8 +14,11 @@ export const TRY_DEMO_DETAIL = "No computer? Explore the app with sample data."; * which also closes the sheet this button may sit in. */ export function TryDemoButton(props: { - /** `row` puts the explanation beside the button, for sheets with little height. */ - readonly layout?: "stack" | "row"; + /** + * `row` puts the explanation beside the button, for sheets with little + * height; `button` shows the button alone. + */ + readonly layout?: "stack" | "row" | "button"; readonly className?: string; }) { const row = props.layout === "row"; @@ -49,7 +52,7 @@ export function TryDemoButton(props: { /> {TRY_DEMO_LABEL} - {row ? null : ( + {row || props.layout === "button" ? null : ( {TRY_DEMO_DETAIL} )} diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index be7a1822cbdef..b765b53510791 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -163,11 +163,18 @@ export function mapRemoteEnvironmentError( traceId: error.traceId, }); case "RemoteEnvironmentAuthInvalidJsonError": - case "RemoteEnvironmentAuthUndeclaredStatusError": return new ConnectionTransientError({ reason: "remote-unavailable", detail: error.message, }); + case "RemoteEnvironmentAuthUndeclaredStatusError": + // Both stay transient so saved environments keep reconnecting; the + // reason tells a server outage (5xx) from an endpoint that is not a + // Control Plane environment, such as a wrong host (4xx). + return new ConnectionTransientError({ + reason: error.status >= 500 ? "remote-unavailable" : "endpoint-unavailable", + detail: error.message, + }); } } diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 5e252df51884c..03e4d2d574a8d 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -5,7 +5,9 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as Option from "effect/Option"; import { remoteHttpClientLayer } from "../rpc/http.ts"; @@ -32,7 +34,12 @@ const CLIENT_PRESENTATION_LAYER = Layer.succeed( function pairingHttpLayer( calls: Array<{ readonly url: string; readonly init: RequestInit }>, - options?: { readonly failDescriptor?: boolean; readonly protocolVersion?: number }, + options?: { + readonly failDescriptor?: boolean; + readonly protocolVersion?: number; + /** Served in order before the normal descriptor, one per request. */ + readonly descriptorFailures?: Array; + }, ) { const fetchFn = ((input, init = {}) => { const url = String(input); @@ -41,9 +48,11 @@ function pairingHttpLayer( if (url.endsWith("/.well-known/t3/environment")) { if (options?.failDescriptor === true) { return Promise.resolve( - Response.json({ message: "descriptor unavailable" }, { status: 503 }), + Response.json({ message: "descriptor unavailable" }, { status: 500 }), ); } + const failure = options?.descriptorFailures?.shift(); + if (failure !== undefined) return Promise.resolve(failure); return Promise.resolve( Response.json({ environmentId: "environment-paired", @@ -282,4 +291,116 @@ describe("connection onboarding", () => { }); }), ); + + const unavailable = (headers?: Record) => + Response.json( + { message: "Review environment unavailable" }, + { status: 503, ...(headers === undefined ? {} : { headers }) }, + ); + + it.effect("retries a 503 descriptor and then pairs", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const fiber = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { descriptorFailures: [unavailable()] }), + ), + ), + Effect.forkChild, + ); + yield* TestClock.adjust("8 seconds"); + const registration = yield* Fiber.join(fiber); + + expect(registration.target.environmentId).toBe("environment-paired"); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + "https://remote.example.test/.well-known/t3/environment", + "https://remote.example.test/oauth/token", + ]); + }), + ); + + it.effect("honors Retry-After when retrying", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const fiber = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { + descriptorFailures: [unavailable({ "Retry-After": "2" })], + }), + ), + ), + Effect.forkChild, + ); + yield* TestClock.adjust("2 seconds"); + const registration = yield* Fiber.join(fiber); + expect(registration.target.environmentId).toBe("environment-paired"); + }), + ); + + it.effect("reports a server that stays unavailable after two retries", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const fiber = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { + descriptorFailures: [unavailable(), unavailable(), unavailable()], + }), + ), + ), + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust("8 seconds"); + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(fiber); + + expect(error).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "remote-unavailable", + }); + expect(calls).toHaveLength(3); + }), + ); + + it.effect("classifies a 4xx descriptor as a wrong endpoint without retrying", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe( + Effect.provide( + Layer.mergeAll( + CLIENT_PRESENTATION_LAYER, + pairingHttpLayer(calls, { + descriptorFailures: [Response.json({ message: "Not found" }, { status: 404 })], + }), + ), + ), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "endpoint-unavailable", + }); + expect(calls).toHaveLength(1); + }), + ); }); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 24c03addfa66b..26d491b658d5b 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -1,6 +1,7 @@ import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -11,6 +12,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { RemoteEnvironmentAuthUndeclaredStatusError } from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, @@ -84,14 +86,47 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso }, ); +// A gateway or overloaded server often recovers within seconds, so pairing +// retries those statuses twice (about 20s in total) before giving up. The +// server's Retry-After wins when it asks for a shorter or bounded wait. +const PAIRING_RETRY_STATUSES: ReadonlySet = new Set([502, 503, 504]); +const PAIRING_RETRY_DELAYS_MS: ReadonlyArray = [8_000, 10_000]; +const PAIRING_MAX_RETRY_AFTER_MS = 12_000; + +export function pairingRetryDelayMs(error: unknown, attempt: number): number | null { + const fallback = PAIRING_RETRY_DELAYS_MS[attempt]; + if ( + fallback === undefined || + !(error instanceof RemoteEnvironmentAuthUndeclaredStatusError) || + !PAIRING_RETRY_STATUSES.has(error.status) + ) { + return null; + } + return error.retryAfterMs === undefined + ? fallback + : Math.min(error.retryAfterMs, PAIRING_MAX_RETRY_AFTER_MS); +} + +const fetchPairingDescriptor = (httpBaseUrl: string, attempt = 0) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.catch((error): ReturnType => { + const delayMs = pairingRetryDelayMs(error, attempt); + return delayMs === null + ? Effect.fail(error) + : Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(fetchPairingDescriptor(httpBaseUrl, attempt + 1)), + ); + }), + ); + export const preparePairingRegistration = Effect.fn( "clientRuntime.connection.onboarding.preparePairingRegistration", )(function* (input: PairingConnectionInput) { const target = yield* resolvePairingTarget(input); const presentation = yield* ClientCapabilities.ClientPresentation; - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: target.httpBaseUrl, - }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const descriptor = yield* fetchPairingDescriptor(target.httpBaseUrl).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); if (compatibilityError !== null) return yield* compatibilityError; const access = yield* bootstrapRemoteBearerSession({ diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index d1c470d171ffd..bdba5be68d2d1 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -9,6 +9,7 @@ import { type EnvironmentScopeRequiredError, } from "@t3tools/contracts"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -40,16 +41,31 @@ export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError readonly message: string; readonly status: number; readonly requestUrl: string; + /** From the response's Retry-After header, when it had a usable one. */ + readonly retryAfterMs?: number; }> { - constructor(requestUrl: string, status: number) { + constructor(requestUrl: string, status: number, retryAfterMs?: number) { super({ message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`, requestUrl, status, + ...(retryAfterMs === undefined ? {} : { retryAfterMs }), }); } } +/** Reads a Retry-After header given as delay seconds or an HTTP date. */ +export function parseRetryAfterMs( + value: string | null | undefined, + nowMs: number, +): number | undefined { + if (value === null || value === undefined) return undefined; + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1_000; + const dateMs = Date.parse(trimmed); + return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - nowMs); +} + export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( "RemoteEnvironmentAuthTimeoutError", )<{ @@ -140,8 +156,16 @@ const failRemoteRequest = ( if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { const response = cause.response; if (response.status < 200 || response.status >= 300) { - return Effect.fail( - new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), + return Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => + Effect.fail( + new RemoteEnvironmentAuthUndeclaredStatusError( + requestUrl, + response.status, + parseRetryAfterMs(response.headers["retry-after"], nowMs), + ), + ), + ), ); } return Effect.fail( diff --git a/packages/client-runtime/src/rpc/retryAfter.test.ts b/packages/client-runtime/src/rpc/retryAfter.test.ts new file mode 100644 index 0000000000000..3835a6293a7ab --- /dev/null +++ b/packages/client-runtime/src/rpc/retryAfter.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseRetryAfterMs } from "./http.ts"; + +describe("parseRetryAfterMs", () => { + const now = Date.parse("2026-09-29T12:00:00.000Z"); + + it("reads delay seconds and HTTP dates", () => { + expect(parseRetryAfterMs("5", now)).toBe(5_000); + expect(parseRetryAfterMs("Tue, 29 Sep 2026 12:00:07 GMT", now)).toBe(7_000); + expect(parseRetryAfterMs("Tue, 29 Sep 2026 11:00:00 GMT", now)).toBe(0); + }); + + it("ignores missing or unusable values", () => { + expect(parseRetryAfterMs(undefined, now)).toBeUndefined(); + expect(parseRetryAfterMs("soon", now)).toBeUndefined(); + }); +}); From 739d72aae8361d5b36654358fbb9979fb8ae70e1 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:22:08 -0600 Subject: [PATCH 15/28] fix(connection): wait out a cold-starting server while pairing The review server cold-starts in about 14s, but the pairing descriptor request times out at 10s, so pairing failed before it ever answered. - Pairing now retries descriptor timeouts as well as 502/503/504, within one 45s budget. Each attempt's timeout is capped to the budget left, Retry-After is honored (capped at 12s), and anything else still fails at once. - PairingConnectionInput takes an optional onWaiting callback. Mobile uses it to show "Waking up the server..." with a note that it can take up to 45 seconds. Web does not pass one and keeps its current pending state. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/connection/onboarding.ts | 16 ++- .../connection/ConnectionsNewRouteScreen.tsx | 23 +++- .../client-runtime/src/connection/index.ts | 1 + .../src/connection/onboarding.test.ts | 110 ++++++++---------- .../src/connection/onboarding.ts | 100 +++++++++++----- 5 files changed, 160 insertions(+), 90 deletions(-) diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index 60a660cb4b8fc..945e72b0de4aa 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -5,9 +5,17 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; +import { appAtomRegistry } from "../state/atom-registry"; import { connectionAtomRuntime } from "./runtime"; +/** True while pairing waits for a server that is starting up or overloaded. */ +export const pairingWakingServerAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:connection:pairing-waking-server"), +); + const onboardingScheduler = createAtomCommandScheduler(); export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { @@ -16,7 +24,13 @@ export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { concurrency: { mode: "singleFlight", key: (pairingUrl: string) => pairingUrl }, execute: (pairingUrl: string) => ConnectionOnboarding.pipe( - Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + Effect.flatMap((onboarding) => + onboarding.registerPairing({ + pairingUrl, + onWaiting: () => appAtomRegistry.set(pairingWakingServerAtom, true), + }), + ), + Effect.ensuring(Effect.sync(() => appAtomRegistry.set(pairingWakingServerAtom, false))), ), }); diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 76f20bf35d3bd..c33c9ca6f3200 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; @@ -18,10 +19,15 @@ import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { pairingWakingServerAtom } from "../../connection/onboarding"; import { useEnvironments } from "../../state/environments"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { TryDemoButton } from "../demo/TryDemoButton"; +const WAKING_SERVER_LABEL = "Waking up the server..."; +const WAKING_SERVER_DETAIL = + "The server is starting up. This can take up to 45 seconds; keep this screen open."; + type ConnectionsNewRouteParams = { readonly mode?: string; readonly pairingUrl?: string; @@ -64,6 +70,8 @@ export function ConnectionsNewRouteScreen({ const headerIconColor = useUniwindTheme()["--color-icon"]; const connectDisabled = isSubmitting || hostInput.trim().length === 0; + const wakingServer = useAtomValue(pairingWakingServerAtom) && isSubmitting; + const submittingLabel = wakingServer ? WAKING_SERVER_LABEL : "Pairing..."; // Someone without a computer yet (an App Store reviewer, say) can explore sample data. const showDemoEntry = useEnvironments().environments.length === 0; @@ -294,14 +302,23 @@ export function ConnectionsNewRouteScreen({ onChangeText={handleCodeChange} /> - {pairingError ? ( + {wakingServer ? ( + + {WAKING_SERVER_DETAIL} + + ) : null} + + {pairingError && !isSubmitting ? ( { @@ -316,7 +333,7 @@ export function ConnectionsNewRouteScreen({ { diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index b6a592ee43305..71cae9506dc69 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -14,6 +14,7 @@ export { type BearerConnectionUpdateInput, ConnectionOnboarding, type PairingConnectionInput, + type PairingWaitProgress, type SshConnectionInput, } from "./onboarding.ts"; export * from "./presentation.ts"; diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 03e4d2d574a8d..2d6c77c4976d9 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -38,7 +38,7 @@ function pairingHttpLayer( readonly failDescriptor?: boolean; readonly protocolVersion?: number; /** Served in order before the normal descriptor, one per request. */ - readonly descriptorFailures?: Array; + readonly descriptorFailures?: Array>; }, ) { const fetchFn = ((input, init = {}) => { @@ -52,7 +52,8 @@ function pairingHttpLayer( ); } const failure = options?.descriptorFailures?.shift(); - if (failure !== undefined) return Promise.resolve(failure); + if (failure !== undefined) + return failure instanceof Promise ? failure : Promise.resolve(failure); return Promise.resolve( Response.json({ environmentId: "environment-paired", @@ -297,26 +298,34 @@ describe("connection onboarding", () => { { message: "Review environment unavailable" }, { status: 503, ...(headers === undefined ? {} : { headers }) }, ); + // Never settles, so the request runs into its timeout. + const hanging = () => new Promise(() => {}); + + const pair = ( + calls: Array<{ readonly url: string; readonly init: RequestInit }>, + descriptorFailures: Array>, + waits: Array<{ readonly reason: string; readonly elapsedMs: number }> = [], + ) => + preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + onWaiting: (progress) => waits.push(progress), + }).pipe( + Effect.provide( + Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls, { descriptorFailures })), + ), + ); it.effect("retries a 503 descriptor and then pairs", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const fiber = yield* preparePairingRegistration({ - host: "remote.example.test", - pairingCode: "pairing-token", - }).pipe( - Effect.provide( - Layer.mergeAll( - CLIENT_PRESENTATION_LAYER, - pairingHttpLayer(calls, { descriptorFailures: [unavailable()] }), - ), - ), - Effect.forkChild, - ); - yield* TestClock.adjust("8 seconds"); + const waits: Array<{ readonly reason: string; readonly elapsedMs: number }> = []; + const fiber = yield* pair(calls, [unavailable()], waits).pipe(Effect.forkChild); + yield* TestClock.adjust("5 seconds"); const registration = yield* Fiber.join(fiber); expect(registration.target.environmentId).toBe("environment-paired"); + expect(waits).toEqual([{ reason: "unavailable", elapsedMs: 0 }]); expect(calls.map((call) => call.url)).toEqual([ "https://remote.example.test/.well-known/t3/environment", "https://remote.example.test/.well-known/t3/environment", @@ -328,18 +337,7 @@ describe("connection onboarding", () => { it.effect("honors Retry-After when retrying", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const fiber = yield* preparePairingRegistration({ - host: "remote.example.test", - pairingCode: "pairing-token", - }).pipe( - Effect.provide( - Layer.mergeAll( - CLIENT_PRESENTATION_LAYER, - pairingHttpLayer(calls, { - descriptorFailures: [unavailable({ "Retry-After": "2" })], - }), - ), - ), + const fiber = yield* pair(calls, [unavailable({ "Retry-After": "2" })]).pipe( Effect.forkChild, ); yield* TestClock.adjust("2 seconds"); @@ -348,59 +346,53 @@ describe("connection onboarding", () => { }), ); - it.effect("reports a server that stays unavailable after two retries", () => + it.effect("waits out a cold start that outlasts one request timeout", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const fiber = yield* preparePairingRegistration({ - host: "remote.example.test", - pairingCode: "pairing-token", - }).pipe( - Effect.provide( - Layer.mergeAll( - CLIENT_PRESENTATION_LAYER, - pairingHttpLayer(calls, { - descriptorFailures: [unavailable(), unavailable(), unavailable()], - }), - ), - ), - Effect.flip, - Effect.forkChild, - ); - yield* TestClock.adjust("8 seconds"); + const waits: Array<{ readonly reason: string; readonly elapsedMs: number }> = []; + const fiber = yield* pair(calls, [hanging()], waits).pipe(Effect.forkChild); yield* TestClock.adjust("10 seconds"); + yield* TestClock.adjust("1 second"); + const registration = yield* Fiber.join(fiber); + + expect(registration.target.environmentId).toBe("environment-paired"); + expect(waits).toEqual([{ reason: "timeout", elapsedMs: 10_000 }]); + }), + ); + + it.effect("gives up once the wake budget is spent", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const failures = Array.from({ length: 20 }, () => unavailable()); + const fiber = yield* pair(calls, failures).pipe(Effect.flip, Effect.forkChild); + for (let step = 0; step < 12; step += 1) yield* TestClock.adjust("5 seconds"); const error = yield* Fiber.join(fiber); expect(error).toMatchObject({ _tag: "ConnectionTransientError", reason: "remote-unavailable", }); - expect(calls).toHaveLength(3); + // One request every 5s until less than a retry's worth of the 45s budget is left. + expect(calls).toHaveLength(9); }), ); it.effect("classifies a 4xx descriptor as a wrong endpoint without retrying", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const error = yield* preparePairingRegistration({ - host: "remote.example.test", - pairingCode: "pairing-token", - }).pipe( - Effect.provide( - Layer.mergeAll( - CLIENT_PRESENTATION_LAYER, - pairingHttpLayer(calls, { - descriptorFailures: [Response.json({ message: "Not found" }, { status: 404 })], - }), - ), - ), - Effect.flip, - ); + const waits: Array<{ readonly reason: string; readonly elapsedMs: number }> = []; + const error = yield* pair( + calls, + [Response.json({ message: "Not found" }, { status: 404 })], + waits, + ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "ConnectionTransientError", reason: "endpoint-unavailable", }); expect(calls).toHaveLength(1); + expect(waits).toEqual([]); }), ); }); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 26d491b658d5b..b3621e11884df 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -1,5 +1,6 @@ import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -12,7 +13,10 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; -import { RemoteEnvironmentAuthUndeclaredStatusError } from "../rpc/http.ts"; +import { + RemoteEnvironmentAuthTimeoutError, + RemoteEnvironmentAuthUndeclaredStatusError, +} from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { BearerConnectionCredential, @@ -39,6 +43,8 @@ export interface PairingConnectionInput { readonly pairingUrl?: string; readonly host?: string; readonly pairingCode?: string; + /** Called before each retry while the environment is starting up or overloaded. */ + readonly onWaiting?: (progress: PairingWaitProgress) => void; } export interface SshConnectionInput { @@ -86,45 +92,85 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso }, ); -// A gateway or overloaded server often recovers within seconds, so pairing -// retries those statuses twice (about 20s in total) before giving up. The -// server's Retry-After wins when it asks for a shorter or bounded wait. +/** + * Pairing waits for a server that is starting or briefly overloaded: a hosted + * environment can take ~15s to cold start, longer than one descriptor + * request's timeout. Timeouts and 502/503/504 retry within one overall + * budget, honoring Retry-After; anything else fails at once. + */ +export const PAIRING_WAKE_BUDGET_MS = 45_000; const PAIRING_RETRY_STATUSES: ReadonlySet = new Set([502, 503, 504]); -const PAIRING_RETRY_DELAYS_MS: ReadonlyArray = [8_000, 10_000]; +const PAIRING_TIMEOUT_RETRY_DELAY_MS = 1_000; +const PAIRING_UNAVAILABLE_RETRY_DELAY_MS = 5_000; const PAIRING_MAX_RETRY_AFTER_MS = 12_000; +const PAIRING_DESCRIPTOR_TIMEOUT_MS = 10_000; +/** A retry needs at least this long left to be worth sending. */ +const PAIRING_MIN_ATTEMPT_MS = 2_000; -export function pairingRetryDelayMs(error: unknown, attempt: number): number | null { - const fallback = PAIRING_RETRY_DELAYS_MS[attempt]; - if ( - fallback === undefined || - !(error instanceof RemoteEnvironmentAuthUndeclaredStatusError) || - !PAIRING_RETRY_STATUSES.has(error.status) +export type PairingWaitReason = "timeout" | "unavailable"; + +export interface PairingWaitProgress { + readonly reason: PairingWaitReason; + /** Time since the first descriptor request. */ + readonly elapsedMs: number; +} + +export function pairingRetryDelayMs( + error: unknown, + remainingMs: number, +): { readonly delayMs: number; readonly reason: PairingWaitReason } | null { + let retry: { readonly delayMs: number; readonly reason: PairingWaitReason } | null = null; + if (error instanceof RemoteEnvironmentAuthTimeoutError) { + retry = { delayMs: PAIRING_TIMEOUT_RETRY_DELAY_MS, reason: "timeout" }; + } else if ( + error instanceof RemoteEnvironmentAuthUndeclaredStatusError && + PAIRING_RETRY_STATUSES.has(error.status) ) { - return null; + retry = { + delayMs: Math.min( + error.retryAfterMs ?? PAIRING_UNAVAILABLE_RETRY_DELAY_MS, + PAIRING_MAX_RETRY_AFTER_MS, + ), + reason: "unavailable", + }; } - return error.retryAfterMs === undefined - ? fallback - : Math.min(error.retryAfterMs, PAIRING_MAX_RETRY_AFTER_MS); + if (retry === null || remainingMs - retry.delayMs < PAIRING_MIN_ATTEMPT_MS) return null; + return retry; } -const fetchPairingDescriptor = (httpBaseUrl: string, attempt = 0) => - fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( - Effect.catch((error): ReturnType => { - const delayMs = pairingRetryDelayMs(error, attempt); - return delayMs === null - ? Effect.fail(error) - : Effect.sleep(Duration.millis(delayMs)).pipe( - Effect.andThen(fetchPairingDescriptor(httpBaseUrl, attempt + 1)), - ); - }), - ); +const fetchPairingDescriptor = Effect.fn("clientRuntime.connection.onboarding.fetchDescriptor")( + function* ( + httpBaseUrl: string, + onWaiting: ((progress: PairingWaitProgress) => void) | undefined, + ) { + const startedAt = yield* Clock.currentTimeMillis; + const attempt = (): ReturnType => + Effect.gen(function* () { + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + const timeoutMs = Math.min(PAIRING_DESCRIPTOR_TIMEOUT_MS, PAIRING_WAKE_BUDGET_MS - elapsed); + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl, timeoutMs }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; + const retry = pairingRetryDelayMs(error, PAIRING_WAKE_BUDGET_MS - elapsedMs); + if (retry === null) return yield* Effect.fail(error); + yield* Effect.sync(() => onWaiting?.({ reason: retry.reason, elapsedMs })); + yield* Effect.sleep(Duration.millis(retry.delayMs)); + return yield* attempt(); + }), + ), + ); + }); + return yield* attempt(); + }, +); export const preparePairingRegistration = Effect.fn( "clientRuntime.connection.onboarding.preparePairingRegistration", )(function* (input: PairingConnectionInput) { const target = yield* resolvePairingTarget(input); const presentation = yield* ClientCapabilities.ClientPresentation; - const descriptor = yield* fetchPairingDescriptor(target.httpBaseUrl).pipe( + const descriptor = yield* fetchPairingDescriptor(target.httpBaseUrl, input.onWaiting).pipe( Effect.mapError(mapRemoteEnvironmentError), ); const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); From 4976c6fd74d71ac1125865ad375e14fd8baffa7f Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:35:26 -0600 Subject: [PATCH 16/28] feat(mobile): keep demo mode across app relaunches A reviewer who relaunches the app lands back in the demo until they tap Exit demo. The flag is a file in the documents folder, read synchronously at launch, so reinstalling starts outside the demo. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/App.tsx | 4 +++ .../mobile/src/features/demo/demoMode.test.ts | 36 +++++++++++++++++++ apps/mobile/src/features/demo/demoMode.ts | 15 ++++++-- apps/mobile/src/features/demo/demoModeFlag.ts | 34 ++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/demo/demoModeFlag.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8bce702b16ee8..a56295fcbd902 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -12,6 +12,7 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { appNavigationRef, DemoModeFrame } from "./features/demo/DemoModeFrame"; +import { restoreDemoMode } from "./features/demo/demoMode"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { @@ -35,6 +36,9 @@ void SplashScreen.preventAutoHideAsync().catch(() => { // The native module can be unavailable in non-native test environments. }); +// Before the first render, so a relaunch in demo mode never flashes the real app. +restoreDemoMode(); + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // Keep the compact thread list available beneath a directly opened thread. diff --git a/apps/mobile/src/features/demo/demoMode.test.ts b/apps/mobile/src/features/demo/demoMode.test.ts index e124670c2bdd9..2cee6364617a3 100644 --- a/apps/mobile/src/features/demo/demoMode.test.ts +++ b/apps/mobile/src/features/demo/demoMode.test.ts @@ -20,7 +20,9 @@ import { enterDemoMode, exitDemoMode, isDemoModeActive, + restoreDemoMode, } from "./demoMode"; +import { readDemoModeFlag } from "./demoModeFlag"; import { DemoServer } from "./demoServer"; vi.mock("../../state/atom-registry", async () => { @@ -28,6 +30,16 @@ vi.mock("../../state/atom-registry", async () => { return { appAtomRegistry: AtomRegistry.make() }; }); +vi.mock("./demoModeFlag", () => { + let stored = false; + return { + readDemoModeFlag: () => stored, + writeDemoModeFlag: (active: boolean) => { + stored = active; + }, + }; +}); + const demoEntry: ConnectionCatalogEntry = { target: DEMO_CONNECTION_TARGET, profile: Option.none(), @@ -78,6 +90,30 @@ describe("demo mode state", () => { }); }); +describe("demo mode across relaunches", () => { + it("remembers the demo until Exit demo clears it", () => { + enterDemoMode(); + assert.isTrue(readDemoModeFlag()); + + // A relaunch starts with fresh in-memory state and restores from the flag. + appAtomRegistry.set(demoModeActiveAtom, false); + restoreDemoMode(); + assert.isTrue(isDemoModeActive()); + assert.isNotNull(currentDemoServer()); + + exitDemoMode(); + assert.isFalse(readDemoModeFlag()); + restoreDemoMode(); + assert.isFalse(isDemoModeActive()); + }); + + it("does not enter the demo on a first launch", () => { + assert.isFalse(readDemoModeFlag()); + restoreDemoMode(); + assert.isFalse(isDemoModeActive()); + }); +}); + describe("demo connection override", () => { it.effect("serves the demo environment in-process and leaves real ones alone", () => Effect.gen(function* () { diff --git a/apps/mobile/src/features/demo/demoMode.ts b/apps/mobile/src/features/demo/demoMode.ts index 15a517de58d78..50de26a2f1165 100644 --- a/apps/mobile/src/features/demo/demoMode.ts +++ b/apps/mobile/src/features/demo/demoMode.ts @@ -26,6 +26,7 @@ import { DEMO_HTTP_BASE_URL, DEMO_WS_BASE_URL, } from "./demoFixtures"; +import { readDemoModeFlag, writeDemoModeFlag } from "./demoModeFlag"; import { DemoServer } from "./demoServer"; /** @@ -33,9 +34,10 @@ import { DemoServer } from "./demoServer"; * explore the app on sample data. It registers one platform environment, * "Demo Mac", whose connection is served by an in-process `DemoServer`, so * the real connection supervisor, shell and thread state, and screens all run - * unchanged. Nothing is persisted: relaunching the app or exiting demo mode - * removes the environment and its cached data, and real environments are - * never touched. + * unchanged. Only an "in demo" flag is persisted, so relaunching returns to + * the demo with fresh sample data. Exiting demo mode clears the flag and + * removes the environment and its cached data; real environments are never + * touched. */ export const demoModeActiveAtom = Atom.make(false).pipe( @@ -65,10 +67,17 @@ export function enterDemoMode(options?: { readonly server?: DemoServer }): void if (appAtomRegistry.get(demoModeActiveAtom)) return; demoServer = options?.server ?? new DemoServer(); appAtomRegistry.set(demoModeActiveAtom, true); + writeDemoModeFlag(true); +} + +/** Re-enters demo mode at launch if the app was closed while in it. */ +export function restoreDemoMode(): void { + if (readDemoModeFlag()) enterDemoMode(); } export function exitDemoMode(): void { if (!appAtomRegistry.get(demoModeActiveAtom) && demoServer === null) return; + writeDemoModeFlag(false); appAtomRegistry.set(demoModeActiveAtom, false); demoServer?.dispose(); demoServer = null; diff --git a/apps/mobile/src/features/demo/demoModeFlag.ts b/apps/mobile/src/features/demo/demoModeFlag.ts new file mode 100644 index 0000000000000..096545cd6e331 --- /dev/null +++ b/apps/mobile/src/features/demo/demoModeFlag.ts @@ -0,0 +1,34 @@ +import { File, Paths } from "expo-file-system"; + +/** + * Remembers that demo mode is on, so a reviewer who relaunches the app lands + * back in the demo until they tap Exit demo. The flag is a file in the app's + * documents folder rather than the keychain, so reinstalling the app starts + * outside the demo. Reads are synchronous so launch restores the demo before + * the first frame. + */ + +function flagFile(): InstanceType { + return new File(Paths.document, "demo-mode-active"); +} + +export function readDemoModeFlag(): boolean { + try { + return flagFile().exists; + } catch { + return false; + } +} + +export function writeDemoModeFlag(active: boolean): void { + try { + const file = flagFile(); + if (active) { + file.write("1"); + } else if (file.exists) { + file.delete(); + } + } catch { + // Device-local convenience. A failed write only affects the next launch. + } +} From ce52eb426bd7f1d3233e08a395d060f8585c5e55 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:01:19 -0600 Subject: [PATCH 17/28] Require agents to use only the built-in Control Plane browser Drop the desktop-browser exceptions from the agent, coordinator and Codex browser instructions, and tell agents to ask the user to sign in once in the Control Plane browser panel instead. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/provider/CodexDeveloperInstructions.ts | 2 +- apps/server/src/provider/Layers/CodexSessionRuntime.test.ts | 2 ++ apps/server/src/provider/assistantRuntime.test.ts | 4 ++-- apps/server/src/provider/assistantRuntime.ts | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 1cbe74f0dba60..d4f6ffd133c23 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -10,7 +10,7 @@ You are running inside Control Plane. The \`${MCP_SERVER_NAME}\` MCP server is t For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the Control Plane preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed Control Plane preview tool call should be inspected and retried with corrected arguments when the error is actionable. +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Never open or drive the user's desktop browsers (never run \`open \`, never AppleScript a browser), including for logged-in sites. Logins persist in the Control Plane browser profile; if a site needs a login you do not have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser), then continue. Use an alternative browser system only when the Control Plane preview tools are absent or \`preview_open\` returns an explicit unsupported/unavailable error. A failed Control Plane preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 4d3d78019ad8f..784a802809d1a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -670,6 +670,8 @@ describe("T3 browser developer instructions", () => { NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); NodeAssert.match(instructions, /Do not switch to global browser skills/); + NodeAssert.match(instructions, /Never open or drive the user's desktop browsers/); + NodeAssert.doesNotMatch(instructions, /user explicitly requests another browser/); } }); diff --git a/apps/server/src/provider/assistantRuntime.test.ts b/apps/server/src/provider/assistantRuntime.test.ts index 30169387bd796..2981418547454 100644 --- a/apps/server/src/provider/assistantRuntime.test.ts +++ b/apps/server/src/provider/assistantRuntime.test.ts @@ -136,9 +136,9 @@ describe("buildAssistantRuntimeBlock", () => { const build = (thread: ReturnType) => buildAssistantRuntimeBlock({ project, thread, memory: "", roleFile: "" }); const coordinatorRule = - "When you delegate, tell each agent to use its own thread's built-in Control Plane browser with the preview_* tools. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; + "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const agentRule = - "Use your own thread's built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. Call preview_open without tabId to initialize or reuse your thread's browser, then use preview_navigate, preview_snapshot and the other preview_* tools. If the tools are not loaded yet, load them first. Keep browser work in this thread, including sign-in: let the user log in to this thread's browser when needed. Do not use computer-use tools or launch or drive the user's desktop browsers unless the user explicitly requests that method. If preview_open reports no automation host, report the tool error; switching to another thread's browser or asking the user to open the Browser panel does not repair the host connection."; + "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; const coordinator = build(makeThread("coordinator")); expect(coordinator?.inline).toContain(coordinatorRule); diff --git a/apps/server/src/provider/assistantRuntime.ts b/apps/server/src/provider/assistantRuntime.ts index c0a1902a9bafe..87f14eef6b5a1 100644 --- a/apps/server/src/provider/assistantRuntime.ts +++ b/apps/server/src/provider/assistantRuntime.ts @@ -33,10 +33,10 @@ const COORDINATOR_SETTLE_RULE = "Settle agents with cp_agent_settle once their work is complete (for example, merged) and no follow-ups remain. Reporting a result does not settle an agent. It fails while an agent is working; a settled agent wakes on a new message."; const COORDINATOR_BROWSER_RULE = - "When you delegate, tell each agent to use its own thread's built-in Control Plane browser with the preview_* tools. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; + "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const AGENT_BROWSER_RULE = - "Use your own thread's built-in Control Plane browser (the preview_* tools) for browsing, testing and screenshots. Call preview_open without tabId to initialize or reuse your thread's browser, then use preview_navigate, preview_snapshot and the other preview_* tools. If the tools are not loaded yet, load them first. Keep browser work in this thread, including sign-in: let the user log in to this thread's browser when needed. Do not use computer-use tools or launch or drive the user's desktop browsers unless the user explicitly requests that method. If preview_open reports no automation host, report the tool error; switching to another thread's browser or asking the user to open the Browser panel does not repair the host connection."; + "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; /** Agent side: a turn that ends reports to the coordinator, so it must not end as a wait. */ const AGENT_TURN_RULE = From 3d3eb2873018f44d53525dfd377fda50da70e9de Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:10:04 -0600 Subject: [PATCH 18/28] fix(server): allow explicitly requested desktop browser use --- .../src/provider/CodexDeveloperInstructions.ts | 2 +- .../src/provider/Layers/CodexSessionRuntime.test.ts | 12 +++++++++++- apps/server/src/provider/assistantRuntime.test.ts | 6 +++--- apps/server/src/provider/assistantRuntime.ts | 4 ++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index d4f6ffd133c23..cfbdf4c4cec09 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -10,7 +10,7 @@ You are running inside Control Plane. The \`${MCP_SERVER_NAME}\` MCP server is t For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Never open or drive the user's desktop browsers (never run \`open \`, never AppleScript a browser), including for logged-in sites. Logins persist in the Control Plane browser profile; if a site needs a login you do not have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser), then continue. Use an alternative browser system only when the Control Plane preview tools are absent or \`preview_open\` returns an explicit unsupported/unavailable error. A failed Control Plane preview tool call should be inspected and retried with corrected arguments when the error is actionable. +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Never open or drive the user's desktop browsers (never run \`open \`, never AppleScript a browser), including for logged-in sites. Exception: if the user explicitly tells you to use their own browser or computer (for example 'use my Helium' or 'use my computer'), you may do so for that request. A coordinator relaying the user's explicit instruction counts. Never decide on your own that you need the user's browser. Logins persist in the Control Plane browser profile; if a site needs a login you do not have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser), then continue. Use an alternative browser system only when the Control Plane preview tools are absent or \`preview_open\` returns an explicit unsupported/unavailable error. A failed Control Plane preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 784a802809d1a..f4458eb5ec4ea 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -671,7 +671,17 @@ describe("T3 browser developer instructions", () => { NodeAssert.match(instructions, /preview_open/); NodeAssert.match(instructions, /Do not switch to global browser skills/); NodeAssert.match(instructions, /Never open or drive the user's desktop browsers/); - NodeAssert.doesNotMatch(instructions, /user explicitly requests another browser/); + NodeAssert.match( + instructions, + /Exception: if the user explicitly tells you to use their own browser or computer/, + ); + NodeAssert.match(instructions, /for example 'use my Helium' or 'use my computer'/); + NodeAssert.match(instructions, /you may do so for that request/); + NodeAssert.match( + instructions, + /A coordinator relaying the user's explicit instruction counts/, + ); + NodeAssert.match(instructions, /Never decide on your own that you need the user's browser/); } }); diff --git a/apps/server/src/provider/assistantRuntime.test.ts b/apps/server/src/provider/assistantRuntime.test.ts index 2981418547454..baaab28f567cc 100644 --- a/apps/server/src/provider/assistantRuntime.test.ts +++ b/apps/server/src/provider/assistantRuntime.test.ts @@ -136,9 +136,9 @@ describe("buildAssistantRuntimeBlock", () => { const build = (thread: ReturnType) => buildAssistantRuntimeBlock({ project, thread, memory: "", roleFile: "" }); const coordinatorRule = - "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; + "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Only when the user explicitly asks for an agent to use their own browser or computer, say so in the delegation message and quote the user's request. Never grant it on your own. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const agentRule = - "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; + "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Exception: if the user explicitly tells you to use their own browser or computer (for example 'use my Helium' or 'use my computer'), you may do so for that request. A coordinator relaying the user's explicit instruction counts. Never decide on your own that you need the user's browser. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; const coordinator = build(makeThread("coordinator")); expect(coordinator?.inline).toContain(coordinatorRule); @@ -160,7 +160,7 @@ describe("buildAssistantRuntimeBlock", () => { build(makeThread("agent", { title: "Research", pinnedAt: PINNED_AT })), build(makeThread("agent")), ]) { - expect(block?.inline).not.toMatch(/chrome|safari|helium|firefox/i); + expect(block?.inline).not.toMatch(/chrome|safari|firefox/i); expect(block?.inline).not.toContain("\u2014"); } }); diff --git a/apps/server/src/provider/assistantRuntime.ts b/apps/server/src/provider/assistantRuntime.ts index 87f14eef6b5a1..59b8681b4faff 100644 --- a/apps/server/src/provider/assistantRuntime.ts +++ b/apps/server/src/provider/assistantRuntime.ts @@ -33,10 +33,10 @@ const COORDINATOR_SETTLE_RULE = "Settle agents with cp_agent_settle once their work is complete (for example, merged) and no follow-ups remain. Reporting a result does not settle an agent. It fails while an agent is working; a settled agent wakes on a new message."; const COORDINATOR_BROWSER_RULE = - "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; + "Every delegation message must tell the agent to use its own built-in Control Plane browser (preview_* tools) for all browsing, including logged-in sites, and never the user's desktop browsers. Only when the user explicitly asks for an agent to use their own browser or computer, say so in the delegation message and quote the user's request. Never grant it on your own. Each coordinator and agent has its own browser tabs; do not send browser work to another coordinator or reuse another thread's browser."; const AGENT_BROWSER_RULE = - "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; + "Always use your own thread's built-in Control Plane browser via the preview_* tools for all browsing, testing and screenshots, including logged-in sites. Load the tools if they are not loaded yet, then call preview_status or preview_open (without tabId) before preview_navigate, preview_snapshot and the other preview_* tools. Never open or drive the user's desktop browsers: never run `open `, never AppleScript a browser, never use computer-use tools for one. Exception: if the user explicitly tells you to use their own browser or computer (for example 'use my Helium' or 'use my computer'), you may do so for that request. A coordinator relaying the user's explicit instruction counts. Never decide on your own that you need the user's browser. Logins persist in the built-in browser profile. If a site needs a login you don't have, stop and ask the user to sign in once in the Control Plane browser panel (or import from their browser with the browser's import option), then continue. If preview_open reports no automation host, report the tool error; switching to another thread's browser does not repair the host connection."; /** Agent side: a turn that ends reports to the coordinator, so it must not end as a wait. */ const AGENT_TURN_RULE = From e8d9d5fd7819737244b51ae53994c0ca532d0c4e Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:06:18 -0600 Subject: [PATCH 19/28] feat: sync Projects and Tasks folder order across clients Adds an orderKey (fractional index) to the project record, written by a new project.reorder command on the owning server and delivered through the shell like projectIcon. Web reads and writes it (drag and Alt+Arrow), seeds it from the saved local order once, and mobile applies it to Projects and Tasks folders. Mobile also draws Lucide glyph icons and workspace icons on Home. Co-Authored-By: Claude Sonnet 5.5 --- apps/mobile/scripts/generate-lucide-icons.mjs | 111 + .../src/components/LucideProjectIcon.tsx | 51 + apps/mobile/src/components/ProjectFavicon.tsx | 8 + .../src/features/home/TaskFolderHeader.tsx | 24 +- .../src/features/home/home-list-options.ts | 8 +- .../src/features/home/homeThreadList.test.ts | 59 + .../src/features/home/homeThreadList.ts | 32 +- .../features/threads/NewTaskRouteScreen.tsx | 4 +- apps/mobile/src/lib/lucideIcon.test.ts | 71 + apps/mobile/src/lib/lucideIcon.ts | 99 + .../src/lib/lucideIconNodes.generated.json | 1924 +++++++++++++++++ apps/mobile/src/lib/projectIcon.test.ts | 68 + apps/mobile/src/lib/projectIcon.ts | 49 +- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/OrchestrationEngine.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 52 + .../Layers/ProjectionPipeline.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 7 + .../decider.project-reorder.test.ts | 159 ++ apps/server/src/orchestration/decider.ts | 45 + .../src/orchestration/projectOrderEvents.ts | 10 + apps/server/src/orchestration/projector.ts | 2 + .../persistence/Layers/ProjectionProjects.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../056_ProjectionProjectsOrderKey.test.ts | 30 + .../056_ProjectionProjectsOrderKey.ts | 18 + .../Services/ProjectionProjects.ts | 2 + apps/server/src/schedules/ScheduleHost.ts | 4 +- apps/server/src/schedules/ScheduleRunner.ts | 4 + apps/web/src/components/CommandPalette.tsx | 21 +- apps/web/src/components/Sidebar.tsx | 177 +- .../src/components/chat/DraftHeroHeadline.tsx | 4 +- .../sidebar/SidebarAssistantsSection.tsx | 4 +- .../components/sidebar/projectArrange.test.ts | 106 + .../src/components/sidebar/projectArrange.ts | 101 + .../sidebar/sidebarAssistants.logic.test.ts | 20 + .../sidebar/sidebarAssistants.logic.ts | 17 + apps/web/src/hooks/useHandleNewThread.ts | 21 +- apps/web/src/hooks/useProjectOrderWriter.ts | 62 + apps/web/src/projectOrderOverrides.ts | 72 + apps/web/src/state/entities.ts | 9 + packages/client-runtime/package.json | 4 + .../client-runtime/src/operations/commands.ts | 11 + .../src/state/assistants.test.ts | 18 + .../client-runtime/src/state/assistants.ts | 7 +- .../src/state/projectCommands.ts | 8 + .../src/state/projectOrder.test.ts | 179 ++ .../client-runtime/src/state/projectOrder.ts | 114 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestration.test.ts | 60 + packages/contracts/src/orchestration.ts | 30 + 52 files changed, 3834 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/scripts/generate-lucide-icons.mjs create mode 100644 apps/mobile/src/components/LucideProjectIcon.tsx create mode 100644 apps/mobile/src/lib/lucideIcon.test.ts create mode 100644 apps/mobile/src/lib/lucideIcon.ts create mode 100644 apps/mobile/src/lib/lucideIconNodes.generated.json create mode 100644 apps/mobile/src/lib/projectIcon.test.ts create mode 100644 apps/server/src/orchestration/decider.project-reorder.test.ts create mode 100644 apps/server/src/orchestration/projectOrderEvents.ts create mode 100644 apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.test.ts create mode 100644 apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.ts create mode 100644 apps/web/src/components/sidebar/projectArrange.test.ts create mode 100644 apps/web/src/components/sidebar/projectArrange.ts create mode 100644 apps/web/src/hooks/useProjectOrderWriter.ts create mode 100644 apps/web/src/projectOrderOverrides.ts create mode 100644 packages/client-runtime/src/state/projectOrder.test.ts create mode 100644 packages/client-runtime/src/state/projectOrder.ts diff --git a/apps/mobile/scripts/generate-lucide-icons.mjs b/apps/mobile/scripts/generate-lucide-icons.mjs new file mode 100644 index 0000000000000..4a52dd28e7b7a --- /dev/null +++ b/apps/mobile/scripts/generate-lucide-icons.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +// Regenerates src/lib/lucideIconNodes.generated.json from the web app's lucide-react, so mobile +// draws every Lucide glyph the web icon picker can save without bundling the Lucide package. +// +// node apps/mobile/scripts/generate-lucide-icons.mjs +// +// Output shape (kept to string values so the formatter never reflows it): +// icons: canonical name -> nodes joined by "|". A bare node is a path's `d`; any other +// node is "tag;attr=value;attr=value". Lucide's React `key` attrs are dropped. +// aliases: alias name -> canonical name, for names `lucide-react/dynamic` maps to the same file. + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const MOBILE_ROOT = NodePath.resolve(import.meta.dirname, ".."); +const LUCIDE_ROOT = NodePath.resolve(MOBILE_ROOT, "../web/node_modules/lucide-react"); +const LUCIDE_ESM = NodePath.join(LUCIDE_ROOT, "dist/esm"); +const OUTPUT_PATH = NodePath.join(MOBILE_ROOT, "src/lib/lucideIconNodes.generated.json"); + +const SUPPORTED_TAGS = new Set([ + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "ellipse", +]); +const RESERVED = /[|;=]/; + +function importLucideModule(relativePath) { + return import(NodeURL.pathToFileURL(NodePath.join(LUCIDE_ESM, relativePath)).href); +} + +/** Every name `lucide-react/dynamic` accepts, aliases included, with the icon file it loads. */ +async function readDynamicIconFiles() { + const { default: dynamicIconImports } = await importLucideModule("dynamicIconImports.js"); + const fileByName = new Map(); + for (const [name, load] of Object.entries(dynamicIconImports)) { + const match = /import\(\s*["']\.\/icons\/([^"']+)\.js["']\s*\)/.exec(String(load)); + if (!match) throw new Error(`Cannot find the icon file for Lucide name "${name}"`); + fileByName.set(name, match[1]); + } + return fileByName; +} + +function encodeNode([tag, attrs], iconName) { + if (!SUPPORTED_TAGS.has(tag)) { + throw new Error(`Lucide icon "${iconName}" uses unsupported element <${tag}>`); + } + const entries = Object.entries(attrs).filter(([name]) => name !== "key"); + for (const [name, value] of entries) { + if (RESERVED.test(name) || RESERVED.test(String(value))) { + throw new Error(`Lucide icon "${iconName}" has a reserved character in ${tag}.${name}`); + } + } + if (tag === "path" && entries.length === 1 && entries[0][0] === "d") { + return String(entries[0][1]); + } + return [tag, ...entries.map(([name, value]) => `${name}=${value}`)].join(";"); +} + +async function main() { + const { version } = JSON.parse( + NodeFS.readFileSync(NodePath.join(LUCIDE_ROOT, "package.json"), "utf8"), + ); + const fileByName = await readDynamicIconFiles(); + + // One canonical name per icon file: the name matching the file, else the first seen. + const namesByFile = new Map(); + for (const [name, file] of fileByName) { + const names = namesByFile.get(file) ?? []; + names.push(name); + namesByFile.set(file, names); + } + + const icons = {}; + const aliases = {}; + for (const [file, names] of namesByFile) { + const canonical = names.includes(file) ? file : names[0]; + const { __iconNode } = await importLucideModule(`icons/${file}.js`); + if (!Array.isArray(__iconNode) || __iconNode.length === 0) { + throw new Error(`Lucide icon file "${file}" exports no __iconNode`); + } + icons[canonical] = __iconNode.map((node) => encodeNode(node, canonical)).join("|"); + for (const name of names) { + if (name !== canonical) aliases[name] = canonical; + } + } + + const sortKeys = (record) => + Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + const output = `${JSON.stringify( + { lucideVersion: version, icons: sortKeys(icons), aliases: sortKeys(aliases) }, + null, + 2, + )}\n`; + NodeFS.writeFileSync(OUTPUT_PATH, output); + + const iconCount = Object.keys(icons).length; + const aliasCount = Object.keys(aliases).length; + console.log( + `Wrote ${NodePath.relative(process.cwd(), OUTPUT_PATH)}: lucide-react ${version}, ` + + `${iconCount} icons + ${aliasCount} aliases (${fileByName.size} names), ` + + `${(Buffer.byteLength(output) / 1024).toFixed(1)} KiB`, + ); +} + +await main(); diff --git a/apps/mobile/src/components/LucideProjectIcon.tsx b/apps/mobile/src/components/LucideProjectIcon.tsx new file mode 100644 index 0000000000000..3134c7522392f --- /dev/null +++ b/apps/mobile/src/components/LucideProjectIcon.tsx @@ -0,0 +1,51 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; +import { memo, type ComponentType } from "react"; +import Svg, { Circle, Ellipse, G, Line, Path, Polygon, Polyline, Rect } from "react-native-svg"; + +import { + lucideNodeToSvgProps, + type LucideIconNode, + type LucideSvgElement, +} from "../lib/lucideIcon"; +import { projectIconColorHex } from "../lib/projectIcon"; + +type LucideSvgComponent = ComponentType>>; + +const SVG_ELEMENTS = { + path: Path, + circle: Circle, + rect: Rect, + line: Line, + polyline: Polyline, + polygon: Polygon, + ellipse: Ellipse, +} as unknown as Record; + +/** A Lucide glyph drawn like lucide-react's defaults: 24 grid, 2px round stroke, no fill. */ +export const LucideProjectIcon = memo(function LucideProjectIcon(props: { + readonly nodes: ReadonlyArray; + readonly color: ProjectIconColor; + readonly size: number; +}) { + const color = projectIconColorHex(props.color); + return ( + + + {props.nodes.map((node) => { + const mapped = lucideNodeToSvgProps(node, color); + if (mapped === null) return null; + // No Lucide icon repeats a node, so its element and attributes are a unique key. + const Element = SVG_ELEMENTS[mapped.element]; + const key = `${mapped.element}:${Object.values(mapped.props).join(",")}`; + return ; + })} + + + ); +}); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 5d9b4282c7052..ef18e193bc6ae 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,5 +1,6 @@ import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; +import { LucideProjectIcon } from "./LucideProjectIcon"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; @@ -85,6 +86,13 @@ export function ProjectFavicon(props: { function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) { const { glyph, size } = props; + if (glyph.kind === "lucide") { + return ( + + + + ); + } if (glyph.kind === "image") { return ( props.onToggle(props.collapseKey)} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - {/* Every workspace shows the same outline folder here, never its favicon. */} - + {/* An assigned icon shows here; otherwise every workspace shows the same + outline folder, never its favicon. */} + {props.project.projectIcon != null ? ( + + ) : ( + + )} {props.title} diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index d280c05ee0b11..50765bb864d95 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,5 +1,4 @@ import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; -import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; import { createContext, createElement, @@ -26,10 +25,9 @@ export interface ResolvedHomeListOptions extends HomeListOptions { function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, - projectSortOrder: - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + // Folders arranged on another client keep that order here; unarranged ones follow + // activity, so a fresh install without any arrangement looks the same as before. + projectSortOrder: "manual", }; } diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index a7fe3aba632dc..7f07d2897dc4d 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -379,4 +379,63 @@ describe("home project scopes", () => { }), ).toHaveLength(2); }); + + it("manual lists arranged folders by synced key first, then the rest by activity", () => { + const environmentId = EnvironmentId.make("environment-1"); + const remoteEnvironmentId = EnvironmentId.make("environment-2"); + const late = makeProject({ + environmentId, + id: ProjectId.make("project-late"), + title: "Late", + orderKey: "t", + }); + const early = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-early"), + title: "Early", + orderKey: "d", + }); + const busy = makeProject({ + environmentId, + id: ProjectId.make("project-busy"), + title: "Busy", + }); + const idle = makeProject({ + environmentId, + id: ProjectId.make("project-idle"), + title: "Idle", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const scopes = buildHomeProjectScopes({ + projects: [idle, late, busy, early], + environmentId: null, + projectGroupingMode: "separate", + }); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("thread-busy"), + projectId: busy.id, + title: "Recent", + updatedAt: "2026-06-05T00:00:00.000Z", + }), + ]; + + expect( + sortHomeProjectScopes({ + scopes, + threads, + pendingTasks: [], + projectSortOrder: "manual", + }).map((scope) => scope.representative.id), + ).toEqual([early.id, late.id, busy.id, idle.id]); + expect( + sortHomeProjectScopes({ + scopes, + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => scope.representative.id)[0], + ).toBe(busy.id); + }); }); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 0c85972e43e52..3342b5bf9d3db 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,4 +1,5 @@ import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; +import { minProjectOrderKey } from "@t3tools/client-runtime/state/project-order"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -19,7 +20,13 @@ import * as Order from "effect/Order"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -export type HomeProjectSortOrder = Exclude; +/** + * `manual` lists arranged folders first in their synced order (the desktop drag order), then + * the rest by activity. Pickers that want plain activity order pass `updated_at`. + */ +export type HomeProjectSortOrder = SidebarProjectSortOrder; + +type HomeActivitySortOrder = Exclude; export interface HomeProjectScope { readonly key: string; @@ -31,7 +38,7 @@ export interface HomeProjectScope { function getProjectSortTimestamp( project: EnvironmentProject, - sortOrder: HomeProjectSortOrder, + sortOrder: HomeActivitySortOrder, ): number { return sortOrder === "created_at" ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY) @@ -79,6 +86,9 @@ export function sortHomeProjectScopes(input: { ), ), ); + // Activity still orders the folders nobody has arranged; `manual` falls back to updates. + const activityOrder: HomeActivitySortOrder = + input.projectSortOrder === "manual" ? "updated_at" : input.projectSortOrder; const latestActivityByScope = new Map(); const recordActivity = (scopeKey: string | undefined, timestamp: number) => { if (!scopeKey || !Number.isFinite(timestamp)) return; @@ -92,7 +102,7 @@ export function sortHomeProjectScopes(input: { if (thread.archivedAt !== null) continue; recordActivity( scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)), - getThreadSortTimestamp(thread, input.projectSortOrder), + getThreadSortTimestamp(thread, activityOrder), ); } for (const pendingTask of input.pendingTasks) { @@ -102,21 +112,31 @@ export function sortHomeProjectScopes(input: { ); } + const arrangedKeyByScope = new Map(); + if (input.projectSortOrder === "manual") { + for (const scope of input.scopes) { + // A folder holds one project per environment; it sits at its lowest key. + const key = minProjectOrderKey(scope.projects.map((project) => project.orderKey)); + if (key !== null) arrangedKeyByScope.set(scope.key, key); + } + } + return Arr.sort( input.scopes, Order.mapInput( Order.Struct({ + // "0" + key sorts every arranged folder ahead of "1" (no key), then by key. + arranged: Order.String, timestamp: Order.flip(Order.Number), title: Order.String, key: Order.String, }), (scope: HomeProjectScope) => ({ + arranged: arrangedKeyByScope.has(scope.key) ? `0${arrangedKeyByScope.get(scope.key)}` : "1", timestamp: latestActivityByScope.get(scope.key) ?? Math.max( - ...scope.projects.map((project) => - getProjectSortTimestamp(project, input.projectSortOrder), - ), + ...scope.projects.map((project) => getProjectSortTimestamp(project, activityOrder)), ), title: scope.title, key: scope.key, diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index d60eb0fe17b2d..bc25cf135f64b 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -331,7 +331,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps } @@ -356,7 +356,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps diff --git a/apps/mobile/src/lib/lucideIcon.test.ts b/apps/mobile/src/lib/lucideIcon.test.ts new file mode 100644 index 0000000000000..154f2af7ae9d3 --- /dev/null +++ b/apps/mobile/src/lib/lucideIcon.test.ts @@ -0,0 +1,71 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { decodeLucideIconNodes, lookupLucideIconNodes, lucideNodeToSvgProps } from "./lucideIcon"; +import { projectIconColorHex } from "./projectIcon"; + +describe("generated Lucide data", () => { + it("matches the lucide-react web draws with (rerun scripts/generate-lucide-icons.mjs)", () => { + const webLucide = NodePath.resolve( + import.meta.dirname, + "../../../web/node_modules/lucide-react/package.json", + ); + const generated = NodePath.resolve(import.meta.dirname, "lucideIconNodes.generated.json"); + const readVersion = (path: string, field: string) => + (JSON.parse(NodeFS.readFileSync(path, "utf8")) as Record)[field]; + expect(readVersion(generated, "lucideVersion")).toBe(readVersion(webLucide, "version")); + }); + + it("resolves canonical names and aliases, and rejects unknown ones", () => { + expect(lookupLucideIconNodes("folder-code")?.length).toBe(3); + expect(lookupLucideIconNodes("alarm-check")).toEqual( + lookupLucideIconNodes("alarm-clock-check"), + ); + expect(lookupLucideIconNodes("not-a-lucide-icon")).toBeNull(); + expect(lookupLucideIconNodes("__proto__")).toBeNull(); + }); +}); + +describe("decodeLucideIconNodes", () => { + it("reads bare paths and tagged elements", () => { + expect(decodeLucideIconNodes("M3 3h18|circle;cx=12;cy=12;r=10;fill=currentColor")).toEqual([ + ["path", { d: "M3 3h18" }], + ["circle", { cx: "12", cy: "12", r: "10", fill: "currentColor" }], + ]); + }); + + it("drops elements it cannot draw", () => { + expect(decodeLucideIconNodes("script;src=x|M1 1")).toEqual([["path", { d: "M1 1" }]]); + }); +}); + +describe("lucideNodeToSvgProps", () => { + const sky = projectIconColorHex("sky"); + + it("maps each element to camelCase props", () => { + expect( + lucideNodeToSvgProps(["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], sky), + ).toEqual({ + element: "rect", + props: { width: "18", height: "18", x: "3", y: "3", rx: "2" }, + }); + expect(lucideNodeToSvgProps(["line", { x1: "2", x2: "22", y1: "2", y2: "22" }], sky)).toEqual({ + element: "line", + props: { x1: "2", x2: "22", y1: "2", y2: "22" }, + }); + expect( + lucideNodeToSvgProps(["path", { d: "M1 1", "stroke-width": "1.5", key: "abc" }], sky), + ).toEqual({ element: "path", props: { d: "M1 1", strokeWidth: "1.5" } }); + }); + + it("tints currentColor with the icon color", () => { + expect( + lucideNodeToSvgProps(["circle", { cx: "12", cy: "12", r: "1", fill: "currentColor" }], sky), + ).toEqual({ element: "circle", props: { cx: "12", cy: "12", r: "1", fill: "#0ea5e9" } }); + }); + + it("returns null for an element react-native-svg is not given", () => { + expect(lucideNodeToSvgProps(["text" as "path", { x: "0" }], sky)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/lucideIcon.ts b/apps/mobile/src/lib/lucideIcon.ts new file mode 100644 index 0000000000000..189298c0ee38a --- /dev/null +++ b/apps/mobile/src/lib/lucideIcon.ts @@ -0,0 +1,99 @@ +// Lucide glyph data for project icons, without bundling the Lucide package. The node data is +// generated from the web app's lucide-react by scripts/generate-lucide-icons.mjs and loaded on +// first use, so only screens that draw a Lucide project icon ever pay to parse it. + +export type LucideSvgElement = + | "path" + | "circle" + | "rect" + | "line" + | "polyline" + | "polygon" + | "ellipse"; + +/** One Lucide SVG child: its tag and attributes, as Lucide's `__iconNode` lists them. */ +export type LucideIconNode = readonly [ + tag: LucideSvgElement, + attributes: Readonly>, +]; + +interface LucideIconData { + readonly icons: Readonly>; + readonly aliases: Readonly>; +} + +const LUCIDE_SVG_ELEMENTS: ReadonlySet = new Set([ + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "ellipse", +]); + +let lucideIconData: LucideIconData | undefined; +const decodedIcons = new Map | null>(); + +function loadLucideIconData(): LucideIconData { + lucideIconData ??= require("./lucideIconNodes.generated.json") as LucideIconData; + return lucideIconData; +} + +function ownValue(record: Readonly>, key: string): string | undefined { + return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; +} + +/** Decodes the generator's compact form: a bare node is a path's `d`, else "tag;attr=value;...". */ +export function decodeLucideIconNodes(encoded: string): ReadonlyArray { + return encoded.split("|").flatMap((part): LucideIconNode[] => { + if (!part.includes(";")) return [["path", { d: part }]]; + const [tag = "", ...pairs] = part.split(";"); + if (!LUCIDE_SVG_ELEMENTS.has(tag)) return []; + const attributes: Record = {}; + for (const pair of pairs) { + const separator = pair.indexOf("="); + if (separator > 0) attributes[pair.slice(0, separator)] = pair.slice(separator + 1); + } + return [[tag as LucideSvgElement, attributes]]; + }); +} + +/** The SVG nodes for a Lucide icon name (aliases included), or null for a name mobile lacks. */ +export function lookupLucideIconNodes(name: string): ReadonlyArray | null { + const cached = decodedIcons.get(name); + if (cached !== undefined) return cached; + const data = loadLucideIconData(); + const canonical = ownValue(data.aliases, name) ?? name; + const encoded = ownValue(data.icons, canonical); + const nodes = encoded === undefined ? null : decodeLucideIconNodes(encoded); + const resolved = nodes !== null && nodes.length > 0 ? nodes : null; + decodedIcons.set(name, resolved); + return resolved; +} + +function camelCaseAttribute(name: string): string { + return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +/** + * Maps one Lucide node to the react-native-svg element and props that draw it: attribute names + * in camelCase, and `currentColor` replaced by the icon's tint, since stroke and fill come from + * the parent group rather than CSS. + */ +export function lucideNodeToSvgProps( + node: LucideIconNode, + color: string, +): { + readonly element: LucideSvgElement; + readonly props: Readonly>; +} | null { + const [element, attributes] = node; + if (!LUCIDE_SVG_ELEMENTS.has(element)) return null; + const props: Record = {}; + for (const [name, value] of Object.entries(attributes)) { + if (name === "key") continue; + props[camelCaseAttribute(name)] = value === "currentColor" ? color : value; + } + return { element, props }; +} diff --git a/apps/mobile/src/lib/lucideIconNodes.generated.json b/apps/mobile/src/lib/lucideIconNodes.generated.json new file mode 100644 index 0000000000000..25d454f5885e6 --- /dev/null +++ b/apps/mobile/src/lib/lucideIconNodes.generated.json @@ -0,0 +1,1924 @@ +{ + "lucideVersion": "0.564.0", + "icons": { + "a-arrow-down": "m14 12 4 4 4-4|M18 16V7|m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16|M3.304 13h6.392", + "a-arrow-up": "m14 11 4-4 4 4|M18 16V7|m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16|M3.304 13h6.392", + "a-large-small": "m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16|M15.697 14h5.606|m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16|M3.304 13h6.392", + "accessibility": "circle;cx=16;cy=4;r=1|m18 19 1-7-6 1|m5 8 3-3 5.5 3-2.36 3.5|M4.24 14.5a5 5 0 0 0 6.88 6|M13.76 17.5a5 5 0 0 0-6.88-6", + "activity": "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2", + "air-vent": "M18 17.5a2.5 2.5 0 1 1-4 2.03V12|M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2|M6 8h12|M6.6 15.572A2 2 0 1 0 10 17v-5", + "airplay": "M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1|m12 15 5 6H7Z", + "alarm-clock": "circle;cx=12;cy=13;r=8|M12 9v4l2 2|M5 3 2 6|m22 6-3-3|M6.38 18.7 4 21|M17.64 18.67 20 21", + "alarm-clock-check": "circle;cx=12;cy=13;r=8|M5 3 2 6|m22 6-3-3|M6.38 18.7 4 21|M17.64 18.67 20 21|m9 13 2 2 4-4", + "alarm-clock-minus": "circle;cx=12;cy=13;r=8|M5 3 2 6|m22 6-3-3|M6.38 18.7 4 21|M17.64 18.67 20 21|M9 13h6", + "alarm-clock-off": "M6.87 6.87a8 8 0 1 0 11.26 11.26|M19.9 14.25a8 8 0 0 0-9.15-9.15|m22 6-3-3|M6.26 18.67 4 21|m2 2 20 20|M4 4 2 6", + "alarm-clock-plus": "circle;cx=12;cy=13;r=8|M5 3 2 6|m22 6-3-3|M6.38 18.7 4 21|M17.64 18.67 20 21|M12 10v6|M9 13h6", + "alarm-smoke": "M11 21c0-2.5 2-2.5 2-5|M16 21c0-2.5 2-2.5 2-5|m19 8-.8 3a1.25 1.25 0 0 1-1.2 1H7a1.25 1.25 0 0 1-1.2-1L5 8|M21 3a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a1 1 0 0 1 1-1z|M6 21c0-2.5 2-2.5 2-5", + "album": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|polyline;points=11 3 11 11 14 8 17 11 17 3", + "align-center-horizontal": "M2 12h20|M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4|M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4|M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1|M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1", + "align-center-vertical": "M12 2v20|M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4|M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4|M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1|M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1", + "align-end-horizontal": "rect;width=6;height=16;x=4;y=2;rx=2|rect;width=6;height=9;x=14;y=9;rx=2|M22 22H2", + "align-end-vertical": "rect;width=16;height=6;x=2;y=4;rx=2|rect;width=9;height=6;x=9;y=14;rx=2|M22 22V2", + "align-horizontal-distribute-center": "rect;width=6;height=14;x=4;y=5;rx=2|rect;width=6;height=10;x=14;y=7;rx=2|M17 22v-5|M17 7V2|M7 22v-3|M7 5V2", + "align-horizontal-distribute-end": "rect;width=6;height=14;x=4;y=5;rx=2|rect;width=6;height=10;x=14;y=7;rx=2|M10 2v20|M20 2v20", + "align-horizontal-distribute-start": "rect;width=6;height=14;x=4;y=5;rx=2|rect;width=6;height=10;x=14;y=7;rx=2|M4 2v20|M14 2v20", + "align-horizontal-justify-center": "rect;width=6;height=14;x=2;y=5;rx=2|rect;width=6;height=10;x=16;y=7;rx=2|M12 2v20", + "align-horizontal-justify-end": "rect;width=6;height=14;x=2;y=5;rx=2|rect;width=6;height=10;x=12;y=7;rx=2|M22 2v20", + "align-horizontal-justify-start": "rect;width=6;height=14;x=6;y=5;rx=2|rect;width=6;height=10;x=16;y=7;rx=2|M2 2v20", + "align-horizontal-space-around": "rect;width=6;height=10;x=9;y=7;rx=2|M4 22V2|M20 22V2", + "align-horizontal-space-between": "rect;width=6;height=14;x=3;y=5;rx=2|rect;width=6;height=10;x=15;y=7;rx=2|M3 2v20|M21 2v20", + "align-start-horizontal": "rect;width=6;height=16;x=4;y=6;rx=2|rect;width=6;height=9;x=14;y=6;rx=2|M22 2H2", + "align-start-vertical": "rect;width=9;height=6;x=6;y=14;rx=2|rect;width=16;height=6;x=6;y=4;rx=2|M2 2v20", + "align-vertical-distribute-center": "M22 17h-3|M22 7h-5|M5 17H2|M7 7H2|rect;x=5;y=14;width=14;height=6;rx=2|rect;x=7;y=4;width=10;height=6;rx=2", + "align-vertical-distribute-end": "rect;width=14;height=6;x=5;y=14;rx=2|rect;width=10;height=6;x=7;y=4;rx=2|M2 20h20|M2 10h20", + "align-vertical-distribute-start": "rect;width=14;height=6;x=5;y=14;rx=2|rect;width=10;height=6;x=7;y=4;rx=2|M2 14h20|M2 4h20", + "align-vertical-justify-center": "rect;width=14;height=6;x=5;y=16;rx=2|rect;width=10;height=6;x=7;y=2;rx=2|M2 12h20", + "align-vertical-justify-end": "rect;width=14;height=6;x=5;y=12;rx=2|rect;width=10;height=6;x=7;y=2;rx=2|M2 22h20", + "align-vertical-justify-start": "rect;width=14;height=6;x=5;y=16;rx=2|rect;width=10;height=6;x=7;y=6;rx=2|M2 2h20", + "align-vertical-space-around": "rect;width=10;height=6;x=7;y=9;rx=2|M22 20H2|M22 4H2", + "align-vertical-space-between": "rect;width=14;height=6;x=5;y=15;rx=2|rect;width=10;height=6;x=7;y=3;rx=2|M2 21h20|M2 3h20", + "ambulance": "M10 10H6|M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2|M19 18h2a1 1 0 0 0 1-1v-3.28a1 1 0 0 0-.684-.948l-1.923-.641a1 1 0 0 1-.578-.502l-1.539-3.076A1 1 0 0 0 16.382 8H14|M8 8v4|M9 18h6|circle;cx=17;cy=18;r=2|circle;cx=7;cy=18;r=2", + "ampersand": "M16 12h3|M17.5 12a8 8 0 0 1-8 8A4.5 4.5 0 0 1 5 15.5c0-6 8-4 8-8.5a3 3 0 1 0-6 0c0 3 2.5 8.5 12 13", + "ampersands": "M10 17c-5-3-7-7-7-9a2 2 0 0 1 4 0c0 2.5-5 2.5-5 6 0 1.7 1.3 3 3 3 2.8 0 5-2.2 5-5|M22 17c-5-3-7-7-7-9a2 2 0 0 1 4 0c0 2.5-5 2.5-5 6 0 1.7 1.3 3 3 3 2.8 0 5-2.2 5-5", + "amphora": "M10 2v5.632c0 .424-.272.795-.653.982A6 6 0 0 0 6 14c.006 4 3 7 5 8|M10 5H8a2 2 0 0 0 0 4h.68|M14 2v5.632c0 .424.272.795.652.982A6 6 0 0 1 18 14c0 4-3 7-5 8|M14 5h2a2 2 0 0 1 0 4h-.68|M18 22H6|M9 2h6", + "anchor": "M12 6v16|m19 13 2-1a9 9 0 0 1-18 0l2 1|M9 11h6|circle;cx=12;cy=4;r=2", + "angry": "circle;cx=12;cy=12;r=10|M16 16s-1.5-2-4-2-4 2-4 2|M7.5 8 10 9|m14 9 2.5-1|M9 10h.01|M15 10h.01", + "annoyed": "circle;cx=12;cy=12;r=10|M8 15h8|M8 9h2|M14 9h2", + "antenna": "M2 12 7 2|m7 12 5-10|m12 12 5-10|m17 12 5-10|M4.5 7h15|M12 16v6", + "anvil": "M7 10H6a4 4 0 0 1-4-4 1 1 0 0 1 1-1h4|M7 5a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1 7 7 0 0 1-7 7H8a1 1 0 0 1-1-1z|M9 12v5|M15 12v5|M5 20a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3 1 1 0 0 1-1 1H6a1 1 0 0 1-1-1", + "aperture": "circle;cx=12;cy=12;r=10|m14.31 8 5.74 9.94|M9.69 8h11.48|m7.38 12 5.74-9.94|M9.69 16 3.95 6.06|M14.31 16H2.83|m16.62 12-5.74 9.94", + "app-window": "rect;x=2;y=4;width=20;height=16;rx=2|M10 4v4|M2 8h20|M6 4v4", + "app-window-mac": "rect;width=20;height=16;x=2;y=4;rx=2|M6 8h.01|M10 8h.01|M14 8h.01", + "apple": "M12 6.528V3a1 1 0 0 1 1-1h0|M18.237 21A15 15 0 0 0 22 11a6 6 0 0 0-10-4.472A6 6 0 0 0 2 11a15.1 15.1 0 0 0 3.763 10 3 3 0 0 0 3.648.648 5.5 5.5 0 0 1 5.178 0A3 3 0 0 0 18.237 21", + "archive": "rect;width=20;height=5;x=2;y=3;rx=1|M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8|M10 12h4", + "archive-restore": "rect;width=20;height=5;x=2;y=3;rx=1|M4 8v11a2 2 0 0 0 2 2h2|M20 8v11a2 2 0 0 1-2 2h-2|m9 15 3-3 3 3|M12 12v9", + "archive-x": "rect;width=20;height=5;x=2;y=3;rx=1|M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8|m9.5 17 5-5|m9.5 12 5 5", + "armchair": "M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3|M3 16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z|M5 18v2|M19 18v2", + "arrow-big-down": "M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z", + "arrow-big-down-dash": "M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z|M9 4h6", + "arrow-big-left": "M13 9a1 1 0 0 1-1-1V5.061a1 1 0 0 0-1.811-.75l-6.835 6.836a1.207 1.207 0 0 0 0 1.707l6.835 6.835a1 1 0 0 0 1.811-.75V16a1 1 0 0 1 1-1h6a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1z", + "arrow-big-left-dash": "M13 9a1 1 0 0 1-1-1V5.061a1 1 0 0 0-1.811-.75l-6.835 6.836a1.207 1.207 0 0 0 0 1.707l6.835 6.835a1 1 0 0 0 1.811-.75V16a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1z|M20 9v6", + "arrow-big-right": "M11 9a1 1 0 0 0 1-1V5.061a1 1 0 0 1 1.811-.75l6.836 6.836a1.207 1.207 0 0 1 0 1.707l-6.836 6.835a1 1 0 0 1-1.811-.75V16a1 1 0 0 0-1-1H5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z", + "arrow-big-right-dash": "M11 9a1 1 0 0 0 1-1V5.061a1 1 0 0 1 1.811-.75l6.836 6.836a1.207 1.207 0 0 1 0 1.707l-6.836 6.835a1 1 0 0 1-1.811-.75V16a1 1 0 0 0-1-1H9a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z|M4 9v6", + "arrow-big-up": "M9 13a1 1 0 0 0-1-1H5.061a1 1 0 0 1-.75-1.811l6.836-6.835a1.207 1.207 0 0 1 1.707 0l6.835 6.835a1 1 0 0 1-.75 1.811H16a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z", + "arrow-big-up-dash": "M9 13a1 1 0 0 0-1-1H5.061a1 1 0 0 1-.75-1.811l6.836-6.835a1.207 1.207 0 0 1 1.707 0l6.835 6.835a1 1 0 0 1-.75 1.811H16a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z|M9 20h6", + "arrow-down": "M12 5v14|m19 12-7 7-7-7", + "arrow-down-0-1": "m3 16 4 4 4-4|M7 20V4|rect;x=15;y=4;width=4;height=6;ry=2|M17 20v-6h-2|M15 20h4", + "arrow-down-1-0": "m3 16 4 4 4-4|M7 20V4|M17 10V4h-2|M15 10h4|rect;x=15;y=14;width=4;height=6;ry=2", + "arrow-down-a-z": "m3 16 4 4 4-4|M7 20V4|M20 8h-5|M15 10V6.5a2.5 2.5 0 0 1 5 0V10|M15 14h5l-5 6h5", + "arrow-down-from-line": "M19 3H5|M12 21V7|m6 15 6 6 6-6", + "arrow-down-left": "M17 7 7 17|M17 17H7V7", + "arrow-down-narrow-wide": "m3 16 4 4 4-4|M7 20V4|M11 4h4|M11 8h7|M11 12h10", + "arrow-down-right": "m7 7 10 10|M17 7v10H7", + "arrow-down-to-dot": "M12 2v14|m19 9-7 7-7-7|circle;cx=12;cy=21;r=1", + "arrow-down-to-line": "M12 17V3|m6 11 6 6 6-6|M19 21H5", + "arrow-down-up": "m3 16 4 4 4-4|M7 20V4|m21 8-4-4-4 4|M17 4v16", + "arrow-down-wide-narrow": "m3 16 4 4 4-4|M7 20V4|M11 4h10|M11 8h7|M11 12h4", + "arrow-down-z-a": "m3 16 4 4 4-4|M7 4v16|M15 4h5l-5 6h5|M15 20v-3.5a2.5 2.5 0 0 1 5 0V20|M20 18h-5", + "arrow-left": "m12 19-7-7 7-7|M19 12H5", + "arrow-left-from-line": "m9 6-6 6 6 6|M3 12h14|M21 19V5", + "arrow-left-right": "M8 3 4 7l4 4|M4 7h16|m16 21 4-4-4-4|M20 17H4", + "arrow-left-to-line": "M3 19V5|m13 6-6 6 6 6|M7 12h14", + "arrow-right": "M5 12h14|m12 5 7 7-7 7", + "arrow-right-from-line": "M3 5v14|M21 12H7|m15 18 6-6-6-6", + "arrow-right-left": "m16 3 4 4-4 4|M20 7H4|m8 21-4-4 4-4|M4 17h16", + "arrow-right-to-line": "M17 12H3|m11 18 6-6-6-6|M21 5v14", + "arrow-up": "m5 12 7-7 7 7|M12 19V5", + "arrow-up-0-1": "m3 8 4-4 4 4|M7 4v16|rect;x=15;y=4;width=4;height=6;ry=2|M17 20v-6h-2|M15 20h4", + "arrow-up-1-0": "m3 8 4-4 4 4|M7 4v16|M17 10V4h-2|M15 10h4|rect;x=15;y=14;width=4;height=6;ry=2", + "arrow-up-a-z": "m3 8 4-4 4 4|M7 4v16|M20 8h-5|M15 10V6.5a2.5 2.5 0 0 1 5 0V10|M15 14h5l-5 6h5", + "arrow-up-down": "m21 16-4 4-4-4|M17 20V4|m3 8 4-4 4 4|M7 4v16", + "arrow-up-from-dot": "m5 9 7-7 7 7|M12 16V2|circle;cx=12;cy=21;r=1", + "arrow-up-from-line": "m18 9-6-6-6 6|M12 3v14|M5 21h14", + "arrow-up-left": "M7 17V7h10|M17 17 7 7", + "arrow-up-narrow-wide": "m3 8 4-4 4 4|M7 4v16|M11 12h4|M11 16h7|M11 20h10", + "arrow-up-right": "M7 7h10v10|M7 17 17 7", + "arrow-up-to-line": "M5 3h14|m18 13-6-6-6 6|M12 7v14", + "arrow-up-wide-narrow": "m3 8 4-4 4 4|M7 4v16|M11 12h10|M11 16h7|M11 20h4", + "arrow-up-z-a": "m3 8 4-4 4 4|M7 4v16|M15 4h5l-5 6h5|M15 20v-3.5a2.5 2.5 0 0 1 5 0V20|M20 18h-5", + "arrows-up-from-line": "m4 6 3-3 3 3|M7 17V3|m14 6 3-3 3 3|M17 17V3|M4 21h16", + "asterisk": "M12 6v12|M17.196 9 6.804 15|m6.804 9 10.392 6", + "at-sign": "circle;cx=12;cy=12;r=4|M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8", + "atom": "circle;cx=12;cy=12;r=1|M20.2 20.2c2.04-2.03.02-7.36-4.5-11.9-4.54-4.52-9.87-6.54-11.9-4.5-2.04 2.03-.02 7.36 4.5 11.9 4.54 4.52 9.87 6.54 11.9 4.5Z|M15.7 15.7c4.52-4.54 6.54-9.87 4.5-11.9-2.03-2.04-7.36-.02-11.9 4.5-4.52 4.54-6.54 9.87-4.5 11.9 2.03 2.04 7.36.02 11.9-4.5Z", + "audio-lines": "M2 10v3|M6 6v11|M10 3v18|M14 8v7|M18 5v13|M22 10v3", + "audio-waveform": "M2 13a2 2 0 0 0 2-2V7a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0V4a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0v-4a2 2 0 0 1 2-2", + "award": "m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526|circle;cx=12;cy=8;r=6", + "axe": "m14 12-8.381 8.38a1 1 0 0 1-3.001-3L11 9|M15 15.5a.5.5 0 0 0 .5.5A6.5 6.5 0 0 0 22 9.5a.5.5 0 0 0-.5-.5h-1.672a2 2 0 0 1-1.414-.586l-5.062-5.062a1.205 1.205 0 0 0-1.704 0L9.352 5.648a1.205 1.205 0 0 0 0 1.704l5.062 5.062A2 2 0 0 1 15 13.828z", + "axis-3d": "M13.5 10.5 15 9|M4 4v15a1 1 0 0 0 1 1h15|M4.293 19.707 6 18|m9 15 1.5-1.5", + "baby": "M10 16c.5.3 1.2.5 2 .5s1.5-.2 2-.5|M15 12h.01|M19.38 6.813A9 9 0 0 1 20.8 10.2a2 2 0 0 1 0 3.6 9 9 0 0 1-17.6 0 2 2 0 0 1 0-3.6A9 9 0 0 1 12 3c2 0 3.5 1.1 3.5 2.5s-.9 2.5-2 2.5c-.8 0-1.5-.4-1.5-1|M9 12h.01", + "backpack": "M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z|M8 10h8|M8 18h8|M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6|M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2", + "badge": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z", + "badge-alert": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|line;x1=12;x2=12;y1=8;y2=12|line;x1=12;x2=12.01;y1=16;y2=16", + "badge-cent": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M12 7v10|M15.4 10a4 4 0 1 0 0 4", + "badge-check": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|m9 12 2 2 4-4", + "badge-dollar-sign": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8|M12 18V6", + "badge-euro": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M7 12h5|M15 9.4a4 4 0 1 0 0 5.2", + "badge-indian-rupee": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M8 8h8|M8 12h8|m13 17-5-1h1a4 4 0 0 0 0-8", + "badge-info": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|line;x1=12;x2=12;y1=16;y2=12|line;x1=12;x2=12.01;y1=8;y2=8", + "badge-japanese-yen": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|m9 8 3 3v7|m12 11 3-3|M9 12h6|M9 16h6", + "badge-minus": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|line;x1=8;x2=16;y1=12;y2=12", + "badge-percent": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|m15 9-6 6|M9 9h.01|M15 15h.01", + "badge-plus": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|line;x1=12;x2=12;y1=8;y2=16|line;x1=8;x2=16;y1=12;y2=12", + "badge-pound-sterling": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M8 12h4|M10 16V9.5a2.5 2.5 0 0 1 5 0|M8 16h7", + "badge-question-mark": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3|line;x1=12;x2=12.01;y1=17;y2=17", + "badge-russian-ruble": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M9 16h5|M9 12h5a2 2 0 1 0 0-4h-3v9", + "badge-swiss-franc": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|M11 17V8h4|M11 12h3|M9 16h4", + "badge-turkish-lira": "M11 7v10a5 5 0 0 0 5-5|m15 8-6 3|M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76", + "badge-x": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z|line;x1=15;x2=9;y1=9;y2=15|line;x1=9;x2=15;y1=9;y2=15", + "baggage-claim": "M22 18H6a2 2 0 0 1-2-2V7a2 2 0 0 0-2-2|M17 14V4a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2v10|rect;width=13;height=8;x=8;y=6;rx=1|circle;cx=18;cy=20;r=2|circle;cx=9;cy=20;r=2", + "balloon": "M12 16v1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v1|M12 6a2 2 0 0 1 2 2|M18 8c0 4-3.5 8-6 8s-6-4-6-8a6 6 0 0 1 12 0", + "ban": "M4.929 4.929 19.07 19.071|circle;cx=12;cy=12;r=10", + "banana": "M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5|M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z", + "bandage": "M10 10.01h.01|M10 14.01h.01|M14 10.01h.01|M14 14.01h.01|M18 6v12|M6 6v12|rect;x=2;y=6;width=20;height=12;rx=2", + "banknote": "rect;width=20;height=12;x=2;y=6;rx=2|circle;cx=12;cy=12;r=2|M6 12h.01M18 12h.01", + "banknote-arrow-down": "M12 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5|m16 19 3 3 3-3|M18 12h.01|M19 16v6|M6 12h.01|circle;cx=12;cy=12;r=2", + "banknote-arrow-up": "M12 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5|M18 12h.01|M19 22v-6|m22 19-3-3-3 3|M6 12h.01|circle;cx=12;cy=12;r=2", + "banknote-x": "M13 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5|m17 17 5 5|M18 12h.01|m22 17-5 5|M6 12h.01|circle;cx=12;cy=12;r=2", + "barcode": "M3 5v14|M8 5v14|M12 5v14|M17 5v14|M21 5v14", + "barrel": "M10 3a41 41 0 0 0 0 18|M14 3a41 41 0 0 1 0 18|M17 3a2 2 0 0 1 1.68.92 15.25 15.25 0 0 1 0 16.16A2 2 0 0 1 17 21H7a2 2 0 0 1-1.68-.92 15.25 15.25 0 0 1 0-16.16A2 2 0 0 1 7 3z|M3.84 17h16.32|M3.84 7h16.32", + "baseline": "M4 20h16|m6 16 6-12 6 12|M8 12h8", + "bath": "M10 4 8 6|M17 19v2|M2 12h20|M7 19v2|M9 5 7.621 3.621A2.121 2.121 0 0 0 4 5v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5", + "battery": "M 22 14 L 22 10|rect;x=2;y=6;width=16;height=12;rx=2", + "battery-charging": "m11 7-3 5h4l-3 5|M14.856 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.935|M22 14v-4|M5.14 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.936", + "battery-full": "M10 10v4|M14 10v4|M22 14v-4|M6 10v4|rect;x=2;y=6;width=16;height=12;rx=2", + "battery-low": "M22 14v-4|M6 14v-4|rect;x=2;y=6;width=16;height=12;rx=2", + "battery-medium": "M10 14v-4|M22 14v-4|M6 14v-4|rect;x=2;y=6;width=16;height=12;rx=2", + "battery-plus": "M10 9v6|M12.543 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.605|M22 14v-4|M7 12h6|M7.606 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.606", + "battery-warning": "M10 17h.01|M10 7v6|M14 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2|M22 14v-4|M6 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2", + "beaker": "M4.5 3h15|M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3|M6 14h12", + "bean": "M10.165 6.598C9.954 7.478 9.64 8.36 9 9c-.64.64-1.521.954-2.402 1.165A6 6 0 0 0 8 22c7.732 0 14-6.268 14-14a6 6 0 0 0-11.835-1.402Z|M5.341 10.62a4 4 0 1 0 5.279-5.28", + "bean-off": "M9 9c-.64.64-1.521.954-2.402 1.165A6 6 0 0 0 8 22a13.96 13.96 0 0 0 9.9-4.1|M10.75 5.093A6 6 0 0 1 22 8c0 2.411-.61 4.68-1.683 6.66|M5.341 10.62a4 4 0 0 0 6.487 1.208M10.62 5.341a4.015 4.015 0 0 1 2.039 2.04|line;x1=2;x2=22;y1=2;y2=22", + "bed": "M2 4v16|M2 8h18a2 2 0 0 1 2 2v10|M2 17h20|M6 8v9", + "bed-double": "M2 20v-8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v8|M4 10V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4|M12 4v6|M2 18h20", + "bed-single": "M3 20v-8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8|M5 10V6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4|M3 18h18", + "beef": "M16.4 13.7A6.5 6.5 0 1 0 6.28 6.6c-1.1 3.13-.78 3.9-3.18 6.08A3 3 0 0 0 5 18c4 0 8.4-1.8 11.4-4.3|m18.5 6 2.19 4.5a6.48 6.48 0 0 1-2.29 7.2C15.4 20.2 11 22 7 22a3 3 0 0 1-2.68-1.66L2.4 16.5|circle;cx=12.5;cy=8.5;r=2.5", + "beer": "M17 11h1a3 3 0 0 1 0 6h-1|M9 12v6|M13 12v6|M14 7.5c-1 0-1.44.5-3 .5s-2-.5-3-.5-1.72.5-2.5.5a2.5 2.5 0 0 1 0-5c.78 0 1.57.5 2.5.5S9.44 2 11 2s2 1.5 3 1.5 1.72-.5 2.5-.5a2.5 2.5 0 0 1 0 5c-.78 0-1.5-.5-2.5-.5Z|M5 8v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8", + "beer-off": "M13 13v5|M17 11.47V8|M17 11h1a3 3 0 0 1 2.745 4.211|m2 2 20 20|M5 8v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3|M7.536 7.535C6.766 7.649 6.154 8 5.5 8a2.5 2.5 0 0 1-1.768-4.268|M8.727 3.204C9.306 2.767 9.885 2 11 2c1.56 0 2 1.5 3 1.5s1.72-.5 2.5-.5a1 1 0 1 1 0 5c-.78 0-1.5-.5-2.5-.5a3.149 3.149 0 0 0-.842.12|M9 14.6V18", + "bell": "M10.268 21a2 2 0 0 0 3.464 0|M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326", + "bell-dot": "M10.268 21a2 2 0 0 0 3.464 0|M11.68 2.009A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673c-.824-.85-1.678-1.731-2.21-3.348|circle;cx=18;cy=5;r=3", + "bell-electric": "M18.518 17.347A7 7 0 0 1 14 19|M18.8 4A11 11 0 0 1 20 9|M9 9h.01|circle;cx=20;cy=16;r=2|circle;cx=9;cy=9;r=7|rect;x=4;y=16;width=10;height=6;rx=2", + "bell-minus": "M10.268 21a2 2 0 0 0 3.464 0|M15 8h6|M16.243 3.757A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673A9.4 9.4 0 0 1 18.667 12", + "bell-off": "M10.268 21a2 2 0 0 0 3.464 0|M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742|m2 2 20 20|M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05", + "bell-plus": "M10.268 21a2 2 0 0 0 3.464 0|M15 8h6|M18 5v6|M20.002 14.464a9 9 0 0 0 .738.863A1 1 0 0 1 20 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 8.75-5.332", + "bell-ring": "M10.268 21a2 2 0 0 0 3.464 0|M22 8c0-2.3-.8-4.3-2-6|M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326|M4 2C2.8 3.7 2 5.7 2 8", + "between-horizontal-end": "rect;width=13;height=7;x=3;y=3;rx=1|m22 15-3-3 3-3|rect;width=13;height=7;x=3;y=14;rx=1", + "between-horizontal-start": "rect;width=13;height=7;x=8;y=3;rx=1|m2 9 3 3-3 3|rect;width=13;height=7;x=8;y=14;rx=1", + "between-vertical-end": "rect;width=7;height=13;x=3;y=3;rx=1|m9 22 3-3 3 3|rect;width=7;height=13;x=14;y=3;rx=1", + "between-vertical-start": "rect;width=7;height=13;x=3;y=8;rx=1|m15 2-3 3-3-3|rect;width=7;height=13;x=14;y=8;rx=1", + "biceps-flexed": "M12.409 13.017A5 5 0 0 1 22 15c0 3.866-4 7-9 7-4.077 0-8.153-.82-10.371-2.462-.426-.316-.631-.832-.62-1.362C2.118 12.723 2.627 2 10 2a3 3 0 0 1 3 3 2 2 0 0 1-2 2c-1.105 0-1.64-.444-2-1|M15 14a5 5 0 0 0-7.584 2|M9.964 6.825C8.019 7.977 9.5 13 8 15", + "bike": "circle;cx=18.5;cy=17.5;r=3.5|circle;cx=5.5;cy=17.5;r=3.5|circle;cx=15;cy=5;r=1|M12 17.5V14l-3-3 4-3 2 3h2", + "binary": "rect;x=14;y=14;width=4;height=6;rx=2|rect;x=6;y=4;width=4;height=6;rx=2|M6 20h4|M14 10h4|M6 14h2v6|M14 4h2v6", + "binoculars": "M10 10h4|M19 7V4a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v3|M20 21a2 2 0 0 0 2-2v-3.851c0-1.39-2-2.962-2-4.829V8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v11a2 2 0 0 0 2 2z|M 22 16 L 2 16|M4 21a2 2 0 0 1-2-2v-3.851c0-1.39 2-2.962 2-4.829V8a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v11a2 2 0 0 1-2 2z|M9 7V4a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v3", + "biohazard": "circle;cx=12;cy=11.9;r=2|M6.7 3.4c-.9 2.5 0 5.2 2.2 6.7C6.5 9 3.7 9.6 2 11.6|m8.9 10.1 1.4.8|M17.3 3.4c.9 2.5 0 5.2-2.2 6.7 2.4-1.2 5.2-.6 6.9 1.5|m15.1 10.1-1.4.8|M16.7 20.8c-2.6-.4-4.6-2.6-4.7-5.3-.2 2.6-2.1 4.8-4.7 5.2|M12 13.9v1.6|M13.5 5.4c-1-.2-2-.2-3 0|M17 16.4c.7-.7 1.2-1.6 1.5-2.5|M5.5 13.9c.3.9.8 1.8 1.5 2.5", + "bird": "M16 7h.01|M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20|m20 7 2 .5-2 .5|M10 18v3|M14 17.75V21|M7 18a6 6 0 0 0 3.84-10.61", + "birdhouse": "M12 18v4|m17 18 1.956-11.468|m3 8 7.82-5.615a2 2 0 0 1 2.36 0L21 8|M4 18h16|M7 18 5.044 6.532|circle;cx=12;cy=10;r=2", + "bitcoin": "M11.767 19.089c4.924.868 6.14-6.025 1.216-6.894m-1.216 6.894L5.86 18.047m5.908 1.042-.347 1.97m1.563-8.864c4.924.869 6.14-6.025 1.215-6.893m-1.215 6.893-3.94-.694m5.155-6.2L8.29 4.26m5.908 1.042.348-1.97M7.48 20.364l3.126-17.727", + "blend": "circle;cx=9;cy=9;r=7|circle;cx=15;cy=15;r=7", + "blinds": "M3 3h18|M20 7H8|M20 11H8|M10 19h10|M8 15h12|M4 3v14|circle;cx=4;cy=19;r=2", + "blocks": "M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2|rect;x=14;y=2;width=8;height=8;rx=1", + "bluetooth": "m7 7 10 10-5 5V2l5 5L7 17", + "bluetooth-connected": "m7 7 10 10-5 5V2l5 5L7 17|line;x1=18;x2=21;y1=12;y2=12|line;x1=3;x2=6;y1=12;y2=12", + "bluetooth-off": "m17 17-5 5V12l-5 5|m2 2 20 20|M14.5 9.5 17 7l-5-5v4.5", + "bluetooth-searching": "m7 7 10 10-5 5V2l5 5L7 17|M20.83 14.83a4 4 0 0 0 0-5.66|M18 12h.01", + "bold": "M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8", + "bolt": "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|circle;cx=12;cy=12;r=4", + "bomb": "circle;cx=11;cy=13;r=9|M14.35 4.65 16.3 2.7a2.41 2.41 0 0 1 3.4 0l1.6 1.6a2.4 2.4 0 0 1 0 3.4l-1.95 1.95|m22 2-1.5 1.5", + "bone": "M17 10c.7-.7 1.69 0 2.5 0a2.5 2.5 0 1 0 0-5 .5.5 0 0 1-.5-.5 2.5 2.5 0 1 0-5 0c0 .81.7 1.8 0 2.5l-7 7c-.7.7-1.69 0-2.5 0a2.5 2.5 0 0 0 0 5c.28 0 .5.22.5.5a2.5 2.5 0 1 0 5 0c0-.81-.7-1.8 0-2.5Z", + "book": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20", + "book-a": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|m8 13 4-7 4 7|M9.1 11h5.7", + "book-alert": "M12 13h.01|M12 6v3|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20", + "book-audio": "M12 6v7|M16 8v3|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M8 8v3", + "book-check": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|m9 9.5 2 2 4-4", + "book-copy": "M5 7a2 2 0 0 0-2 2v11|M5.803 18H5a2 2 0 0 0 0 4h9.5a.5.5 0 0 0 .5-.5V21|M9 15V4a2 2 0 0 1 2-2h9.5a.5.5 0 0 1 .5.5v14a.5.5 0 0 1-.5.5H11a2 2 0 0 1 0-4h10", + "book-dashed": "M12 17h1.5|M12 22h1.5|M12 2h1.5|M17.5 22H19a1 1 0 0 0 1-1|M17.5 2H19a1 1 0 0 1 1 1v1.5|M20 14v3h-2.5|M20 8.5V10|M4 10V8.5|M4 19.5V14|M4 4.5A2.5 2.5 0 0 1 6.5 2H8|M8 22H6.5a1 1 0 0 1 0-5H8", + "book-down": "M12 13V7|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|m9 10 3 3 3-3", + "book-headphones": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M8 12v-2a4 4 0 0 1 8 0v2|circle;cx=15;cy=12;r=1|circle;cx=9;cy=12;r=1", + "book-heart": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M8.62 9.8A2.25 2.25 0 1 1 12 6.836a2.25 2.25 0 1 1 3.38 2.966l-2.626 2.856a.998.998 0 0 1-1.507 0z", + "book-image": "m20 13.7-2.1-2.1a2 2 0 0 0-2.8 0L9.7 17|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|circle;cx=10;cy=8;r=2", + "book-key": "M13 2H6.5A2.5 2.5 0 0 0 4 4.5v15|M17 2v6|M17 4h2|M20 15.2V21a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|circle;cx=17;cy=10;r=2", + "book-lock": "M18 6V4a2 2 0 1 0-4 0v2|M20 15v6a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H10|rect;x=12;y=6;width=8;height=5;rx=1", + "book-marked": "M10 2v8l3-3 3 3V2|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20", + "book-minus": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M9 10h6", + "book-open": "M12 7v14|M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z", + "book-open-check": "M12 21V7|m16 12 2 2 4-4|M22 6V4a1 1 0 0 0-1-1h-5a4 4 0 0 0-4 4 4 4 0 0 0-4-4H3a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h6a3 3 0 0 1 3 3 3 3 0 0 1 3-3h6a1 1 0 0 0 1-1v-1.3", + "book-open-text": "M12 7v14|M16 12h2|M16 8h2|M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z|M6 12h2|M6 8h2", + "book-plus": "M12 7v6|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M9 10h6", + "book-search": "M11 22H5.5a1 1 0 0 1 0-5h4.501|m21 22-1.879-1.878|M3 19.5v-15A2.5 2.5 0 0 1 5.5 2H18a1 1 0 0 1 1 1v8|circle;cx=17;cy=18;r=3", + "book-text": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M8 11h8|M8 7h6", + "book-type": "M10 13h4|M12 6v7|M16 8V6H8v2|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20", + "book-up": "M12 13V7|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|m9 10 3-3 3 3", + "book-up-2": "M12 13V7|M18 2h1a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2|m9 10 3-3 3 3|m9 5 3-3 3 3", + "book-user": "M15 13a3 3 0 1 0-6 0|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|circle;cx=12;cy=8;r=2", + "book-x": "m14.5 7-5 5|M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20|m9.5 7 5 5", + "bookmark": "M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z", + "bookmark-check": "M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z|m9 10 2 2 4-4", + "bookmark-minus": "M15 10H9|M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z", + "bookmark-plus": "M12 7v6|M15 10H9|M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z", + "bookmark-x": "m14.5 7.5-5 5|M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z|m9.5 7.5 5 5", + "boom-box": "M4 9V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v4|M8 8v1|M12 8v1|M16 8v1|rect;width=20;height=12;x=2;y=9;rx=2|circle;cx=8;cy=15;r=2|circle;cx=16;cy=15;r=2", + "bot": "M12 8V4H8|rect;width=16;height=12;x=4;y=8;rx=2|M2 14h2|M20 14h2|M15 13v2|M9 13v2", + "bot-message-square": "M12 6V2H8|M15 11v2|M2 12h2|M20 12h2|M20 16a2 2 0 0 1-2 2H8.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 4 20.286V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z|M9 11v2", + "bot-off": "M13.67 8H18a2 2 0 0 1 2 2v4.33|M2 14h2|M20 14h2|M22 22 2 2|M8 8H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 1.414-.586|M9 13v2|M9.67 4H12v2.33", + "bottle-wine": "M10 3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a6 6 0 0 0 1.2 3.6l.6.8A6 6 0 0 1 17 13v8a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-8a6 6 0 0 1 1.2-3.6l.6-.8A6 6 0 0 0 10 5z|M17 13h-4a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h4", + "bow-arrow": "M17 3h4v4|M18.575 11.082a13 13 0 0 1 1.048 9.027 1.17 1.17 0 0 1-1.914.597L14 17|M7 10 3.29 6.29a1.17 1.17 0 0 1 .6-1.91 13 13 0 0 1 9.03 1.05|M7 14a1.7 1.7 0 0 0-1.207.5l-2.646 2.646A.5.5 0 0 0 3.5 18H5a1 1 0 0 1 1 1v1.5a.5.5 0 0 0 .854.354L9.5 18.207A1.7 1.7 0 0 0 10 17v-2a1 1 0 0 0-1-1z|M9.707 14.293 21 3", + "box": "M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z|m3.3 7 8.7 5 8.7-5|M12 22V12", + "boxes": "M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z|m7 16.5-4.74-2.85|m7 16.5 5-3|M7 16.5v5.17|M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z|m17 16.5-5-3|m17 16.5 4.74-2.85|M17 16.5v5.17|M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z|M12 8 7.26 5.15|m12 8 4.74-2.85|M12 13.5V8", + "braces": "M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1|M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1", + "brackets": "M16 3h3a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-3|M8 21H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h3", + "brain": "M12 18V5|M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4|M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5|M17.997 5.125a4 4 0 0 1 2.526 5.77|M18 18a4 4 0 0 0 2-7.464|M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517|M6 18a4 4 0 0 1-2-7.464|M6.003 5.125a4 4 0 0 0-2.526 5.77", + "brain-circuit": "M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z|M9 13a4.5 4.5 0 0 0 3-4|M6.003 5.125A3 3 0 0 0 6.401 6.5|M3.477 10.896a4 4 0 0 1 .585-.396|M6 18a4 4 0 0 1-1.967-.516|M12 13h4|M12 18h6a2 2 0 0 1 2 2v1|M12 8h8|M16 8V5a2 2 0 0 1 2-2|circle;cx=16;cy=13;r=.5|circle;cx=18;cy=3;r=.5|circle;cx=20;cy=21;r=.5|circle;cx=20;cy=8;r=.5", + "brain-cog": "m10.852 14.772-.383.923|m10.852 9.228-.383-.923|m13.148 14.772.382.924|m13.531 8.305-.383.923|m14.772 10.852.923-.383|m14.772 13.148.923.383|M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446 3 3 0 0 0-.368 1.571 4 4 0 0 0-2.525 5.771|M17.998 5.125a4 4 0 0 1 2.525 5.771|M19.505 10.294a4 4 0 0 1-1.5 7.706|M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516|M4.5 10.291A4 4 0 0 0 6 18|M6.002 5.125a3 3 0 0 0 .4 1.375|m9.228 10.852-.923-.383|m9.228 13.148-.923.383|circle;cx=12;cy=12;r=3", + "brick-wall": "rect;width=18;height=18;x=3;y=3;rx=2|M12 9v6|M16 15v6|M16 3v6|M3 15h18|M3 9h18|M8 15v6|M8 3v6", + "brick-wall-fire": "M16 3v2.107|M17 9c1 3 2.5 3.5 3.5 4.5A5 5 0 0 1 22 17a5 5 0 0 1-10 0c0-.3 0-.6.1-.9a2 2 0 1 0 3.3-2C13 11.5 16 9 17 9|M21 8.274V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.938|M3 15h5.253|M3 9h8.228|M8 15v6|M8 3v6", + "brick-wall-shield": "M12 9v1.258|M16 3v5.46|M21 9.118V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5.75|M22 17.5c0 2.499-1.75 3.749-3.83 4.474a.5.5 0 0 1-.335-.005c-2.085-.72-3.835-1.97-3.835-4.47V14a.5.5 0 0 1 .5-.499c1 0 2.25-.6 3.12-1.36a.6.6 0 0 1 .76-.001c.875.765 2.12 1.36 3.12 1.36a.5.5 0 0 1 .5.5z|M3 15h7|M3 9h12.142|M8 15v6|M8 3v6", + "briefcase": "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16|rect;width=20;height=14;x=2;y=6;rx=2", + "briefcase-business": "M12 12h.01|M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2|M22 13a18.15 18.15 0 0 1-20 0|rect;width=20;height=14;x=2;y=6;rx=2", + "briefcase-conveyor-belt": "M10 20v2|M14 20v2|M18 20v2|M21 20H3|M6 20v2|M8 16V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v12|rect;x=4;y=6;width=16;height=10;rx=2", + "briefcase-medical": "M12 11v4|M14 13h-4|M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2|M18 6v14|M6 6v14|rect;width=20;height=14;x=2;y=6;rx=2", + "bring-to-front": "rect;x=8;y=8;width=8;height=8;rx=2|M4 10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2|M14 20a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2", + "brush": "m11 10 3 3|M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z|M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031", + "brush-cleaning": "m16 22-1-4|M19 14a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v1a1 1 0 0 0 1 1|M19 14H5l-1.973 6.767A1 1 0 0 0 4 22h16a1 1 0 0 0 .973-1.233z|m8 22 1-4", + "bubbles": "M7.001 15.085A1.5 1.5 0 0 1 9 16.5|circle;cx=18.5;cy=8.5;r=3.5|circle;cx=7.5;cy=16.5;r=5.5|circle;cx=7.5;cy=4.5;r=2.5", + "bug": "M12 20v-9|M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z|M14.12 3.88 16 2|M21 21a4 4 0 0 0-3.81-4|M21 5a4 4 0 0 1-3.55 3.97|M22 13h-4|M3 21a4 4 0 0 1 3.81-4|M3 5a4 4 0 0 0 3.55 3.97|M6 13H2|m8 2 1.88 1.88|M9 7.13V6a3 3 0 1 1 6 0v1.13", + "bug-off": "M12 20v-8|M12.656 7H14a4 4 0 0 1 4 4v1.344|M14.12 3.88 16 2|M17.123 17.123A6 6 0 0 1 6 14v-3a4 4 0 0 1 1.72-3.287|m2 2 20 20|M21 5a4 4 0 0 1-3.55 3.97|M22 13h-3.344|M3 21a4 4 0 0 1 3.81-4|M3 5a4 4 0 0 0 3.55 3.97|M6 13H2|m8 2 1.88 1.88|M9.712 4.06A3 3 0 0 1 15 6v1.13", + "bug-play": "M10 19.655A6 6 0 0 1 6 14v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 3.97|M14 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z|M14.12 3.88 16 2|M21 5a4 4 0 0 1-3.55 3.97|M3 21a4 4 0 0 1 3.81-4|M3 5a4 4 0 0 0 3.55 3.97|M6 13H2|m8 2 1.88 1.88|M9 7.13V6a3 3 0 1 1 6 0v1.13", + "building": "M12 10h.01|M12 14h.01|M12 6h.01|M16 10h.01|M16 14h.01|M16 6h.01|M8 10h.01|M8 14h.01|M8 6h.01|M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3|rect;x=4;y=2;width=16;height=20;rx=2", + "building-2": "M10 12h4|M10 8h4|M14 21v-3a2 2 0 0 0-4 0v3|M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2|M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16", + "bus": "M8 6v6|M15 6v6|M2 12h19.6|M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3|circle;cx=7;cy=18;r=2|M9 18h5|circle;cx=16;cy=18;r=2", + "bus-front": "M4 6 2 7|M10 6h4|m22 7-2-1|rect;width=16;height=16;x=4;y=3;rx=2|M4 11h16|M8 15h.01|M16 15h.01|M6 19v2|M18 21v-2", + "cable": "M17 19a1 1 0 0 1-1-1v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2a1 1 0 0 1-1 1z|M17 21v-2|M19 14V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V10|M21 21v-2|M3 5V3|M4 10a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2z|M7 5V3", + "cable-car": "M10 3h.01|M14 2h.01|m2 9 20-5|M12 12V6.5|rect;width=16;height=10;x=4;y=12;rx=3|M9 12v5|M15 12v5|M4 17h16", + "cake": "M20 21v-8a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8|M4 16s.5-1 2-1 2.5 2 4 2 2.5-2 4-2 2.5 2 4 2 2-1 2-1|M2 21h20|M7 8v3|M12 8v3|M17 8v3|M7 4h.01|M12 4h.01|M17 4h.01", + "cake-slice": "M16 13H3|M16 17H3|m7.2 7.9-3.388 2.5A2 2 0 0 0 3 12.01V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-8.654c0-2-2.44-6.026-6.44-8.026a1 1 0 0 0-1.082.057L10.4 5.6|circle;cx=9;cy=7;r=2", + "calculator": "rect;width=16;height=20;x=4;y=2;rx=2|line;x1=8;x2=16;y1=6;y2=6|line;x1=16;x2=16;y1=14;y2=18|M16 10h.01|M12 10h.01|M8 10h.01|M12 14h.01|M8 14h.01|M12 18h.01|M8 18h.01", + "calendar": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18", + "calendar-1": "M11 14h1v4|M16 2v4|M3 10h18|M8 2v4|rect;x=3;y=4;width=18;height=18;rx=2", + "calendar-arrow-down": "m14 18 4 4 4-4|M16 2v4|M18 14v8|M21 11.354V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.343|M3 10h18|M8 2v4", + "calendar-arrow-up": "m14 18 4-4 4 4|M16 2v4|M18 22v-8|M21 11.343V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h9|M3 10h18|M8 2v4", + "calendar-check": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18|m9 16 2 2 4-4", + "calendar-check-2": "M8 2v4|M16 2v4|M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8|M3 10h18|m16 20 2 2 4-4", + "calendar-clock": "M16 14v2.2l1.6 1|M16 2v4|M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5|M3 10h5|M8 2v4|circle;cx=16;cy=16;r=6", + "calendar-cog": "m15.228 16.852-.923-.383|m15.228 19.148-.923.383|M16 2v4|m16.47 14.305.382.923|m16.852 20.772-.383.924|m19.148 15.228.383-.923|m19.53 21.696-.382-.924|m20.772 16.852.924-.383|m20.772 19.148.924.383|M21 10.592V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6|M3 10h18|M8 2v4|circle;cx=18;cy=18;r=3", + "calendar-days": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18|M8 14h.01|M12 14h.01|M16 14h.01|M8 18h.01|M12 18h.01|M16 18h.01", + "calendar-fold": "M3 20a2 2 0 0 0 2 2h10a2.4 2.4 0 0 0 1.706-.706l3.588-3.588A2.4 2.4 0 0 0 21 16V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2z|M15 22v-5a1 1 0 0 1 1-1h5|M8 2v4|M16 2v4|M3 10h18", + "calendar-heart": "M12.127 22H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.125|M14.62 18.8A2.25 2.25 0 1 1 18 15.836a2.25 2.25 0 1 1 3.38 2.966l-2.626 2.856a.998.998 0 0 1-1.507 0z|M16 2v4|M3 10h18|M8 2v4", + "calendar-minus": "M16 19h6|M16 2v4|M21 15V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5|M3 10h18|M8 2v4", + "calendar-minus-2": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18|M10 16h4", + "calendar-off": "M4.2 4.2A2 2 0 0 0 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 1.82-1.18|M21 15.5V6a2 2 0 0 0-2-2H9.5|M16 2v4|M3 10h7|M21 10h-5.5|m2 2 20 20", + "calendar-plus": "M16 19h6|M16 2v4|M19 16v6|M21 12.598V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5|M3 10h18|M8 2v4", + "calendar-plus-2": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18|M10 16h4|M12 14v4", + "calendar-range": "rect;width=18;height=18;x=3;y=4;rx=2|M16 2v4|M3 10h18|M8 2v4|M17 14h-6|M13 18H7|M7 14h.01|M17 18h.01", + "calendar-search": "M16 2v4|M21 11.75V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.25|m22 22-1.875-1.875|M3 10h18|M8 2v4|circle;cx=18;cy=18;r=3", + "calendar-sync": "M11 10v4h4|m11 14 1.535-1.605a5 5 0 0 1 8 1.5|M16 2v4|m21 18-1.535 1.605a5 5 0 0 1-8-1.5|M21 22v-4h-4|M21 8.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4.3|M3 10h4|M8 2v4", + "calendar-x": "M8 2v4|M16 2v4|rect;width=18;height=18;x=3;y=4;rx=2|M3 10h18|m14 14-4 4|m10 14 4 4", + "calendar-x-2": "M8 2v4|M16 2v4|M21 13V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8|M3 10h18|m17 22 5-5|m17 17 5 5", + "calendars": "M12 2v2|M15.726 21.01A2 2 0 0 1 14 22H4a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2|M18 2v2|M2 13h2|M8 8h14|rect;x=8;y=3;width=14;height=14;rx=2", + "camera": "M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z|circle;cx=12;cy=13;r=3", + "camera-off": "M14.564 14.558a3 3 0 1 1-4.122-4.121|m2 2 20 20|M20 20H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 .819-.175|M9.695 4.024A2 2 0 0 1 10.004 4h3.993a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v7.344", + "candy": "M10 7v10.9|M14 6.1V17|M16 7V3a1 1 0 0 1 1.707-.707 2.5 2.5 0 0 0 2.152.717 1 1 0 0 1 1.131 1.131 2.5 2.5 0 0 0 .717 2.152A1 1 0 0 1 21 8h-4|M16.536 7.465a5 5 0 0 0-7.072 0l-2 2a5 5 0 0 0 0 7.07 5 5 0 0 0 7.072 0l2-2a5 5 0 0 0 0-7.07|M8 17v4a1 1 0 0 1-1.707.707 2.5 2.5 0 0 0-2.152-.717 1 1 0 0 1-1.131-1.131 2.5 2.5 0 0 0-.717-2.152A1 1 0 0 1 3 16h4", + "candy-cane": "M5.7 21a2 2 0 0 1-3.5-2l8.6-14a6 6 0 0 1 10.4 6 2 2 0 1 1-3.464-2 2 2 0 1 0-3.464-2Z|M17.75 7 15 2.1|M10.9 4.8 13 9|m7.9 9.7 2 4.4|M4.9 14.7 7 18.9", + "candy-off": "M10 10v7.9|M11.802 6.145a5 5 0 0 1 6.053 6.053|M14 6.1v2.243|m15.5 15.571-.964.964a5 5 0 0 1-7.071 0 5 5 0 0 1 0-7.07l.964-.965|M16 7V3a1 1 0 0 1 1.707-.707 2.5 2.5 0 0 0 2.152.717 1 1 0 0 1 1.131 1.131 2.5 2.5 0 0 0 .717 2.152A1 1 0 0 1 21 8h-4|m2 2 20 20|M8 17v4a1 1 0 0 1-1.707.707 2.5 2.5 0 0 0-2.152-.717 1 1 0 0 1-1.131-1.131 2.5 2.5 0 0 0-.717-2.152A1 1 0 0 1 3 16h4", + "cannabis": "M12 22v-4|M7 12c-1.5 0-4.5 1.5-5 3 3.5 1.5 6 1 6 1-1.5 1.5-2 3.5-2 5 2.5 0 4.5-1.5 6-3 1.5 1.5 3.5 3 6 3 0-1.5-.5-3.5-2-5 0 0 2.5.5 6-1-.5-1.5-3.5-3-5-3 1.5-1 4-4 4-6-2.5 0-5.5 1.5-7 3 0-2.5-.5-5-2-7-1.5 2-2 4.5-2 7-1.5-1.5-4.5-3-7-3 0 2 2.5 5 4 6", + "cannabis-off": "M12 22v-4c1.5 1.5 3.5 3 6 3 0-1.5-.5-3.5-2-5|M13.988 8.327C13.902 6.054 13.365 3.82 12 2a9.3 9.3 0 0 0-1.445 2.9|M17.375 11.725C18.882 10.53 21 7.841 21 6c-2.324 0-5.08 1.296-6.662 2.684|m2 2 20 20|M21.024 15.378A15 15 0 0 0 22 15c-.426-1.279-2.67-2.557-4.25-2.907|M6.995 6.992C5.714 6.4 4.29 6 3 6c0 2 2.5 5 4 6-1.5 0-4.5 1.5-5 3 3.5 1.5 6 1 6 1-1.5 1.5-2 3.5-2 5 2.5 0 4.5-1.5 6-3", + "captions": "rect;width=18;height=14;x=3;y=5;rx=2;ry=2|M7 15h4M15 15h2M7 11h2M13 11h4", + "captions-off": "M10.5 5H19a2 2 0 0 1 2 2v8.5|M17 11h-.5|M19 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2|m2 2 20 20|M7 11h4|M7 15h2.5", + "car": "M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2|circle;cx=7;cy=17;r=2|M9 17h6|circle;cx=17;cy=17;r=2", + "car-front": "m21 8-2 2-1.5-3.7A2 2 0 0 0 15.646 5H8.4a2 2 0 0 0-1.903 1.257L5 10 3 8|M7 14h.01|M17 14h.01|rect;width=18;height=8;x=3;y=10;rx=2|M5 18v2|M19 18v2", + "car-taxi-front": "M10 2h4|m21 8-2 2-1.5-3.7A2 2 0 0 0 15.646 5H8.4a2 2 0 0 0-1.903 1.257L5 10 3 8|M7 14h.01|M17 14h.01|rect;width=18;height=8;x=3;y=10;rx=2|M5 18v2|M19 18v2", + "caravan": "M18 19V9a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v8a2 2 0 0 0 2 2h2|M2 9h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2|M22 17v1a1 1 0 0 1-1 1H10v-9a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v9|circle;cx=8;cy=19;r=2", + "card-sim": "M12 14v4|M14.172 2a2 2 0 0 1 1.414.586l3.828 3.828A2 2 0 0 1 20 7.828V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z|M8 14h8|rect;x=8;y=10;width=8;height=8;rx=1", + "carrot": "M2.27 21.7s9.87-3.5 12.73-6.36a4.5 4.5 0 0 0-6.36-6.37C5.77 11.84 2.27 21.7 2.27 21.7zM8.64 14l-2.05-2.04M15.34 15l-2.46-2.46|M22 9s-1.33-2-3.5-2C16.86 7 15 9 15 9s1.33 2 3.5 2S22 9 22 9z|M15 2s-2 1.33-2 3.5S15 9 15 9s2-1.84 2-3.5C17 3.33 15 2 15 2z", + "case-lower": "M10 9v7|M14 6v10|circle;cx=17.5;cy=12.5;r=3.5|circle;cx=6.5;cy=12.5;r=3.5", + "case-sensitive": "m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16|M22 9v7|M3.304 13h6.392|circle;cx=18.5;cy=12.5;r=3.5", + "case-upper": "M15 11h4.5a1 1 0 0 1 0 5h-4a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h3a1 1 0 0 1 0 5|m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16|M3.304 13h6.392", + "cassette-tape": "rect;width=20;height=16;x=2;y=4;rx=2|circle;cx=8;cy=10;r=2|M8 12h8|circle;cx=16;cy=10;r=2|m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3", + "cast": "M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6|M2 12a9 9 0 0 1 8 8|M2 16a5 5 0 0 1 4 4|line;x1=2;x2=2.01;y1=20;y2=20", + "castle": "M10 5V3|M14 5V3|M15 21v-3a3 3 0 0 0-6 0v3|M18 3v8|M18 5H6|M22 11H2|M22 9v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9|M6 3v8", + "cat": "M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z|M8 14v.5|M16 14v.5|M11.25 16.25h1.5L12 17l-.75-.75Z", + "cctv": "M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-1.708.134l-2.124-2.97|M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z|M2 19h3.76a2 2 0 0 0 1.8-1.1L9 15|M2 21v-4|M7 9h.01", + "chart-area": "M3 3v16a2 2 0 0 0 2 2h16|M7 11.207a.5.5 0 0 1 .146-.353l2-2a.5.5 0 0 1 .708 0l3.292 3.292a.5.5 0 0 0 .708 0l4.292-4.292a.5.5 0 0 1 .854.353V16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1z", + "chart-bar": "M3 3v16a2 2 0 0 0 2 2h16|M7 16h8|M7 11h12|M7 6h3", + "chart-bar-big": "M3 3v16a2 2 0 0 0 2 2h16|rect;x=7;y=13;width=9;height=4;rx=1|rect;x=7;y=5;width=12;height=4;rx=1", + "chart-bar-decreasing": "M3 3v16a2 2 0 0 0 2 2h16|M7 11h8|M7 16h3|M7 6h12", + "chart-bar-increasing": "M3 3v16a2 2 0 0 0 2 2h16|M7 11h8|M7 16h12|M7 6h3", + "chart-bar-stacked": "M11 13v4|M15 5v4|M3 3v16a2 2 0 0 0 2 2h16|rect;x=7;y=13;width=9;height=4;rx=1|rect;x=7;y=5;width=12;height=4;rx=1", + "chart-candlestick": "M9 5v4|rect;width=4;height=6;x=7;y=9;rx=1|M9 15v2|M17 3v2|rect;width=4;height=8;x=15;y=5;rx=1|M17 13v3|M3 3v16a2 2 0 0 0 2 2h16", + "chart-column": "M3 3v16a2 2 0 0 0 2 2h16|M18 17V9|M13 17V5|M8 17v-3", + "chart-column-big": "M3 3v16a2 2 0 0 0 2 2h16|rect;x=15;y=5;width=4;height=12;rx=1|rect;x=7;y=8;width=4;height=9;rx=1", + "chart-column-decreasing": "M13 17V9|M18 17v-3|M3 3v16a2 2 0 0 0 2 2h16|M8 17V5", + "chart-column-increasing": "M13 17V9|M18 17V5|M3 3v16a2 2 0 0 0 2 2h16|M8 17v-3", + "chart-column-stacked": "M11 13H7|M19 9h-4|M3 3v16a2 2 0 0 0 2 2h16|rect;x=15;y=5;width=4;height=12;rx=1|rect;x=7;y=8;width=4;height=9;rx=1", + "chart-gantt": "M10 6h8|M12 16h6|M3 3v16a2 2 0 0 0 2 2h16|M8 11h7", + "chart-line": "M3 3v16a2 2 0 0 0 2 2h16|m19 9-5 5-4-4-3 3", + "chart-network": "m13.11 7.664 1.78 2.672|m14.162 12.788-3.324 1.424|m20 4-6.06 1.515|M3 3v16a2 2 0 0 0 2 2h16|circle;cx=12;cy=6;r=2|circle;cx=16;cy=12;r=2|circle;cx=9;cy=15;r=2", + "chart-no-axes-column": "M5 21v-6|M12 21V3|M19 21V9", + "chart-no-axes-column-decreasing": "M5 21V3|M12 21V9|M19 21v-6", + "chart-no-axes-column-increasing": "M5 21v-6|M12 21V9|M19 21V3", + "chart-no-axes-combined": "M12 16v5|M16 14v7|M20 10v11|m22 3-8.646 8.646a.5.5 0 0 1-.708 0L9.354 8.354a.5.5 0 0 0-.707 0L2 15|M4 18v3|M8 14v7", + "chart-no-axes-gantt": "M6 5h12|M4 12h10|M12 19h8", + "chart-pie": "M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z|M21.21 15.89A10 10 0 1 1 8 2.83", + "chart-scatter": "circle;cx=7.5;cy=7.5;r=.5;fill=currentColor|circle;cx=18.5;cy=5.5;r=.5;fill=currentColor|circle;cx=11.5;cy=11.5;r=.5;fill=currentColor|circle;cx=7.5;cy=16.5;r=.5;fill=currentColor|circle;cx=17.5;cy=14.5;r=.5;fill=currentColor|M3 3v16a2 2 0 0 0 2 2h16", + "chart-spline": "M3 3v16a2 2 0 0 0 2 2h16|M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7", + "check": "M20 6 9 17l-5-5", + "check-check": "M18 6 7 17l-5-5|m22 10-7.5 7.5L13 16", + "check-line": "M20 4L9 15|M21 19L3 19|M9 15L4 10", + "chef-hat": "M17 21a1 1 0 0 0 1-1v-5.35c0-.457.316-.844.727-1.041a4 4 0 0 0-2.134-7.589 5 5 0 0 0-9.186 0 4 4 0 0 0-2.134 7.588c.411.198.727.585.727 1.041V20a1 1 0 0 0 1 1Z|M6 17h12", + "cherry": "M2 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z|M12 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3-2.5-2-5 .24-5 3Z|M7 14c3.22-2.91 4.29-8.75 5-12 1.66 2.38 4.94 9 5 12|M22 9c-4.29 0-7.14-2.33-10-7 5.71 0 10 4.67 10 7Z", + "chess-bishop": "M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z|M15 18c1.5-.615 3-2.461 3-4.923C18 8.769 14.5 4.462 12 2 9.5 4.462 6 8.77 6 13.077 6 15.539 7.5 17.385 9 18|m16 7-2.5 2.5|M9 2h6", + "chess-king": "M4 20a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z|m6.7 18-1-1C4.35 15.682 3 14.09 3 12a5 5 0 0 1 4.95-5c1.584 0 2.7.455 4.05 1.818C13.35 7.455 14.466 7 16.05 7A5 5 0 0 1 21 12c0 2.082-1.359 3.673-2.7 5l-1 1|M10 4h4|M12 2v6.818", + "chess-knight": "M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z|M16.5 18c1-2 2.5-5 2.5-9a7 7 0 0 0-7-7H6.635a1 1 0 0 0-.768 1.64L7 5l-2.32 5.802a2 2 0 0 0 .95 2.526l2.87 1.456|m15 5 1.425-1.425|m17 8 1.53-1.53|M9.713 12.185 7 18", + "chess-pawn": "M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z|m14.5 10 1.5 8|M7 10h10|m8 18 1.5-8|circle;cx=12;cy=6;r=4", + "chess-queen": "M4 20a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z|m12.474 5.943 1.567 5.34a1 1 0 0 0 1.75.328l2.616-3.402|m20 9-3 9|m5.594 8.209 2.615 3.403a1 1 0 0 0 1.75-.329l1.567-5.34|M7 18 4 9|circle;cx=12;cy=4;r=2|circle;cx=20;cy=7;r=2|circle;cx=4;cy=7;r=2", + "chess-rook": "M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z|M10 2v2|M14 2v2|m17 18-1-9|M6 2v5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2|M6 4h12|m7 18 1-9", + "chevron-down": "m6 9 6 6 6-6", + "chevron-first": "m17 18-6-6 6-6|M7 6v12", + "chevron-last": "m7 18 6-6-6-6|M17 6v12", + "chevron-left": "m15 18-6-6 6-6", + "chevron-right": "m9 18 6-6-6-6", + "chevron-up": "m18 15-6-6-6 6", + "chevrons-down": "m7 6 5 5 5-5|m7 13 5 5 5-5", + "chevrons-down-up": "m7 20 5-5 5 5|m7 4 5 5 5-5", + "chevrons-left": "m11 17-5-5 5-5|m18 17-5-5 5-5", + "chevrons-left-right": "m9 7-5 5 5 5|m15 7 5 5-5 5", + "chevrons-left-right-ellipsis": "M12 12h.01|M16 12h.01|m17 7 5 5-5 5|m7 7-5 5 5 5|M8 12h.01", + "chevrons-right": "m6 17 5-5-5-5|m13 17 5-5-5-5", + "chevrons-right-left": "m20 17-5-5 5-5|m4 17 5-5-5-5", + "chevrons-up": "m17 11-5-5-5 5|m17 18-5-5-5 5", + "chevrons-up-down": "m7 15 5 5 5-5|m7 9 5-5 5 5", + "chromium": "M10.88 21.94 15.46 14|M21.17 8H12|M3.95 6.06 8.54 14|circle;cx=12;cy=12;r=10|circle;cx=12;cy=12;r=4", + "church": "M10 9h4|M12 7v5|M14 21v-3a2 2 0 0 0-4 0v3|m18 9 3.52 2.147a1 1 0 0 1 .48.854V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.999a1 1 0 0 1 .48-.854L6 9|M6 21V7a1 1 0 0 1 .376-.782l5-3.999a1 1 0 0 1 1.249.001l5 4A1 1 0 0 1 18 7v14", + "cigarette": "M17 12H3a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h14|M18 8c0-2.5-2-2.5-2-5|M21 16a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1|M22 8c0-2.5-2-2.5-2-5|M7 12v4", + "cigarette-off": "M12 12H3a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h13|M18 8c0-2.5-2-2.5-2-5|m2 2 20 20|M21 12a1 1 0 0 1 1 1v2a1 1 0 0 1-.5.866|M22 8c0-2.5-2-2.5-2-5|M7 12v4", + "circle": "circle;cx=12;cy=12;r=10", + "circle-alert": "circle;cx=12;cy=12;r=10|line;x1=12;x2=12;y1=8;y2=12|line;x1=12;x2=12.01;y1=16;y2=16", + "circle-arrow-down": "circle;cx=12;cy=12;r=10|M12 8v8|m8 12 4 4 4-4", + "circle-arrow-left": "circle;cx=12;cy=12;r=10|m12 8-4 4 4 4|M16 12H8", + "circle-arrow-out-down-left": "M2 12a10 10 0 1 1 10 10|m2 22 10-10|M8 22H2v-6", + "circle-arrow-out-down-right": "M12 22a10 10 0 1 1 10-10|M22 22 12 12|M22 16v6h-6", + "circle-arrow-out-up-left": "M2 8V2h6|m2 2 10 10|M12 2A10 10 0 1 1 2 12", + "circle-arrow-out-up-right": "M22 12A10 10 0 1 1 12 2|M22 2 12 12|M16 2h6v6", + "circle-arrow-right": "circle;cx=12;cy=12;r=10|m12 16 4-4-4-4|M8 12h8", + "circle-arrow-up": "circle;cx=12;cy=12;r=10|m16 12-4-4-4 4|M12 16V8", + "circle-check": "circle;cx=12;cy=12;r=10|m9 12 2 2 4-4", + "circle-check-big": "M21.801 10A10 10 0 1 1 17 3.335|m9 11 3 3L22 4", + "circle-chevron-down": "circle;cx=12;cy=12;r=10|m16 10-4 4-4-4", + "circle-chevron-left": "circle;cx=12;cy=12;r=10|m14 16-4-4 4-4", + "circle-chevron-right": "circle;cx=12;cy=12;r=10|m10 8 4 4-4 4", + "circle-chevron-up": "circle;cx=12;cy=12;r=10|m8 14 4-4 4 4", + "circle-dashed": "M10.1 2.182a10 10 0 0 1 3.8 0|M13.9 21.818a10 10 0 0 1-3.8 0|M17.609 3.721a10 10 0 0 1 2.69 2.7|M2.182 13.9a10 10 0 0 1 0-3.8|M20.279 17.609a10 10 0 0 1-2.7 2.69|M21.818 10.1a10 10 0 0 1 0 3.8|M3.721 6.391a10 10 0 0 1 2.7-2.69|M6.391 20.279a10 10 0 0 1-2.69-2.7", + "circle-divide": "line;x1=8;x2=16;y1=12;y2=12|line;x1=12;x2=12;y1=16;y2=16|line;x1=12;x2=12;y1=8;y2=8|circle;cx=12;cy=12;r=10", + "circle-dollar-sign": "circle;cx=12;cy=12;r=10|M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8|M12 18V6", + "circle-dot": "circle;cx=12;cy=12;r=10|circle;cx=12;cy=12;r=1", + "circle-dot-dashed": "M10.1 2.18a9.93 9.93 0 0 1 3.8 0|M17.6 3.71a9.95 9.95 0 0 1 2.69 2.7|M21.82 10.1a9.93 9.93 0 0 1 0 3.8|M20.29 17.6a9.95 9.95 0 0 1-2.7 2.69|M13.9 21.82a9.94 9.94 0 0 1-3.8 0|M6.4 20.29a9.95 9.95 0 0 1-2.69-2.7|M2.18 13.9a9.93 9.93 0 0 1 0-3.8|M3.71 6.4a9.95 9.95 0 0 1 2.7-2.69|circle;cx=12;cy=12;r=1", + "circle-ellipsis": "circle;cx=12;cy=12;r=10|M17 12h.01|M12 12h.01|M7 12h.01", + "circle-equal": "M7 10h10|M7 14h10|circle;cx=12;cy=12;r=10", + "circle-fading-arrow-up": "M12 2a10 10 0 0 1 7.38 16.75|m16 12-4-4-4 4|M12 16V8|M2.5 8.875a10 10 0 0 0-.5 3|M2.83 16a10 10 0 0 0 2.43 3.4|M4.636 5.235a10 10 0 0 1 .891-.857|M8.644 21.42a10 10 0 0 0 7.631-.38", + "circle-fading-plus": "M12 2a10 10 0 0 1 7.38 16.75|M12 8v8|M16 12H8|M2.5 8.875a10 10 0 0 0-.5 3|M2.83 16a10 10 0 0 0 2.43 3.4|M4.636 5.235a10 10 0 0 1 .891-.857|M8.644 21.42a10 10 0 0 0 7.631-.38", + "circle-gauge": "M15.6 2.7a10 10 0 1 0 5.7 5.7|circle;cx=12;cy=12;r=2|M13.4 10.6 19 5", + "circle-minus": "circle;cx=12;cy=12;r=10|M8 12h8", + "circle-off": "m2 2 20 20|M8.35 2.69A10 10 0 0 1 21.3 15.65|M19.08 19.08A10 10 0 1 1 4.92 4.92", + "circle-parking": "circle;cx=12;cy=12;r=10|M9 17V7h4a3 3 0 0 1 0 6H9", + "circle-parking-off": "M12.656 7H13a3 3 0 0 1 2.984 3.307|M13 13H9|M19.071 19.071A1 1 0 0 1 4.93 4.93|m2 2 20 20|M8.357 2.687a10 10 0 0 1 12.956 12.956|M9 17V9", + "circle-pause": "circle;cx=12;cy=12;r=10|line;x1=10;x2=10;y1=15;y2=9|line;x1=14;x2=14;y1=15;y2=9", + "circle-percent": "circle;cx=12;cy=12;r=10|m15 9-6 6|M9 9h.01|M15 15h.01", + "circle-pile": "circle;cx=12;cy=19;r=2|circle;cx=12;cy=5;r=2|circle;cx=16;cy=12;r=2|circle;cx=20;cy=19;r=2|circle;cx=4;cy=19;r=2|circle;cx=8;cy=12;r=2", + "circle-play": "M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z|circle;cx=12;cy=12;r=10", + "circle-plus": "circle;cx=12;cy=12;r=10|M8 12h8|M12 8v8", + "circle-pound-sterling": "M10 16V9.5a1 1 0 0 1 5 0|M8 12h4|M8 16h7|circle;cx=12;cy=12;r=10", + "circle-power": "M12 7v4|M7.998 9.003a5 5 0 1 0 8-.005|circle;cx=12;cy=12;r=10", + "circle-question-mark": "circle;cx=12;cy=12;r=10|M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3|M12 17h.01", + "circle-slash": "circle;cx=12;cy=12;r=10|line;x1=9;x2=15;y1=15;y2=9", + "circle-slash-2": "M22 2 2 22|circle;cx=12;cy=12;r=10", + "circle-small": "circle;cx=12;cy=12;r=6", + "circle-star": "M11.051 7.616a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.867l-1.156-1.152a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z|circle;cx=12;cy=12;r=10", + "circle-stop": "circle;cx=12;cy=12;r=10|rect;x=9;y=9;width=6;height=6;rx=1", + "circle-user": "circle;cx=12;cy=12;r=10|circle;cx=12;cy=10;r=3|M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662", + "circle-user-round": "M18 20a6 6 0 0 0-12 0|circle;cx=12;cy=10;r=4|circle;cx=12;cy=12;r=10", + "circle-x": "circle;cx=12;cy=12;r=10|m15 9-6 6|m9 9 6 6", + "circuit-board": "rect;width=18;height=18;x=3;y=3;rx=2|M11 9h4a2 2 0 0 0 2-2V3|circle;cx=9;cy=9;r=2|M7 21v-4a2 2 0 0 1 2-2h4|circle;cx=15;cy=15;r=2", + "citrus": "M21.66 17.67a1.08 1.08 0 0 1-.04 1.6A12 12 0 0 1 4.73 2.38a1.1 1.1 0 0 1 1.61-.04z|M19.65 15.66A8 8 0 0 1 8.35 4.34|m14 10-5.5 5.5|M14 17.85V10H6.15", + "clapperboard": "M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z|m6.2 5.3 3.1 3.9|m12.4 3.4 3.1 4|M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z", + "clipboard": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2", + "clipboard-check": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|m9 14 2 2 4-4", + "clipboard-clock": "M16 14v2.2l1.6 1|M16 4h2a2 2 0 0 1 2 2v.832|M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h2|circle;cx=16;cy=16;r=6|rect;x=8;y=2;width=8;height=4;rx=1", + "clipboard-copy": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2|M16 4h2a2 2 0 0 1 2 2v4|M21 14H11|m15 10-4 4 4 4", + "clipboard-list": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|M12 11h4|M12 16h4|M8 11h.01|M8 16h.01", + "clipboard-minus": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|M9 14h6", + "clipboard-paste": "M11 14h10|M16 4h2a2 2 0 0 1 2 2v1.344|m17 18 4-4-4-4|M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 1.793-1.113|rect;x=8;y=2;width=8;height=4;rx=1", + "clipboard-pen": "rect;width=8;height=4;x=8;y=2;rx=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5.5|M4 13.5V6a2 2 0 0 1 2-2h2|M13.378 15.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z", + "clipboard-pen-line": "rect;width=8;height=4;x=8;y=2;rx=1|M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-.5|M16 4h2a2 2 0 0 1 1.73 1|M8 18h1|M21.378 12.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z", + "clipboard-plus": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|M9 14h6|M12 17v-6", + "clipboard-type": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|M9 12v-1h6v1|M11 17h2|M12 11v6", + "clipboard-x": "rect;width=8;height=4;x=8;y=2;rx=1;ry=1|M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|m15 11-6 6|m9 11 6 6", + "clock": "M12 6v6l4 2|circle;cx=12;cy=12;r=10", + "clock-1": "M12 6v6l2-4|circle;cx=12;cy=12;r=10", + "clock-10": "M12 6v6l-4-2|circle;cx=12;cy=12;r=10", + "clock-11": "M12 6v6l-2-4|circle;cx=12;cy=12;r=10", + "clock-12": "M12 6v6|circle;cx=12;cy=12;r=10", + "clock-2": "M12 6v6l4-2|circle;cx=12;cy=12;r=10", + "clock-3": "M12 6v6h4|circle;cx=12;cy=12;r=10", + "clock-4": "M12 6v6l4 2|circle;cx=12;cy=12;r=10", + "clock-5": "M12 6v6l2 4|circle;cx=12;cy=12;r=10", + "clock-6": "M12 6v10|circle;cx=12;cy=12;r=10", + "clock-7": "M12 6v6l-2 4|circle;cx=12;cy=12;r=10", + "clock-8": "M12 6v6l-4 2|circle;cx=12;cy=12;r=10", + "clock-9": "M12 6v6H8|circle;cx=12;cy=12;r=10", + "clock-alert": "M12 6v6l4 2|M20 12v5|M20 21h.01|M21.25 8.2A10 10 0 1 0 16 21.16", + "clock-arrow-down": "M12 6v6l2 1|M12.337 21.994a10 10 0 1 1 9.588-8.767|m14 18 4 4 4-4|M18 14v8", + "clock-arrow-up": "M12 6v6l1.56.78|M13.227 21.925a10 10 0 1 1 8.767-9.588|m14 18 4-4 4 4|M18 22v-8", + "clock-check": "M12 6v6l4 2|M22 12a10 10 0 1 0-11 9.95|m22 16-5.5 5.5L14 19", + "clock-fading": "M12 2a10 10 0 0 1 7.38 16.75|M12 6v6l4 2|M2.5 8.875a10 10 0 0 0-.5 3|M2.83 16a10 10 0 0 0 2.43 3.4|M4.636 5.235a10 10 0 0 1 .891-.857|M8.644 21.42a10 10 0 0 0 7.631-.38", + "clock-plus": "M12 6v6l3.644 1.822|M16 19h6|M19 16v6|M21.92 13.267a10 10 0 1 0-8.653 8.653", + "closed-caption": "M10 9.17a3 3 0 1 0 0 5.66|M17 9.17a3 3 0 1 0 0 5.66|rect;x=2;y=5;width=20;height=14;rx=2", + "cloud": "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z", + "cloud-alert": "M12 12v4|M12 20h.01|M8.128 16.949A7 7 0 1 1 15.71 8h1.79a1 1 0 0 1 0 9h-1.642", + "cloud-backup": "M21 15.251A4.5 4.5 0 0 0 17.5 8h-1.79A7 7 0 1 0 3 13.607|M7 11v4h4|M8 19a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5 4.82 4.82 0 0 0-3.41 1.41L7 15", + "cloud-check": "m17 15-5.5 5.5L9 18|M5.516 16.07A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 3.501 7.327", + "cloud-cog": "m10.852 19.772-.383.924|m13.148 14.228.383-.923|M13.148 19.772a3 3 0 1 0-2.296-5.544l-.383-.923|m13.53 20.696-.382-.924a3 3 0 1 1-2.296-5.544|m14.772 15.852.923-.383|m14.772 18.148.923.383|M4.2 15.1a7 7 0 1 1 9.93-9.858A7 7 0 0 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.2|m9.228 15.852-.923-.383|m9.228 18.148-.923.383", + "cloud-download": "M12 13v8l-4-4|m12 21 4-4|M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284", + "cloud-drizzle": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|M8 19v1|M8 14v1|M16 19v1|M16 14v1|M12 21v1|M12 16v1", + "cloud-fog": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|M16 17H7|M17 21H9", + "cloud-hail": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|M16 14v2|M8 14v2|M16 20h.01|M8 20h.01|M12 16v2|M12 22h.01", + "cloud-lightning": "M6 16.326A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 .5 8.973|m13 12-3 5h4l-3 5", + "cloud-moon": "M13 16a3 3 0 0 1 0 6H7a5 5 0 1 1 4.9-6z|M18.376 14.512a6 6 0 0 0 3.461-4.127c.148-.625-.659-.97-1.248-.714a4 4 0 0 1-5.259-5.26c.255-.589-.09-1.395-.716-1.248a6 6 0 0 0-4.594 5.36", + "cloud-moon-rain": "M11 20v2|M18.376 14.512a6 6 0 0 0 3.461-4.127c.148-.625-.659-.97-1.248-.714a4 4 0 0 1-5.259-5.26c.255-.589-.09-1.395-.716-1.248a6 6 0 0 0-4.594 5.36|M3 20a5 5 0 1 1 8.9-4H13a3 3 0 0 1 2 5.24|M7 19v2", + "cloud-off": "M10.94 5.274A7 7 0 0 1 15.71 10h1.79a4.5 4.5 0 0 1 4.222 6.057|M18.796 18.81A4.5 4.5 0 0 1 17.5 19H9A7 7 0 0 1 5.79 5.78|m2 2 20 20", + "cloud-rain": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|M16 14v6|M8 14v6|M12 16v6", + "cloud-rain-wind": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|m9.2 22 3-7|m9 13-3 7|m17 13-3 7", + "cloud-snow": "M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|M8 15h.01|M8 19h.01|M12 17h.01|M12 21h.01|M16 15h.01|M16 19h.01", + "cloud-sun": "M12 2v2|m4.93 4.93 1.41 1.41|M20 12h2|m19.07 4.93-1.41 1.41|M15.947 12.65a4 4 0 0 0-5.925-4.128|M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z", + "cloud-sun-rain": "M12 2v2|m4.93 4.93 1.41 1.41|M20 12h2|m19.07 4.93-1.41 1.41|M15.947 12.65a4 4 0 0 0-5.925-4.128|M3 20a5 5 0 1 1 8.9-4H13a3 3 0 0 1 2 5.24|M11 20v2|M7 19v2", + "cloud-sync": "m17 18-1.535 1.605a5 5 0 0 1-8-1.5|M17 22v-4h-4|M20.996 15.251A4.5 4.5 0 0 0 17.495 8h-1.79a7 7 0 1 0-12.709 5.607|M7 10v4h4|m7 14 1.535-1.605a5 5 0 0 1 8 1.5", + "cloud-upload": "M12 13v8|M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242|m8 17 4-4 4 4", + "cloudy": "M17.5 12a1 1 0 1 1 0 9H9.006a7 7 0 1 1 6.702-9z|M21.832 9A3 3 0 0 0 19 7h-2.207a5.5 5.5 0 0 0-10.72.61", + "clover": "M16.17 7.83 2 22|M4.02 12a2.827 2.827 0 1 1 3.81-4.17A2.827 2.827 0 1 1 12 4.02a2.827 2.827 0 1 1 4.17 3.81A2.827 2.827 0 1 1 19.98 12a2.827 2.827 0 1 1-3.81 4.17A2.827 2.827 0 1 1 12 19.98a2.827 2.827 0 1 1-4.17-3.81A1 1 0 1 1 4 12|m7.83 7.83 8.34 8.34", + "club": "M17.28 9.05a5.5 5.5 0 1 0-10.56 0A5.5 5.5 0 1 0 12 17.66a5.5 5.5 0 1 0 5.28-8.6Z|M12 17.66L12 22", + "code": "m16 18 6-6-6-6|m8 6-6 6 6 6", + "code-xml": "m18 16 4-4-4-4|m6 8-4 4 4 4|m14.5 4-5 16", + "codepen": "polygon;points=12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2|line;x1=12;x2=12;y1=22;y2=15.5|polyline;points=22 8.5 12 15.5 2 8.5|polyline;points=2 15.5 12 8.5 22 15.5|line;x1=12;x2=12;y1=2;y2=8.5", + "codesandbox": "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|polyline;points=7.5 4.21 12 6.81 16.5 4.21|polyline;points=7.5 19.79 7.5 14.6 3 12|polyline;points=21 12 16.5 14.6 16.5 19.79|polyline;points=3.27 6.96 12 12.01 20.73 6.96|line;x1=12;x2=12;y1=22.08;y2=12", + "coffee": "M10 2v2|M14 2v2|M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1|M6 2v2", + "cog": "M11 10.27 7 3.34|m11 13.73-4 6.93|M12 22v-2|M12 2v2|M14 12h8|m17 20.66-1-1.73|m17 3.34-1 1.73|M2 12h2|m20.66 17-1.73-1|m20.66 7-1.73 1|m3.34 17 1.73-1|m3.34 7 1.73 1|circle;cx=12;cy=12;r=2|circle;cx=12;cy=12;r=8", + "coins": "circle;cx=8;cy=8;r=6|M18.09 10.37A6 6 0 1 1 10.34 18|M7 6h1v4|m16.71 13.88.7.71-2.82 2.82", + "columns-2": "rect;width=18;height=18;x=3;y=3;rx=2|M12 3v18", + "columns-3": "rect;width=18;height=18;x=3;y=3;rx=2|M9 3v18|M15 3v18", + "columns-3-cog": "M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5|m14.3 19.6 1-.4|M15 3v7.5|m15.2 16.9-.9-.3|m16.6 21.7.3-.9|m16.8 15.3-.4-1|m19.1 15.2.3-.9|m19.6 21.7-.4-1|m20.7 16.8 1-.4|m21.7 19.4-.9-.3|M9 3v18|circle;cx=18;cy=18;r=3", + "columns-4": "rect;width=18;height=18;x=3;y=3;rx=2|M7.5 3v18|M12 3v18|M16.5 3v18", + "combine": "M14 3a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1|M19 3a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1|m7 15 3 3|m7 21 3-3H5a2 2 0 0 1-2-2v-2|rect;x=14;y=14;width=7;height=7;rx=1|rect;x=3;y=3;width=7;height=7;rx=1", + "command": "M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3", + "compass": "m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z|circle;cx=12;cy=12;r=10", + "component": "M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z|M2.297 11.293a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0z|M8.916 17.912a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0z|M8.916 4.674a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z", + "computer": "rect;width=14;height=8;x=5;y=2;rx=2|rect;width=20;height=8;x=2;y=14;rx=2|M6 18h2|M12 18h6", + "concierge-bell": "M3 20a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1Z|M20 16a8 8 0 1 0-16 0|M12 4v4|M10 4h4", + "cone": "m20.9 18.55-8-15.98a1 1 0 0 0-1.8 0l-8 15.98|ellipse;cx=12;cy=19;rx=9;ry=3", + "construction": "rect;x=2;y=6;width=20;height=8;rx=1|M17 14v7|M7 14v7|M17 3v3|M7 3v3|M10 14 2.3 6.3|m14 6 7.7 7.7|m8 6 8 8", + "contact": "M16 2v2|M7 22v-2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2|M8 2v2|circle;cx=12;cy=11;r=3|rect;x=3;y=4;width=18;height=18;rx=2", + "contact-round": "M16 2v2|M17.915 22a6 6 0 0 0-12 0|M8 2v2|circle;cx=12;cy=12;r=4|rect;x=3;y=4;width=18;height=18;rx=2", + "container": "M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z|M10 21.9V14L2.1 9.1|m10 14 11.9-6.9|M14 19.8v-8.1|M18 17.5V9.4", + "contrast": "circle;cx=12;cy=12;r=10|M12 18a6 6 0 0 0 0-12v12z", + "cookie": "M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5|M8.5 8.5v.01|M16 15.5v.01|M12 12v.01|M11 17v.01|M7 14v.01", + "cooking-pot": "M2 12h20|M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8|m4 8 16-4|m8.86 6.78-.45-1.81a2 2 0 0 1 1.45-2.43l1.94-.48a2 2 0 0 1 2.43 1.46l.45 1.8", + "copy": "rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copy-check": "m12 15 2 2 4-4|rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copy-minus": "line;x1=12;x2=18;y1=15;y2=15|rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copy-plus": "line;x1=15;x2=15;y1=12;y2=18|line;x1=12;x2=18;y1=15;y2=15|rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copy-slash": "line;x1=12;x2=18;y1=18;y2=12|rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copy-x": "line;x1=12;x2=18;y1=12;y2=18|line;x1=12;x2=18;y1=18;y2=12|rect;width=14;height=14;x=8;y=8;rx=2;ry=2|M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "copyleft": "circle;cx=12;cy=12;r=10|M9.17 14.83a4 4 0 1 0 0-5.66", + "copyright": "circle;cx=12;cy=12;r=10|M14.83 14.83a4 4 0 1 1 0-5.66", + "corner-down-left": "M20 4v7a4 4 0 0 1-4 4H4|m9 10-5 5 5 5", + "corner-down-right": "m15 10 5 5-5 5|M4 4v7a4 4 0 0 0 4 4h12", + "corner-left-down": "m14 15-5 5-5-5|M20 4h-7a4 4 0 0 0-4 4v12", + "corner-left-up": "M14 9 9 4 4 9|M20 20h-7a4 4 0 0 1-4-4V4", + "corner-right-down": "m10 15 5 5 5-5|M4 4h7a4 4 0 0 1 4 4v12", + "corner-right-up": "m10 9 5-5 5 5|M4 20h7a4 4 0 0 0 4-4V4", + "corner-up-left": "M20 20v-7a4 4 0 0 0-4-4H4|M9 14 4 9l5-5", + "corner-up-right": "m15 14 5-5-5-5|M4 20v-7a4 4 0 0 1 4-4h12", + "cpu": "M12 20v2|M12 2v2|M17 20v2|M17 2v2|M2 12h2|M2 17h2|M2 7h2|M20 12h2|M20 17h2|M20 7h2|M7 20v2|M7 2v2|rect;x=4;y=4;width=16;height=16;rx=2|rect;x=8;y=8;width=8;height=8;rx=1", + "creative-commons": "circle;cx=12;cy=12;r=10|M10 9.3a2.8 2.8 0 0 0-3.5 1 3.1 3.1 0 0 0 0 3.4 2.7 2.7 0 0 0 3.5 1|M17 9.3a2.8 2.8 0 0 0-3.5 1 3.1 3.1 0 0 0 0 3.4 2.7 2.7 0 0 0 3.5 1", + "credit-card": "rect;width=20;height=14;x=2;y=5;rx=2|line;x1=2;x2=22;y1=10;y2=10", + "croissant": "M10.2 18H4.774a1.5 1.5 0 0 1-1.352-.97 11 11 0 0 1 .132-6.487|M18 10.2V4.774a1.5 1.5 0 0 0-.97-1.352 11 11 0 0 0-6.486.132|M18 5a4 3 0 0 1 4 3 2 2 0 0 1-2 2 10 10 0 0 0-5.139 1.42|M5 18a3 4 0 0 0 3 4 2 2 0 0 0 2-2 10 10 0 0 1 1.42-5.14|M8.709 2.554a10 10 0 0 0-6.155 6.155 1.5 1.5 0 0 0 .676 1.626l9.807 5.42a2 2 0 0 0 2.718-2.718l-5.42-9.807a1.5 1.5 0 0 0-1.626-.676", + "crop": "M6 2v14a2 2 0 0 0 2 2h14|M18 22V8a2 2 0 0 0-2-2H2", + "cross": "M4 9a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h4a1 1 0 0 1 1 1v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-4a1 1 0 0 1 1-1h4a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-4a1 1 0 0 1-1-1V4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4a1 1 0 0 1-1 1z", + "crosshair": "circle;cx=12;cy=12;r=10|line;x1=22;x2=18;y1=12;y2=12|line;x1=6;x2=2;y1=12;y2=12|line;x1=12;x2=12;y1=6;y2=2|line;x1=12;x2=12;y1=22;y2=18", + "crown": "M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z|M5 21h14", + "cuboid": "m21.12 6.4-6.05-4.06a2 2 0 0 0-2.17-.05L2.95 8.41a2 2 0 0 0-.95 1.7v5.82a2 2 0 0 0 .88 1.66l6.05 4.07a2 2 0 0 0 2.17.05l9.95-6.12a2 2 0 0 0 .95-1.7V8.06a2 2 0 0 0-.88-1.66Z|M10 22v-8L2.25 9.15|m10 14 11.77-6.87", + "cup-soda": "m6 8 1.75 12.28a2 2 0 0 0 2 1.72h4.54a2 2 0 0 0 2-1.72L18 8|M5 8h14|M7 15a6.47 6.47 0 0 1 5 0 6.47 6.47 0 0 0 5 0|m12 8 1-6h2", + "currency": "circle;cx=12;cy=12;r=8|line;x1=3;x2=6;y1=3;y2=6|line;x1=21;x2=18;y1=3;y2=6|line;x1=3;x2=6;y1=21;y2=18|line;x1=21;x2=18;y1=21;y2=18", + "cylinder": "ellipse;cx=12;cy=5;rx=9;ry=3|M3 5v14a9 3 0 0 0 18 0V5", + "dam": "M11 11.31c1.17.56 1.54 1.69 3.5 1.69 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M11.75 18c.35.5 1.45 1 2.75 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M2 10h4|M2 14h4|M2 18h4|M2 6h4|M7 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1L10 4a1 1 0 0 0-1-1z", + "database": "ellipse;cx=12;cy=5;rx=9;ry=3|M3 5V19A9 3 0 0 0 21 19V5|M3 12A9 3 0 0 0 21 12", + "database-backup": "ellipse;cx=12;cy=5;rx=9;ry=3|M3 12a9 3 0 0 0 5 2.69|M21 9.3V5|M3 5v14a9 3 0 0 0 6.47 2.88|M12 12v4h4|M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16", + "database-search": "M21 11.693V5|m22 22-1.875-1.875|M3 12a9 3 0 0 0 8.697 2.998|M3 5v14a9 3 0 0 0 9.28 2.999|circle;cx=18;cy=18;r=3|ellipse;cx=12;cy=5;rx=9;ry=3", + "database-zap": "ellipse;cx=12;cy=5;rx=9;ry=3|M3 5V19A9 3 0 0 0 15 21.84|M21 5V8|M21 12L18 17H22L19 22|M3 12A9 3 0 0 0 14.59 14.87", + "decimals-arrow-left": "m13 21-3-3 3-3|M20 18H10|M3 11h.01|rect;x=6;y=3;width=5;height=8;rx=2.5", + "decimals-arrow-right": "M10 18h10|m17 21 3-3-3-3|M3 11h.01|rect;x=15;y=3;width=5;height=8;rx=2.5|rect;x=6;y=3;width=5;height=8;rx=2.5", + "delete": "M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z|m12 9 6 6|m18 9-6 6", + "dessert": "M10.162 3.167A10 10 0 0 0 2 13a2 2 0 0 0 4 0v-1a2 2 0 0 1 4 0v4a2 2 0 0 0 4 0v-4a2 2 0 0 1 4 0v1a2 2 0 0 0 4-.006 10 10 0 0 0-8.161-9.826|M20.804 14.869a9 9 0 0 1-17.608 0|circle;cx=12;cy=4;r=2", + "diameter": "circle;cx=19;cy=19;r=2|circle;cx=5;cy=5;r=2|M6.48 3.66a10 10 0 0 1 13.86 13.86|m6.41 6.41 11.18 11.18|M3.66 6.48a10 10 0 0 0 13.86 13.86", + "diamond": "M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z", + "diamond-minus": "M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41L13.7 2.71a2.41 2.41 0 0 0-3.41 0z|M8 12h8", + "diamond-percent": "M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41L13.7 2.71a2.41 2.41 0 0 0-3.41 0Z|M9.2 9.2h.01|m14.5 9.5-5 5|M14.7 14.8h.01", + "diamond-plus": "M12 8v8|M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41L13.7 2.71a2.41 2.41 0 0 0-3.41 0z|M8 12h8", + "dice-1": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M12 12h.01", + "dice-2": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M15 9h.01|M9 15h.01", + "dice-3": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M16 8h.01|M12 12h.01|M8 16h.01", + "dice-4": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M16 8h.01|M8 8h.01|M8 16h.01|M16 16h.01", + "dice-5": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M16 8h.01|M8 8h.01|M8 16h.01|M16 16h.01|M12 12h.01", + "dice-6": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M16 8h.01|M16 12h.01|M16 16h.01|M8 8h.01|M8 12h.01|M8 16h.01", + "dices": "rect;width=12;height=12;x=2;y=10;rx=2;ry=2|m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6|M6 18h.01|M10 14h.01|M15 6h.01|M18 9h.01", + "diff": "M12 3v14|M5 10h14|M5 21h14", + "disc": "circle;cx=12;cy=12;r=10|circle;cx=12;cy=12;r=2", + "disc-2": "circle;cx=12;cy=12;r=10|circle;cx=12;cy=12;r=4|M12 12h.01", + "disc-3": "circle;cx=12;cy=12;r=10|M6 12c0-1.7.7-3.2 1.8-4.2|circle;cx=12;cy=12;r=2|M18 12c0 1.7-.7 3.2-1.8 4.2", + "disc-album": "rect;width=18;height=18;x=3;y=3;rx=2|circle;cx=12;cy=12;r=5|M12 12h.01", + "divide": "circle;cx=12;cy=6;r=1|line;x1=5;x2=19;y1=12;y2=12|circle;cx=12;cy=18;r=1", + "dna": "m10 16 1.5 1.5|m14 8-1.5-1.5|M15 2c-1.798 1.998-2.518 3.995-2.807 5.993|m16.5 10.5 1 1|m17 6-2.891-2.891|M2 15c6.667-6 13.333 0 20-6|m20 9 .891.891|M3.109 14.109 4 15|m6.5 12.5 1 1|m7 18 2.891 2.891|M9 22c1.798-1.998 2.518-3.995 2.807-5.993", + "dna-off": "M15 2c-1.35 1.5-2.092 3-2.5 4.5L14 8|m17 6-2.891-2.891|M2 15c3.333-3 6.667-3 10-3|m2 2 20 20|m20 9 .891.891|M22 9c-1.5 1.35-3 2.092-4.5 2.5l-1-1|M3.109 14.109 4 15|m6.5 12.5 1 1|m7 18 2.891 2.891|M9 22c1.35-1.5 2.092-3 2.5-4.5L10 16", + "dock": "M2 8h20|rect;width=20;height=16;x=2;y=4;rx=2|M6 16h12", + "dog": "M11.25 16.25h1.5L12 17z|M16 14v.5|M4.42 11.247A13.152 13.152 0 0 0 4 14.556C4 18.728 7.582 21 12 21s8-2.272 8-6.444a11.702 11.702 0 0 0-.493-3.309|M8 14v.5|M8.5 8.5c-.384 1.05-1.083 2.028-2.344 2.5-1.931.722-3.576-.297-3.656-1-.113-.994 1.177-6.53 4-7 1.923-.321 3.651.845 3.651 2.235A7.497 7.497 0 0 1 14 5.277c0-1.39 1.844-2.598 3.767-2.277 2.823.47 4.113 6.006 4 7-.08.703-1.725 1.722-3.656 1-1.261-.472-1.855-1.45-2.239-2.5", + "dollar-sign": "line;x1=12;x2=12;y1=2;y2=22|M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6", + "donut": "M20.5 10a2.5 2.5 0 0 1-2.4-3H18a2.95 2.95 0 0 1-2.6-4.4 10 10 0 1 0 6.3 7.1c-.3.2-.8.3-1.2.3|circle;cx=12;cy=12;r=3", + "door-closed": "M10 12h.01|M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14|M2 20h20", + "door-closed-locked": "M10 12h.01|M18 9V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14|M2 20h8|M20 17v-2a2 2 0 1 0-4 0v2|rect;x=14;y=17;width=8;height=5;rx=1", + "door-open": "M11 20H2|M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z|M11 4H8a2 2 0 0 0-2 2v14|M14 12h.01|M22 20h-3", + "dot": "circle;cx=12.1;cy=12.1;r=1", + "download": "M12 15V3|M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|m7 10 5 5 5-5", + "drafting-compass": "m12.99 6.74 1.93 3.44|M19.136 12a10 10 0 0 1-14.271 0|m21 21-2.16-3.84|m3 21 8.02-14.26|circle;cx=12;cy=5;r=2", + "drama": "M10 11h.01|M14 6h.01|M18 6h.01|M6.5 13.1h.01|M22 5c0 9-4 12-6 12s-6-3-6-12c0-2 2-3 6-3s6 1 6 3|M17.4 9.9c-.8.8-2 .8-2.8 0|M10.1 7.1C9 7.2 7.7 7.7 6 8.6c-3.5 2-4.7 3.9-3.7 5.6 4.5 7.8 9.5 8.4 11.2 7.4.9-.5 1.9-2.1 1.9-4.7|M9.1 16.5c.3-1.1 1.4-1.7 2.4-1.4", + "dribbble": "circle;cx=12;cy=12;r=10|M19.13 5.09C15.22 9.14 10 10.44 2.25 10.94|M21.75 12.84c-6.62-1.41-12.14 1-16.38 6.32|M8.56 2.75c4.37 6 6 9.42 8 17.72", + "drill": "M10 18a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H5a3 3 0 0 1-3-3 1 1 0 0 1 1-1z|M13 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1l-.81 3.242a1 1 0 0 1-.97.758H8|M14 4h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-3|M18 6h4|m5 10-2 8|m7 18 2-8", + "drone": "M10 10 7 7|m10 14-3 3|m14 10 3-3|m14 14 3 3|M14.205 4.139a4 4 0 1 1 5.439 5.863|M19.637 14a4 4 0 1 1-5.432 5.868|M4.367 10a4 4 0 1 1 5.438-5.862|M9.795 19.862a4 4 0 1 1-5.429-5.873|rect;x=10;y=8;width=4;height=8;rx=1", + "droplet": "M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z", + "droplet-off": "M18.715 13.186C18.29 11.858 17.384 10.607 16 9.5c-2-1.6-3.5-4-4-6.5a10.7 10.7 0 0 1-.884 2.586|m2 2 20 20|M8.795 8.797A11 11 0 0 1 8 9.5C6 11.1 5 13 5 15a7 7 0 0 0 13.222 3.208", + "droplets": "M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z|M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97", + "drum": "m2 2 8 8|m22 2-8 8|ellipse;cx=12;cy=9;rx=10;ry=5|M7 13.4v7.9|M12 14v8|M17 13.4v7.9|M2 9v8a10 5 0 0 0 20 0V9", + "drumstick": "M15.4 15.63a7.875 6 135 1 1 6.23-6.23 4.5 3.43 135 0 0-6.23 6.23|m8.29 12.71-2.6 2.6a2.5 2.5 0 1 0-1.65 4.65A2.5 2.5 0 1 0 8.7 18.3l2.59-2.59", + "dumbbell": "M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z|m2.5 21.5 1.4-1.4|m20.1 3.9 1.4-1.4|M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z|m9.6 14.4 4.8-4.8", + "ear": "M6 8.5a6.5 6.5 0 1 1 13 0c0 6-6 6-6 10a3.5 3.5 0 1 1-7 0|M15 8.5a2.5 2.5 0 0 0-5 0v1a2 2 0 1 1 0 4", + "ear-off": "M6 18.5a3.5 3.5 0 1 0 7 0c0-1.57.92-2.52 2.04-3.46|M6 8.5c0-.75.13-1.47.36-2.14|M8.8 3.15A6.5 6.5 0 0 1 19 8.5c0 1.63-.44 2.81-1.09 3.76|M12.5 6A2.5 2.5 0 0 1 15 8.5M10 13a2 2 0 0 0 1.82-1.18|line;x1=2;x2=22;y1=2;y2=22", + "earth": "M21.54 15H17a2 2 0 0 0-2 2v4.54|M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17|M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05|circle;cx=12;cy=12;r=10", + "earth-lock": "M7 3.34V5a3 3 0 0 0 3 3|M11 21.95V18a2 2 0 0 0-2-2 2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05|M21.54 15H17a2 2 0 0 0-2 2v4.54|M12 2a10 10 0 1 0 9.54 13|M20 6V4a2 2 0 1 0-4 0v2|rect;width=8;height=5;x=14;y=6;rx=1", + "eclipse": "circle;cx=12;cy=12;r=10|M12 2a7 7 0 1 0 10 10", + "egg": "M12 2C8 2 4 8 4 14a8 8 0 0 0 16 0c0-6-4-12-8-12", + "egg-fried": "circle;cx=11.5;cy=12.5;r=3.5|M3 8c0-3.5 2.5-6 6.5-6 5 0 4.83 3 7.5 5s5 2 5 6c0 4.5-2.5 6.5-7 6.5-2.5 0-2.5 2.5-6 2.5s-7-2-7-5.5c0-3 1.5-3 1.5-5C3.5 10 3 9 3 8Z", + "egg-off": "m2 2 20 20|M20 14.347V14c0-6-4-12-8-12-1.078 0-2.157.436-3.157 1.19|M6.206 6.21C4.871 8.4 4 11.2 4 14a8 8 0 0 0 14.568 4.568", + "ellipsis": "circle;cx=12;cy=12;r=1|circle;cx=19;cy=12;r=1|circle;cx=5;cy=12;r=1", + "ellipsis-vertical": "circle;cx=12;cy=12;r=1|circle;cx=12;cy=5;r=1|circle;cx=12;cy=19;r=1", + "equal": "line;x1=5;x2=19;y1=9;y2=9|line;x1=5;x2=19;y1=15;y2=15", + "equal-approximately": "M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0|M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0", + "equal-not": "line;x1=5;x2=19;y1=9;y2=9|line;x1=5;x2=19;y1=15;y2=15|line;x1=19;x2=5;y1=5;y2=19", + "eraser": "M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21|m5.082 11.09 8.828 8.828", + "ethernet-port": "m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z|M6 8v1|M10 8v1|M14 8v1|M18 8v1", + "euro": "M4 10h12|M4 14h9|M19 6a7.7 7.7 0 0 0-5.2-2A7.9 7.9 0 0 0 6 12c0 4.4 3.5 8 7.8 8 2 0 3.8-.8 5.2-2", + "ev-charger": "M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 4 0v-6.998a2 2 0 0 0-.59-1.42L18 5|M14 21V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v16|M2 21h13|M3 7h11|m9 11-2 3h3l-2 3", + "expand": "m15 15 6 6|m15 9 6-6|M21 16v5h-5|M21 8V3h-5|M3 16v5h5|m3 21 6-6|M3 8V3h5|M9 9 3 3", + "external-link": "M15 3h6v6|M10 14 21 3|M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6", + "eye": "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0|circle;cx=12;cy=12;r=3", + "eye-closed": "m15 18-.722-3.25|M2 8a10.645 10.645 0 0 0 20 0|m20 15-1.726-2.05|m4 15 1.726-2.05|m9 18 .722-3.25", + "eye-off": "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49|M14.084 14.158a3 3 0 0 1-4.242-4.242|M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143|m2 2 20 20", + "facebook": "M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z", + "factory": "M12 16h.01|M16 16h.01|M3 19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8.5a.5.5 0 0 0-.769-.422l-4.462 2.844A.5.5 0 0 1 15 10.5v-2a.5.5 0 0 0-.769-.422L9.77 10.922A.5.5 0 0 1 9 10.5V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2z|M8 16h.01", + "fan": "M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z|M12 12v.01", + "fast-forward": "M12 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 12 18z|M2 6a2 2 0 0 1 3.414-1.414l6 6a2 2 0 0 1 0 2.828l-6 6A2 2 0 0 1 2 18z", + "feather": "M12.67 19a2 2 0 0 0 1.416-.588l6.154-6.172a6 6 0 0 0-8.49-8.49L5.586 9.914A2 2 0 0 0 5 11.328V18a1 1 0 0 0 1 1z|M16 8 2 22|M17.5 15H9", + "fence": "M4 3 2 5v15c0 .6.4 1 1 1h2c.6 0 1-.4 1-1V5Z|M6 8h4|M6 18h4|m12 3-2 2v15c0 .6.4 1 1 1h2c.6 0 1-.4 1-1V5Z|M14 8h4|M14 18h4|m20 3-2 2v15c0 .6.4 1 1 1h2c.6 0 1-.4 1-1V5Z", + "ferris-wheel": "circle;cx=12;cy=12;r=2|M12 2v4|m6.8 15-3.5 2|m20.7 7-3.5 2|M6.8 9 3.3 7|m20.7 17-3.5-2|m9 22 3-8 3 8|M8 22h8|M18 18.7a9 9 0 1 0-12 0", + "figma": "M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z|M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z|M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z|M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z|M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z", + "file": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5", + "file-archive": "M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5|M14 2v5a1 1 0 0 0 1 1h5|M8 12v-1|M8 18v-2|M8 7V6|circle;cx=8;cy=20;r=2", + "file-axis-3d": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|m8 18 4-4|M8 10v8h8", + "file-badge": "M13 22h5a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v3.3|M14 2v5a1 1 0 0 0 1 1h5|m7.69 16.479 1.29 4.88a.5.5 0 0 1-.698.591l-1.843-.849a1 1 0 0 0-.879.001l-1.846.85a.5.5 0 0 1-.692-.593l1.29-4.88|circle;cx=6;cy=14;r=3", + "file-box": "M14.5 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v3.8|M14 2v5a1 1 0 0 0 1 1h5|M11.7 14.2 7 17l-4.7-2.8|M3 13.1a2 2 0 0 0-.999 1.76v3.24a2 2 0 0 0 .969 1.78L6 21.7a2 2 0 0 0 2.03.01L11 19.9a2 2 0 0 0 1-1.76V14.9a2 2 0 0 0-.97-1.78L8 11.3a2 2 0 0 0-2.03-.01z|M7 17v5", + "file-braces": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1|M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1", + "file-braces-corner": "M14 22h4a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6|M14 2v5a1 1 0 0 0 1 1h5|M5 14a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1 1 1 0 0 1 1 1v2a1 1 0 0 0 1 1|M9 22a1 1 0 0 0 1-1v-2a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-2a1 1 0 0 0-1-1", + "file-chart-column": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M8 18v-1|M12 18v-6|M16 18v-3", + "file-chart-column-increasing": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M8 18v-2|M12 18v-4|M16 18v-6", + "file-chart-line": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|m16 13-3.5 3.5-2-2L8 17", + "file-chart-pie": "M15.941 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.704l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v3.512|M14 2v5a1 1 0 0 0 1 1h5|M4.017 11.512a6 6 0 1 0 8.466 8.475|M9 16a1 1 0 0 1-1-1v-4c0-.552.45-1.008.995-.917a6 6 0 0 1 4.922 4.922c.091.544-.365.995-.917.995z", + "file-check": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|m9 15 2 2 4-4", + "file-check-corner": "M10.5 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v6|M14 2v5a1 1 0 0 0 1 1h5|m14 20 2 2 4-4", + "file-clock": "M16 22h2a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v2.85|M14 2v5a1 1 0 0 0 1 1h5|M8 14v2.2l1.6 1|circle;cx=8;cy=16;r=6", + "file-code": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M10 12.5 8 15l2 2.5|m14 12.5 2 2.5-2 2.5", + "file-code-corner": "M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35|M14 2v5a1 1 0 0 0 1 1h5|m5 16-3 3 3 3|m9 22 3-3-3-3", + "file-cog": "M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z|M20 8v12a2 2 0 0 1-2 2h-4.182|m3.305 19.53.923-.382|M4 10.592V4a2 2 0 0 1 2-2h8|m4.228 16.852-.924-.383|m5.852 15.228-.383-.923|m5.852 20.772-.383.924|m8.148 15.228.383-.923|m8.53 21.696-.382-.924|m9.773 16.852.922-.383|m9.773 19.148.922.383|circle;cx=7;cy=18;r=3", + "file-diff": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M9 10h6|M12 13V7|M9 17h6", + "file-digit": "M4 12V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2|M14 2v5a1 1 0 0 0 1 1h5|M10 16h2v6|M10 22h4|rect;x=2;y=16;width=4;height=6;rx=2", + "file-down": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M12 18v-6|m9 15 3 3 3-3", + "file-exclamation-point": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M12 9v4|M12 17h.01", + "file-headphone": "M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343|M14 2v5a1 1 0 0 0 1 1h5|M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0", + "file-heart": "M13 22h5a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v7|M14 2v5a1 1 0 0 0 1 1h5|M3.62 18.8A2.25 2.25 0 1 1 7 15.836a2.25 2.25 0 1 1 3.38 2.966l-2.626 2.856a1 1 0 0 1-1.507 0z", + "file-image": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|circle;cx=10;cy=12;r=2|m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22", + "file-input": "M4 11V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1|M14 2v5a1 1 0 0 0 1 1h5|M2 15h10|m9 18 3-3-3-3", + "file-key": "M14 2v5a1 1 0 0 0 1 1h5|M4 12v6|M4 14h2|M9.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v4|circle;cx=4;cy=20;r=2", + "file-lock": "M4 9.8V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3|M14 2v5a1 1 0 0 0 1 1h5|M9 17v-2a2 2 0 0 0-4 0v2|rect;width=8;height=5;x=3;y=17;rx=1", + "file-minus": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M9 15h6", + "file-minus-corner": "M20 14V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12|M14 2v5a1 1 0 0 0 1 1h5|M14 18h6", + "file-music": "M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35|M14 2v5a1 1 0 0 0 1 1h5|M8 20v-7l3 1.474|circle;cx=6;cy=20;r=2", + "file-output": "M4.226 20.925A2 2 0 0 0 6 22h12a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v3.127|M14 2v5a1 1 0 0 0 1 1h5|m5 11-3 3|m5 17-3-3h10", + "file-pen": "M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34|M14 2v5a1 1 0 0 0 1 1h5|M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z", + "file-pen-line": "m18.226 5.226-2.52-2.52A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-.351|M21.378 12.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z|M8 18h1", + "file-play": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z", + "file-plus": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M9 15h6|M12 18v-6", + "file-plus-corner": "M11.35 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5.35|M14 2v5a1 1 0 0 0 1 1h5|M14 19h6|M17 16v6", + "file-question-mark": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M12 17h.01|M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3", + "file-scan": "M20 10V8a2.4 2.4 0 0 0-.706-1.704l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4.35|M14 2v5a1 1 0 0 0 1 1h5|M16 14a2 2 0 0 0-2 2|M16 22a2 2 0 0 1-2-2|M20 14a2 2 0 0 1 2 2|M20 22a2 2 0 0 0 2-2", + "file-search": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|circle;cx=11.5;cy=14.5;r=2.5|M13.3 16.3 15 18", + "file-search-corner": "M11.1 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.589 3.588A2.4 2.4 0 0 1 20 8v3.25|M14 2v5a1 1 0 0 0 1 1h5|m21 22-2.88-2.88|circle;cx=16;cy=17;r=3", + "file-signal": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M8 15h.01|M11.5 13.5a2.5 2.5 0 0 1 0 3|M15 12a5 5 0 0 1 0 6", + "file-sliders": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M8 12h8|M10 11v2|M8 17h8|M14 16v2", + "file-spreadsheet": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M8 13h2|M14 13h2|M8 17h2|M14 17h2", + "file-stack": "M11 21a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1|M16 16a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1|M21 6a2 2 0 0 0-.586-1.414l-2-2A2 2 0 0 0 17 2h-3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1z", + "file-symlink": "M4 11V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h7|M14 2v5a1 1 0 0 0 1 1h5|m10 18 3-3-3-3", + "file-terminal": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|m8 16 2-2-2-2|M12 18h4", + "file-text": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M10 9H8|M16 13H8|M16 17H8", + "file-type": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M11 18h2|M12 12v6|M9 13v-.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v.5", + "file-type-corner": "M12 22h6a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6|M14 2v5a1 1 0 0 0 1 1h5|M3 16v-1.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V16|M6 22h2|M7 14v8", + "file-up": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M12 12v6|m15 15-3-3-3 3", + "file-user": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|M16 22a4 4 0 0 0-8 0|circle;cx=12;cy=15;r=3", + "file-video-camera": "M4 12V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2|M14 2v5a1 1 0 0 0 1 1h5|m10 17.843 3.033-1.755a.64.64 0 0 1 .967.56v4.704a.65.65 0 0 1-.967.56L10 20.157|rect;width=7;height=6;x=3;y=16;rx=1", + "file-volume": "M4 11.55V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-1.95|M14 2v5a1 1 0 0 0 1 1h5|M12 15a5 5 0 0 1 0 6|M8 14.502a.5.5 0 0 0-.826-.381l-1.893 1.631a1 1 0 0 1-.651.243H3.5a.5.5 0 0 0-.5.501v3.006a.5.5 0 0 0 .5.501h1.129a1 1 0 0 1 .652.243l1.893 1.633a.5.5 0 0 0 .826-.38z", + "file-x": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z|M14 2v5a1 1 0 0 0 1 1h5|m14.5 12.5-5 5|m9.5 12.5 5 5", + "file-x-corner": "M11 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5|M14 2v5a1 1 0 0 0 1 1h5|m15 17 5 5|m20 17-5 5", + "files": "M15 2h-4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8|M16.706 2.706A2.4 2.4 0 0 0 15 2v5a1 1 0 0 0 1 1h5a2.4 2.4 0 0 0-.706-1.706z|M5 7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 1.732-1", + "film": "rect;width=18;height=18;x=3;y=3;rx=2|M7 3v18|M3 7.5h4|M3 12h18|M3 16.5h4|M17 3v18|M17 7.5h4|M17 16.5h4", + "fingerprint-pattern": "M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4|M14 13.12c0 2.38 0 6.38-1 8.88|M17.29 21.02c.12-.6.43-2.3.5-3.02|M2 12a10 10 0 0 1 18-6|M2 16h.01|M21.8 16c.2-2 .131-5.354 0-6|M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2|M8.65 22c.21-.66.45-1.32.57-2|M9 6.8a6 6 0 0 1 9 5.2v2", + "fire-extinguisher": "M15 6.5V3a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v3.5|M9 18h8|M18 3h-3|M11 3a6 6 0 0 0-6 6v11|M5 13h4|M17 10a4 4 0 0 0-8 0v10a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2Z", + "fish": "M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 6.06 2.54 7 6-.94 3.47-3.44 6-7 6s-7.56-2.53-8.5-6Z|M18 12v.5|M16 17.93a9.77 9.77 0 0 1 0-11.86|M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33|M10.46 7.26C10.2 5.88 9.17 4.24 8 3h5.8a2 2 0 0 1 1.98 1.67l.23 1.4|m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98", + "fish-off": "M18 12.47v.03m0-.5v.47m-.475 5.056A6.744 6.744 0 0 1 15 18c-3.56 0-7.56-2.53-8.5-6 .348-1.28 1.114-2.433 2.121-3.38m3.444-2.088A8.802 8.802 0 0 1 15 6c3.56 0 6.06 2.54 7 6-.309 1.14-.786 2.177-1.413 3.058|M7 10.67C7 8 5.58 5.97 2.73 5.5c-1 1.5-1 5 .23 6.5-1.24 1.5-1.24 5-.23 6.5C5.58 18.03 7 16 7 13.33m7.48-4.372A9.77 9.77 0 0 1 16 6.07m0 11.86a9.77 9.77 0 0 1-1.728-3.618|m16.01 17.93-.23 1.4A2 2 0 0 1 13.8 21H9.5a5.96 5.96 0 0 0 1.49-3.98M8.53 3h5.27a2 2 0 0 1 1.98 1.67l.23 1.4M2 2l20 20", + "fish-symbol": "M2 16s9-15 20-4C11 23 2 8 2 8", + "fishing-hook": "m17.586 11.414-5.93 5.93a1 1 0 0 1-8-8l3.137-3.137a.707.707 0 0 1 1.207.5V10|M20.414 8.586 22 7|circle;cx=19;cy=10;r=2", + "flag": "M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528", + "flag-off": "M16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528|m2 2 20 20|M4 22V4|M7.656 2H8c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10.347", + "flag-triangle-left": "M18 22V2.8a.8.8 0 0 0-1.17-.71L5.45 7.78a.8.8 0 0 0 0 1.44L18 15.5", + "flag-triangle-right": "M6 22V2.8a.8.8 0 0 1 1.17-.71l11.38 5.69a.8.8 0 0 1 0 1.44L6 15.5", + "flame": "M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4", + "flame-kindling": "M12 2c1 3 2.5 3.5 3.5 4.5A5 5 0 0 1 17 10a5 5 0 1 1-10 0c0-.3 0-.6.1-.9a2 2 0 1 0 3.3-2C8 4.5 11 2 12 2Z|m5 22 14-4|m5 18 14 4", + "flashlight": "M12 13v1|M17 2a1 1 0 0 1 1 1v4a3 3 0 0 1-.6 1.8l-.6.8A4 4 0 0 0 16 12v8a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-8a4 4 0 0 0-.8-2.4l-.6-.8A3 3 0 0 1 6 7V3a1 1 0 0 1 1-1z|M6 6h12", + "flashlight-off": "M11.652 6H18|M12 13v1|M16 16v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-8a4 4 0 0 0-.8-2.4l-.6-.8A3 3 0 0 1 6 7V6|m2 2 20 20|M7.649 2H17a1 1 0 0 1 1 1v4a3 3 0 0 1-.6 1.8l-.6.8a4 4 0 0 0-.55 1.007", + "flask-conical": "M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2|M6.453 15h11.094|M8.5 2h7", + "flask-conical-off": "M10 2v2.343|M14 2v6.343|m2 2 20 20|M20 20a2 2 0 0 1-2 2H6a2 2 0 0 1-1.755-2.96l5.227-9.563|M6.453 15H15|M8.5 2h7", + "flask-round": "M10 2v6.292a7 7 0 1 0 4 0V2|M5 15h14|M8.5 2h7", + "flip-horizontal": "M8 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h3|M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3|M12 20v2|M12 14v2|M12 8v2|M12 2v2", + "flip-horizontal-2": "m3 7 5 5-5 5V7|m21 7-5 5 5 5V7|M12 20v2|M12 14v2|M12 8v2|M12 2v2", + "flip-vertical": "M21 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v3|M21 16v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3|M4 12H2|M10 12H8|M16 12h-2|M22 12h-2", + "flip-vertical-2": "m17 3-5 5-5-5h10|m17 21-5-5-5 5h10|M4 12H2|M10 12H8|M16 12h-2|M22 12h-2", + "flower": "circle;cx=12;cy=12;r=3|M12 16.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 1 1 4.5 4.5 4.5 4.5 0 1 1-4.5 4.5|M12 7.5V9|M7.5 12H9|M16.5 12H15|M12 16.5V15|m8 8 1.88 1.88|M14.12 9.88 16 8|m8 16 1.88-1.88|M14.12 14.12 16 16", + "flower-2": "M12 5a3 3 0 1 1 3 3m-3-3a3 3 0 1 0-3 3m3-3v1M9 8a3 3 0 1 0 3 3M9 8h1m5 0a3 3 0 1 1-3 3m3-3h-1m-2 3v-1|circle;cx=12;cy=8;r=2|M12 10v12|M12 22c4.2 0 7-1.667 7-5-4.2 0-7 1.667-7 5Z|M12 22c-4.2 0-7-1.667-7-5 4.2 0 7 1.667 7 5Z", + "focus": "circle;cx=12;cy=12;r=3|M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2", + "fold-horizontal": "M2 12h6|M22 12h-6|M12 2v2|M12 8v2|M12 14v2|M12 20v2|m19 9-3 3 3 3|m5 15 3-3-3-3", + "fold-vertical": "M12 22v-6|M12 8V2|M4 12H2|M10 12H8|M16 12h-2|M22 12h-2|m15 19-3-3-3 3|m15 5-3 3-3-3", + "folder": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", + "folder-archive": "circle;cx=15;cy=19;r=2|M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1|M15 11v-1|M15 17v-2", + "folder-check": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|m9 13 2 2 4-4", + "folder-clock": "M16 14v2.2l1.6 1|M7 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2|circle;cx=16;cy=16;r=6", + "folder-closed": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|M2 10h20", + "folder-code": "M10 10.5 8 13l2 2.5|m14 10.5 2 2.5-2 2.5|M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z", + "folder-cog": "M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3|m14.305 19.53.923-.382|m15.228 16.852-.923-.383|m16.852 15.228-.383-.923|m16.852 20.772-.383.924|m19.148 15.228.383-.923|m19.53 21.696-.382-.924|m20.772 16.852.924-.383|m20.772 19.148.924.383|circle;cx=18;cy=18;r=3", + "folder-dot": "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z|circle;cx=12;cy=13;r=1", + "folder-down": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|M12 10v6|m15 13-3 3-3-3", + "folder-git": "circle;cx=12;cy=13;r=2|M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|M14 13h3|M7 13h3", + "folder-git-2": "M18 19a5 5 0 0 1-5-5v8|M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5|circle;cx=13;cy=12;r=2|circle;cx=20;cy=19;r=2", + "folder-heart": "M10.638 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.417|M14.62 18.8A2.25 2.25 0 1 1 18 15.836a2.25 2.25 0 1 1 3.38 2.966l-2.626 2.856a.998.998 0 0 1-1.507 0z", + "folder-input": "M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-1|M2 13h10|m9 16 3-3-3-3", + "folder-kanban": "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z|M8 10v4|M12 10v2|M16 10v6", + "folder-key": "M13 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v1.36|M19 12v6|M19 14h2|circle;cx=19;cy=20;r=2", + "folder-lock": "rect;width=8;height=5;x=14;y=17;rx=1|M10 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v2.5|M20 17v-2a2 2 0 1 0-4 0v2", + "folder-minus": "M9 13h6|M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", + "folder-open": "m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2", + "folder-open-dot": "m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2|circle;cx=14;cy=15;r=1", + "folder-output": "M2 7.5V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5|M2 13h10|m5 10-3 3 3 3", + "folder-pen": "M2 11.5V5a2 2 0 0 1 2-2h3.9c.7 0 1.3.3 1.7.9l.8 1.2c.4.6 1 .9 1.7.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-9.5|M11.378 13.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z", + "folder-plus": "M12 10v6|M9 13h6|M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", + "folder-root": "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z|circle;cx=12;cy=13;r=2|M12 15v5", + "folder-search": "M10.7 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v4.1|m21 21-1.9-1.9|circle;cx=17;cy=17;r=3", + "folder-search-2": "circle;cx=11.5;cy=12.5;r=2.5|M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|M13.3 14.3 15 16", + "folder-symlink": "M2 9.35V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h7|m8 16 3-3-3-3", + "folder-sync": "M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5|M12 10v4h4|m12 14 1.535-1.605a5 5 0 0 1 8 1.5|M22 22v-4h-4|m22 18-1.535 1.605a5 5 0 0 1-8-1.5", + "folder-tree": "M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z|M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z|M3 5a2 2 0 0 0 2 2h3|M3 3v13a2 2 0 0 0 2 2h3", + "folder-up": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|M12 10v6|m9 13 3-3 3 3", + "folder-x": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z|m9.5 10.5 5 5|m14.5 10.5-5 5", + "folders": "M20 5a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h2.5a1.5 1.5 0 0 1 1.2.6l.6.8a1.5 1.5 0 0 0 1.2.6z|M3 8.268a2 2 0 0 0-1 1.738V19a2 2 0 0 0 2 2h11a2 2 0 0 0 1.732-1", + "footprints": "M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z|M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z|M16 17h4|M4 13h4", + "forklift": "M12 12H5a2 2 0 0 0-2 2v5|circle;cx=13;cy=19;r=2|circle;cx=5;cy=19;r=2|M8 19h3m5-17v17h6M6 12V7c0-1.1.9-2 2-2h3l5 5", + "form": "M4 14h6|M4 2h10|rect;x=4;y=18;width=16;height=4;rx=1|rect;x=4;y=6;width=16;height=4;rx=1", + "forward": "m15 17 5-5-5-5|M4 18v-2a4 4 0 0 1 4-4h12", + "frame": "line;x1=22;x2=2;y1=6;y2=6|line;x1=22;x2=2;y1=18;y2=18|line;x1=6;x2=6;y1=2;y2=22|line;x1=18;x2=18;y1=2;y2=22", + "framer": "M5 16V9h14V2H5l14 14h-7m-7 0 7 7v-7m-7 0h7", + "frown": "circle;cx=12;cy=12;r=10|M16 16s-1.5-2-4-2-4 2-4 2|line;x1=9;x2=9.01;y1=9;y2=9|line;x1=15;x2=15.01;y1=9;y2=9", + "fuel": "M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 4 0v-6.998a2 2 0 0 0-.59-1.42L18 5|M14 21V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v16|M2 21h13|M3 9h11", + "fullscreen": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|rect;width=10;height=8;x=7;y=8;rx=1", + "funnel": "M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z", + "funnel-plus": "M13.354 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14v6a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341l1.218-1.348|M16 6h6|M19 3v6", + "funnel-x": "M12.531 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14v6a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341l.427-.473|m16.5 3.5 5 5|m21.5 3.5-5 5", + "gallery-horizontal": "M2 3v18|rect;width=12;height=18;x=6;y=3;rx=2|M22 3v18", + "gallery-horizontal-end": "M2 7v10|M6 5v14|rect;width=12;height=18;x=10;y=3;rx=2", + "gallery-thumbnails": "rect;width=18;height=14;x=3;y=3;rx=2|M4 21h1|M9 21h1|M14 21h1|M19 21h1", + "gallery-vertical": "M3 2h18|rect;width=18;height=12;x=3;y=6;rx=2|M3 22h18", + "gallery-vertical-end": "M7 2h10|M5 6h14|rect;width=18;height=12;x=3;y=10;rx=2", + "gamepad": "line;x1=6;x2=10;y1=12;y2=12|line;x1=8;x2=8;y1=10;y2=14|line;x1=15;x2=15.01;y1=13;y2=13|line;x1=18;x2=18.01;y1=11;y2=11|rect;width=20;height=12;x=2;y=6;rx=2", + "gamepad-2": "line;x1=6;x2=10;y1=11;y2=11|line;x1=8;x2=8;y1=9;y2=13|line;x1=15;x2=15.01;y1=12;y2=12|line;x1=18;x2=18.01;y1=10;y2=10|M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z", + "gamepad-directional": "M11.146 15.854a1.207 1.207 0 0 1 1.708 0l1.56 1.56A2 2 0 0 1 15 18.828V21a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2.172a2 2 0 0 1 .586-1.414z|M18.828 15a2 2 0 0 1-1.414-.586l-1.56-1.56a1.207 1.207 0 0 1 0-1.708l1.56-1.56A2 2 0 0 1 18.828 9H21a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1z|M6.586 14.414A2 2 0 0 1 5.172 15H3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2.172a2 2 0 0 1 1.414.586l1.56 1.56a1.207 1.207 0 0 1 0 1.708z|M9 3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2.172a2 2 0 0 1-.586 1.414l-1.56 1.56a1.207 1.207 0 0 1-1.708 0l-1.56-1.56A2 2 0 0 1 9 5.172z", + "gauge": "m12 14 4-4|M3.34 19a10 10 0 1 1 17.32 0", + "gavel": "m14 13-8.381 8.38a1 1 0 0 1-3.001-3l8.384-8.381|m16 16 6-6|m21.5 10.5-8-8|m8 8 6-6|m8.5 7.5 8 8", + "gem": "M10.5 3 8 9l4 13 4-13-2.5-6|M17 3a2 2 0 0 1 1.6.8l3 4a2 2 0 0 1 .013 2.382l-7.99 10.986a2 2 0 0 1-3.247 0l-7.99-10.986A2 2 0 0 1 2.4 7.8l2.998-3.997A2 2 0 0 1 7 3z|M2 9h20", + "georgian-lari": "M11.5 21a7.5 7.5 0 1 1 7.35-9|M13 12V3|M4 21h16|M9 12V3", + "ghost": "M9 10h.01|M15 10h.01|M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z", + "gift": "rect;x=3;y=8;width=18;height=4;rx=1|M12 8v13|M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7|M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5", + "git-branch": "M15 6a9 9 0 0 0-9 9V3|circle;cx=18;cy=6;r=3|circle;cx=6;cy=18;r=3", + "git-branch-minus": "M15 6a9 9 0 0 0-9 9V3|M21 18h-6|circle;cx=18;cy=6;r=3|circle;cx=6;cy=18;r=3", + "git-branch-plus": "M6 3v12|M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 6a9 9 0 0 0-9 9|M18 15v6|M21 18h-6", + "git-commit-horizontal": "circle;cx=12;cy=12;r=3|line;x1=3;x2=9;y1=12;y2=12|line;x1=15;x2=21;y1=12;y2=12", + "git-commit-vertical": "M12 3v6|circle;cx=12;cy=12;r=3|M12 15v6", + "git-compare": "circle;cx=18;cy=18;r=3|circle;cx=6;cy=6;r=3|M13 6h3a2 2 0 0 1 2 2v7|M11 18H8a2 2 0 0 1-2-2V9", + "git-compare-arrows": "circle;cx=5;cy=6;r=3|M12 6h5a2 2 0 0 1 2 2v7|m15 9-3-3 3-3|circle;cx=19;cy=18;r=3|M12 18H7a2 2 0 0 1-2-2V9|m9 15 3 3-3 3", + "git-fork": "circle;cx=12;cy=18;r=3|circle;cx=6;cy=6;r=3|circle;cx=18;cy=6;r=3|M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9|M12 12v3", + "git-graph": "circle;cx=5;cy=6;r=3|M5 9v6|circle;cx=5;cy=18;r=3|M12 3v18|circle;cx=19;cy=6;r=3|M16 15.7A9 9 0 0 0 19 9", + "git-merge": "circle;cx=18;cy=18;r=3|circle;cx=6;cy=6;r=3|M6 21V9a9 9 0 0 0 9 9", + "git-pull-request": "circle;cx=18;cy=18;r=3|circle;cx=6;cy=6;r=3|M13 6h3a2 2 0 0 1 2 2v7|line;x1=6;x2=6;y1=9;y2=21", + "git-pull-request-arrow": "circle;cx=5;cy=6;r=3|M5 9v12|circle;cx=19;cy=18;r=3|m15 9-3-3 3-3|M12 6h5a2 2 0 0 1 2 2v7", + "git-pull-request-closed": "circle;cx=6;cy=6;r=3|M6 9v12|m21 3-6 6|m21 9-6-6|M18 11.5V15|circle;cx=18;cy=18;r=3", + "git-pull-request-create": "circle;cx=6;cy=6;r=3|M6 9v12|M13 6h3a2 2 0 0 1 2 2v3|M18 15v6|M21 18h-6", + "git-pull-request-create-arrow": "circle;cx=5;cy=6;r=3|M5 9v12|m15 9-3-3 3-3|M12 6h5a2 2 0 0 1 2 2v3|M19 15v6|M22 18h-6", + "git-pull-request-draft": "circle;cx=18;cy=18;r=3|circle;cx=6;cy=6;r=3|M18 6V5|M18 11v-1|line;x1=6;x2=6;y1=9;y2=21", + "github": "M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4|M9 18c-4.51 2-5-2-7-2", + "gitlab": "m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26a.42.42 0 0 0-.1-.18.38.38 0 0 0-.26-.08.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z", + "glass-water": "M5.116 4.104A1 1 0 0 1 6.11 3h11.78a1 1 0 0 1 .994 1.105L17.19 20.21A2 2 0 0 1 15.2 22H8.8a2 2 0 0 1-2-1.79z|M6 12a5 5 0 0 1 6 0 5 5 0 0 0 6 0", + "glasses": "circle;cx=6;cy=15;r=4|circle;cx=18;cy=15;r=4|M14 15a2 2 0 0 0-2-2 2 2 0 0 0-2 2|M2.5 13 5 7c.7-1.3 1.4-2 3-2|M21.5 13 19 7c-.7-1.3-1.5-2-3-2", + "globe": "circle;cx=12;cy=12;r=10|M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20|M2 12h20", + "globe-lock": "M15.686 15A14.5 14.5 0 0 1 12 22a14.5 14.5 0 0 1 0-20 10 10 0 1 0 9.542 13|M2 12h8.5|M20 6V4a2 2 0 1 0-4 0v2|rect;width=8;height=5;x=14;y=6;rx=1", + "globe-x": "m16 3 5 5|M2 12h20A10 10 0 1 1 12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 4-10|m21 3-5 5", + "goal": "M12 13V2l8 4-8 4|M20.561 10.222a9 9 0 1 1-12.55-5.29|M8.002 9.997a5 5 0 1 0 8.9 2.02", + "gpu": "M2 21V3|M2 5h18a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2.26|M7 17v3a1 1 0 0 0 1 1h5a1 1 0 0 0 1-1v-3|circle;cx=16;cy=11;r=2|circle;cx=8;cy=11;r=2", + "graduation-cap": "M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z|M22 10v6|M6 12.5V16a6 3 0 0 0 12 0v-3.5", + "grape": "M22 5V2l-5.89 5.89|circle;cx=16.6;cy=15.89;r=3|circle;cx=8.11;cy=7.4;r=3|circle;cx=12.35;cy=11.65;r=3|circle;cx=13.91;cy=5.85;r=3|circle;cx=18.15;cy=10.09;r=3|circle;cx=6.56;cy=13.2;r=3|circle;cx=10.8;cy=17.44;r=3|circle;cx=5;cy=19;r=3", + "grid-2x2": "M12 3v18|M3 12h18|rect;x=3;y=3;width=18;height=18;rx=2", + "grid-2x2-check": "M12 3v17a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1H3|m16 19 2 2 4-4", + "grid-2x2-plus": "M12 3v17a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1H3|M16 19h6|M19 22v-6", + "grid-2x2-x": "M12 3v17a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1H3|m16 16 5 5|m16 21 5-5", + "grid-3x2": "M15 3v18|M3 12h18|M9 3v18|rect;x=3;y=3;width=18;height=18;rx=2", + "grid-3x3": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18|M3 15h18|M9 3v18|M15 3v18", + "grip": "circle;cx=12;cy=5;r=1|circle;cx=19;cy=5;r=1|circle;cx=5;cy=5;r=1|circle;cx=12;cy=12;r=1|circle;cx=19;cy=12;r=1|circle;cx=5;cy=12;r=1|circle;cx=12;cy=19;r=1|circle;cx=19;cy=19;r=1|circle;cx=5;cy=19;r=1", + "grip-horizontal": "circle;cx=12;cy=9;r=1|circle;cx=19;cy=9;r=1|circle;cx=5;cy=9;r=1|circle;cx=12;cy=15;r=1|circle;cx=19;cy=15;r=1|circle;cx=5;cy=15;r=1", + "grip-vertical": "circle;cx=9;cy=12;r=1|circle;cx=9;cy=5;r=1|circle;cx=9;cy=19;r=1|circle;cx=15;cy=12;r=1|circle;cx=15;cy=5;r=1|circle;cx=15;cy=19;r=1", + "group": "M3 7V5c0-1.1.9-2 2-2h2|M17 3h2c1.1 0 2 .9 2 2v2|M21 17v2c0 1.1-.9 2-2 2h-2|M7 21H5c-1.1 0-2-.9-2-2v-2|rect;width=7;height=5;x=7;y=7;rx=1|rect;width=7;height=5;x=10;y=12;rx=1", + "guitar": "m11.9 12.1 4.514-4.514|M20.1 2.3a1 1 0 0 0-1.4 0l-1.114 1.114A2 2 0 0 0 17 4.828v1.344a2 2 0 0 1-.586 1.414A2 2 0 0 1 17.828 7h1.344a2 2 0 0 0 1.414-.586L21.7 5.3a1 1 0 0 0 0-1.4z|m6 16 2 2|M8.23 9.85A3 3 0 0 1 11 8a5 5 0 0 1 5 5 3 3 0 0 1-1.85 2.77l-.92.38A2 2 0 0 0 12 18a4 4 0 0 1-4 4 6 6 0 0 1-6-6 4 4 0 0 1 4-4 2 2 0 0 0 1.85-1.23z", + "ham": "M13.144 21.144A7.274 10.445 45 1 0 2.856 10.856|M13.144 21.144A7.274 4.365 45 0 0 2.856 10.856a7.274 4.365 45 0 0 10.288 10.288|M16.565 10.435 18.6 8.4a2.501 2.501 0 1 0 1.65-4.65 2.5 2.5 0 1 0-4.66 1.66l-2.024 2.025|m8.5 16.5-1-1", + "hamburger": "M12 16H4a2 2 0 1 1 0-4h16a2 2 0 1 1 0 4h-4.25|M5 12a2 2 0 0 1-2-2 9 7 0 0 1 18 0 2 2 0 0 1-2 2|M5 16a2 2 0 0 0-2 2 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 2 2 0 0 0-2-2q0 0 0 0|m6.67 12 6.13 4.6a2 2 0 0 0 2.8-.4l3.15-4.2", + "hammer": "m15 12-9.373 9.373a1 1 0 0 1-3.001-3L12 9|m18 15 4-4|m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172v-.344a2 2 0 0 0-.586-1.414l-1.657-1.657A6 6 0 0 0 12.516 3H9l1.243 1.243A6 6 0 0 1 12 8.485V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5", + "hand": "M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2|M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2|M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8|M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15", + "hand-coins": "M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17|m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9|m2 16 6 6|circle;cx=16;cy=9;r=2.9|circle;cx=6;cy=5;r=3", + "hand-fist": "M12.035 17.012a3 3 0 0 0-3-3l-.311-.002a.72.72 0 0 1-.505-1.229l1.195-1.195A2 2 0 0 1 10.828 11H12a2 2 0 0 0 0-4H9.243a3 3 0 0 0-2.122.879l-2.707 2.707A4.83 4.83 0 0 0 3 14a8 8 0 0 0 8 8h2a8 8 0 0 0 8-8V7a2 2 0 1 0-4 0v2a2 2 0 1 0 4 0|M13.888 9.662A2 2 0 0 0 17 8V5A2 2 0 1 0 13 5|M9 5A2 2 0 1 0 5 5V10|M9 7V4A2 2 0 1 1 13 4V7.268", + "hand-grab": "M18 11.5V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1.4|M14 10V8a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2|M10 9.9V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v5|M6 14a2 2 0 0 0-2-2a2 2 0 0 0-2 2|M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-4a8 8 0 0 1-8-8 2 2 0 1 1 4 0", + "hand-heart": "M11 14h2a2 2 0 0 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 16|m14.45 13.39 5.05-4.694C20.196 8 21 6.85 21 5.75a2.75 2.75 0 0 0-4.797-1.837.276.276 0 0 1-.406 0A2.75 2.75 0 0 0 11 5.75c0 1.2.802 2.248 1.5 2.946L16 11.95|m2 15 6 6|m7 20 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a1 1 0 0 0-2.75-2.91", + "hand-helping": "M11 12h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 14|m7 18 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9|m2 13 6 6", + "hand-metal": "M18 12.5V10a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1.4|M14 11V9a2 2 0 1 0-4 0v2|M10 10.5V5a2 2 0 1 0-4 0v9|m7 15-1.76-1.76a2 2 0 0 0-2.83 2.82l3.6 3.6C7.5 21.14 9.2 22 12 22h2a8 8 0 0 0 8-8V7a2 2 0 1 0-4 0v5", + "hand-platter": "M12 3V2|m15.4 17.4 3.2-2.8a2 2 0 1 1 2.8 2.9l-3.6 3.3c-.7.8-1.7 1.2-2.8 1.2h-4c-1.1 0-2.1-.4-2.8-1.2l-1.302-1.464A1 1 0 0 0 6.151 19H5|M2 14h12a2 2 0 0 1 0 4h-2|M4 10h16|M5 10a7 7 0 0 1 14 0|M5 14v6a1 1 0 0 1-1 1H2", + "handbag": "M2.048 18.566A2 2 0 0 0 4 21h16a2 2 0 0 0 1.952-2.434l-2-9A2 2 0 0 0 18 8H6a2 2 0 0 0-1.952 1.566z|M8 11V6a4 4 0 0 1 8 0v5", + "handshake": "m11 17 2 2a1 1 0 1 0 3-3|m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4|m21 3 1 11h-2|M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3|M3 4h8", + "hard-drive": "M10 16h.01|M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z|M21.946 12.013H2.054|M6 16h.01", + "hard-drive-download": "M12 2v8|m16 6-4 4-4-4|rect;width=20;height=8;x=2;y=14;rx=2|M6 18h.01|M10 18h.01", + "hard-drive-upload": "m16 6-4-4-4 4|M12 2v8|rect;width=20;height=8;x=2;y=14;rx=2|M6 18h.01|M10 18h.01", + "hard-hat": "M10 10V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5|M14 6a6 6 0 0 1 6 6v3|M4 15v-3a6 6 0 0 1 6-6|rect;x=2;y=15;width=20;height=4;rx=1", + "hash": "line;x1=4;x2=20;y1=9;y2=9|line;x1=4;x2=20;y1=15;y2=15|line;x1=10;x2=8;y1=3;y2=21|line;x1=16;x2=14;y1=3;y2=21", + "hat-glasses": "M14 18a2 2 0 0 0-4 0|m19 11-2.11-6.657a2 2 0 0 0-2.752-1.148l-1.276.61A2 2 0 0 1 12 4H8.5a2 2 0 0 0-1.925 1.456L5 11|M2 11h20|circle;cx=17;cy=18;r=3|circle;cx=7;cy=18;r=3", + "haze": "m5.2 6.2 1.4 1.4|M2 13h2|M20 13h2|m17.4 7.6 1.4-1.4|M22 17H2|M22 21H2|M16 13a4 4 0 0 0-8 0|M12 5V2.5", + "hd": "M10 12H6|M10 15V9|M14 14.5a.5.5 0 0 0 .5.5h1a2.5 2.5 0 0 0 2.5-2.5v-1A2.5 2.5 0 0 0 15.5 9h-1a.5.5 0 0 0-.5.5z|M6 15V9|rect;x=2;y=5;width=20;height=14;rx=2", + "hdmi-port": "M22 9a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h1l2 2h12l2-2h1a1 1 0 0 0 1-1Z|M7.5 12h9", + "heading": "M6 12h12|M6 20V4|M18 20V4", + "heading-1": "M4 12h8|M4 18V6|M12 18V6|m17 12 3-2v8", + "heading-2": "M4 12h8|M4 18V6|M12 18V6|M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1", + "heading-3": "M4 12h8|M4 18V6|M12 18V6|M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2|M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2", + "heading-4": "M12 18V6|M17 10v3a1 1 0 0 0 1 1h3|M21 10v8|M4 12h8|M4 18V6", + "heading-5": "M4 12h8|M4 18V6|M12 18V6|M17 13v-3h4|M17 17.7c.4.2.8.3 1.3.3 1.5 0 2.7-1.1 2.7-2.5S19.8 13 18.3 13H17", + "heading-6": "M4 12h8|M4 18V6|M12 18V6|circle;cx=19;cy=16;r=2|M20 10c-2 2-3 3.5-3 6", + "headphone-off": "M21 14h-1.343|M9.128 3.47A9 9 0 0 1 21 12v3.343|m2 2 20 20|M20.414 20.414A2 2 0 0 1 19 21h-1a2 2 0 0 1-2-2v-3|M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 2.636-6.364", + "headphones": "M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3", + "headset": "M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5Zm0 0a9 9 0 1 1 18 0m0 0v5a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3Z|M21 16v2a4 4 0 0 1-4 4h-5", + "heart": "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5", + "heart-crack": "M12.409 5.824c-.702.792-1.15 1.496-1.415 2.166l2.153 2.156a.5.5 0 0 1 0 .707l-2.293 2.293a.5.5 0 0 0 0 .707L12 15|M13.508 20.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.677.6.6 0 0 0 .818.001A5.5 5.5 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5z", + "heart-handshake": "M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762", + "heart-minus": "m14.876 18.99-1.368 1.323a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5a5.2 5.2 0 0 1-.244 1.572|M15 15h6", + "heart-off": "M10.5 4.893a5.5 5.5 0 0 1 1.091.931.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 1.872-1.002 3.356-2.187 4.655|m16.967 16.967-3.459 3.346a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 2.747-4.761|m2 2 20 20", + "heart-plus": "m14.479 19.374-.971.939a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5a5.2 5.2 0 0 1-.219 1.49|M15 15h6|M18 12v6", + "heart-pulse": "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5|M3.22 13H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27", + "heater": "M11 8c2-3-2-3 0-6|M15.5 8c2-3-2-3 0-6|M6 10h.01|M6 14h.01|M10 16v-4|M14 16v-4|M18 16v-4|M20 6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3|M5 20v2|M19 20v2", + "helicopter": "M11 17v4|M14 3v8a2 2 0 0 0 2 2h5.865|M17 17v4|M18 17a4 4 0 0 0 4-4 8 6 0 0 0-8-6 6 5 0 0 0-6 5v3a2 2 0 0 0 2 2z|M2 10v5|M6 3h16|M7 21h14|M8 13H2", + "hexagon": "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z", + "highlighter": "m9 11-6 6v3h9l3-3|m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4", + "history": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8|M3 3v5h5|M12 7v5l4 2", + "hop": "M10.82 16.12c1.69.6 3.91.79 5.18.85.55.03 1-.42.97-.97-.06-1.27-.26-3.5-.85-5.18|M11.5 6.5c1.64 0 5-.38 6.71-1.07.52-.2.55-.82.12-1.17A10 10 0 0 0 4.26 18.33c.35.43.96.4 1.17-.12.69-1.71 1.07-5.07 1.07-6.71 1.34.45 3.1.9 4.88.62a.88.88 0 0 0 .73-.74c.3-2.14-.15-3.5-.61-4.88|M15.62 16.95c.2.85.62 2.76.5 4.28a.77.77 0 0 1-.9.7 16.64 16.64 0 0 1-4.08-1.36|M16.13 21.05c1.65.63 3.68.84 4.87.91a.9.9 0 0 0 .96-.96 17.68 17.68 0 0 0-.9-4.87|M16.94 15.62c.86.2 2.77.62 4.29.5a.77.77 0 0 0 .7-.9 16.64 16.64 0 0 0-1.36-4.08|M17.99 5.52a20.82 20.82 0 0 1 3.15 4.5.8.8 0 0 1-.68 1.13c-2.33.2-5.3-.32-8.27-1.57|M4.93 4.93 3 3a.7.7 0 0 1 0-1|M9.58 12.18c1.24 2.98 1.77 5.95 1.57 8.28a.8.8 0 0 1-1.13.68 20.82 20.82 0 0 1-4.5-3.15", + "hop-off": "M10.82 16.12c1.69.6 3.91.79 5.18.85.28.01.53-.09.7-.27|M11.14 20.57c.52.24 2.44 1.12 4.08 1.37.46.06.86-.25.9-.71.12-1.52-.3-3.43-.5-4.28|M16.13 21.05c1.65.63 3.68.84 4.87.91a.9.9 0 0 0 .7-.26|M17.99 5.52a20.83 20.83 0 0 1 3.15 4.5.8.8 0 0 1-.68 1.13c-1.17.1-2.5.02-3.9-.25|M20.57 11.14c.24.52 1.12 2.44 1.37 4.08.04.3-.08.59-.31.75|M4.93 4.93a10 10 0 0 0-.67 13.4c.35.43.96.4 1.17-.12.69-1.71 1.07-5.07 1.07-6.71 1.34.45 3.1.9 4.88.62a.85.85 0 0 0 .48-.24|M5.52 17.99c1.05.95 2.91 2.42 4.5 3.15a.8.8 0 0 0 1.13-.68c.2-2.34-.33-5.3-1.57-8.28|M8.35 2.68a10 10 0 0 1 9.98 1.58c.43.35.4.96-.12 1.17-1.5.6-4.3.98-6.07 1.05|m2 2 20 20", + "hospital": "M12 7v4|M14 21v-3a2 2 0 0 0-4 0v3|M14 9h-4|M18 11h2a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h2|M18 21V5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16", + "hotel": "M10 22v-6.57|M12 11h.01|M12 7h.01|M14 15.43V22|M15 16a5 5 0 0 0-6 0|M16 11h.01|M16 7h.01|M8 11h.01|M8 7h.01|rect;x=4;y=2;width=16;height=20;rx=2", + "hourglass": "M5 22h14|M5 2h14|M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22|M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2", + "house": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8|M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", + "house-heart": "M8.62 13.8A2.25 2.25 0 1 1 12 10.836a2.25 2.25 0 1 1 3.38 2.966l-2.626 2.856a.998.998 0 0 1-1.507 0z|M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", + "house-plug": "M10 12V8.964|M14 12V8.964|M15 12a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2a1 1 0 0 1 1-1z|M8.5 21H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-2", + "house-plus": "M12.35 21H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 .71-1.53l7-6a2 2 0 0 1 2.58 0l7 6A2 2 0 0 1 21 10v2.35|M14.8 12.4A1 1 0 0 0 14 12h-4a1 1 0 0 0-1 1v8|M15 18h6|M18 15v6", + "house-wifi": "M9.5 13.866a4 4 0 0 1 5 .01|M12 17h.01|M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|M7 10.754a8 8 0 0 1 10 0", + "ice-cream-bowl": "M12 17c5 0 8-2.69 8-6H4c0 3.31 3 6 8 6m-4 4h8m-4-3v3M5.14 11a3.5 3.5 0 1 1 6.71 0|M12.14 11a3.5 3.5 0 1 1 6.71 0|M15.5 6.5a3.5 3.5 0 1 0-7 0", + "ice-cream-cone": "m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11|M17 7A5 5 0 0 0 7 7|M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4", + "id-card": "M16 10h2|M16 14h2|M6.17 15a3 3 0 0 1 5.66 0|circle;cx=9;cy=11;r=2|rect;x=2;y=5;width=20;height=14;rx=2", + "id-card-lanyard": "M13.5 8h-3|m15 2-1 2h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h3|M16.899 22A5 5 0 0 0 7.1 22|m9 2 3 6|circle;cx=12;cy=15;r=3", + "image": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|circle;cx=9;cy=9;r=2|m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21", + "image-down": "M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21|m14 19 3 3v-5.5|m17 22 3-3|circle;cx=9;cy=9;r=2", + "image-minus": "M21 9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7|line;x1=16;x2=22;y1=5;y2=5|circle;cx=9;cy=9;r=2|m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21", + "image-off": "line;x1=2;x2=22;y1=2;y2=22|M10.41 10.41a2 2 0 1 1-2.83-2.83|line;x1=13.5;x2=6;y1=13.5;y2=21|line;x1=18;x2=21;y1=12;y2=15|M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59|M21 15V5a2 2 0 0 0-2-2H9", + "image-play": "M15 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z|M21 12.17V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6|m6 21 5-5|circle;cx=9;cy=9;r=2", + "image-plus": "M16 5h6|M19 2v6|M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5|m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21|circle;cx=9;cy=9;r=2", + "image-up": "M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21|m14 19.5 3-3 3 3|M17 22v-5.5|circle;cx=9;cy=9;r=2", + "image-upscale": "M16 3h5v5|M17 21h2a2 2 0 0 0 2-2|M21 12v3|m21 3-5 5|M3 7V5a2 2 0 0 1 2-2|m5 21 4.144-4.144a1.21 1.21 0 0 1 1.712 0L13 19|M9 3h3|rect;x=3;y=11;width=10;height=10;rx=1", + "images": "m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16|M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2|circle;cx=13;cy=7;r=1;fill=currentColor|rect;x=8;y=2;width=14;height=14;rx=2", + "import": "M12 3v12|m8 11 4 4 4-4|M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4", + "inbox": "polyline;points=22 12 16 12 14 15 10 15 8 12 2 12|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z", + "indian-rupee": "M6 3h12|M6 8h12|m6 13 8.5 8|M6 13h3|M9 13c6.667 0 6.667-10 0-10", + "infinity": "M6 16c5 0 7-8 12-8a4 4 0 0 1 0 8c-5 0-7-8-12-8a4 4 0 1 0 0 8", + "info": "circle;cx=12;cy=12;r=10|M12 16v-4|M12 8h.01", + "inspection-panel": "rect;width=18;height=18;x=3;y=3;rx=2|M7 7h.01|M17 7h.01|M7 17h.01|M17 17h.01", + "instagram": "rect;width=20;height=20;x=2;y=2;rx=5;ry=5|M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z|line;x1=17.5;x2=17.51;y1=6.5;y2=6.5", + "italic": "line;x1=19;x2=10;y1=4;y2=4|line;x1=14;x2=5;y1=20;y2=20|line;x1=15;x2=9;y1=4;y2=20", + "iteration-ccw": "m16 14 4 4-4 4|M20 10a8 8 0 1 0-8 8h8", + "iteration-cw": "M4 10a8 8 0 1 1 8 8H4|m8 22-4-4 4-4", + "japanese-yen": "M12 9.5V21m0-11.5L6 3m6 6.5L18 3|M6 15h12|M6 11h12", + "joystick": "M21 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2Z|M6 15v-2|M12 15V9|circle;cx=12;cy=6;r=3", + "kanban": "M5 3v14|M12 3v8|M19 3v18", + "kayak": "M18 17a1 1 0 0 0-1 1v1a2 2 0 1 0 2-2z|M20.97 3.61a.45.45 0 0 0-.58-.58C10.2 6.6 6.6 10.2 3.03 20.39a.45.45 0 0 0 .58.58C13.8 17.4 17.4 13.8 20.97 3.61|m6.707 6.707 10.586 10.586|M7 5a2 2 0 1 0-2 2h1a1 1 0 0 0 1-1z", + "key": "m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4|m21 2-9.6 9.6|circle;cx=7.5;cy=15.5;r=5.5", + "key-round": "M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z|circle;cx=16.5;cy=7.5;r=.5;fill=currentColor", + "key-square": "M12.4 2.7a2.5 2.5 0 0 1 3.4 0l5.5 5.5a2.5 2.5 0 0 1 0 3.4l-3.7 3.7a2.5 2.5 0 0 1-3.4 0L8.7 9.8a2.5 2.5 0 0 1 0-3.4z|m14 7 3 3|m9.4 10.6-6.814 6.814A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814", + "keyboard": "M10 8h.01|M12 12h.01|M14 8h.01|M16 12h.01|M18 8h.01|M6 8h.01|M7 16h10|M8 12h.01|rect;width=20;height=16;x=2;y=4;rx=2", + "keyboard-music": "rect;width=20;height=16;x=2;y=4;rx=2|M6 8h4|M14 8h.01|M18 8h.01|M2 12h20|M6 12v4|M10 12v4|M14 12v4|M18 12v4", + "keyboard-off": "M 20 4 A2 2 0 0 1 22 6|M 22 6 L 22 16.41|M 7 16 L 16 16|M 9.69 4 L 20 4|M14 8h.01|M18 8h.01|m2 2 20 20|M20 20H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2|M6 8h.01|M8 12h.01", + "lamp": "M12 12v6|M4.077 10.615A1 1 0 0 0 5 12h14a1 1 0 0 0 .923-1.385l-3.077-7.384A2 2 0 0 0 15 2H9a2 2 0 0 0-1.846 1.23Z|M8 20a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1z", + "lamp-ceiling": "M12 2v5|M14.829 15.998a3 3 0 1 1-5.658 0|M20.92 14.606A1 1 0 0 1 20 16H4a1 1 0 0 1-.92-1.394l3-7A1 1 0 0 1 7 7h10a1 1 0 0 1 .92.606z", + "lamp-desk": "M10.293 2.293a1 1 0 0 1 1.414 0l2.5 2.5 5.994 1.227a1 1 0 0 1 .506 1.687l-7 7a1 1 0 0 1-1.687-.506l-1.227-5.994-2.5-2.5a1 1 0 0 1 0-1.414z|m14.207 4.793-3.414 3.414|M3 20a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z|m9.086 6.5-4.793 4.793a1 1 0 0 0-.18 1.17L7 18", + "lamp-floor": "M12 10v12|M17.929 7.629A1 1 0 0 1 17 9H7a1 1 0 0 1-.928-1.371l2-5A1 1 0 0 1 9 2h6a1 1 0 0 1 .928.629z|M9 22h6", + "lamp-wall-down": "M19.929 18.629A1 1 0 0 1 19 20H9a1 1 0 0 1-.928-1.371l2-5A1 1 0 0 1 11 13h6a1 1 0 0 1 .928.629z|M6 3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z|M8 6h4a2 2 0 0 1 2 2v5", + "lamp-wall-up": "M19.929 9.629A1 1 0 0 1 19 11H9a1 1 0 0 1-.928-1.371l2-5A1 1 0 0 1 11 4h6a1 1 0 0 1 .928.629z|M6 15a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H5a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z|M8 18h4a2 2 0 0 0 2-2v-5", + "land-plot": "m12 8 6-3-6-3v10|m8 11.99-5.5 3.14a1 1 0 0 0 0 1.74l8.5 4.86a2 2 0 0 0 2 0l8.5-4.86a1 1 0 0 0 0-1.74L16 12|m6.49 12.85 11.02 6.3|M17.51 12.85 6.5 19.15", + "landmark": "M10 18v-7|M11.12 2.198a2 2 0 0 1 1.76.006l7.866 3.847c.476.233.31.949-.22.949H3.474c-.53 0-.695-.716-.22-.949z|M14 18v-7|M18 18v-7|M3 22h18|M6 18v-7", + "languages": "m5 8 6 6|m4 14 6-6 2-3|M2 5h12|M7 2h1|m22 22-5-10-5 10|M14 18h6", + "laptop": "M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z|M20.054 15.987H3.946", + "laptop-minimal": "rect;width=18;height=12;x=3;y=4;rx=2;ry=2|line;x1=2;x2=22;y1=20;y2=20", + "laptop-minimal-check": "M2 20h20|m9 10 2 2 4-4|rect;x=3;y=4;width=18;height=12;rx=2", + "lasso": "M3.704 14.467a10 8 0 1 1 3.115 2.375|M7 22a5 5 0 0 1-2-3.994|circle;cx=5;cy=16;r=2", + "lasso-select": "M7 22a5 5 0 0 1-2-4|M7 16.93c.96.43 1.96.74 2.99.91|M3.34 14A6.8 6.8 0 0 1 2 10c0-4.42 4.48-8 10-8s10 3.58 10 8a7.19 7.19 0 0 1-.33 2|M5 18a2 2 0 1 0 0-4 2 2 0 0 0 0 4z|M14.33 22h-.09a.35.35 0 0 1-.24-.32v-10a.34.34 0 0 1 .33-.34c.08 0 .15.03.21.08l7.34 6a.33.33 0 0 1-.21.59h-4.49l-2.57 3.85a.35.35 0 0 1-.28.14z", + "laugh": "circle;cx=12;cy=12;r=10|M18 13a6 6 0 0 1-6 5 6 6 0 0 1-6-5h12Z|line;x1=9;x2=9.01;y1=9;y2=9|line;x1=15;x2=15.01;y1=9;y2=9", + "layers": "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z|M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12|M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17", + "layers-2": "M13 13.74a2 2 0 0 1-2 0L2.5 8.87a1 1 0 0 1 0-1.74L11 2.26a2 2 0 0 1 2 0l8.5 4.87a1 1 0 0 1 0 1.74z|m20 14.285 1.5.845a1 1 0 0 1 0 1.74L13 21.74a2 2 0 0 1-2 0l-8.5-4.87a1 1 0 0 1 0-1.74l1.5-.845", + "layers-plus": "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 .83.18 2 2 0 0 0 .83-.18l8.58-3.9a1 1 0 0 0 0-1.831z|M16 17h6|M19 14v6|M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 .825.178|M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l2.116-.962", + "layout-dashboard": "rect;width=7;height=9;x=3;y=3;rx=1|rect;width=7;height=5;x=14;y=3;rx=1|rect;width=7;height=9;x=14;y=12;rx=1|rect;width=7;height=5;x=3;y=16;rx=1", + "layout-grid": "rect;width=7;height=7;x=3;y=3;rx=1|rect;width=7;height=7;x=14;y=3;rx=1|rect;width=7;height=7;x=14;y=14;rx=1|rect;width=7;height=7;x=3;y=14;rx=1", + "layout-list": "rect;width=7;height=7;x=3;y=3;rx=1|rect;width=7;height=7;x=3;y=14;rx=1|M14 4h7|M14 9h7|M14 15h7|M14 20h7", + "layout-panel-left": "rect;width=7;height=18;x=3;y=3;rx=1|rect;width=7;height=7;x=14;y=3;rx=1|rect;width=7;height=7;x=14;y=14;rx=1", + "layout-panel-top": "rect;width=18;height=7;x=3;y=3;rx=1|rect;width=7;height=7;x=3;y=14;rx=1|rect;width=7;height=7;x=14;y=14;rx=1", + "layout-template": "rect;width=18;height=7;x=3;y=3;rx=1|rect;width=9;height=7;x=3;y=14;rx=1|rect;width=5;height=7;x=16;y=14;rx=1", + "leaf": "M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z|M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12", + "leafy-green": "M2 22c1.25-.987 2.27-1.975 3.9-2.2a5.56 5.56 0 0 1 3.8 1.5 4 4 0 0 0 6.187-2.353 3.5 3.5 0 0 0 3.69-5.116A3.5 3.5 0 0 0 20.95 8 3.5 3.5 0 1 0 16 3.05a3.5 3.5 0 0 0-5.831 1.373 3.5 3.5 0 0 0-5.116 3.69 4 4 0 0 0-2.348 6.155C3.499 15.42 4.409 16.712 4.2 18.1 3.926 19.743 3.014 20.732 2 22|M2 22 17 7", + "lectern": "M16 12h3a2 2 0 0 0 1.902-1.38l1.056-3.333A1 1 0 0 0 21 6H3a1 1 0 0 0-.958 1.287l1.056 3.334A2 2 0 0 0 5 12h3|M18 6V3a1 1 0 0 0-1-1h-3|rect;width=8;height=12;x=8;y=10;rx=1", + "library": "m16 6 4 14|M12 6v14|M8 8v12|M4 4v16", + "library-big": "rect;width=8;height=18;x=3;y=3;rx=1|M7 3v18|M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z", + "life-buoy": "circle;cx=12;cy=12;r=10|m4.93 4.93 4.24 4.24|m14.83 9.17 4.24-4.24|m14.83 14.83 4.24 4.24|m9.17 14.83-4.24 4.24|circle;cx=12;cy=12;r=4", + "ligature": "M14 12h2v8|M14 20h4|M6 12h4|M6 20h4|M8 20V8a4 4 0 0 1 7.464-2", + "lightbulb": "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5|M9 18h6|M10 22h4", + "lightbulb-off": "M16.8 11.2c.8-.9 1.2-2 1.2-3.2a6 6 0 0 0-9.3-5|m2 2 20 20|M6.3 6.3a4.67 4.67 0 0 0 1.2 5.2c.7.7 1.3 1.5 1.5 2.5|M9 18h6|M10 22h4", + "line-squiggle": "M7 3.5c5-2 7 2.5 3 4C1.5 10 2 15 5 16c5 2 9-10 14-7s.5 13.5-4 12c-5-2.5.5-11 6-2", + "link": "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71|M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", + "link-2": "M9 17H7A5 5 0 0 1 7 7h2|M15 7h2a5 5 0 1 1 0 10h-2|line;x1=8;x2=16;y1=12;y2=12", + "link-2-off": "M9 17H7A5 5 0 0 1 7 7|M15 7h2a5 5 0 0 1 4 8|line;x1=8;x2=12;y1=12;y2=12|line;x1=2;x2=22;y1=2;y2=22", + "linkedin": "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z|rect;width=4;height=12;x=2;y=9|circle;cx=4;cy=4;r=2", + "list": "M3 5h.01|M3 12h.01|M3 19h.01|M8 5h13|M8 12h13|M8 19h13", + "list-check": "M16 5H3|M16 12H3|M11 19H3|m15 18 2 2 4-4", + "list-checks": "M13 5h8|M13 12h8|M13 19h8|m3 17 2 2 4-4|m3 7 2 2 4-4", + "list-chevrons-down-up": "M3 5h8|M3 12h8|M3 19h8|m15 5 3 3 3-3|m15 19 3-3 3 3", + "list-chevrons-up-down": "M3 5h8|M3 12h8|M3 19h8|m15 8 3-3 3 3|m15 16 3 3 3-3", + "list-collapse": "M10 5h11|M10 12h11|M10 19h11|m3 10 3-3-3-3|m3 20 3-3-3-3", + "list-end": "M16 5H3|M16 12H3|M9 19H3|m16 16-3 3 3 3|M21 5v12a2 2 0 0 1-2 2h-6", + "list-filter": "M2 5h20|M6 12h12|M9 19h6", + "list-filter-plus": "M12 5H2|M6 12h12|M9 19h6|M16 5h6|M19 8V2", + "list-indent-decrease": "M21 5H11|M21 12H11|M21 19H11|m7 8-4 4 4 4", + "list-indent-increase": "M21 5H11|M21 12H11|M21 19H11|m3 8 4 4-4 4", + "list-minus": "M16 5H3|M11 12H3|M16 19H3|M21 12h-6", + "list-music": "M16 5H3|M11 12H3|M11 19H3|M21 16V5|circle;cx=18;cy=16;r=3", + "list-ordered": "M11 5h10|M11 12h10|M11 19h10|M4 4h1v5|M4 9h2|M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02", + "list-plus": "M16 5H3|M11 12H3|M16 19H3|M18 9v6|M21 12h-6", + "list-restart": "M21 5H3|M7 12H3|M7 19H3|M12 18a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L11 14|M11 10v4h4", + "list-start": "M3 5h6|M3 12h13|M3 19h13|m16 8-3-3 3-3|M21 19V7a2 2 0 0 0-2-2h-6", + "list-todo": "M13 5h8|M13 12h8|M13 19h8|m3 17 2 2 4-4|rect;x=3;y=4;width=6;height=6;rx=1", + "list-tree": "M8 5h13|M13 12h8|M13 19h8|M3 10a2 2 0 0 0 2 2h3|M3 5v12a2 2 0 0 0 2 2h3", + "list-video": "M21 5H3|M10 12H3|M10 19H3|M15 12.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z", + "list-x": "M16 5H3|M11 12H3|M16 19H3|m15.5 9.5 5 5|m20.5 9.5-5 5", + "loader": "M12 2v4|m16.2 7.8 2.9-2.9|M18 12h4|m16.2 16.2 2.9 2.9|M12 18v4|m4.9 19.1 2.9-2.9|M2 12h4|m4.9 4.9 2.9 2.9", + "loader-circle": "M21 12a9 9 0 1 1-6.219-8.56", + "loader-pinwheel": "M22 12a1 1 0 0 1-10 0 1 1 0 0 0-10 0|M7 20.7a1 1 0 1 1 5-8.7 1 1 0 1 0 5-8.6|M7 3.3a1 1 0 1 1 5 8.6 1 1 0 1 0 5 8.6|circle;cx=12;cy=12;r=10", + "locate": "line;x1=2;x2=5;y1=12;y2=12|line;x1=19;x2=22;y1=12;y2=12|line;x1=12;x2=12;y1=2;y2=5|line;x1=12;x2=12;y1=19;y2=22|circle;cx=12;cy=12;r=7", + "locate-fixed": "line;x1=2;x2=5;y1=12;y2=12|line;x1=19;x2=22;y1=12;y2=12|line;x1=12;x2=12;y1=2;y2=5|line;x1=12;x2=12;y1=19;y2=22|circle;cx=12;cy=12;r=7|circle;cx=12;cy=12;r=3", + "locate-off": "M12 19v3|M12 2v3|M18.89 13.24a7 7 0 0 0-8.13-8.13|M19 12h3|M2 12h3|m2 2 20 20|M7.05 7.05a7 7 0 0 0 9.9 9.9", + "lock": "rect;width=18;height=11;x=3;y=11;rx=2;ry=2|M7 11V7a5 5 0 0 1 10 0v4", + "lock-keyhole": "circle;cx=12;cy=16;r=1|rect;x=3;y=10;width=18;height=12;rx=2|M7 10V7a5 5 0 0 1 10 0v3", + "lock-keyhole-open": "circle;cx=12;cy=16;r=1|rect;width=18;height=12;x=3;y=10;rx=2|M7 10V7a5 5 0 0 1 9.33-2.5", + "lock-open": "rect;width=18;height=11;x=3;y=11;rx=2;ry=2|M7 11V7a5 5 0 0 1 9.9-1", + "log-in": "m10 17 5-5-5-5|M15 12H3|M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4", + "log-out": "m16 17 5-5-5-5|M21 12H9|M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", + "logs": "M3 5h1|M3 12h1|M3 19h1|M8 5h1|M8 12h1|M8 19h1|M13 5h8|M13 12h8|M13 19h8", + "lollipop": "circle;cx=11;cy=11;r=8|m21 21-4.3-4.3|M11 11a2 2 0 0 0 4 0 4 4 0 0 0-8 0 6 6 0 0 0 12 0", + "luggage": "M6 20a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2|M8 18V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v14|M10 20h4|circle;cx=16;cy=20;r=2|circle;cx=8;cy=20;r=2", + "magnet": "m12 15 4 4|M2.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.029-6.029a1 1 0 1 1 3 3l-6.029 6.029a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.365-6.367A1 1 0 0 0 8.716 4.282z|m5 8 4 4", + "mail": "m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7|rect;x=2;y=4;width=20;height=16;rx=2", + "mail-check": "M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h8|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|m16 19 2 2 4-4", + "mail-minus": "M22 15V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h8|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|M16 19h6", + "mail-open": "M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z|m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10", + "mail-plus": "M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h8|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|M19 16v6|M16 19h6", + "mail-question-mark": "M22 10.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h12.5|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|M18 15.28c.2-.4.5-.8.9-1a2.1 2.1 0 0 1 2.6.4c.3.4.5.8.5 1.3 0 1.3-2 2-2 2|M20 22v.01", + "mail-search": "M22 12.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h7.5|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z|circle;cx=18;cy=18;r=3|m22 22-1.5-1.5", + "mail-warning": "M22 10.5V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h12.5|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|M20 14v4|M20 22v.01", + "mail-x": "M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h9|m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7|m17 17 4 4|m21 17-4 4", + "mailbox": "M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z|polyline;points=15,9 18,9 18,11|M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2|line;x1=6;x2=7;y1=10;y2=10", + "mails": "M17 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 1-1.732|m22 5.5-6.419 4.179a2 2 0 0 1-2.162 0L7 5.5|rect;x=7;y=3;width=15;height=12;rx=2", + "map": "M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z|M15 5.764v15|M9 3.236v15", + "map-minus": "m11 19-1.106-.552a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0l4.212 2.106a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619V14|M15 5.764V14|M21 18h-6|M9 3.236v15", + "map-pin": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0|circle;cx=12;cy=10;r=3", + "map-pin-check": "M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728|circle;cx=12;cy=10;r=3|m16 18 2 2 4-4", + "map-pin-check-inside": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0|m9 10 2 2 4-4", + "map-pin-house": "M15 22a1 1 0 0 1-1-1v-4a1 1 0 0 1 .445-.832l3-2a1 1 0 0 1 1.11 0l3 2A1 1 0 0 1 22 17v4a1 1 0 0 1-1 1z|M18 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 .601.2|M18 22v-3|circle;cx=10;cy=10;r=3", + "map-pin-minus": "M18.977 14C19.6 12.701 20 11.343 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738|circle;cx=12;cy=10;r=3|M16 18h6", + "map-pin-minus-inside": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0|M9 10h6", + "map-pin-off": "M12.75 7.09a3 3 0 0 1 2.16 2.16|M17.072 17.072c-1.634 2.17-3.527 3.912-4.471 4.727a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 1.432-4.568|m2 2 20 20|M8.475 2.818A8 8 0 0 1 20 10c0 1.183-.31 2.377-.81 3.533|M9.13 9.13a3 3 0 0 0 3.74 3.74", + "map-pin-pen": "M17.97 9.304A8 8 0 0 0 2 10c0 4.69 4.887 9.562 7.022 11.468|M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z|circle;cx=10;cy=10;r=3", + "map-pin-plus": "M19.914 11.105A7.298 7.298 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738|circle;cx=12;cy=10;r=3|M16 18h6|M19 15v6", + "map-pin-plus-inside": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0|M12 7v6|M9 10h6", + "map-pin-x": "M19.752 11.901A7.78 7.78 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 19 19 0 0 0 .09-.077|circle;cx=12;cy=10;r=3|m21.5 15.5-5 5|m21.5 20.5-5-5", + "map-pin-x-inside": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0|m14.5 7.5-5 5|m9.5 7.5 5 5", + "map-pinned": "M18 8c0 3.613-3.869 7.429-5.393 8.795a1 1 0 0 1-1.214 0C9.87 15.429 6 11.613 6 8a6 6 0 0 1 12 0|circle;cx=12;cy=8;r=2|M8.714 14h-3.71a1 1 0 0 0-.948.683l-2.004 6A1 1 0 0 0 3 22h18a1 1 0 0 0 .948-1.316l-2-6a1 1 0 0 0-.949-.684h-3.712", + "map-plus": "m11 19-1.106-.552a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0l4.212 2.106a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619V12|M15 5.764V12|M18 15v6|M21 18h-6|M9 3.236v15", + "mars": "M16 3h5v5|m21 3-6.75 6.75|circle;cx=10;cy=14;r=6", + "mars-stroke": "m14 6 4 4|M17 3h4v4|m21 3-7.75 7.75|circle;cx=9;cy=15;r=6", + "martini": "M8 22h8|M12 11v11|m19 3-7 8-7-8Z", + "maximize": "M8 3H5a2 2 0 0 0-2 2v3|M21 8V5a2 2 0 0 0-2-2h-3|M3 16v3a2 2 0 0 0 2 2h3|M16 21h3a2 2 0 0 0 2-2v-3", + "maximize-2": "M15 3h6v6|m21 3-7 7|m3 21 7-7|M9 21H3v-6", + "medal": "M7.21 15 2.66 7.14a2 2 0 0 1 .13-2.2L4.4 2.8A2 2 0 0 1 6 2h12a2 2 0 0 1 1.6.8l1.6 2.14a2 2 0 0 1 .14 2.2L16.79 15|M11 12 5.12 2.2|m13 12 5.88-9.8|M8 7h8|circle;cx=12;cy=17;r=5|M12 18v-2h-.5", + "megaphone": "M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z|M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14|M8 6v8", + "megaphone-off": "M11.636 6A13 13 0 0 0 19.4 3.2 1 1 0 0 1 21 4v11.344|M14.378 14.357A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h1|m2 2 20 20|M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14|M8 8v6", + "meh": "circle;cx=12;cy=12;r=10|line;x1=8;x2=16;y1=15;y2=15|line;x1=9;x2=9.01;y1=9;y2=9|line;x1=15;x2=15.01;y1=9;y2=9", + "memory-stick": "M12 12v-2|M12 18v-2|M16 12v-2|M16 18v-2|M2 11h1.5|M20 18v-2|M20.5 11H22|M4 18v-2|M8 12v-2|M8 18v-2|rect;x=2;y=6;width=20;height=10;rx=2", + "menu": "M4 5h16|M4 12h16|M4 19h16", + "merge": "m8 6 4-4 4 4|M12 2v10.3a4 4 0 0 1-1.172 2.872L4 22|m20 22-5-5", + "message-circle": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719", + "message-circle-code": "m10 9-3 3 3 3|m14 15 3-3-3-3|M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719", + "message-circle-dashed": "M10.1 2.182a10 10 0 0 1 3.8 0|M13.9 21.818a10 10 0 0 1-3.8 0|M17.609 3.72a10 10 0 0 1 2.69 2.7|M2.182 13.9a10 10 0 0 1 0-3.8|M20.28 17.61a10 10 0 0 1-2.7 2.69|M21.818 10.1a10 10 0 0 1 0 3.8|M3.721 6.391a10 10 0 0 1 2.7-2.69|m6.163 21.117-2.906.85a1 1 0 0 1-1.236-1.169l.965-2.98", + "message-circle-heart": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|M7.828 13.07A3 3 0 0 1 12 8.764a3 3 0 0 1 5.004 2.224 3 3 0 0 1-.832 2.083l-3.447 3.62a1 1 0 0 1-1.45-.001z", + "message-circle-more": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|M8 12h.01|M12 12h.01|M16 12h.01", + "message-circle-off": "m2 2 20 20|M4.93 4.929a10 10 0 0 0-1.938 11.412 2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 0 0 11.302-1.989|M8.35 2.69A10 10 0 0 1 21.3 15.65", + "message-circle-plus": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|M8 12h8|M12 8v8", + "message-circle-question-mark": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3|M12 17h.01", + "message-circle-reply": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|m10 15-3-3 3-3|M7 12h8a2 2 0 0 1 2 2v1", + "message-circle-warning": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|M12 8v4|M12 16h.01", + "message-circle-x": "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719|m15 9-6 6|m9 9 6 6", + "message-square": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z", + "message-square-code": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|m10 8-3 3 3 3|m14 14 3-3-3-3", + "message-square-dashed": "M14 3h2|M16 19h-2|M2 12v-2|M2 16v5.286a.71.71 0 0 0 1.212.502l1.149-1.149|M20 19a2 2 0 0 0 2-2v-1|M22 10v2|M22 6V5a2 2 0 0 0-2-2|M4 3a2 2 0 0 0-2 2v1|M8 19h2|M8 3h2", + "message-square-diff": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M10 15h4|M10 9h4|M12 7v4", + "message-square-dot": "M12.7 3H4a2 2 0 0 0-2 2v16.286a.71.71 0 0 0 1.212.502l2.202-2.202A2 2 0 0 1 6.828 19H20a2 2 0 0 0 2-2v-4.7|circle;cx=19;cy=6;r=3", + "message-square-heart": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M7.5 9.5c0 .687.265 1.383.697 1.844l3.009 3.264a1.14 1.14 0 0 0 .407.314 1 1 0 0 0 .783-.004 1.14 1.14 0 0 0 .398-.31l3.008-3.264A2.77 2.77 0 0 0 16.5 9.5 2.5 2.5 0 0 0 12 8a2.5 2.5 0 0 0-4.5 1.5", + "message-square-lock": "M22 8.5V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v16.286a.71.71 0 0 0 1.212.502l2.202-2.202A2 2 0 0 1 6.828 19H10|M20 15v-2a2 2 0 0 0-4 0v2|rect;x=14;y=15;width=8;height=5;rx=1", + "message-square-more": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M12 11h.01|M16 11h.01|M8 11h.01", + "message-square-off": "M19 19H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.7.7 0 0 1 2 21.286V5a2 2 0 0 1 1.184-1.826|m2 2 20 20|M8.656 3H20a2 2 0 0 1 2 2v11.344", + "message-square-plus": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M12 8v6|M9 11h6", + "message-square-quote": "M14 14a2 2 0 0 0 2-2V8h-2|M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M8 14a2 2 0 0 0 2-2V8H8", + "message-square-reply": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|m10 8-3 3 3 3|M17 14v-1a2 2 0 0 0-2-2H7", + "message-square-share": "M12 3H4a2 2 0 0 0-2 2v16.286a.71.71 0 0 0 1.212.502l2.202-2.202A2 2 0 0 1 6.828 19H20a2 2 0 0 0 2-2v-4|M16 3h6v6|m16 9 6-6", + "message-square-text": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M7 11h10|M7 15h6|M7 7h8", + "message-square-warning": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|M12 15h.01|M12 7v4", + "message-square-x": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z|m14.5 8.5-5 5|m9.5 8.5 5 5", + "messages-square": "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z|M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1", + "mic": "M12 19v3|M19 10v2a7 7 0 0 1-14 0v-2|rect;x=9;y=2;width=6;height=13;rx=3", + "mic-off": "M12 19v3|M15 9.34V5a3 3 0 0 0-5.68-1.33|M16.95 16.95A7 7 0 0 1 5 12v-2|M18.89 13.23A7 7 0 0 0 19 12v-2|m2 2 20 20|M9 9v3a3 3 0 0 0 5.12 2.12", + "mic-vocal": "m11 7.601-5.994 8.19a1 1 0 0 0 .1 1.298l.817.818a1 1 0 0 0 1.314.087L15.09 12|M16.5 21.174C15.5 20.5 14.372 20 13 20c-2.058 0-3.928 2.356-6 2-2.072-.356-2.775-3.369-1.5-4.5|circle;cx=16;cy=7;r=5", + "microchip": "M10 12h4|M10 17h4|M10 7h4|M18 12h2|M18 18h2|M18 6h2|M4 12h2|M4 18h2|M4 6h2|rect;x=6;y=2;width=12;height=20;rx=2", + "microscope": "M6 18h8|M3 22h18|M14 22a7 7 0 1 0 0-14h-1|M9 14h2|M9 12a2 2 0 0 1-2-2V6h6v4a2 2 0 0 1-2 2Z|M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3", + "microwave": "rect;width=20;height=15;x=2;y=4;rx=2|rect;width=8;height=7;x=6;y=8;rx=1|M18 8v7|M6 19v2|M18 19v2", + "milestone": "M12 13v8|M12 3v3|M4 6a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h13a2 2 0 0 0 1.152-.365l3.424-2.317a1 1 0 0 0 0-1.635l-3.424-2.318A2 2 0 0 0 17 6z", + "milk": "M8 2h8|M9 2v2.789a4 4 0 0 1-.672 2.219l-.656.984A4 4 0 0 0 7 10.212V20a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-9.789a4 4 0 0 0-.672-2.219l-.656-.984A4 4 0 0 1 15 4.788V2|M7 15a6.472 6.472 0 0 1 5 0 6.47 6.47 0 0 0 5 0", + "milk-off": "M8 2h8|M9 2v1.343M15 2v2.789a4 4 0 0 0 .672 2.219l.656.984a4 4 0 0 1 .672 2.22v1.131M7.8 7.8l-.128.192A4 4 0 0 0 7 10.212V20a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-3|M7 15a6.47 6.47 0 0 1 5 0 6.472 6.472 0 0 0 3.435.435|line;x1=2;x2=22;y1=2;y2=22", + "minimize": "M8 3v3a2 2 0 0 1-2 2H3|M21 8h-3a2 2 0 0 1-2-2V3|M3 16h3a2 2 0 0 1 2 2v3|M16 21v-3a2 2 0 0 1 2-2h3", + "minimize-2": "m14 10 7-7|M20 10h-6V4|m3 21 7-7|M4 14h6v6", + "minus": "M5 12h14", + "monitor": "rect;width=20;height=14;x=2;y=3;rx=2|line;x1=8;x2=16;y1=21;y2=21|line;x1=12;x2=12;y1=17;y2=21", + "monitor-check": "m9 10 2 2 4-4|rect;width=20;height=14;x=2;y=3;rx=2|M12 17v4|M8 21h8", + "monitor-cloud": "M11 13a3 3 0 1 1 2.83-4H14a2 2 0 0 1 0 4z|M12 17v4|M8 21h8|rect;x=2;y=3;width=20;height=14;rx=2", + "monitor-cog": "M12 17v4|m14.305 7.53.923-.382|m15.228 4.852-.923-.383|m16.852 3.228-.383-.924|m16.852 8.772-.383.923|m19.148 3.228.383-.924|m19.53 9.696-.382-.924|m20.772 4.852.924-.383|m20.772 7.148.924.383|M22 13v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7|M8 21h8|circle;cx=18;cy=6;r=3", + "monitor-dot": "M12 17v4|M22 12.307V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8.693|M8 21h8|circle;cx=19;cy=6;r=3", + "monitor-down": "M12 13V7|m15 10-3 3-3-3|rect;width=20;height=14;x=2;y=3;rx=2|M12 17v4|M8 21h8", + "monitor-off": "M12 17v4|M17 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 1.184-1.826|m2 2 20 20|M8 21h8|M8.656 3H20a2 2 0 0 1 2 2v10a2 2 0 0 1-.293 1.042", + "monitor-pause": "M10 13V7|M14 13V7|rect;width=20;height=14;x=2;y=3;rx=2|M12 17v4|M8 21h8", + "monitor-play": "M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z|M12 17v4|M8 21h8|rect;x=2;y=3;width=20;height=14;rx=2", + "monitor-smartphone": "M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8|M10 19v-3.96 3.15|M7 19h5|rect;width=6;height=10;x=16;y=12;rx=2", + "monitor-speaker": "M5.5 20H8|M17 9h.01|rect;width=10;height=16;x=12;y=4;rx=2|M8 6H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h4|circle;cx=17;cy=15;r=1", + "monitor-stop": "M12 17v4|M8 21h8|rect;x=2;y=3;width=20;height=14;rx=2|rect;x=9;y=7;width=6;height=6;rx=1", + "monitor-up": "m9 10 3-3 3 3|M12 13V7|rect;width=20;height=14;x=2;y=3;rx=2|M12 17v4|M8 21h8", + "monitor-x": "m14.5 12.5-5-5|m9.5 12.5 5-5|rect;width=20;height=14;x=2;y=3;rx=2|M12 17v4|M8 21h8", + "moon": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401", + "moon-star": "M18 5h4|M20 3v4|M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401", + "motorbike": "m18 14-1-3|m3 9 6 2a2 2 0 0 1 2-2h2a2 2 0 0 1 1.99 1.81|M8 17h3a1 1 0 0 0 1-1 6 6 0 0 1 6-6 1 1 0 0 0 1-1v-.75A5 5 0 0 0 17 5|circle;cx=19;cy=17;r=3|circle;cx=5;cy=17;r=3", + "mountain": "m8 3 4 8 5-5 5 15H2L8 3z", + "mountain-snow": "m8 3 4 8 5-5 5 15H2L8 3z|M4.14 15.08c2.62-1.57 5.24-1.43 7.86.42 2.74 1.94 5.49 2 8.23.19", + "mouse": "rect;x=5;y=2;width=14;height=20;rx=7|M12 6v4", + "mouse-off": "M12 6v.343|M18.218 18.218A7 7 0 0 1 5 15V9a7 7 0 0 1 .782-3.218|M19 13.343V9A7 7 0 0 0 8.56 2.902|M22 22 2 2", + "mouse-pointer": "M12.586 12.586 19 19|M3.688 3.037a.497.497 0 0 0-.651.651l6.5 15.999a.501.501 0 0 0 .947-.062l1.569-6.083a2 2 0 0 1 1.448-1.479l6.124-1.579a.5.5 0 0 0 .063-.947z", + "mouse-pointer-2": "M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z", + "mouse-pointer-2-off": "m15.55 8.45 5.138 2.087a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063L8.45 15.551|M22 2 2 22|m6.816 11.528-2.779-6.84a.495.495 0 0 1 .651-.651l6.84 2.779", + "mouse-pointer-ban": "M2.034 2.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.944L8.204 7.545a1 1 0 0 0-.66.66l-1.066 3.443a.5.5 0 0 1-.944.033z|circle;cx=16;cy=16;r=6|m11.8 11.8 8.4 8.4", + "mouse-pointer-click": "M14 4.1 12 6|m5.1 8-2.9-.8|m6 12-1.9 2|M7.2 2.2 8 5.1|M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z", + "move": "M12 2v20|m15 19-3 3-3-3|m19 9 3 3-3 3|M2 12h20|m5 9-3 3 3 3|m9 5 3-3 3 3", + "move-3d": "M5 3v16h16|m5 19 6-6|m2 6 3-3 3 3|m18 16 3 3-3 3", + "move-diagonal": "M11 19H5v-6|M13 5h6v6|M19 5 5 19", + "move-diagonal-2": "M19 13v6h-6|M5 11V5h6|m5 5 14 14", + "move-down": "M8 18L12 22L16 18|M12 2V22", + "move-down-left": "M11 19H5V13|M19 5L5 19", + "move-down-right": "M19 13V19H13|M5 5L19 19", + "move-horizontal": "m18 8 4 4-4 4|M2 12h20|m6 8-4 4 4 4", + "move-left": "M6 8L2 12L6 16|M2 12H22", + "move-right": "M18 8L22 12L18 16|M2 12H22", + "move-up": "M8 6L12 2L16 6|M12 2V22", + "move-up-left": "M5 11V5H11|M5 5L19 19", + "move-up-right": "M13 5H19V11|M19 5L5 19", + "move-vertical": "M12 2v20|m8 18 4 4 4-4|m8 6 4-4 4 4", + "music": "M9 18V5l12-2v13|circle;cx=6;cy=18;r=3|circle;cx=18;cy=16;r=3", + "music-2": "circle;cx=8;cy=18;r=4|M12 18V2l7 4", + "music-3": "circle;cx=12;cy=18;r=4|M16 18V2", + "music-4": "M9 18V5l12-2v13|m9 9 12-2|circle;cx=6;cy=18;r=3|circle;cx=18;cy=16;r=3", + "navigation": "polygon;points=3 11 22 2 13 21 11 13 3 11", + "navigation-2": "polygon;points=12 2 19 21 12 17 5 21 12 2", + "navigation-2-off": "M9.31 9.31 5 21l7-4 7 4-1.17-3.17|M14.53 8.88 12 2l-1.17 3.17|line;x1=2;x2=22;y1=2;y2=22", + "navigation-off": "M8.43 8.43 3 11l8 2 2 8 2.57-5.43|M17.39 11.73 22 2l-9.73 4.61|line;x1=2;x2=22;y1=2;y2=22", + "network": "rect;x=16;y=16;width=6;height=6;rx=1|rect;x=2;y=16;width=6;height=6;rx=1|rect;x=9;y=2;width=6;height=6;rx=1|M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3|M12 12V8", + "newspaper": "M15 18h-5|M18 14h-8|M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2|rect;width=8;height=4;x=10;y=6;rx=1", + "nfc": "M6 8.32a7.43 7.43 0 0 1 0 7.36|M9.46 6.21a11.76 11.76 0 0 1 0 11.58|M12.91 4.1a15.91 15.91 0 0 1 .01 15.8|M16.37 2a20.16 20.16 0 0 1 0 20", + "non-binary": "M12 2v10|m8.5 4 7 4|m8.5 8 7-4|circle;cx=12;cy=17;r=5", + "notebook": "M2 6h4|M2 10h4|M2 14h4|M2 18h4|rect;width=16;height=20;x=4;y=2;rx=2|M16 2v20", + "notebook-pen": "M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4|M2 6h4|M2 10h4|M2 14h4|M2 18h4|M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z", + "notebook-tabs": "M2 6h4|M2 10h4|M2 14h4|M2 18h4|rect;width=16;height=20;x=4;y=2;rx=2|M15 2v20|M15 7h5|M15 12h5|M15 17h5", + "notebook-text": "M2 6h4|M2 10h4|M2 14h4|M2 18h4|rect;width=16;height=20;x=4;y=2;rx=2|M9.5 8h5|M9.5 12H16|M9.5 16H14", + "notepad-text": "M8 2v4|M12 2v4|M16 2v4|rect;width=16;height=18;x=4;y=4;rx=2|M8 10h6|M8 14h8|M8 18h5", + "notepad-text-dashed": "M8 2v4|M12 2v4|M16 2v4|M16 4h2a2 2 0 0 1 2 2v2|M20 12v2|M20 18v2a2 2 0 0 1-2 2h-1|M13 22h-2|M7 22H6a2 2 0 0 1-2-2v-2|M4 14v-2|M4 8V6a2 2 0 0 1 2-2h2|M8 10h6|M8 14h8|M8 18h5", + "nut": "M12 4V2|M5 10v4a7.004 7.004 0 0 0 5.277 6.787c.412.104.802.292 1.102.592L12 22l.621-.621c.3-.3.69-.488 1.102-.592A7.003 7.003 0 0 0 19 14v-4|M12 4C8 4 4.5 6 4 8c-.243.97-.919 1.952-2 3 1.31-.082 1.972-.29 3-1 .54.92.982 1.356 2 2 1.452-.647 1.954-1.098 2.5-2 .595.995 1.151 1.427 2.5 2 1.31-.621 1.862-1.058 2.5-2 .629.977 1.162 1.423 2.5 2 1.209-.548 1.68-.967 2-2 1.032.916 1.683 1.157 3 1-1.297-1.036-1.758-2.03-2-3-.5-2-4-4-8-4Z", + "nut-off": "M12 4V2|M5 10v4a7.004 7.004 0 0 0 5.277 6.787c.412.104.802.292 1.102.592L12 22l.621-.621c.3-.3.69-.488 1.102-.592a7.01 7.01 0 0 0 4.125-2.939|M19 10v3.343|M12 12c-1.349-.573-1.905-1.005-2.5-2-.546.902-1.048 1.353-2.5 2-1.018-.644-1.46-1.08-2-2-1.028.71-1.69.918-3 1 1.081-1.048 1.757-2.03 2-3 .194-.776.84-1.551 1.79-2.21m11.654 5.997c.887-.457 1.28-.891 1.556-1.787 1.032.916 1.683 1.157 3 1-1.297-1.036-1.758-2.03-2-3-.5-2-4-4-8-4-.74 0-1.461.068-2.15.192|line;x1=2;x2=22;y1=2;y2=22", + "octagon": "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z", + "octagon-alert": "M12 16h.01|M12 8v4|M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z", + "octagon-minus": "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z|M8 12h8", + "octagon-pause": "M10 15V9|M14 15V9|M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z", + "octagon-x": "m15 9-6 6|M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z|m9 9 6 6", + "omega": "M3 20h4.5a.5.5 0 0 0 .5-.5v-.282a.52.52 0 0 0-.247-.437 8 8 0 1 1 8.494-.001.52.52 0 0 0-.247.438v.282a.5.5 0 0 0 .5.5H21", + "option": "M3 3h6l6 18h6|M14 3h7", + "orbit": "M20.341 6.484A10 10 0 0 1 10.266 21.85|M3.659 17.516A10 10 0 0 1 13.74 2.152|circle;cx=12;cy=12;r=3|circle;cx=19;cy=5;r=2|circle;cx=5;cy=19;r=2", + "origami": "M12 12V4a1 1 0 0 1 1-1h6.297a1 1 0 0 1 .651 1.759l-4.696 4.025|m12 21-7.414-7.414A2 2 0 0 1 4 12.172V6.415a1.002 1.002 0 0 1 1.707-.707L20 20.009|m12.214 3.381 8.414 14.966a1 1 0 0 1-.167 1.199l-1.168 1.163a1 1 0 0 1-.706.291H6.351a1 1 0 0 1-.625-.219L3.25 18.8a1 1 0 0 1 .631-1.781l4.165.027", + "package": "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z|M12 22V12|polyline;points=3.29 7 12 12 20.71 7|m7.5 4.27 9 5.15", + "package-2": "M12 3v6|M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z|M3.054 9.013h17.893", + "package-check": "m16 16 2 2 4-4|M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14|m7.5 4.27 9 5.15|polyline;points=3.29 7 12 12 20.71 7|line;x1=12;x2=12;y1=22;y2=12", + "package-minus": "M16 16h6|M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14|m7.5 4.27 9 5.15|polyline;points=3.29 7 12 12 20.71 7|line;x1=12;x2=12;y1=22;y2=12", + "package-open": "M12 22v-9|M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z|M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13|M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z", + "package-plus": "M16 16h6|M19 13v6|M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14|m7.5 4.27 9 5.15|polyline;points=3.29 7 12 12 20.71 7|line;x1=12;x2=12;y1=22;y2=12", + "package-search": "M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14|m7.5 4.27 9 5.15|polyline;points=3.29 7 12 12 20.71 7|line;x1=12;x2=12;y1=22;y2=12|circle;cx=18.5;cy=15.5;r=2.5|M20.27 17.27 22 19", + "package-x": "M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14|m7.5 4.27 9 5.15|polyline;points=3.29 7 12 12 20.71 7|line;x1=12;x2=12;y1=22;y2=12|m17 13 5 5m-5 0 5-5", + "paint-bucket": "M11 7 6 2|M18.992 12H2.041|M21.145 18.38A3.34 3.34 0 0 1 20 16.5a3.3 3.3 0 0 1-1.145 1.88c-.575.46-.855 1.02-.855 1.595A2 2 0 0 0 20 22a2 2 0 0 0 2-2.025c0-.58-.285-1.13-.855-1.595|m8.5 4.5 2.148-2.148a1.205 1.205 0 0 1 1.704 0l7.296 7.296a1.205 1.205 0 0 1 0 1.704l-7.592 7.592a3.615 3.615 0 0 1-5.112 0l-3.888-3.888a3.615 3.615 0 0 1 0-5.112L5.67 7.33", + "paint-roller": "rect;width=16;height=6;x=2;y=2;rx=2|M10 16v-2a2 2 0 0 1 2-2h8a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2|rect;width=4;height=6;x=8;y=16;rx=1", + "paintbrush": "m14.622 17.897-10.68-2.913|M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z|M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15", + "paintbrush-vertical": "M10 2v2|M14 2v4|M17 2a1 1 0 0 1 1 1v9H6V3a1 1 0 0 1 1-1z|M6 12a1 1 0 0 0-1 1v1a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v2.9a2 2 0 1 0 4 0V17a1 1 0 0 1 1-1h2a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1", + "palette": "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z|circle;cx=13.5;cy=6.5;r=.5;fill=currentColor|circle;cx=17.5;cy=10.5;r=.5;fill=currentColor|circle;cx=6.5;cy=12.5;r=.5;fill=currentColor|circle;cx=8.5;cy=7.5;r=.5;fill=currentColor", + "panda": "M11.25 17.25h1.5L12 18z|m15 12 2 2|M18 6.5a.5.5 0 0 0-.5-.5|M20.69 9.67a4.5 4.5 0 1 0-7.04-5.5 8.35 8.35 0 0 0-3.3 0 4.5 4.5 0 1 0-7.04 5.5C2.49 11.2 2 12.88 2 14.5 2 19.47 6.48 22 12 22s10-2.53 10-7.5c0-1.62-.48-3.3-1.3-4.83|M6 6.5a.495.495 0 0 1 .5-.5|m9 12-2 2", + "panel-bottom": "rect;width=18;height=18;x=3;y=3;rx=2|M3 15h18", + "panel-bottom-close": "rect;width=18;height=18;x=3;y=3;rx=2|M3 15h18|m15 8-3 3-3-3", + "panel-bottom-dashed": "rect;width=18;height=18;x=3;y=3;rx=2|M14 15h1|M19 15h2|M3 15h2|M9 15h1", + "panel-bottom-open": "rect;width=18;height=18;x=3;y=3;rx=2|M3 15h18|m9 10 3-3 3 3", + "panel-left": "rect;width=18;height=18;x=3;y=3;rx=2|M9 3v18", + "panel-left-close": "rect;width=18;height=18;x=3;y=3;rx=2|M9 3v18|m16 15-3-3 3-3", + "panel-left-dashed": "rect;width=18;height=18;x=3;y=3;rx=2|M9 14v1|M9 19v2|M9 3v2|M9 9v1", + "panel-left-open": "rect;width=18;height=18;x=3;y=3;rx=2|M9 3v18|m14 9 3 3-3 3", + "panel-left-right-dashed": "M15 10V9|M15 15v-1|M15 21v-2|M15 5V3|M9 10V9|M9 15v-1|M9 21v-2|M9 5V3|rect;x=3;y=3;width=18;height=18;rx=2", + "panel-right": "rect;width=18;height=18;x=3;y=3;rx=2|M15 3v18", + "panel-right-close": "rect;width=18;height=18;x=3;y=3;rx=2|M15 3v18|m8 9 3 3-3 3", + "panel-right-dashed": "rect;width=18;height=18;x=3;y=3;rx=2|M15 14v1|M15 19v2|M15 3v2|M15 9v1", + "panel-right-open": "rect;width=18;height=18;x=3;y=3;rx=2|M15 3v18|m10 15-3-3 3-3", + "panel-top": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18", + "panel-top-bottom-dashed": "M14 15h1|M14 9h1|M19 15h2|M19 9h2|M3 15h2|M3 9h2|M9 15h1|M9 9h1|rect;x=3;y=3;width=18;height=18;rx=2", + "panel-top-close": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18|m9 16 3-3 3 3", + "panel-top-dashed": "rect;width=18;height=18;x=3;y=3;rx=2|M14 9h1|M19 9h2|M3 9h2|M9 9h1", + "panel-top-open": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18|m15 14-3 3-3-3", + "panels-left-bottom": "rect;width=18;height=18;x=3;y=3;rx=2|M9 3v18|M9 15h12", + "panels-right-bottom": "rect;width=18;height=18;x=3;y=3;rx=2|M3 15h12|M15 3v18", + "panels-top-left": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18|M9 21V9", + "paperclip": "m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551", + "parentheses": "M8 21s-4-3-4-9 4-9 4-9|M16 3s4 3 4 9-4 9-4 9", + "parking-meter": "M11 15h2|M12 12v3|M12 19v3|M15.282 19a1 1 0 0 0 .948-.68l2.37-6.988a7 7 0 1 0-13.2 0l2.37 6.988a1 1 0 0 0 .948.68z|M9 9a3 3 0 1 1 6 0", + "party-popper": "M5.8 11.3 2 22l10.7-3.79|M4 3h.01|M22 8h.01|M15 2h.01|M22 20h.01|m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10|m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17|m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7|M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z", + "pause": "rect;x=14;y=3;width=5;height=18;rx=1|rect;x=5;y=3;width=5;height=18;rx=1", + "paw-print": "circle;cx=11;cy=4;r=2|circle;cx=18;cy=8;r=2|circle;cx=20;cy=16;r=2|M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z", + "pc-case": "rect;width=14;height=20;x=5;y=2;rx=2|M15 14h.01|M9 6h6|M9 10h6", + "pen": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", + "pen-line": "M13 21h8|M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", + "pen-off": "m10 10-6.157 6.162a2 2 0 0 0-.5.833l-1.322 4.36a.5.5 0 0 0 .622.624l4.358-1.323a2 2 0 0 0 .83-.5L14 13.982|m12.829 7.172 4.359-4.346a1 1 0 1 1 3.986 3.986l-4.353 4.353|m2 2 20 20", + "pen-tool": "M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z|m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18|m2.3 2.3 7.286 7.286|circle;cx=11;cy=11;r=2", + "pencil": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z|m15 5 4 4", + "pencil-line": "M13 21h8|m15 5 4 4|M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", + "pencil-off": "m10 10-6.157 6.162a2 2 0 0 0-.5.833l-1.322 4.36a.5.5 0 0 0 .622.624l4.358-1.323a2 2 0 0 0 .83-.5L14 13.982|m12.829 7.172 4.359-4.346a1 1 0 1 1 3.986 3.986l-4.353 4.353|m15 5 4 4|m2 2 20 20", + "pencil-ruler": "M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13|m8 6 2-2|m18 16 2-2|m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17|M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z|m15 5 4 4", + "pentagon": "M10.83 2.38a2 2 0 0 1 2.34 0l8 5.74a2 2 0 0 1 .73 2.25l-3.04 9.26a2 2 0 0 1-1.9 1.37H7.04a2 2 0 0 1-1.9-1.37L2.1 10.37a2 2 0 0 1 .73-2.25z", + "percent": "line;x1=19;x2=5;y1=5;y2=19|circle;cx=6.5;cy=6.5;r=2.5|circle;cx=17.5;cy=17.5;r=2.5", + "person-standing": "circle;cx=12;cy=5;r=1|m9 20 3-6 3 6|m6 8 6 2 6-2|M12 10v4", + "philippine-peso": "M20 11H4|M20 7H4|M7 21V4a1 1 0 0 1 1-1h4a1 1 0 0 1 0 12H7", + "phone": "M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "phone-call": "M13 2a9 9 0 0 1 9 9|M13 6a5 5 0 0 1 5 5|M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "phone-forwarded": "M14 6h8|m18 2 4 4-4 4|M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "phone-incoming": "M16 2v6h6|m22 2-6 6|M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "phone-missed": "m16 2 6 6|m22 2-6 6|M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "phone-off": "M10.1 13.9a14 14 0 0 0 3.732 2.668 1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2 18 18 0 0 1-12.728-5.272|M22 2 2 22|M4.76 13.582A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 .244.473", + "phone-outgoing": "m16 8 6-6|M22 8V2h-6|M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384", + "pi": "line;x1=9;x2=9;y1=4;y2=20|M4 7c0-1.7 1.3-3 3-3h13|M18 20c-1.7 0-3-1.3-3-3V4", + "piano": "M18.5 8c-1.4 0-2.6-.8-3.2-2A6.87 6.87 0 0 0 2 9v11a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-8.5C22 9.6 20.4 8 18.5 8|M2 14h20|M6 14v4|M10 14v4|M14 14v4|M18 14v4", + "pickaxe": "m14 13-8.381 8.38a1 1 0 0 1-3.001-3L11 9.999|M15.973 4.027A13 13 0 0 0 5.902 2.373c-1.398.342-1.092 2.158.277 2.601a19.9 19.9 0 0 1 5.822 3.024|M16.001 11.999a19.9 19.9 0 0 1 3.024 5.824c.444 1.369 2.26 1.676 2.603.278A13 13 0 0 0 20 8.069|M18.352 3.352a1.205 1.205 0 0 0-1.704 0l-5.296 5.296a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l5.296-5.296a1.205 1.205 0 0 0 0-1.704z", + "picture-in-picture": "M2 10h6V4|m2 4 6 6|M21 10V7a2 2 0 0 0-2-2h-7|M3 14v2a2 2 0 0 0 2 2h3|rect;x=12;y=14;width=10;height=7;rx=1", + "picture-in-picture-2": "M21 9V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h4|rect;width=10;height=7;x=12;y=13;rx=2", + "piggy-bank": "M11 17h3v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3a3.16 3.16 0 0 0 2-2h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-1a5 5 0 0 0-2-4V3a4 4 0 0 0-3.2 1.6l-.3.4H11a6 6 0 0 0-6 6v1a5 5 0 0 0 2 4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1z|M16 10h.01|M2 8v1a2 2 0 0 0 2 2h1", + "pilcrow": "M13 4v16|M17 4v16|M19 4H9.5a4.5 4.5 0 0 0 0 9H13", + "pilcrow-left": "M14 3v11|M14 9h-3a3 3 0 0 1 0-6h9|M18 3v11|M22 18H2l4-4|m6 22-4-4", + "pilcrow-right": "M10 3v11|M10 9H7a1 1 0 0 1 0-6h8|M14 3v11|m18 14 4 4H2|m22 18-4 4", + "pill": "m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z|m8.5 8.5 7 7", + "pill-bottle": "M18 11h-4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h4|M6 7v13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7|rect;width=16;height=5;x=4;y=2;rx=1", + "pin": "M12 17v5|M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z", + "pin-off": "M12 17v5|M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89|m2 2 20 20|M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11", + "pipette": "m12 9-8.414 8.414A2 2 0 0 0 3 18.828v1.344a2 2 0 0 1-.586 1.414A2 2 0 0 1 3.828 21h1.344a2 2 0 0 0 1.414-.586L15 12|m18 9 .4.4a1 1 0 1 1-3 3l-3.8-3.8a1 1 0 1 1 3-3l.4.4 3.4-3.4a1 1 0 1 1 3 3z|m2 22 .414-.414", + "pizza": "m12 14-1 1|m13.75 18.25-1.25 1.42|M17.775 5.654a15.68 15.68 0 0 0-12.121 12.12|M18.8 9.3a1 1 0 0 0 2.1 7.7|M21.964 20.732a1 1 0 0 1-1.232 1.232l-18-5a1 1 0 0 1-.695-1.232A19.68 19.68 0 0 1 15.732 2.037a1 1 0 0 1 1.232.695z", + "plane": "M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z", + "plane-landing": "M2 22h20|M3.77 10.77 2 9l2-4.5 1.1.55c.55.28.9.84.9 1.45s.35 1.17.9 1.45L8 8.5l3-6 1.05.53a2 2 0 0 1 1.09 1.52l.72 5.4a2 2 0 0 0 1.09 1.52l4.4 2.2c.42.22.78.55 1.01.96l.6 1.03c.49.88-.06 1.98-1.06 2.1l-1.18.15c-.47.06-.95-.02-1.37-.24L4.29 11.15a2 2 0 0 1-.52-.38Z", + "plane-takeoff": "M2 22h20|M6.36 17.4 4 17l-2-4 1.1-.55a2 2 0 0 1 1.8 0l.17.1a2 2 0 0 0 1.8 0L8 12 5 6l.9-.45a2 2 0 0 1 2.09.2l4.02 3a2 2 0 0 0 2.1.2l4.19-2.06a2.41 2.41 0 0 1 1.73-.17L21 7a1.4 1.4 0 0 1 .87 1.99l-.38.76c-.23.46-.6.84-1.07 1.08L7.58 17.2a2 2 0 0 1-1.22.18Z", + "play": "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z", + "plug": "M12 22v-5|M15 8V2|M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z|M9 8V2", + "plug-2": "M9 2v6|M15 2v6|M12 17v5|M5 8h14|M6 11V8h12v3a6 6 0 1 1-12 0Z", + "plug-zap": "M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z|m2 22 3-3|M7.5 13.5 10 11|M10.5 16.5 13 14|m18 3-4 4h6l-4 4", + "plus": "M5 12h14|M12 5v14", + "pocket": "M20 3a2 2 0 0 1 2 2v6a1 1 0 0 1-20 0V5a2 2 0 0 1 2-2z|m8 10 4 4 4-4", + "pocket-knife": "M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2|M18 6h.01|M6 18h.01|M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z|M18 11.66V22a4 4 0 0 0 4-4V6", + "podcast": "path;d=M13 17a1 1 0 1 0-2 0l.5 4.5a0.5 0.5 0 0 0 1 0z;fill=currentColor|M16.85 18.58a9 9 0 1 0-9.7 0|M8 14a5 5 0 1 1 8 0|circle;cx=12;cy=11;r=1;fill=currentColor", + "pointer": "M22 14a8 8 0 0 1-8 8|M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2|M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1|M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10|M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15", + "pointer-off": "M10 4.5V4a2 2 0 0 0-2.41-1.957|M13.9 8.4a2 2 0 0 0-1.26-1.295|M21.7 16.2A8 8 0 0 0 22 14v-3a2 2 0 1 0-4 0v-1a2 2 0 0 0-3.63-1.158|m7 15-1.8-1.8a2 2 0 0 0-2.79 2.86L6 19.7a7.74 7.74 0 0 0 6 2.3h2a8 8 0 0 0 5.657-2.343|M6 6v8|m2 2 20 20", + "popcorn": "M18 8a2 2 0 0 0 0-4 2 2 0 0 0-4 0 2 2 0 0 0-4 0 2 2 0 0 0-4 0 2 2 0 0 0 0 4|M10 22 9 8|m14 22 1-14|M20 8c.5 0 .9.4.8 1l-2.6 12c-.1.5-.7 1-1.2 1H7c-.6 0-1.1-.4-1.2-1L3.2 9c-.1-.6.3-1 .8-1Z", + "popsicle": "M18.6 14.4c.8-.8.8-2 0-2.8l-8.1-8.1a4.95 4.95 0 1 0-7.1 7.1l8.1 8.1c.9.7 2.1.7 2.9-.1Z|m22 22-5.5-5.5", + "pound-sterling": "M18 7c0-5.333-8-5.333-8 0|M10 7v14|M6 21h12|M6 13h10", + "power": "M12 2v10|M18.4 6.6a9 9 0 1 1-12.77.04", + "power-off": "M18.36 6.64A9 9 0 0 1 20.77 15|M6.16 6.16a9 9 0 1 0 12.68 12.68|M12 2v4|m2 2 20 20", + "presentation": "M2 3h20|M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3|m7 21 5-5 5 5", + "printer": "M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2|M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6|rect;x=6;y=14;width=12;height=8;rx=1", + "printer-check": "M13.5 22H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v.5|m16 19 2 2 4-4|M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v2|M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6", + "printer-x": "M12.531 22H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6.377|m16.5 16.5 5 5|m16.5 21.5 5-5|M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.5|M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6", + "projector": "M5 7 3 5|M9 6V3|m13 7 2-2|circle;cx=9;cy=13;r=3|M11.83 12H20a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h2.17|M16 16h2", + "proportions": "rect;width=20;height=16;x=2;y=4;rx=2|M12 9v11|M2 9h13a2 2 0 0 1 2 2v9", + "puzzle": "M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z", + "pyramid": "M2.5 16.88a1 1 0 0 1-.32-1.43l9-13.02a1 1 0 0 1 1.64 0l9 13.01a1 1 0 0 1-.32 1.44l-8.51 4.86a2 2 0 0 1-1.98 0Z|M12 2v20", + "qr-code": "rect;width=5;height=5;x=3;y=3;rx=1|rect;width=5;height=5;x=16;y=3;rx=1|rect;width=5;height=5;x=3;y=16;rx=1|M21 16h-3a2 2 0 0 0-2 2v3|M21 21v.01|M12 7v3a2 2 0 0 1-2 2H7|M3 12h.01|M12 3h.01|M12 16v.01|M16 12h1|M21 12v.01|M12 21v-1", + "quote": "M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z|M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z", + "rabbit": "M13 16a3 3 0 0 1 2.24 5|M18 12h.01|M18 21h-8a4 4 0 0 1-4-4 7 7 0 0 1 7-7h.2L9.6 6.4a1 1 0 1 1 2.8-2.8L15.8 7h.2c3.3 0 6 2.7 6 6v1a2 2 0 0 1-2 2h-1a3 3 0 0 0-3 3|M20 8.54V4a2 2 0 1 0-4 0v3|M7.612 12.524a3 3 0 1 0-1.6 4.3", + "radar": "M19.07 4.93A10 10 0 0 0 6.99 3.34|M4 6h.01|M2.29 9.62A10 10 0 1 0 21.31 8.35|M16.24 7.76A6 6 0 1 0 8.23 16.67|M12 18h.01|M17.99 11.66A6 6 0 0 1 15.77 16.67|circle;cx=12;cy=12;r=2|m13.41 10.59 5.66-5.66", + "radiation": "M12 12h.01|M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z|M16 12a4 4 0 0 0-2-3.464l2.472-4.282a1 1 0 0 1 1.46-.305 10 10 0 0 1 4.006 6.94A1 1 0 0 1 21 12z|M8 12a4 4 0 0 1 2-3.464L7.528 4.254a1 1 0 0 0-1.46-.305 10 10 0 0 0-4.006 6.94A1 1 0 0 0 3 12z", + "radical": "M3 12h3.28a1 1 0 0 1 .948.684l2.298 7.934a.5.5 0 0 0 .96-.044L13.82 4.771A1 1 0 0 1 14.792 4H21", + "radio": "M16.247 7.761a6 6 0 0 1 0 8.478|M19.075 4.933a10 10 0 0 1 0 14.134|M4.925 19.067a10 10 0 0 1 0-14.134|M7.753 16.239a6 6 0 0 1 0-8.478|circle;cx=12;cy=12;r=2", + "radio-receiver": "M5 16v2|M19 16v2|rect;width=20;height=8;x=2;y=8;rx=2|M18 12h.01", + "radio-tower": "M4.9 16.1C1 12.2 1 5.8 4.9 1.9|M7.8 4.7a6.14 6.14 0 0 0-.8 7.5|circle;cx=12;cy=9;r=2|M16.2 4.8c2 2 2.26 5.11.8 7.47|M19.1 1.9a9.96 9.96 0 0 1 0 14.1|M9.5 18h5|m8 22 4-11 4 11", + "radius": "M20.34 17.52a10 10 0 1 0-2.82 2.82|circle;cx=19;cy=19;r=2|m13.41 13.41 4.18 4.18|circle;cx=12;cy=12;r=2", + "rail-symbol": "M5 15h14|M5 9h14|m14 20-5-5 6-6-5-5", + "rainbow": "M22 17a10 10 0 0 0-20 0|M6 17a6 6 0 0 1 12 0|M10 17a2 2 0 0 1 4 0", + "rat": "M13 22H4a2 2 0 0 1 0-4h12|M13.236 18a3 3 0 0 0-2.2-5|M16 9h.01|M16.82 3.94a3 3 0 1 1 3.237 4.868l1.815 2.587a1.5 1.5 0 0 1-1.5 2.1l-2.872-.453a3 3 0 0 0-3.5 3|M17 4.988a3 3 0 1 0-5.2 2.052A7 7 0 0 0 4 14.015 4 4 0 0 0 8 18", + "ratio": "rect;width=12;height=20;x=6;y=2;rx=2|rect;width=20;height=12;x=2;y=6;rx=2", + "receipt": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8|M12 17.5v-11", + "receipt-cent": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M12 6.5v11|M15 9.4a4 4 0 1 0 0 5.2", + "receipt-euro": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M8 12h5|M16 9.5a4 4 0 1 0 0 5.2", + "receipt-indian-rupee": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M8 7h8|M12 17.5 8 15h1a4 4 0 0 0 0-8|M8 11h8", + "receipt-japanese-yen": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|m12 10 3-3|m9 7 3 3v7.5|M9 11h6|M9 15h6", + "receipt-pound-sterling": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M8 13h5|M10 17V9.5a2.5 2.5 0 0 1 5 0|M8 17h7", + "receipt-russian-ruble": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M8 15h5|M8 11h5a2 2 0 1 0 0-4h-3v10", + "receipt-swiss-franc": "M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z|M10 17V7h5|M10 11h4|M8 15h5", + "receipt-text": "M13 16H8|M14 8H8|M16 12H8|M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z", + "receipt-turkish-lira": "M10 6.5v11a5.5 5.5 0 0 0 5.5-5.5|m14 8-6 3|M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z", + "rectangle-circle": "M14 4v16H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z|circle;cx=14;cy=12;r=8", + "rectangle-ellipsis": "rect;width=20;height=12;x=2;y=6;rx=2|M12 12h.01|M17 12h.01|M7 12h.01", + "rectangle-goggles": "M20 6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-4a2 2 0 0 1-1.6-.8l-1.6-2.13a1 1 0 0 0-1.6 0L9.6 17.2A2 2 0 0 1 8 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z", + "rectangle-horizontal": "rect;width=20;height=12;x=2;y=6;rx=2", + "rectangle-vertical": "rect;width=12;height=20;x=6;y=2;rx=2", + "recycle": "M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5|M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12|m14 16-3 3 3 3|M8.293 13.596 7.196 9.5 3.1 10.598|m9.344 5.811 1.093-1.892A1.83 1.83 0 0 1 11.985 3a1.784 1.784 0 0 1 1.546.888l3.943 6.843|m13.378 9.633 4.096 1.098 1.097-4.096", + "redo": "M21 7v6h-6|M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7", + "redo-2": "m15 14 5-5-5-5|M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13", + "redo-dot": "circle;cx=12;cy=17;r=1|M21 7v6h-6|M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7", + "refresh-ccw": "M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8|M3 3v5h5|M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16|M16 16h5v5", + "refresh-ccw-dot": "M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8|M3 3v5h5|M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16|M16 16h5v5|circle;cx=12;cy=12;r=1", + "refresh-cw": "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8|M21 3v5h-5|M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16|M8 16H3v5", + "refresh-cw-off": "M21 8L18.74 5.74A9.75 9.75 0 0 0 12 3C11 3 10.03 3.16 9.13 3.47|M8 16H3v5|M3 12C3 9.51 4 7.26 5.64 5.64|m3 16 2.26 2.26A9.75 9.75 0 0 0 12 21c2.49 0 4.74-1 6.36-2.64|M21 12c0 1-.16 1.97-.47 2.87|M21 3v5h-5|M22 22 2 2", + "refrigerator": "M5 6a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6Z|M5 10h14|M15 7v6", + "regex": "M17 3v10|m12.67 5.5 8.66 5|m12.67 10.5 8.66-5|M9 17a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2z", + "remove-formatting": "M4 7V4h16v3|M5 20h6|M13 4 8 20|m15 15 5 5|m20 15-5 5", + "repeat": "m17 2 4 4-4 4|M3 11v-1a4 4 0 0 1 4-4h14|m7 22-4-4 4-4|M21 13v1a4 4 0 0 1-4 4H3", + "repeat-1": "m17 2 4 4-4 4|M3 11v-1a4 4 0 0 1 4-4h14|m7 22-4-4 4-4|M21 13v1a4 4 0 0 1-4 4H3|M11 10h1v4", + "repeat-2": "m2 9 3-3 3 3|M13 18H7a2 2 0 0 1-2-2V6|m22 15-3 3-3-3|M11 6h6a2 2 0 0 1 2 2v10", + "replace": "M14 4a1 1 0 0 1 1-1|M15 10a1 1 0 0 1-1-1|M21 4a1 1 0 0 0-1-1|M21 9a1 1 0 0 1-1 1|m3 7 3 3 3-3|M6 10V5a2 2 0 0 1 2-2h2|rect;x=3;y=14;width=7;height=7;rx=1", + "replace-all": "M14 14a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1|M14 4a1 1 0 0 1 1-1|M15 10a1 1 0 0 1-1-1|M19 14a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1|M21 4a1 1 0 0 0-1-1|M21 9a1 1 0 0 1-1 1|m3 7 3 3 3-3|M6 10V5a2 2 0 0 1 2-2h2|rect;x=3;y=14;width=7;height=7;rx=1", + "reply": "M20 18v-2a4 4 0 0 0-4-4H4|m9 17-5-5 5-5", + "reply-all": "m12 17-5-5 5-5|M22 18v-2a4 4 0 0 0-4-4H7|m7 17-5-5 5-5", + "rewind": "M12 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 12 18z|M22 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 22 18z", + "ribbon": "M12 11.22C11 9.997 10 9 10 8a2 2 0 0 1 4 0c0 1-.998 2.002-2.01 3.22|m12 18 2.57-3.5|M6.243 9.016a7 7 0 0 1 11.507-.009|M9.35 14.53 12 11.22|M9.35 14.53C7.728 12.246 6 10.221 6 7a6 5 0 0 1 12 0c-.005 3.22-1.778 5.235-3.43 7.5l3.557 4.527a1 1 0 0 1-.203 1.43l-1.894 1.36a1 1 0 0 1-1.384-.215L12 18l-2.679 3.593a1 1 0 0 1-1.39.213l-1.865-1.353a1 1 0 0 1-.203-1.422z", + "rocket": "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z|m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z|M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0|M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5", + "rocking-chair": "polyline;points=3.5 2 6.5 12.5 18 12.5|line;x1=9.5;x2=5.5;y1=12.5;y2=20|line;x1=15;x2=18.5;y1=12.5;y2=20|M2.75 18a13 13 0 0 0 18.5 0", + "roller-coaster": "M6 19V5|M10 19V6.8|M14 19v-7.8|M18 5v4|M18 19v-6|M22 19V9|M2 19V9a4 4 0 0 1 4-4c2 0 4 1.33 6 4s4 4 6 4a4 4 0 1 0-3-6.65", + "rose": "M17 10h-1a4 4 0 1 1 4-4v.534|M17 6h1a4 4 0 0 1 1.42 7.74l-2.29.87a6 6 0 0 1-5.339-10.68l2.069-1.31|M4.5 17c2.8-.5 4.4 0 5.5.8s1.8 2.2 2.3 3.7c-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2|M9.77 12C4 15 2 22 2 22|circle;cx=17;cy=8;r=2", + "rotate-3d": "M16.466 7.5C15.643 4.237 13.952 2 12 2 9.239 2 7 6.477 7 12s2.239 10 5 10c.342 0 .677-.069 1-.2|m15.194 13.707 3.814 1.86-1.86 3.814|M19 15.57c-1.804.885-4.274 1.43-7 1.43-5.523 0-10-2.239-10-5s4.477-5 10-5c4.838 0 8.873 1.718 9.8 4", + "rotate-ccw": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8|M3 3v5h5", + "rotate-ccw-key": "M12 7v6|M12 9h2|M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8|M3 3v5h5|circle;cx=12;cy=15;r=2", + "rotate-ccw-square": "M20 9V7a2 2 0 0 0-2-2h-6|m15 2-3 3 3 3|M20 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2", + "rotate-cw": "M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8|M21 3v5h-5", + "rotate-cw-square": "M12 5H6a2 2 0 0 0-2 2v3|m9 8 3-3-3-3|M4 14v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2", + "route": "circle;cx=6;cy=19;r=3|M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15|circle;cx=18;cy=5;r=3", + "route-off": "circle;cx=6;cy=19;r=3|M9 19h8.5c.4 0 .9-.1 1.3-.2|M5.2 5.2A3.5 3.53 0 0 0 6.5 12H12|m2 2 20 20|M21 15.3a3.5 3.5 0 0 0-3.3-3.3|M15 5h-4.3|circle;cx=18;cy=5;r=3", + "router": "rect;width=20;height=8;x=2;y=14;rx=2|M6.01 18H6|M10.01 18H10|M15 10v4|M17.84 7.17a4 4 0 0 0-5.66 0|M20.66 4.34a8 8 0 0 0-11.31 0", + "rows-2": "rect;width=18;height=18;x=3;y=3;rx=2|M3 12h18", + "rows-3": "rect;width=18;height=18;x=3;y=3;rx=2|M21 9H3|M21 15H3", + "rows-4": "rect;width=18;height=18;x=3;y=3;rx=2|M21 7.5H3|M21 12H3|M21 16.5H3", + "rss": "M4 11a9 9 0 0 1 9 9|M4 4a16 16 0 0 1 16 16|circle;cx=5;cy=19;r=1", + "ruler": "M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z|m14.5 12.5 2-2|m11.5 9.5 2-2|m8.5 6.5 2-2|m17.5 15.5 2-2", + "ruler-dimension-line": "M10 15v-3|M14 15v-3|M18 15v-3|M2 8V4|M22 6H2|M22 8V4|M6 15v-3|rect;x=2;y=12;width=20;height=8;rx=2", + "russian-ruble": "M6 11h8a4 4 0 0 0 0-8H9v18|M6 15h8", + "sailboat": "M10 2v15|M7 22a4 4 0 0 1-4-4 1 1 0 0 1 1-1h16a1 1 0 0 1 1 1 4 4 0 0 1-4 4z|M9.159 2.46a1 1 0 0 1 1.521-.193l9.977 8.98A1 1 0 0 1 20 13H4a1 1 0 0 1-.824-1.567z", + "salad": "M7 21h10|M12 21a9 9 0 0 0 9-9H3a9 9 0 0 0 9 9Z|M11.38 12a2.4 2.4 0 0 1-.4-4.77 2.4 2.4 0 0 1 3.2-2.77 2.4 2.4 0 0 1 3.47-.63 2.4 2.4 0 0 1 3.37 3.37 2.4 2.4 0 0 1-1.1 3.7 2.51 2.51 0 0 1 .03 1.1|m13 12 4-4|M10.9 7.25A3.99 3.99 0 0 0 4 10c0 .73.2 1.41.54 2", + "sandwich": "m2.37 11.223 8.372-6.777a2 2 0 0 1 2.516 0l8.371 6.777|M21 15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-5.25|M3 15a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h9|m6.67 15 6.13 4.6a2 2 0 0 0 2.8-.4l3.15-4.2|rect;width=20;height=4;x=2;y=11;rx=1", + "satellite": "m13.5 6.5-3.148-3.148a1.205 1.205 0 0 0-1.704 0L6.352 5.648a1.205 1.205 0 0 0 0 1.704L9.5 10.5|M16.5 7.5 19 5|m17.5 10.5 3.148 3.148a1.205 1.205 0 0 1 0 1.704l-2.296 2.296a1.205 1.205 0 0 1-1.704 0L13.5 14.5|M9 21a6 6 0 0 0-6-6|M9.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l4.296-4.296a1.205 1.205 0 0 0 0-1.704l-2.296-2.296a1.205 1.205 0 0 0-1.704 0z", + "satellite-dish": "M4 10a7.31 7.31 0 0 0 10 10Z|m9 15 3-3|M17 13a6 6 0 0 0-6-6|M21 13A10 10 0 0 0 11 3", + "saudi-riyal": "m20 19.5-5.5 1.2|M14.5 4v11.22a1 1 0 0 0 1.242.97L20 15.2|m2.978 19.351 5.549-1.363A2 2 0 0 0 10 16V2|M20 10 4 13.5", + "save": "M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z|M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7|M7 3v4a1 1 0 0 0 1 1h7", + "save-all": "M10 2v3a1 1 0 0 0 1 1h5|M18 18v-6a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6|M18 22H4a2 2 0 0 1-2-2V6|M8 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9.172a2 2 0 0 1 1.414.586l2.828 2.828A2 2 0 0 1 22 6.828V16a2 2 0 0 1-2.01 2z", + "save-off": "M13 13H8a1 1 0 0 0-1 1v7|M14 8h1|M17 21v-4|m2 2 20 20|M20.41 20.41A2 2 0 0 1 19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 .59-1.41|M29.5 11.5s5 5 4 5|M9 3h6.2a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V15", + "scale": "M12 3v18|m19 8 3 8a5 5 0 0 1-6 0zV7|M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1|m5 8 3 8a5 5 0 0 1-6 0zV7|M7 21h10", + "scale-3d": "M5 7v11a1 1 0 0 0 1 1h11|M5.293 18.707 11 13|circle;cx=19;cy=19;r=2|circle;cx=5;cy=5;r=2", + "scaling": "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7|M14 15H9v-5|M16 3h5v5|M21 3 9 15", + "scan": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2", + "scan-barcode": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|M8 7v10|M12 7v10|M17 7v10", + "scan-eye": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|circle;cx=12;cy=12;r=1|M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0", + "scan-face": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|M8 14s1.5 2 4 2 4-2 4-2|M9 9h.01|M15 9h.01", + "scan-heart": "M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M3 7V5a2 2 0 0 1 2-2h2|M7 21H5a2 2 0 0 1-2-2v-2|M7.828 13.07A3 3 0 0 1 12 8.764a3 3 0 0 1 4.172 4.306l-3.447 3.62a1 1 0 0 1-1.449 0z", + "scan-line": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|M7 12h10", + "scan-qr-code": "M17 12v4a1 1 0 0 1-1 1h-4|M17 3h2a2 2 0 0 1 2 2v2|M17 8V7|M21 17v2a2 2 0 0 1-2 2h-2|M3 7V5a2 2 0 0 1 2-2h2|M7 17h.01|M7 21H5a2 2 0 0 1-2-2v-2|rect;x=7;y=7;width=5;height=5;rx=1", + "scan-search": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|circle;cx=12;cy=12;r=3|m16 16-1.9-1.9", + "scan-text": "M3 7V5a2 2 0 0 1 2-2h2|M17 3h2a2 2 0 0 1 2 2v2|M21 17v2a2 2 0 0 1-2 2h-2|M7 21H5a2 2 0 0 1-2-2v-2|M7 8h8|M7 12h10|M7 16h6", + "school": "M14 21v-3a2 2 0 0 0-4 0v3|M18 5v16|m4 6 7.106-3.79a2 2 0 0 1 1.788 0L20 6|m6 11-3.52 2.147a1 1 0 0 0-.48.854V19a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a1 1 0 0 0-.48-.853L18 11|M6 5v16|circle;cx=12;cy=9;r=2", + "scissors": "circle;cx=6;cy=6;r=3|M8.12 8.12 12 12|M20 4 8.12 15.88|circle;cx=6;cy=18;r=3|M14.8 14.8 20 20", + "scissors-line-dashed": "M5.42 9.42 8 12|circle;cx=4;cy=8;r=2|m14 6-8.58 8.58|circle;cx=4;cy=16;r=2|M10.8 14.8 14 18|M16 12h-2|M22 12h-2", + "scooter": "M21 4h-3.5l2 11.05|M6.95 17h5.142c.523 0 .95-.406 1.063-.916a6.5 6.5 0 0 1 5.345-5.009|circle;cx=19.5;cy=17.5;r=2.5|circle;cx=4.5;cy=17.5;r=2.5", + "screen-share": "M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3|M8 21h8|M12 17v4|m17 8 5-5|M17 3h5v5", + "screen-share-off": "M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3|M8 21h8|M12 17v4|m22 3-5 5|m17 3 5 5", + "scroll": "M19 17V5a2 2 0 0 0-2-2H4|M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3", + "scroll-text": "M15 12h-5|M15 8h-5|M19 17V5a2 2 0 0 0-2-2H4|M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3", + "search": "m21 21-4.34-4.34|circle;cx=11;cy=11;r=8", + "search-alert": "circle;cx=11;cy=11;r=8|m21 21-4.3-4.3|M11 7v4|M11 15h.01", + "search-check": "m8 11 2 2 4-4|circle;cx=11;cy=11;r=8|m21 21-4.3-4.3", + "search-code": "m13 13.5 2-2.5-2-2.5|m21 21-4.3-4.3|M9 8.5 7 11l2 2.5|circle;cx=11;cy=11;r=8", + "search-slash": "m13.5 8.5-5 5|circle;cx=11;cy=11;r=8|m21 21-4.3-4.3", + "search-x": "m13.5 8.5-5 5|m8.5 8.5 5 5|circle;cx=11;cy=11;r=8|m21 21-4.3-4.3", + "section": "M16 5a4 3 0 0 0-8 0c0 4 8 3 8 7a4 3 0 0 1-8 0|M8 19a4 3 0 0 0 8 0c0-4-8-3-8-7a4 3 0 0 1 8 0", + "send": "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z|m21.854 2.147-10.94 10.939", + "send-horizontal": "M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z|M6 12h16", + "send-to-back": "rect;x=14;y=14;width=8;height=8;rx=2|rect;x=2;y=2;width=8;height=8;rx=2|M7 14v1a2 2 0 0 0 2 2h1|M14 7h1a2 2 0 0 1 2 2v1", + "separator-horizontal": "m16 16-4 4-4-4|M3 12h18|m8 8 4-4 4 4", + "separator-vertical": "M12 3v18|m16 16 4-4-4-4|m8 8-4 4 4 4", + "server": "rect;width=20;height=8;x=2;y=2;rx=2;ry=2|rect;width=20;height=8;x=2;y=14;rx=2;ry=2|line;x1=6;x2=6.01;y1=6;y2=6|line;x1=6;x2=6.01;y1=18;y2=18", + "server-cog": "m10.852 14.772-.383.923|M13.148 14.772a3 3 0 1 0-2.296-5.544l-.383-.923|m13.148 9.228.383-.923|m13.53 15.696-.382-.924a3 3 0 1 1-2.296-5.544|m14.772 10.852.923-.383|m14.772 13.148.923.383|M4.5 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-.5|M4.5 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-.5|M6 18h.01|M6 6h.01|m9.228 10.852-.923-.383|m9.228 13.148-.923.383", + "server-crash": "M6 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2|M6 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-2|M6 6h.01|M6 18h.01|m13 6-4 6h6l-4 6", + "server-off": "M7 2h13a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5|M10 10 2.5 2.5C2 2 2 2.5 2 5v3a2 2 0 0 0 2 2h6z|M22 17v-1a2 2 0 0 0-2-2h-1|M4 14a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16.5l1-.5.5.5-8-8H4z|M6 18h.01|m2 2 20 20", + "settings": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915|circle;cx=12;cy=12;r=3", + "settings-2": "M14 17H5|M19 7h-9|circle;cx=17;cy=17;r=3|circle;cx=7;cy=7;r=3", + "shapes": "M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z|rect;x=3;y=14;width=7;height=7;rx=1|circle;cx=17.5;cy=17.5;r=3.5", + "share": "M12 2v13|m16 6-4-4-4 4|M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8", + "share-2": "circle;cx=18;cy=5;r=3|circle;cx=6;cy=12;r=3|circle;cx=18;cy=19;r=3|line;x1=8.59;x2=15.42;y1=13.51;y2=17.49|line;x1=15.41;x2=8.59;y1=6.51;y2=10.49", + "sheet": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|line;x1=3;x2=21;y1=9;y2=9|line;x1=3;x2=21;y1=15;y2=15|line;x1=9;x2=9;y1=9;y2=21|line;x1=15;x2=15;y1=9;y2=21", + "shell": "M14 11a2 2 0 1 1-4 0 4 4 0 0 1 8 0 6 6 0 0 1-12 0 8 8 0 0 1 16 0 10 10 0 1 1-20 0 11.93 11.93 0 0 1 2.42-7.22 2 2 0 1 1 3.16 2.44", + "shield": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z", + "shield-alert": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M12 8v4|M12 16h.01", + "shield-ban": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|m4.243 5.21 14.39 12.472", + "shield-check": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|m9 12 2 2 4-4", + "shield-ellipsis": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M8 12h.01|M12 12h.01|M16 12h.01", + "shield-half": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M12 22V2", + "shield-minus": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M9 12h6", + "shield-off": "m2 2 20 20|M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71|M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264", + "shield-plus": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M9 12h6|M12 9v6", + "shield-question-mark": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3|M12 17h.01", + "shield-user": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|M6.376 18.91a6 6 0 0 1 11.249.003|circle;cx=12;cy=11;r=4", + "shield-x": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z|m14.5 9.5-5 5|m9.5 9.5 5 5", + "ship": "M12 10.189V14|M12 2v3|M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6|M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76|M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1", + "ship-wheel": "circle;cx=12;cy=12;r=8|M12 2v7.5|m19 5-5.23 5.23|M22 12h-7.5|m19 19-5.23-5.23|M12 14.5V22|M10.23 13.77 5 19|M9.5 12H2|M10.23 10.23 5 5|circle;cx=12;cy=12;r=2.5", + "shirt": "M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z", + "shopping-bag": "M16 10a4 4 0 0 1-8 0|M3.103 6.034h17.794|M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z", + "shopping-basket": "m15 11-1 9|m19 11-4-7|M2 11h20|m3.5 11 1.6 7.4a2 2 0 0 0 2 1.6h9.8a2 2 0 0 0 2-1.6l1.7-7.4|M4.5 15.5h15|m5 11 4-7|m9 11 1 9", + "shopping-cart": "circle;cx=8;cy=21;r=1|circle;cx=19;cy=21;r=1|M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12", + "shovel": "M21.56 4.56a1.5 1.5 0 0 1 0 2.122l-.47.47a3 3 0 0 1-4.212-.03 3 3 0 0 1 0-4.243l.44-.44a1.5 1.5 0 0 1 2.121 0z|M3 22a1 1 0 0 1-1-1v-3.586a1 1 0 0 1 .293-.707l3.355-3.355a1.205 1.205 0 0 1 1.704 0l3.296 3.296a1.205 1.205 0 0 1 0 1.704l-3.355 3.355a1 1 0 0 1-.707.293z|m9 15 7.879-7.878", + "shower-head": "m4 4 2.5 2.5|M13.5 6.5a4.95 4.95 0 0 0-7 7|M15 5 5 15|M14 17v.01|M10 16v.01|M13 13v.01|M16 10v.01|M11 20v.01|M17 14v.01|M20 11v.01", + "shredder": "M4 13V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5|M14 2v5a1 1 0 0 0 1 1h5|M10 22v-5|M14 19v-2|M18 20v-3|M2 13h20|M6 20v-3", + "shrimp": "M11 12h.01|M13 22c.5-.5 1.12-1 2.5-1-1.38 0-2-.5-2.5-1|M14 2a3.28 3.28 0 0 1-3.227 1.798l-6.17-.561A2.387 2.387 0 1 0 4.387 8H15.5a1 1 0 0 1 0 13 1 1 0 0 0 0-5H12a7 7 0 0 1-7-7V8|M14 8a8.5 8.5 0 0 1 0 8|M16 16c2 0 4.5-4 4-6", + "shrink": "m15 15 6 6m-6-6v4.8m0-4.8h4.8|M9 19.8V15m0 0H4.2M9 15l-6 6|M15 4.2V9m0 0h4.8M15 9l6-6|M9 4.2V9m0 0H4.2M9 9 3 3", + "shrub": "M12 22v-5.172a2 2 0 0 0-.586-1.414L9.5 13.5|M14.5 14.5 12 17|M17 8.8A6 6 0 0 1 13.8 20H10A6.5 6.5 0 0 1 7 8a5 5 0 0 1 10 0z", + "shuffle": "m18 14 4 4-4 4|m18 2 4 4-4 4|M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22|M2 6h1.972a4 4 0 0 1 3.6 2.2|M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45", + "sigma": "M18 7V5a1 1 0 0 0-1-1H6.5a.5.5 0 0 0-.4.8l4.5 6a2 2 0 0 1 0 2.4l-4.5 6a.5.5 0 0 0 .4.8H17a1 1 0 0 0 1-1v-2", + "signal": "M2 20h.01|M7 20v-4|M12 20v-8|M17 20V8|M22 4v16", + "signal-high": "M2 20h.01|M7 20v-4|M12 20v-8|M17 20V8", + "signal-low": "M2 20h.01|M7 20v-4", + "signal-medium": "M2 20h.01|M7 20v-4|M12 20v-8", + "signal-zero": "M2 20h.01", + "signature": "m21 17-2.156-1.868A.5.5 0 0 0 18 15.5v.5a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1c0-2.545-3.991-3.97-8.5-4a1 1 0 0 0 0 5c4.153 0 4.745-11.295 5.708-13.5a2.5 2.5 0 1 1 3.31 3.284|M3 21h18", + "signpost": "M12 13v8|M12 3v3|M18 6a2 2 0 0 1 1.387.56l2.307 2.22a1 1 0 0 1 0 1.44l-2.307 2.22A2 2 0 0 1 18 13H6a2 2 0 0 1-1.387-.56l-2.306-2.22a1 1 0 0 1 0-1.44l2.306-2.22A2 2 0 0 1 6 6z", + "signpost-big": "M10 9H4L2 7l2-2h6|M14 5h6l2 2-2 2h-6|M10 22V4a2 2 0 1 1 4 0v18|M8 22h8", + "siren": "M7 18v-6a5 5 0 1 1 10 0v6|M5 21a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-1a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2z|M21 12h1|M18.5 4.5 18 5|M2 12h1|M12 2v1|m4.929 4.929.707.707|M12 12v6", + "skip-back": "M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z|M3 20V4", + "skip-forward": "M21 4v16|M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z", + "skull": "m12.5 17-.5-1-.5 1h1z|M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z|circle;cx=15;cy=12;r=1|circle;cx=9;cy=12;r=1", + "slack": "rect;width=3;height=8;x=13;y=2;rx=1.5|M19 8.5V10h1.5A1.5 1.5 0 1 0 19 8.5|rect;width=3;height=8;x=8;y=14;rx=1.5|M5 15.5V14H3.5A1.5 1.5 0 1 0 5 15.5|rect;width=8;height=3;x=14;y=13;rx=1.5|M15.5 19H14v1.5a1.5 1.5 0 1 0 1.5-1.5|rect;width=8;height=3;x=2;y=8;rx=1.5|M8.5 5H10V3.5A1.5 1.5 0 1 0 8.5 5", + "slash": "M22 2 2 22", + "slice": "M11 16.586V19a1 1 0 0 1-1 1H2L18.37 3.63a1 1 0 1 1 3 3l-9.663 9.663a1 1 0 0 1-1.414 0L8 14", + "sliders-horizontal": "M10 5H3|M12 19H3|M14 3v4|M16 17v4|M21 12h-9|M21 19h-5|M21 5h-7|M8 10v4|M8 12H3", + "sliders-vertical": "M10 8h4|M12 21v-9|M12 8V3|M17 16h4|M19 12V3|M19 21v-5|M3 14h4|M5 10V3|M5 21v-7", + "smartphone": "rect;width=14;height=20;x=5;y=2;rx=2;ry=2|M12 18h.01", + "smartphone-charging": "rect;width=14;height=20;x=5;y=2;rx=2;ry=2|M12.667 8 10 12h4l-2.667 4", + "smartphone-nfc": "rect;width=7;height=12;x=2;y=6;rx=1|M13 8.32a7.43 7.43 0 0 1 0 7.36|M16.46 6.21a11.76 11.76 0 0 1 0 11.58|M19.91 4.1a15.91 15.91 0 0 1 .01 15.8", + "smile": "circle;cx=12;cy=12;r=10|M8 14s1.5 2 4 2 4-2 4-2|line;x1=9;x2=9.01;y1=9;y2=9|line;x1=15;x2=15.01;y1=9;y2=9", + "smile-plus": "M22 11v1a10 10 0 1 1-9-10|M8 14s1.5 2 4 2 4-2 4-2|line;x1=9;x2=9.01;y1=9;y2=9|line;x1=15;x2=15.01;y1=9;y2=9|M16 5h6|M19 2v6", + "snail": "M2 13a6 6 0 1 0 12 0 4 4 0 1 0-8 0 2 2 0 0 0 4 0|circle;cx=10;cy=13;r=8|M2 21h12c4.4 0 8-3.6 8-8V7a2 2 0 1 0-4 0v6|M18 3 19.1 5.2|M22 3 20.9 5.2", + "snowflake": "m10 20-1.25-2.5L6 18|M10 4 8.75 6.5 6 6|m14 20 1.25-2.5L18 18|m14 4 1.25 2.5L18 6|m17 21-3-6h-4|m17 3-3 6 1.5 3|M2 12h6.5L10 9|m20 10-1.5 2 1.5 2|M22 12h-6.5L14 15|m4 10 1.5 2L4 14|m7 21 3-6-1.5-3|m7 3 3 6h4", + "soap-dispenser-droplet": "M10.5 2v4|M14 2H7a2 2 0 0 0-2 2|M19.29 14.76A6.67 6.67 0 0 1 17 11a6.6 6.6 0 0 1-2.29 3.76c-1.15.92-1.71 2.04-1.71 3.19 0 2.22 1.8 4.05 4 4.05s4-1.83 4-4.05c0-1.16-.57-2.26-1.71-3.19|M9.607 21H6a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h7V7a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3", + "sofa": "M20 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3|M2 16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0z|M4 18v2|M20 18v2|M12 4v9", + "solar-panel": "M11 2h2|m14.28 14-4.56 8|m21 22-1.558-4H4.558|M3 10v2|M6.245 15.04A2 2 0 0 1 8 14h12a1 1 0 0 1 .864 1.505l-3.11 5.457A2 2 0 0 1 16 22H4a1 1 0 0 1-.863-1.506z|M7 2a4 4 0 0 1-4 4|m8.66 7.66 1.41 1.41", + "soup": "M12 21a9 9 0 0 0 9-9H3a9 9 0 0 0 9 9Z|M7 21h10|M19.5 12 22 6|M16.25 3c.27.1.8.53.75 1.36-.06.83-.93 1.2-1 2.02-.05.78.34 1.24.73 1.62|M11.25 3c.27.1.8.53.74 1.36-.05.83-.93 1.2-.98 2.02-.06.78.33 1.24.72 1.62|M6.25 3c.27.1.8.53.75 1.36-.06.83-.93 1.2-1 2.02-.05.78.34 1.24.74 1.62", + "space": "M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1", + "spade": "M12 18v4|M2 14.499a5.5 5.5 0 0 0 9.591 3.675.6.6 0 0 1 .818.001A5.5 5.5 0 0 0 22 14.5c0-2.29-1.5-4-3-5.5l-5.492-5.312a2 2 0 0 0-3-.02L5 8.999c-1.5 1.5-3 3.2-3 5.5", + "sparkle": "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z", + "sparkles": "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z|M20 2v4|M22 4h-4|circle;cx=4;cy=20;r=2", + "speaker": "rect;width=16;height=20;x=4;y=2;rx=2|M12 6h.01|circle;cx=12;cy=14;r=4|M12 14h.01", + "speech": "M8.8 20v-4.1l1.9.2a2.3 2.3 0 0 0 2.164-2.1V8.3A5.37 5.37 0 0 0 2 8.25c0 2.8.656 3.054 1 4.55a5.77 5.77 0 0 1 .029 2.758L2 20|M19.8 17.8a7.5 7.5 0 0 0 .003-10.603|M17 15a3.5 3.5 0 0 0-.025-4.975", + "spell-check": "m6 16 6-12 6 12|M8 12h8|m16 20 2 2 4-4", + "spell-check-2": "m6 16 6-12 6 12|M8 12h8|M4 21c1.1 0 1.1-1 2.3-1s1.1 1 2.3 1c1.1 0 1.1-1 2.3-1 1.1 0 1.1 1 2.3 1 1.1 0 1.1-1 2.3-1 1.1 0 1.1 1 2.3 1 1.1 0 1.1-1 2.3-1", + "spline": "circle;cx=19;cy=5;r=2|circle;cx=5;cy=19;r=2|M5 17A12 12 0 0 1 17 5", + "spline-pointer": "M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z|M5 17A12 12 0 0 1 17 5|circle;cx=19;cy=5;r=2|circle;cx=5;cy=19;r=2", + "split": "M16 3h5v5|M8 3H3v5|M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3|m15 9 6-6", + "spool": "M17 13.44 4.442 17.082A2 2 0 0 0 4.982 21H19a2 2 0 0 0 .558-3.921l-1.115-.32A2 2 0 0 1 17 14.837V7.66|m7 10.56 12.558-3.642A2 2 0 0 0 19.018 3H5a2 2 0 0 0-.558 3.921l1.115.32A2 2 0 0 1 7 9.163v7.178", + "spotlight": "M15.295 19.562 16 22|m17 16 3.758 2.098|m19 12.5 3.026-.598|M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z|M8 9V2", + "spray-can": "M3 3h.01|M7 5h.01|M11 7h.01|M3 7h.01|M7 9h.01|M3 11h.01|rect;width=4;height=4;x=15;y=5|m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2|m13 14 8-2|m13 19 8-2", + "sprout": "M14 9.536V7a4 4 0 0 1 4-4h1.5a.5.5 0 0 1 .5.5V5a4 4 0 0 1-4 4 4 4 0 0 0-4 4c0 2 1 3 1 5a5 5 0 0 1-1 3|M4 9a5 5 0 0 1 8 4 5 5 0 0 1-8-4|M5 21h14", + "square": "rect;width=18;height=18;x=3;y=3;rx=2", + "square-activity": "rect;width=18;height=18;x=3;y=3;rx=2|M17 12h-2l-2 5-2-10-2 5H7", + "square-arrow-down": "rect;width=18;height=18;x=3;y=3;rx=2|M12 8v8|m8 12 4 4 4-4", + "square-arrow-down-left": "rect;width=18;height=18;x=3;y=3;rx=2|m16 8-8 8|M16 16H8V8", + "square-arrow-down-right": "rect;width=18;height=18;x=3;y=3;rx=2|m8 8 8 8|M16 8v8H8", + "square-arrow-left": "rect;width=18;height=18;x=3;y=3;rx=2|m12 8-4 4 4 4|M16 12H8", + "square-arrow-out-down-left": "M13 21h6a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6|m3 21 9-9|M9 21H3v-6", + "square-arrow-out-down-right": "M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6|m21 21-9-9|M21 15v6h-6", + "square-arrow-out-up-left": "M13 3h6a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6|m3 3 9 9|M3 9V3h6", + "square-arrow-out-up-right": "M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6|m21 3-9 9|M15 3h6v6", + "square-arrow-right": "rect;width=18;height=18;x=3;y=3;rx=2|M8 12h8|m12 16 4-4-4-4", + "square-arrow-up": "rect;width=18;height=18;x=3;y=3;rx=2|m16 12-4-4-4 4|M12 16V8", + "square-arrow-up-left": "rect;width=18;height=18;x=3;y=3;rx=2|M8 16V8h8|M16 16 8 8", + "square-arrow-up-right": "rect;width=18;height=18;x=3;y=3;rx=2|M8 8h8v8|m8 16 8-8", + "square-asterisk": "rect;width=18;height=18;x=3;y=3;rx=2|M12 8v8|m8.5 14 7-4|m8.5 10 7 4", + "square-bottom-dashed-scissors": "line;x1=5;y1=3;x2=19;y2=3|line;x1=3;y1=5;x2=3;y2=19|line;x1=21;y1=5;x2=21;y2=19|line;x1=9;y1=21;x2=10;y2=21|line;x1=14;y1=21;x2=15;y2=21|M 3 5 A2 2 0 0 1 5 3|M 19 3 A2 2 0 0 1 21 5|M 5 21 A2 2 0 0 1 3 19|M 21 19 A2 2 0 0 1 19 21|circle;cx=8.5;cy=8.5;r=1.5|line;x1=9.56066;y1=9.56066;x2=12;y2=12|line;x1=17;y1=17;x2=14.82;y2=14.82|circle;cx=8.5;cy=15.5;r=1.5|line;x1=9.56066;y1=14.43934;x2=17;y2=7", + "square-chart-gantt": "rect;width=18;height=18;x=3;y=3;rx=2|M9 8h7|M8 12h6|M11 16h5", + "square-check": "rect;width=18;height=18;x=3;y=3;rx=2|m9 12 2 2 4-4", + "square-check-big": "M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.344|m9 11 3 3L22 4", + "square-chevron-down": "rect;width=18;height=18;x=3;y=3;rx=2|m16 10-4 4-4-4", + "square-chevron-left": "rect;width=18;height=18;x=3;y=3;rx=2|m14 16-4-4 4-4", + "square-chevron-right": "rect;width=18;height=18;x=3;y=3;rx=2|m10 8 4 4-4 4", + "square-chevron-up": "rect;width=18;height=18;x=3;y=3;rx=2|m8 14 4-4 4 4", + "square-code": "m10 9-3 3 3 3|m14 15 3-3-3-3|rect;x=3;y=3;width=18;height=18;rx=2", + "square-dashed": "M5 3a2 2 0 0 0-2 2|M19 3a2 2 0 0 1 2 2|M21 19a2 2 0 0 1-2 2|M5 21a2 2 0 0 1-2-2|M9 3h1|M9 21h1|M14 3h1|M14 21h1|M3 9v1|M21 9v1|M3 14v1|M21 14v1", + "square-dashed-bottom": "M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2|M9 21h1|M14 21h1", + "square-dashed-bottom-code": "M10 9.5 8 12l2 2.5|M14 21h1|m14 9.5 2 2.5-2 2.5|M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2|M9 21h1", + "square-dashed-kanban": "M8 7v7|M12 7v4|M16 7v9|M5 3a2 2 0 0 0-2 2|M9 3h1|M14 3h1|M19 3a2 2 0 0 1 2 2|M21 9v1|M21 14v1|M21 19a2 2 0 0 1-2 2|M14 21h1|M9 21h1|M5 21a2 2 0 0 1-2-2|M3 14v1|M3 9v1", + "square-dashed-mouse-pointer": "M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z|M5 3a2 2 0 0 0-2 2|M19 3a2 2 0 0 1 2 2|M5 21a2 2 0 0 1-2-2|M9 3h1|M9 21h2|M14 3h1|M3 9v1|M21 9v2|M3 14v1", + "square-dashed-top-solid": "M14 21h1|M21 14v1|M21 19a2 2 0 0 1-2 2|M21 9v1|M3 14v1|M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2|M3 9v1|M5 21a2 2 0 0 1-2-2|M9 21h1", + "square-divide": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|line;x1=8;x2=16;y1=12;y2=12|line;x1=12;x2=12;y1=16;y2=16|line;x1=12;x2=12;y1=8;y2=8", + "square-dot": "rect;width=18;height=18;x=3;y=3;rx=2|circle;cx=12;cy=12;r=1", + "square-equal": "rect;width=18;height=18;x=3;y=3;rx=2|M7 10h10|M7 14h10", + "square-function": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3|M9 11.2h5.7", + "square-kanban": "rect;width=18;height=18;x=3;y=3;rx=2|M8 7v7|M12 7v4|M16 7v9", + "square-library": "rect;width=18;height=18;x=3;y=3;rx=2|M7 7v10|M11 7v10|m15 7 2 10", + "square-m": "M8 16V8.5a.5.5 0 0 1 .9-.3l2.7 3.599a.5.5 0 0 0 .8 0l2.7-3.6a.5.5 0 0 1 .9.3V16|rect;x=3;y=3;width=18;height=18;rx=2", + "square-menu": "rect;width=18;height=18;x=3;y=3;rx=2|M7 8h10|M7 12h10|M7 16h10", + "square-minus": "rect;width=18;height=18;x=3;y=3;rx=2|M8 12h8", + "square-mouse-pointer": "M12.034 12.681a.498.498 0 0 1 .647-.647l9 3.5a.5.5 0 0 1-.033.943l-3.444 1.068a1 1 0 0 0-.66.66l-1.067 3.443a.5.5 0 0 1-.943.033z|M21 11V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6", + "square-parking": "rect;width=18;height=18;x=3;y=3;rx=2|M9 17V7h4a3 3 0 0 1 0 6H9", + "square-parking-off": "M3.6 3.6A2 2 0 0 1 5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-.59 1.41|M3 8.7V19a2 2 0 0 0 2 2h10.3|m2 2 20 20|M13 13a3 3 0 1 0 0-6H9v2|M9 17v-2.3", + "square-pause": "rect;width=18;height=18;x=3;y=3;rx=2|line;x1=10;x2=10;y1=15;y2=9|line;x1=14;x2=14;y1=15;y2=9", + "square-pen": "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7|M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z", + "square-percent": "rect;width=18;height=18;x=3;y=3;rx=2|m15 9-6 6|M9 9h.01|M15 15h.01", + "square-pi": "rect;width=18;height=18;x=3;y=3;rx=2|M7 7h10|M10 7v10|M16 17a2 2 0 0 1-2-2V7", + "square-pilcrow": "rect;width=18;height=18;x=3;y=3;rx=2|M12 12H9.5a2.5 2.5 0 0 1 0-5H17|M12 7v10|M16 7v10", + "square-play": "rect;x=3;y=3;width=18;height=18;rx=2|M9 9.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997A1 1 0 0 1 9 14.996z", + "square-plus": "rect;width=18;height=18;x=3;y=3;rx=2|M8 12h8|M12 8v8", + "square-power": "M12 7v4|M7.998 9.003a5 5 0 1 0 8-.005|rect;x=3;y=3;width=18;height=18;rx=2", + "square-radical": "M7 12h2l2 5 2-10h4|rect;x=3;y=3;width=18;height=18;rx=2", + "square-round-corner": "M21 11a8 8 0 0 0-8-8|M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", + "square-scissors": "rect;width=18;height=18;x=3;y=3;rx=2|circle;cx=8.5;cy=8.5;r=1.5|line;x1=9.56066;y1=9.56066;x2=12;y2=12|line;x1=17;y1=17;x2=14.82;y2=14.82|circle;cx=8.5;cy=15.5;r=1.5|line;x1=9.56066;y1=14.43934;x2=17;y2=7", + "square-sigma": "rect;width=18;height=18;x=3;y=3;rx=2|M16 8.9V7H8l4 5-4 5h8v-1.9", + "square-slash": "rect;width=18;height=18;x=3;y=3;rx=2|line;x1=9;x2=15;y1=15;y2=9", + "square-split-horizontal": "M8 19H5c-1 0-2-1-2-2V7c0-1 1-2 2-2h3|M16 5h3c1 0 2 1 2 2v10c0 1-1 2-2 2h-3|line;x1=12;x2=12;y1=4;y2=20", + "square-split-vertical": "M5 8V5c0-1 1-2 2-2h10c1 0 2 1 2 2v3|M19 16v3c0 1-1 2-2 2H7c-1 0-2-1-2-2v-3|line;x1=4;x2=20;y1=12;y2=12", + "square-square": "rect;x=3;y=3;width=18;height=18;rx=2|rect;x=8;y=8;width=8;height=8;rx=1", + "square-stack": "M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2|M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2|rect;width=8;height=8;x=14;y=14;rx=2", + "square-star": "M11.035 7.69a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.866l-1.156-1.153a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z|rect;x=3;y=3;width=18;height=18;rx=2", + "square-stop": "rect;width=18;height=18;x=3;y=3;rx=2|rect;x=9;y=9;width=6;height=6;rx=1", + "square-terminal": "m7 11 2-2-2-2|M11 13h4|rect;width=18;height=18;x=3;y=3;rx=2;ry=2", + "square-user": "rect;width=18;height=18;x=3;y=3;rx=2|circle;cx=12;cy=10;r=3|M7 21v-2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2", + "square-user-round": "M18 21a6 6 0 0 0-12 0|circle;cx=12;cy=11;r=4|rect;width=18;height=18;x=3;y=3;rx=2", + "square-x": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|m15 9-6 6|m9 9 6 6", + "squares-exclude": "M16 12v2a2 2 0 0 1-2 2H9a1 1 0 0 0-1 1v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h0|M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3a1 1 0 0 1-1 1h-5a2 2 0 0 0-2 2v2", + "squares-intersect": "M10 22a2 2 0 0 1-2-2|M14 2a2 2 0 0 1 2 2|M16 22h-2|M2 10V8|M2 4a2 2 0 0 1 2-2|M20 8a2 2 0 0 1 2 2|M22 14v2|M22 20a2 2 0 0 1-2 2|M4 16a2 2 0 0 1-2-2|M8 10a2 2 0 0 1 2-2h5a1 1 0 0 1 1 1v5a2 2 0 0 1-2 2H9a1 1 0 0 1-1-1z|M8 2h2", + "squares-subtract": "M10 22a2 2 0 0 1-2-2|M16 22h-2|M16 4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-5a2 2 0 0 1 2-2h5a1 1 0 0 0 1-1z|M20 8a2 2 0 0 1 2 2|M22 14v2|M22 20a2 2 0 0 1-2 2", + "squares-unite": "M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3a1 1 0 0 0 1 1h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-3a1 1 0 0 0-1-1z", + "squircle": "M12 3c7.2 0 9 1.8 9 9s-1.8 9-9 9-9-1.8-9-9 1.8-9 9-9", + "squircle-dashed": "M13.77 3.043a34 34 0 0 0-3.54 0|M13.771 20.956a33 33 0 0 1-3.541.001|M20.18 17.74c-.51 1.15-1.29 1.93-2.439 2.44|M20.18 6.259c-.51-1.148-1.291-1.929-2.44-2.438|M20.957 10.23a33 33 0 0 1 0 3.54|M3.043 10.23a34 34 0 0 0 .001 3.541|M6.26 20.179c-1.15-.508-1.93-1.29-2.44-2.438|M6.26 3.82c-1.149.51-1.93 1.291-2.44 2.44", + "squirrel": "M15.236 22a3 3 0 0 0-2.2-5|M16 20a3 3 0 0 1 3-3h1a2 2 0 0 0 2-2v-2a4 4 0 0 0-4-4V4|M18 13h.01|M18 6a4 4 0 0 0-4 4 7 7 0 0 0-7 7c0-5 4-5 4-10.5a4.5 4.5 0 1 0-9 0 2.5 2.5 0 0 0 5 0C7 10 3 11 3 17c0 2.8 2.2 5 5 5h10", + "stamp": "M14 13V8.5C14 7 15 7 15 5a3 3 0 0 0-6 0c0 2 1 2 1 3.5V13|M20 15.5a2.5 2.5 0 0 0-2.5-2.5h-11A2.5 2.5 0 0 0 4 15.5V17a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1z|M5 22h14", + "star": "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z", + "star-half": "M12 18.338a2.1 2.1 0 0 0-.987.244L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.12 2.12 0 0 0 1.597-1.16l2.309-4.679A.53.53 0 0 1 12 2", + "star-off": "m10.344 4.688 1.181-2.393a.53.53 0 0 1 .95 0l2.31 4.679a2.12 2.12 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.237 3.152|m17.945 17.945.43 2.505a.53.53 0 0 1-.771.56l-4.618-2.428a2.12 2.12 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a8 8 0 0 0 .4-.099|m2 2 20 20", + "step-back": "M13.971 4.285A2 2 0 0 1 17 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z|M21 20V4", + "step-forward": "M10.029 4.285A2 2 0 0 0 7 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z|M3 4v16", + "stethoscope": "M11 2v2|M5 2v2|M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1|M8 15a6 6 0 0 0 12 0v-3|circle;cx=20;cy=10;r=2", + "sticker": "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z|M15 3v5a1 1 0 0 0 1 1h5|M8 13h.01|M16 13h.01|M10 16s.8 1 2 1c1.3 0 2-1 2-1", + "sticky-note": "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z|M15 3v5a1 1 0 0 0 1 1h5", + "stone": "M11.264 2.205A4 4 0 0 0 6.42 4.211l-4 8a4 4 0 0 0 1.359 5.117l6 4a4 4 0 0 0 4.438 0l6-4a4 4 0 0 0 1.576-4.592l-2-6a4 4 0 0 0-2.53-2.53z|M11.99 22 14 12l7.822 3.184|M14 12 8.47 2.302", + "store": "M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5|M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244|M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05", + "stretch-horizontal": "rect;width=20;height=6;x=2;y=4;rx=2|rect;width=20;height=6;x=2;y=14;rx=2", + "stretch-vertical": "rect;width=6;height=20;x=4;y=2;rx=2|rect;width=6;height=20;x=14;y=2;rx=2", + "strikethrough": "M16 4H9a3 3 0 0 0-2.83 4|M14 12a4 4 0 0 1 0 8H6|line;x1=4;x2=20;y1=12;y2=12", + "subscript": "m4 5 8 8|m12 5-8 8|M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07", + "sun": "circle;cx=12;cy=12;r=4|M12 2v2|M12 20v2|m4.93 4.93 1.41 1.41|m17.66 17.66 1.41 1.41|M2 12h2|M20 12h2|m6.34 17.66-1.41 1.41|m19.07 4.93-1.41 1.41", + "sun-dim": "circle;cx=12;cy=12;r=4|M12 4h.01|M20 12h.01|M12 20h.01|M4 12h.01|M17.657 6.343h.01|M17.657 17.657h.01|M6.343 17.657h.01|M6.343 6.343h.01", + "sun-medium": "circle;cx=12;cy=12;r=4|M12 3v1|M12 20v1|M3 12h1|M20 12h1|m18.364 5.636-.707.707|m6.343 17.657-.707.707|m5.636 5.636.707.707|m17.657 17.657.707.707", + "sun-moon": "M12 2v2|M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715|M16 12a4 4 0 0 0-4-4|m19 5-1.256 1.256|M20 12h2", + "sun-snow": "M10 21v-1|M10 4V3|M10 9a3 3 0 0 0 0 6|m14 20 1.25-2.5L18 18|m14 4 1.25 2.5L18 6|m17 21-3-6 1.5-3H22|m17 3-3 6 1.5 3|M2 12h1|m20 10-1.5 2 1.5 2|m3.64 18.36.7-.7|m4.34 6.34-.7-.7", + "sunrise": "M12 2v8|m4.93 10.93 1.41 1.41|M2 18h2|M20 18h2|m19.07 10.93-1.41 1.41|M22 22H2|m8 6 4-4 4 4|M16 18a4 4 0 0 0-8 0", + "sunset": "M12 10V2|m4.93 10.93 1.41 1.41|M2 18h2|M20 18h2|m19.07 10.93-1.41 1.41|M22 22H2|m16 6-4 4-4-4|M16 18a4 4 0 0 0-8 0", + "superscript": "m4 19 8-8|m12 19-8-8|M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06", + "swatch-book": "M11 17a4 4 0 0 1-8 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2Z|M16.7 13H19a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H7|M 7 17h.01|m11 8 2.3-2.3a2.4 2.4 0 0 1 3.404.004L18.6 7.6a2.4 2.4 0 0 1 .026 3.434L9.9 19.8", + "swiss-franc": "M10 21V3h8|M6 16h9|M10 9.5h7", + "switch-camera": "M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5|M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5|circle;cx=12;cy=12;r=3|m18 22-3-3 3-3|m6 2 3 3-3 3", + "sword": "m11 19-6-6|m5 21-2-2|m8 16-4 4|M9.5 17.5 21 6V3h-3L6.5 14.5", + "swords": "polyline;points=14.5 17.5 3 6 3 3 6 3 17.5 14.5|line;x1=13;x2=19;y1=19;y2=13|line;x1=16;x2=20;y1=16;y2=20|line;x1=19;x2=21;y1=21;y2=19|polyline;points=14.5 6.5 18 3 21 3 21 6 17.5 9.5|line;x1=5;x2=9;y1=14;y2=18|line;x1=7;x2=4;y1=17;y2=20|line;x1=3;x2=5;y1=19;y2=21", + "syringe": "m18 2 4 4|m17 7 3-3|M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5|m9 11 4 4|m5 19-3 3|m14 4 6 6", + "table": "M12 3v18|rect;width=18;height=18;x=3;y=3;rx=2|M3 9h18|M3 15h18", + "table-2": "M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18", + "table-cells-merge": "M12 21v-6|M12 9V3|M3 15h18|M3 9h18|rect;width=18;height=18;x=3;y=3;rx=2", + "table-cells-split": "M12 15V9|M3 15h18|M3 9h18|rect;width=18;height=18;x=3;y=3;rx=2", + "table-columns-split": "M14 14v2|M14 20v2|M14 2v2|M14 8v2|M2 15h8|M2 3h6a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H2|M2 9h8|M22 15h-4|M22 3h-2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h2|M22 9h-4|M5 3v18", + "table-of-contents": "M16 5H3|M16 12H3|M16 19H3|M21 5h.01|M21 12h.01|M21 19h.01", + "table-properties": "M15 3v18|rect;width=18;height=18;x=3;y=3;rx=2|M21 9H3|M21 15H3", + "table-rows-split": "M14 10h2|M15 22v-8|M15 2v4|M2 10h2|M20 10h2|M3 19h18|M3 22v-6a2 2 135 0 1 2-2h14a2 2 45 0 1 2 2v6|M3 2v2a2 2 45 0 0 2 2h14a2 2 135 0 0 2-2V2|M8 10h2|M9 22v-8|M9 2v4", + "tablet": "rect;width=16;height=20;x=4;y=2;rx=2;ry=2|line;x1=12;x2=12.01;y1=18;y2=18", + "tablet-smartphone": "rect;width=10;height=14;x=3;y=8;rx=2|M5 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2.4|M8 18h.01", + "tablets": "circle;cx=7;cy=7;r=5|circle;cx=17;cy=17;r=5|M12 17h10|m3.46 10.54 7.08-7.08", + "tag": "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z|circle;cx=7.5;cy=7.5;r=.5;fill=currentColor", + "tags": "M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z|M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193|circle;cx=10.5;cy=6.5;r=.5;fill=currentColor", + "tally-1": "M4 4v16", + "tally-2": "M4 4v16|M9 4v16", + "tally-3": "M4 4v16|M9 4v16|M14 4v16", + "tally-4": "M4 4v16|M9 4v16|M14 4v16|M19 4v16", + "tally-5": "M4 4v16|M9 4v16|M14 4v16|M19 4v16|M22 6 2 18", + "tangent": "circle;cx=17;cy=4;r=2|M15.59 5.41 5.41 15.59|circle;cx=4;cy=17;r=2|M12 22s-4-9-1.5-11.5S22 12 22 12", + "target": "circle;cx=12;cy=12;r=10|circle;cx=12;cy=12;r=6|circle;cx=12;cy=12;r=2", + "telescope": "m10.065 12.493-6.18 1.318a.934.934 0 0 1-1.108-.702l-.537-2.15a1.07 1.07 0 0 1 .691-1.265l13.504-4.44|m13.56 11.747 4.332-.924|m16 21-3.105-6.21|M16.485 5.94a2 2 0 0 1 1.455-2.425l1.09-.272a1 1 0 0 1 1.212.727l1.515 6.06a1 1 0 0 1-.727 1.213l-1.09.272a2 2 0 0 1-2.425-1.455z|m6.158 8.633 1.114 4.456|m8 21 3.105-6.21|circle;cx=12;cy=13;r=2", + "tent": "M3.5 21 14 3|M20.5 21 10 3|M15.5 21 12 15l-3.5 6|M2 21h20", + "tent-tree": "circle;cx=4;cy=4;r=2|m14 5 3-3 3 3|m14 10 3-3 3 3|M17 14V2|M17 14H7l-5 8h20Z|M8 14v8|m9 14 5 8", + "terminal": "M12 19h8|m4 17 6-6-6-6", + "test-tube": "M14.5 2v17.5c0 1.4-1.1 2.5-2.5 2.5c-1.4 0-2.5-1.1-2.5-2.5V2|M8.5 2h7|M14.5 16h-5", + "test-tube-diagonal": "M21 7 6.82 21.18a2.83 2.83 0 0 1-3.99-.01a2.83 2.83 0 0 1 0-4L17 3|m16 2 6 6|M12 16H4", + "test-tubes": "M9 2v17.5A2.5 2.5 0 0 1 6.5 22A2.5 2.5 0 0 1 4 19.5V2|M20 2v17.5a2.5 2.5 0 0 1-2.5 2.5a2.5 2.5 0 0 1-2.5-2.5V2|M3 2h7|M14 2h7|M9 16H4|M20 16h-5", + "text-align-center": "M21 5H3|M17 12H7|M19 19H5", + "text-align-end": "M21 5H3|M21 12H9|M21 19H7", + "text-align-justify": "M3 5h18|M3 12h18|M3 19h18", + "text-align-start": "M21 5H3|M15 12H3|M17 19H3", + "text-cursor": "M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1|M7 22h1a4 4 0 0 0 4-4v-1|M7 2h1a4 4 0 0 1 4 4v1", + "text-cursor-input": "M12 20h-1a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6|M13 8h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7|M5 16H4a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h1|M6 4h1a2 2 0 0 1 2 2 2 2 0 0 1 2-2h1|M9 6v12", + "text-initial": "M15 5h6|M15 12h6|M3 19h18|m3 12 3.553-7.724a.5.5 0 0 1 .894 0L11 12|M3.92 10h6.16", + "text-quote": "M17 5H3|M21 12H8|M21 19H8|M3 12v7", + "text-search": "M21 5H3|M10 12H3|M10 19H3|circle;cx=17;cy=15;r=3|m21 19-1.9-1.9", + "text-select": "M14 21h1|M14 3h1|M19 3a2 2 0 0 1 2 2|M21 14v1|M21 19a2 2 0 0 1-2 2|M21 9v1|M3 14v1|M3 9v1|M5 21a2 2 0 0 1-2-2|M5 3a2 2 0 0 0-2 2|M7 12h10|M7 16h6|M7 8h8|M9 21h1|M9 3h1", + "text-wrap": "m16 16-3 3 3 3|M3 12h14.5a1 1 0 0 1 0 7H13|M3 19h6|M3 5h18", + "theater": "M2 10s3-3 3-8|M22 10s-3-3-3-8|M10 2c0 4.4-3.6 8-8 8|M14 2c0 4.4 3.6 8 8 8|M2 10s2 2 2 5|M22 10s-2 2-2 5|M8 15h8|M2 22v-1a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1|M14 22v-1a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v1", + "thermometer": "M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z", + "thermometer-snowflake": "m10 20-1.25-2.5L6 18|M10 4 8.75 6.5 6 6|M10.585 15H10|M2 12h6.5L10 9|M20 14.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0z|m4 10 1.5 2L4 14|m7 21 3-6-1.5-3|m7 3 3 6h2", + "thermometer-sun": "M12 2v2|M12 8a4 4 0 0 0-1.645 7.647|M2 12h2|M20 14.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0z|m4.93 4.93 1.41 1.41|m6.34 17.66-1.41 1.41", + "thumbs-down": "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z|M17 14V2", + "thumbs-up": "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z|M7 10v12", + "ticket": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|M13 5v2|M13 17v2|M13 11v2", + "ticket-check": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|m9 12 2 2 4-4", + "ticket-minus": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|M9 12h6", + "ticket-percent": "M2 9a3 3 0 1 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 1 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|M9 9h.01|m15 9-6 6|M15 15h.01", + "ticket-plus": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|M9 12h6|M12 9v6", + "ticket-slash": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|m9.5 14.5 5-5", + "ticket-x": "M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z|m9.5 14.5 5-5|m9.5 9.5 5 5", + "tickets": "m3.173 8.18 11-5a2 2 0 0 1 2.647.993L18.56 8|M6 10V8|M6 14v1|M6 19v2|rect;x=2;y=8;width=20;height=13;rx=2", + "tickets-plane": "M10.5 17h1.227a2 2 0 0 0 1.345-.52L18 12|m12 13.5 3.794.506|m3.173 8.18 11-5a2 2 0 0 1 2.647.993L18.56 8|M6 10V8|M6 14v1|M6 19v2|rect;x=2;y=8;width=20;height=13;rx=2", + "timer": "line;x1=10;x2=14;y1=2;y2=2|line;x1=12;x2=15;y1=14;y2=11|circle;cx=12;cy=14;r=8", + "timer-off": "M10 2h4|M4.6 11a8 8 0 0 0 1.7 8.7 8 8 0 0 0 8.7 1.7|M7.4 7.4a8 8 0 0 1 10.3 1 8 8 0 0 1 .9 10.2|m2 2 20 20|M12 12v-2", + "timer-reset": "M10 2h4|M12 14v-4|M4 13a8 8 0 0 1 8-7 8 8 0 1 1-5.3 14L4 17.6|M9 17H4v5", + "toggle-left": "circle;cx=9;cy=12;r=3|rect;width=20;height=14;x=2;y=5;rx=7", + "toggle-right": "circle;cx=15;cy=12;r=3|rect;width=20;height=14;x=2;y=5;rx=7", + "toilet": "M7 12h13a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-.598a.5.5 0 0 0-.424.765l1.544 2.47a.5.5 0 0 1-.424.765H5.402a.5.5 0 0 1-.424-.765L7 18|M8 18a5 5 0 0 1-5-5V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8", + "tool-case": "M10 15h4|m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27|m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122|M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z", + "toolbox": "M16 12v4|M16 6a2 2 0 0 1 1.414.586l4 4A2 2 0 0 1 22 12v7a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 .586-1.414l4-4A2 2 0 0 1 8 6z|M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2|M2 14h20|M8 12v4", + "tornado": "M21 4H3|M18 8H6|M19 12H9|M16 16h-6|M11 20H9", + "torus": "ellipse;cx=12;cy=11;rx=3;ry=2|ellipse;cx=12;cy=12.5;rx=10;ry=8.5", + "touchpad": "rect;width=20;height=16;x=2;y=4;rx=2|M2 14h20|M12 20v-6", + "touchpad-off": "M12 20v-6|M19.656 14H22|M2 14h12|m2 2 20 20|M20 20H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2|M9.656 4H20a2 2 0 0 1 2 2v10.344", + "tower-control": "M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z|M8 13v9|M16 22v-9|m9 6 1 7|m15 6-1 7|M12 6V2|M13 2h-2", + "toy-brick": "rect;width=18;height=12;x=3;y=8;rx=1|M10 8V5c0-.6-.4-1-1-1H6a1 1 0 0 0-1 1v3|M19 8V5c0-.6-.4-1-1-1h-3a1 1 0 0 0-1 1v3", + "tractor": "m10 11 11 .9a1 1 0 0 1 .8 1.1l-.665 4.158a1 1 0 0 1-.988.842H20|M16 18h-5|M18 5a1 1 0 0 0-1 1v5.573|M3 4h8.129a1 1 0 0 1 .99.863L13 11.246|M4 11V4|M7 15h.01|M8 10.1V4|circle;cx=18;cy=18;r=2|circle;cx=7;cy=15;r=5", + "traffic-cone": "M16.05 10.966a5 2.5 0 0 1-8.1 0|m16.923 14.049 4.48 2.04a1 1 0 0 1 .001 1.831l-8.574 3.9a2 2 0 0 1-1.66 0l-8.574-3.91a1 1 0 0 1 0-1.83l4.484-2.04|M16.949 14.14a5 2.5 0 1 1-9.9 0L10.063 3.5a2 2 0 0 1 3.874 0z|M9.194 6.57a5 2.5 0 0 0 5.61 0", + "train-front": "M8 3.1V7a4 4 0 0 0 8 0V3.1|m9 15-1-1|m15 15 1-1|M9 19c-2.8 0-5-2.2-5-5v-4a8 8 0 0 1 16 0v4c0 2.8-2.2 5-5 5Z|m8 19-2 3|m16 19 2 3", + "train-front-tunnel": "M2 22V12a10 10 0 1 1 20 0v10|M15 6.8v1.4a3 2.8 0 1 1-6 0V6.8|M10 15h.01|M14 15h.01|M10 19a4 4 0 0 1-4-4v-3a6 6 0 1 1 12 0v3a4 4 0 0 1-4 4Z|m9 19-2 3|m15 19 2 3", + "train-track": "M2 17 17 2|m2 14 8 8|m5 11 8 8|m8 8 8 8|m11 5 8 8|m14 2 8 8|M7 22 22 7", + "tram-front": "rect;width=16;height=16;x=4;y=3;rx=2|M4 11h16|M12 3v8|m8 19-2 3|m18 22-2-3|M8 15h.01|M16 15h.01", + "transgender": "M12 16v6|M14 20h-4|M18 2h4v4|m2 2 7.17 7.17|M2 5.355V2h3.357|m22 2-7.17 7.17|M8 5 5 8|circle;cx=12;cy=12;r=4", + "trash": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6|M3 6h18|M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2", + "trash-2": "M10 11v6|M14 11v6|M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6|M3 6h18|M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2", + "tree-deciduous": "M8 19a4 4 0 0 1-2.24-7.32A3.5 3.5 0 0 1 9 6.03V6a3 3 0 1 1 6 0v.04a3.5 3.5 0 0 1 3.24 5.65A4 4 0 0 1 16 19Z|M12 19v3", + "tree-palm": "M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4|M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3|M5.89 9.71c-2.15 2.15-2.3 5.47-.35 7.43l4.24-4.25.7-.7.71-.71 2.12-2.12c-1.95-1.96-5.27-1.8-7.42.35|M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14", + "tree-pine": "m17 14 3 3.3a1 1 0 0 1-.7 1.7H4.7a1 1 0 0 1-.7-1.7L7 14h-.3a1 1 0 0 1-.7-1.7L9 9h-.2A1 1 0 0 1 8 7.3L12 3l4 4.3a1 1 0 0 1-.8 1.7H15l3 3.3a1 1 0 0 1-.7 1.7H17Z|M12 22v-3", + "trees": "M10 10v.2A3 3 0 0 1 8.9 16H5a3 3 0 0 1-1-5.8V10a3 3 0 0 1 6 0Z|M7 16v6|M13 19v3|M12 19h8.3a1 1 0 0 0 .7-1.7L18 14h.3a1 1 0 0 0 .7-1.7L16 9h.2a1 1 0 0 0 .8-1.7L13 3l-1.4 1.5", + "trello": "rect;width=18;height=18;x=3;y=3;rx=2;ry=2|rect;width=3;height=9;x=7;y=7|rect;width=3;height=5;x=14;y=7", + "trending-down": "M16 17h6v-6|m22 17-8.5-8.5-5 5L2 7", + "trending-up": "M16 7h6v6|m22 7-8.5 8.5-5-5L2 17", + "trending-up-down": "M14.828 14.828 21 21|M21 16v5h-5|m21 3-9 9-4-4-6 6|M21 8V3h-5", + "triangle": "M13.73 4a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z", + "triangle-alert": "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3|M12 9v4|M12 17h.01", + "triangle-dashed": "M10.17 4.193a2 2 0 0 1 3.666.013|M14 21h2|m15.874 7.743 1 1.732|m18.849 12.952 1 1.732|M21.824 18.18a2 2 0 0 1-1.835 2.824|M4.024 21a2 2 0 0 1-1.839-2.839|m5.136 12.952-1 1.732|M8 21h2|m8.102 7.743-1 1.732", + "triangle-right": "M22 18a2 2 0 0 1-2 2H3c-1.1 0-1.3-.6-.4-1.3L20.4 4.3c.9-.7 1.6-.4 1.6.7Z", + "trophy": "M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978|M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978|M18 9h1.5a1 1 0 0 0 0-5H18|M4 22h16|M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z|M6 9H4.5a1 1 0 0 1 0-5H6", + "truck": "M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2|M15 18H9|M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14|circle;cx=17;cy=18;r=2|circle;cx=7;cy=18;r=2", + "truck-electric": "M14 19V7a2 2 0 0 0-2-2H9|M15 19H9|M19 19h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62L18.3 9.38a1 1 0 0 0-.78-.38H14|M2 13v5a1 1 0 0 0 1 1h2|M4 3 2.15 5.15a.495.495 0 0 0 .35.86h2.15a.47.47 0 0 1 .35.86L3 9.02|circle;cx=17;cy=19;r=2|circle;cx=7;cy=19;r=2", + "turkish-lira": "M15 4 5 9|m15 8.5-10 5|M18 12a9 9 0 0 1-9 9V3", + "turntable": "M10 12.01h.01|M18 8v4a8 8 0 0 1-1.07 4|circle;cx=10;cy=12;r=4|rect;x=2;y=4;width=20;height=16;rx=2", + "turtle": "m12 10 2 4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3a8 8 0 1 0-16 0v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3l2-4h4Z|M4.82 7.9 8 10|M15.18 7.9 12 10|M16.93 10H20a2 2 0 0 1 0 4H2", + "tv": "m17 2-5 5-5-5|rect;width=20;height=15;x=2;y=7;rx=2", + "tv-minimal": "M7 21h10|rect;width=20;height=14;x=2;y=3;rx=2", + "tv-minimal-play": "M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z|M7 21h10|rect;width=20;height=14;x=2;y=3;rx=2", + "twitch": "M21 2H3v16h5v4l4-4h5l4-4V2zm-10 9V7m5 4V7", + "twitter": "M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z", + "type": "M12 4v16|M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2|M9 20h6", + "type-outline": "M14 16.5a.5.5 0 0 0 .5.5h.5a2 2 0 0 1 0 4H9a2 2 0 0 1 0-4h.5a.5.5 0 0 0 .5-.5v-9a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5V8a2 2 0 0 1-4 0V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v3a2 2 0 0 1-4 0v-.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5Z", + "umbrella": "M12 13v7a2 2 0 0 0 4 0|M12 2v2|M20.992 13a1 1 0 0 0 .97-1.274 10.284 10.284 0 0 0-19.923 0A1 1 0 0 0 3 13z", + "umbrella-off": "M12 13v7a2 2 0 0 0 4 0|M12 2v2|M18.656 13h2.336a1 1 0 0 0 .97-1.274 10.284 10.284 0 0 0-12.07-7.51|m2 2 20 20|M5.961 5.957a10.28 10.28 0 0 0-3.922 5.769A1 1 0 0 0 3 13h10", + "underline": "M6 4v6a6 6 0 0 0 12 0V4|line;x1=4;x2=20;y1=20;y2=20", + "undo": "M3 7v6h6|M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13", + "undo-2": "M9 14 4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11", + "undo-dot": "M21 17a9 9 0 0 0-15-6.7L3 13|M3 7v6h6|circle;cx=12;cy=17;r=1", + "unfold-horizontal": "M16 12h6|M8 12H2|M12 2v2|M12 8v2|M12 14v2|M12 20v2|m19 15 3-3-3-3|m5 9-3 3 3 3", + "unfold-vertical": "M12 22v-6|M12 8V2|M4 12H2|M10 12H8|M16 12h-2|M22 12h-2|m15 19-3 3-3-3|m15 5-3-3-3 3", + "ungroup": "rect;width=8;height=6;x=5;y=4;rx=1|rect;width=8;height=6;x=11;y=14;rx=1", + "university": "M14 21v-3a2 2 0 0 0-4 0v3|M18 12h.01|M18 16h.01|M22 7a1 1 0 0 0-1-1h-2a2 2 0 0 1-1.143-.359L13.143 2.36a2 2 0 0 0-2.286-.001L6.143 5.64A2 2 0 0 1 5 6H3a1 1 0 0 0-1 1v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2z|M6 12h.01|M6 16h.01|circle;cx=12;cy=10;r=2", + "unlink": "m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71|m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71|line;x1=8;x2=8;y1=2;y2=5|line;x1=2;x2=5;y1=8;y2=8|line;x1=16;x2=16;y1=19;y2=22|line;x1=19;x2=22;y1=16;y2=16", + "unlink-2": "M15 7h2a5 5 0 0 1 0 10h-2m-6 0H7A5 5 0 0 1 7 7h2", + "unplug": "m19 5 3-3|m2 22 3-3|M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z|M7.5 13.5 10 11|M10.5 16.5 13 14|m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z", + "upload": "M12 3v12|m17 8-5-5-5 5|M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", + "usb": "circle;cx=10;cy=7;r=1|circle;cx=4;cy=20;r=1|M4.7 19.3 19 5|m21 3-3 1 2 2Z|M9.26 7.68 5 12l2 5|m10 14 5 2 3.5-3.5|m18 12 1-1 1 1-1 1Z", + "user": "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2|circle;cx=12;cy=7;r=4", + "user-check": "m16 11 2 2 4-4|M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|circle;cx=9;cy=7;r=4", + "user-cog": "M10 15H6a4 4 0 0 0-4 4v2|m14.305 16.53.923-.382|m15.228 13.852-.923-.383|m16.852 12.228-.383-.923|m16.852 17.772-.383.924|m19.148 12.228.383-.923|m19.53 18.696-.382-.924|m20.772 13.852.924-.383|m20.772 16.148.924.383|circle;cx=18;cy=15;r=3|circle;cx=9;cy=7;r=4", + "user-key": "M20 11v6|M20 13h2|M3 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 2.072.578|circle;cx=10;cy=7;r=4|circle;cx=20;cy=19;r=2", + "user-lock": "M19 16v-2a2 2 0 0 0-4 0v2|M9.5 15H7a4 4 0 0 0-4 4v2|circle;cx=10;cy=7;r=4|rect;x=13;y=16;width=8;height=5;rx=.899", + "user-minus": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|circle;cx=9;cy=7;r=4|line;x1=22;x2=16;y1=11;y2=11", + "user-pen": "M11.5 15H7a4 4 0 0 0-4 4v2|M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z|circle;cx=10;cy=7;r=4", + "user-plus": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|circle;cx=9;cy=7;r=4|line;x1=19;x2=19;y1=8;y2=14|line;x1=22;x2=16;y1=11;y2=11", + "user-round": "circle;cx=12;cy=8;r=5|M20 21a8 8 0 0 0-16 0", + "user-round-check": "M2 21a8 8 0 0 1 13.292-6|circle;cx=10;cy=8;r=5|m16 19 2 2 4-4", + "user-round-cog": "m14.305 19.53.923-.382|m15.228 16.852-.923-.383|m16.852 15.228-.383-.923|m16.852 20.772-.383.924|m19.148 15.228.383-.923|m19.53 21.696-.382-.924|M2 21a8 8 0 0 1 10.434-7.62|m20.772 16.852.924-.383|m20.772 19.148.924.383|circle;cx=10;cy=8;r=5|circle;cx=18;cy=18;r=3", + "user-round-key": "M19 11v6|M19 13h2|M2 21a8 8 0 0 1 12.868-6.349|circle;cx=10;cy=8;r=5|circle;cx=19;cy=19;r=2", + "user-round-minus": "M2 21a8 8 0 0 1 13.292-6|circle;cx=10;cy=8;r=5|M22 19h-6", + "user-round-pen": "M2 21a8 8 0 0 1 10.821-7.487|M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z|circle;cx=10;cy=8;r=5", + "user-round-plus": "M2 21a8 8 0 0 1 13.292-6|circle;cx=10;cy=8;r=5|M19 16v6|M22 19h-6", + "user-round-search": "circle;cx=10;cy=8;r=5|M2 21a8 8 0 0 1 10.434-7.62|circle;cx=18;cy=18;r=3|m22 22-1.9-1.9", + "user-round-x": "M2 21a8 8 0 0 1 11.873-7|circle;cx=10;cy=8;r=5|m17 17 5 5|m22 17-5 5", + "user-search": "circle;cx=10;cy=7;r=4|M10.3 15H7a4 4 0 0 0-4 4v2|circle;cx=17;cy=17;r=3|m21 21-1.9-1.9", + "user-star": "M16.051 12.616a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.866l-1.156-1.153a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z|M8 15H7a4 4 0 0 0-4 4v2|circle;cx=10;cy=7;r=4", + "user-x": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|circle;cx=9;cy=7;r=4|line;x1=17;x2=22;y1=8;y2=13|line;x1=22;x2=17;y1=8;y2=13", + "users": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M16 3.128a4 4 0 0 1 0 7.744|M22 21v-2a4 4 0 0 0-3-3.87|circle;cx=9;cy=7;r=4", + "users-round": "M18 21a8 8 0 0 0-16 0|circle;cx=10;cy=8;r=5|M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3", + "utensils": "M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2|M7 2v20|M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7", + "utensils-crossed": "m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8|M15 15 3.3 3.3a4.2 4.2 0 0 0 0 6l7.3 7.3c.7.7 2 .7 2.8 0L15 15Zm0 0 7 7|m2.1 21.8 6.4-6.3|m19 5-7 7", + "utility-pole": "M12 2v20|M2 5h20|M3 3v2|M7 3v2|M17 3v2|M21 3v2|m19 5-7 7-7-7", + "van": "M13 6v5a1 1 0 0 0 1 1h6.102a1 1 0 0 1 .712.298l.898.91a1 1 0 0 1 .288.702V17a1 1 0 0 1-1 1h-3|M5 18H3a1 1 0 0 1-1-1V8a2 2 0 0 1 2-2h12c1.1 0 2.1.8 2.4 1.8l1.176 4.2|M9 18h5|circle;cx=16;cy=18;r=2|circle;cx=7;cy=18;r=2", + "variable": "M8 21s-4-3-4-9 4-9 4-9|M16 3s4 3 4 9-4 9-4 9|line;x1=15;x2=9;y1=9;y2=15|line;x1=9;x2=15;y1=9;y2=15", + "vault": "rect;width=18;height=18;x=3;y=3;rx=2|circle;cx=7.5;cy=7.5;r=.5;fill=currentColor|m7.9 7.9 2.7 2.7|circle;cx=16.5;cy=7.5;r=.5;fill=currentColor|m13.4 10.6 2.7-2.7|circle;cx=7.5;cy=16.5;r=.5;fill=currentColor|m7.9 16.1 2.7-2.7|circle;cx=16.5;cy=16.5;r=.5;fill=currentColor|m13.4 13.4 2.7 2.7|circle;cx=12;cy=12;r=2", + "vector-square": "M19.5 7a24 24 0 0 1 0 10|M4.5 7a24 24 0 0 0 0 10|M7 19.5a24 24 0 0 0 10 0|M7 4.5a24 24 0 0 1 10 0|rect;x=17;y=17;width=5;height=5;rx=1|rect;x=17;y=2;width=5;height=5;rx=1|rect;x=2;y=17;width=5;height=5;rx=1|rect;x=2;y=2;width=5;height=5;rx=1", + "vegan": "M16 8q6 0 6-6-6 0-6 6|M17.41 3.59a10 10 0 1 0 3 3|M2 2a26.6 26.6 0 0 1 10 20c.9-6.82 1.5-9.5 4-14", + "venetian-mask": "M18 11c-1.5 0-2.5.5-3 2|M4 6a2 2 0 0 0-2 2v4a5 5 0 0 0 5 5 8 8 0 0 1 5 2 8 8 0 0 1 5-2 5 5 0 0 0 5-5V8a2 2 0 0 0-2-2h-3a8 8 0 0 0-5 2 8 8 0 0 0-5-2z|M6 11c1.5 0 2.5.5 3 2", + "venus": "M12 15v7|M9 19h6|circle;cx=12;cy=9;r=6", + "venus-and-mars": "M10 20h4|M12 16v6|M17 2h4v4|m21 2-5.46 5.46|circle;cx=12;cy=11;r=5", + "vibrate": "m2 8 2 2-2 2 2 2-2 2|m22 8-2 2 2 2-2 2 2 2|rect;width=8;height=14;x=8;y=5;rx=1", + "vibrate-off": "m2 8 2 2-2 2 2 2-2 2|m22 8-2 2 2 2-2 2 2 2|M8 8v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2|M16 10.34V6c0-.55-.45-1-1-1h-4.34|line;x1=2;x2=22;y1=2;y2=22", + "video": "m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5|rect;x=2;y=6;width=14;height=12;rx=2", + "video-off": "M10.66 6H14a2 2 0 0 1 2 2v2.5l5.248-3.062A.5.5 0 0 1 22 7.87v8.196|M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2|m2 2 20 20", + "videotape": "rect;width=20;height=16;x=2;y=4;rx=2|M2 8h20|circle;cx=8;cy=14;r=2|M8 12h8|circle;cx=16;cy=14;r=2", + "view": "M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2|M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2|circle;cx=12;cy=12;r=1|M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0", + "voicemail": "circle;cx=6;cy=12;r=4|circle;cx=18;cy=12;r=4|line;x1=6;x2=18;y1=16;y2=16", + "volleyball": "M11.1 7.1a16.55 16.55 0 0 1 10.9 4|M12 12a12.6 12.6 0 0 1-8.7 5|M16.8 13.6a16.55 16.55 0 0 1-9 7.5|M20.7 17a12.8 12.8 0 0 0-8.7-5 13.3 13.3 0 0 1 0-10|M6.3 3.8a16.55 16.55 0 0 0 1.9 11.5|circle;cx=12;cy=12;r=10", + "volume": "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z", + "volume-1": "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z|M16 9a5 5 0 0 1 0 6", + "volume-2": "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z|M16 9a5 5 0 0 1 0 6|M19.364 18.364a9 9 0 0 0 0-12.728", + "volume-off": "M16 9a5 5 0 0 1 .95 2.293|M19.364 5.636a9 9 0 0 1 1.889 9.96|m2 2 20 20|m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11|M9.828 4.172A.686.686 0 0 1 11 4.657v.686", + "volume-x": "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z|line;x1=22;x2=16;y1=9;y2=15|line;x1=16;x2=22;y1=9;y2=15", + "vote": "m9 12 2 2 4-4|M5 7c0-1.1.9-2 2-2h10a2 2 0 0 1 2 2v12H5V7Z|M22 19H2", + "wallet": "M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1|M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4", + "wallet-cards": "rect;width=18;height=18;x=3;y=3;rx=2|M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2|M3 11h3c.8 0 1.6.3 2.1.9l1.1.9c1.6 1.6 4.1 1.6 5.7 0l1.1-.9c.5-.5 1.3-.9 2.1-.9H21", + "wallet-minimal": "M17 14h.01|M7 7h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14", + "wallpaper": "M12 17v4|M8 21h8|m9 17 6.1-6.1a2 2 0 0 1 2.81.01L22 15|circle;cx=8;cy=9;r=2|rect;x=2;y=3;width=20;height=14;rx=2", + "wand": "M15 4V2|M15 16v-2|M8 9h2|M20 9h2|M17.8 11.8 19 13|M15 9h.01|M17.8 6.2 19 5|m3 21 9-9|M12.2 6.2 11 5", + "wand-sparkles": "m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72|m14 7 3 3|M5 6v4|M19 14v4|M10 2v2|M7 8H3|M21 16h-4|M11 3H9", + "warehouse": "M18 21V10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v11|M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 1.132-1.803l7.95-3.974a2 2 0 0 1 1.837 0l7.948 3.974A2 2 0 0 1 22 8z|M6 13h12|M6 17h12", + "washing-machine": "M3 6h3|M17 6h.01|rect;width=18;height=20;x=3;y=2;rx=2|circle;cx=12;cy=13;r=5|M12 18a2.5 2.5 0 0 0 0-5 2.5 2.5 0 0 1 0-5", + "watch": "M12 10v2.2l1.6 1|m16.13 7.66-.81-4.05a2 2 0 0 0-2-1.61h-2.68a2 2 0 0 0-2 1.61l-.78 4.05|m7.88 16.36.8 4a2 2 0 0 0 2 1.61h2.72a2 2 0 0 0 2-1.61l.81-4.05|circle;cx=12;cy=12;r=6", + "waves": "M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1", + "waves-arrow-down": "M12 10L12 2|M16 6L12 10L8 6|M2 15C2.6 15.5 3.2 16 4.5 16C7 16 7 14 9.5 14C12.1 14 11.9 16 14.5 16C17 16 17 14 19.5 14C20.8 14 21.4 14.5 22 15|M2 21C2.6 21.5 3.2 22 4.5 22C7 22 7 20 9.5 20C12.1 20 11.9 22 14.5 22C17 22 17 20 19.5 20C20.8 20 21.4 20.5 22 21", + "waves-arrow-up": "M12 2v8|M2 15c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|m8 6 4-4 4 4", + "waves-ladder": "M19 5a2 2 0 0 0-2 2v11|M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1|M7 13h10|M7 9h10|M9 5a2 2 0 0 0-2 2v11", + "waypoints": "m10.586 5.414-5.172 5.172|m18.586 13.414-5.172 5.172|M6 12h12|circle;cx=12;cy=20;r=2|circle;cx=12;cy=4;r=2|circle;cx=20;cy=12;r=2|circle;cx=4;cy=12;r=2", + "webcam": "circle;cx=12;cy=10;r=8|circle;cx=12;cy=10;r=3|M7 22h10|M12 22v-4", + "webhook": "M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2|m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06|m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8", + "webhook-off": "M17 17h-5c-1.09-.02-1.94.92-2.5 1.9A3 3 0 1 1 2.57 15|M9 3.4a4 4 0 0 1 6.52.66|m6 17 3.1-5.8a2.5 2.5 0 0 0 .057-2.05|M20.3 20.3a4 4 0 0 1-2.3.7|M18.6 13a4 4 0 0 1 3.357 3.414|m12 6 .6 1|m2 2 20 20", + "weight": "circle;cx=12;cy=5;r=3|M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z", + "weight-tilde": "M6.5 8a2 2 0 0 0-1.906 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8z|M7.999 15a2.5 2.5 0 0 1 4 0 2.5 2.5 0 0 0 4 0|circle;cx=12;cy=5;r=3", + "wheat": "M2 22 16 8|M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z|M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z|M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z|M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z|M11.47 17.47 13 19l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L5 19l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z|M15.47 13.47 17 15l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L9 15l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z|M19.47 9.47 21 11l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L13 11l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z", + "wheat-off": "m2 22 10-10|m16 8-1.17 1.17|M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z|m8 8-.53.53a3.5 3.5 0 0 0 0 4.94L9 15l1.53-1.53c.55-.55.88-1.25.98-1.97|M10.91 5.26c.15-.26.34-.51.56-.73L13 3l1.53 1.53a3.5 3.5 0 0 1 .28 4.62|M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z|M11.47 17.47 13 19l-1.53 1.53a3.5 3.5 0 0 1-4.94 0L5 19l1.53-1.53a3.5 3.5 0 0 1 4.94 0Z|m16 16-.53.53a3.5 3.5 0 0 1-4.94 0L9 15l1.53-1.53a3.49 3.49 0 0 1 1.97-.98|M18.74 13.09c.26-.15.51-.34.73-.56L21 11l-1.53-1.53a3.5 3.5 0 0 0-4.62-.28|line;x1=2;x2=22;y1=2;y2=22", + "whole-word": "circle;cx=7;cy=12;r=3|M10 9v6|circle;cx=17;cy=12;r=3|M14 7v8|M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1", + "wifi": "M12 20h.01|M2 8.82a15 15 0 0 1 20 0|M5 12.859a10 10 0 0 1 14 0|M8.5 16.429a5 5 0 0 1 7 0", + "wifi-cog": "m14.305 19.53.923-.382|m15.228 16.852-.923-.383|m16.852 15.228-.383-.923|m16.852 20.772-.383.924|m19.148 15.228.383-.923|m19.53 21.696-.382-.924|M2 7.82a15 15 0 0 1 20 0|m20.772 16.852.924-.383|m20.772 19.148.924.383|M5 11.858a10 10 0 0 1 11.5-1.785|M8.5 15.429a5 5 0 0 1 2.413-1.31|circle;cx=18;cy=18;r=3", + "wifi-high": "M12 20h.01|M5 12.859a10 10 0 0 1 14 0|M8.5 16.429a5 5 0 0 1 7 0", + "wifi-low": "M12 20h.01|M8.5 16.429a5 5 0 0 1 7 0", + "wifi-off": "M12 20h.01|M8.5 16.429a5 5 0 0 1 7 0|M5 12.859a10 10 0 0 1 5.17-2.69|M19 12.859a10 10 0 0 0-2.007-1.523|M2 8.82a15 15 0 0 1 4.177-2.643|M22 8.82a15 15 0 0 0-11.288-3.764|m2 2 20 20", + "wifi-pen": "M2 8.82a15 15 0 0 1 20 0|M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z|M5 12.859a10 10 0 0 1 10.5-2.222|M8.5 16.429a5 5 0 0 1 3-1.406", + "wifi-sync": "M11.965 10.105v4L13.5 12.5a5 5 0 0 1 8 1.5|M11.965 14.105h4|M17.965 18.105h4L20.43 19.71a5 5 0 0 1-8-1.5|M2 8.82a15 15 0 0 1 20 0|M21.965 22.105v-4|M5 12.86a10 10 0 0 1 3-2.032|M8.5 16.429h.01", + "wifi-zero": "M12 20h.01", + "wind": "M12.8 19.6A2 2 0 1 0 14 16H2|M17.5 8a2.5 2.5 0 1 1 2 4H2|M9.8 4.4A2 2 0 1 1 11 8H2", + "wind-arrow-down": "M10 2v8|M12.8 21.6A2 2 0 1 0 14 18H2|M17.5 10a2.5 2.5 0 1 1 2 4H2|m6 6 4 4 4-4", + "wine": "M8 22h8|M7 10h10|M12 15v7|M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z", + "wine-off": "M8 22h8|M7 10h3m7 0h-1.343|M12 15v7|M7.307 7.307A12.33 12.33 0 0 0 7 10a5 5 0 0 0 7.391 4.391M8.638 2.981C8.75 2.668 8.872 2.34 9 2h6c1.5 4 2 6 2 8 0 .407-.05.809-.145 1.198|line;x1=2;x2=22;y1=2;y2=22", + "workflow": "rect;width=8;height=8;x=3;y=3;rx=2|M7 11v4a2 2 0 0 0 2 2h4|rect;width=8;height=8;x=13;y=13;rx=2", + "worm": "m19 12-1.5 3|M19.63 18.81 22 20|M6.47 8.23a1.68 1.68 0 0 1 2.44 1.93l-.64 2.08a6.76 6.76 0 0 0 10.16 7.67l.42-.27a1 1 0 1 0-2.73-4.21l-.42.27a1.76 1.76 0 0 1-2.63-1.99l.64-2.08A6.66 6.66 0 0 0 3.94 3.9l-.7.4a1 1 0 1 0 2.55 4.34z", + "wrench": "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z", + "x": "M18 6 6 18|m6 6 12 12", + "youtube": "M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17|m10 15 5-3-5-3z", + "zap": "M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z", + "zap-off": "M10.513 4.856 13.12 2.17a.5.5 0 0 1 .86.46l-1.377 4.317|M15.656 10H20a1 1 0 0 1 .78 1.63l-1.72 1.773|M16.273 16.273 10.88 21.83a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14H4a1 1 0 0 1-.78-1.63l4.507-4.643|m2 2 20 20", + "zoom-in": "circle;cx=11;cy=11;r=8|line;x1=21;x2=16.65;y1=21;y2=16.65|line;x1=11;x2=11;y1=8;y2=14|line;x1=8;x2=14;y1=11;y2=11", + "zoom-out": "circle;cx=11;cy=11;r=8|line;x1=21;x2=16.65;y1=21;y2=16.65|line;x1=8;x2=14;y1=11;y2=11" + }, + "aliases": { + "activity-square": "square-activity", + "alarm-check": "alarm-clock-check", + "alarm-minus": "alarm-clock-minus", + "alarm-plus": "alarm-clock-plus", + "alert-circle": "circle-alert", + "alert-octagon": "octagon-alert", + "alert-triangle": "triangle-alert", + "align-center": "text-align-center", + "align-justify": "text-align-justify", + "align-left": "text-align-start", + "align-right": "text-align-end", + "area-chart": "chart-area", + "arrow-down-01": "arrow-down-0-1", + "arrow-down-10": "arrow-down-1-0", + "arrow-down-az": "arrow-down-a-z", + "arrow-down-circle": "circle-arrow-down", + "arrow-down-left-from-circle": "circle-arrow-out-down-left", + "arrow-down-left-from-square": "square-arrow-out-down-left", + "arrow-down-left-square": "square-arrow-down-left", + "arrow-down-right-from-circle": "circle-arrow-out-down-right", + "arrow-down-right-from-square": "square-arrow-out-down-right", + "arrow-down-right-square": "square-arrow-down-right", + "arrow-down-square": "square-arrow-down", + "arrow-down-za": "arrow-down-z-a", + "arrow-left-circle": "circle-arrow-left", + "arrow-left-square": "square-arrow-left", + "arrow-right-circle": "circle-arrow-right", + "arrow-right-square": "square-arrow-right", + "arrow-up-01": "arrow-up-0-1", + "arrow-up-10": "arrow-up-1-0", + "arrow-up-az": "arrow-up-a-z", + "arrow-up-circle": "circle-arrow-up", + "arrow-up-left-from-circle": "circle-arrow-out-up-left", + "arrow-up-left-from-square": "square-arrow-out-up-left", + "arrow-up-left-square": "square-arrow-up-left", + "arrow-up-right-from-circle": "circle-arrow-out-up-right", + "arrow-up-right-from-square": "square-arrow-out-up-right", + "arrow-up-right-square": "square-arrow-up-right", + "arrow-up-square": "square-arrow-up", + "arrow-up-za": "arrow-up-z-a", + "asterisk-square": "square-asterisk", + "axis-3-d": "axis-3d", + "badge-help": "badge-question-mark", + "bar-chart": "chart-no-axes-column-increasing", + "bar-chart-2": "chart-no-axes-column", + "bar-chart-3": "chart-column", + "bar-chart-4": "chart-column-increasing", + "bar-chart-big": "chart-column-big", + "bar-chart-horizontal": "chart-bar", + "bar-chart-horizontal-big": "chart-bar-big", + "between-horizonal-end": "between-horizontal-end", + "between-horizonal-start": "between-horizontal-start", + "book-template": "book-dashed", + "box-select": "square-dashed", + "candlestick-chart": "chart-candlestick", + "check-circle": "circle-check-big", + "check-circle-2": "circle-check", + "check-square": "square-check-big", + "check-square-2": "square-check", + "chevron-down-circle": "circle-chevron-down", + "chevron-down-square": "square-chevron-down", + "chevron-left-circle": "circle-chevron-left", + "chevron-left-square": "square-chevron-left", + "chevron-right-circle": "circle-chevron-right", + "chevron-right-square": "square-chevron-right", + "chevron-up-circle": "circle-chevron-up", + "chevron-up-square": "square-chevron-up", + "chrome": "chromium", + "circle-help": "circle-question-mark", + "circle-slashed": "circle-slash-2", + "clipboard-edit": "clipboard-pen", + "clipboard-signature": "clipboard-pen-line", + "code-2": "code-xml", + "code-square": "square-code", + "columns": "columns-2", + "columns-settings": "columns-3-cog", + "contact-2": "contact-round", + "curly-braces": "braces", + "divide-circle": "circle-divide", + "divide-square": "square-divide", + "dot-square": "square-dot", + "download-cloud": "cloud-download", + "edit": "square-pen", + "edit-2": "pen", + "edit-3": "pen-line", + "equal-square": "square-equal", + "file-audio": "file-headphone", + "file-audio-2": "file-headphone", + "file-axis-3-d": "file-axis-3d", + "file-badge-2": "file-badge", + "file-bar-chart": "file-chart-column-increasing", + "file-bar-chart-2": "file-chart-column", + "file-check-2": "file-check-corner", + "file-code-2": "file-code-corner", + "file-cog-2": "file-cog", + "file-edit": "file-pen", + "file-json": "file-braces", + "file-json-2": "file-braces-corner", + "file-key-2": "file-key", + "file-line-chart": "file-chart-line", + "file-lock-2": "file-lock", + "file-minus-2": "file-minus-corner", + "file-pie-chart": "file-chart-pie", + "file-plus-2": "file-plus-corner", + "file-question": "file-question-mark", + "file-search-2": "file-search-corner", + "file-signature": "file-pen-line", + "file-type-2": "file-type-corner", + "file-video": "file-play", + "file-video-2": "file-video-camera", + "file-volume-2": "file-signal", + "file-warning": "file-exclamation-point", + "file-x-2": "file-x-corner", + "filter": "funnel", + "filter-x": "funnel-x", + "fingerprint": "fingerprint-pattern", + "folder-cog-2": "folder-cog", + "folder-edit": "folder-pen", + "fork-knife": "utensils", + "fork-knife-crossed": "utensils-crossed", + "form-input": "rectangle-ellipsis", + "function-square": "square-function", + "gantt-chart": "chart-no-axes-gantt", + "gantt-chart-square": "square-chart-gantt", + "gauge-circle": "circle-gauge", + "git-commit": "git-commit-horizontal", + "globe-2": "earth", + "grab": "hand-grab", + "grid": "grid-3x3", + "grid-2-x-2": "grid-2x2", + "grid-2-x-2-check": "grid-2x2-check", + "grid-2-x-2-plus": "grid-2x2-plus", + "grid-2-x-2-x": "grid-2x2-x", + "grid-3-x-3": "grid-3x3", + "help-circle": "circle-question-mark", + "helping-hand": "hand-helping", + "home": "house", + "ice-cream": "ice-cream-cone", + "ice-cream-2": "ice-cream-bowl", + "indent": "list-indent-increase", + "indent-decrease": "list-indent-decrease", + "indent-increase": "list-indent-increase", + "inspect": "square-mouse-pointer", + "kanban-square": "square-kanban", + "kanban-square-dashed": "square-dashed-kanban", + "laptop-2": "laptop-minimal", + "layers-3": "layers", + "layout": "panels-top-left", + "letter-text": "text-initial", + "library-square": "square-library", + "line-chart": "chart-line", + "loader-2": "loader-circle", + "location-edit": "map-pin-pen", + "m-square": "square-m", + "mail-question": "mail-question-mark", + "menu-square": "square-menu", + "message-circle-question": "message-circle-question-mark", + "mic-2": "mic-vocal", + "minus-circle": "circle-minus", + "minus-square": "square-minus", + "more-horizontal": "ellipsis", + "more-vertical": "ellipsis-vertical", + "mouse-pointer-square-dashed": "square-dashed-mouse-pointer", + "move-3-d": "move-3d", + "outdent": "list-indent-decrease", + "paintbrush-2": "paintbrush-vertical", + "palmtree": "tree-palm", + "panel-bottom-inactive": "panel-bottom-dashed", + "panel-left-inactive": "panel-left-dashed", + "panel-right-inactive": "panel-right-dashed", + "panel-top-inactive": "panel-top-dashed", + "panels-left-right": "columns-3", + "panels-top-bottom": "rows-3", + "parking-circle": "circle-parking", + "parking-circle-off": "circle-parking-off", + "parking-square": "square-parking", + "parking-square-off": "square-parking-off", + "pause-circle": "circle-pause", + "pause-octagon": "octagon-pause", + "pen-box": "square-pen", + "pen-square": "square-pen", + "percent-circle": "circle-percent", + "percent-diamond": "diamond-percent", + "percent-square": "square-percent", + "pi-square": "square-pi", + "pie-chart": "chart-pie", + "pilcrow-square": "square-pilcrow", + "play-circle": "circle-play", + "play-square": "square-play", + "plug-zap-2": "plug-zap", + "plus-circle": "circle-plus", + "plus-square": "square-plus", + "power-circle": "circle-power", + "power-square": "square-power", + "rotate-3-d": "rotate-3d", + "rows": "rows-2", + "scale-3-d": "scale-3d", + "scatter-chart": "chart-scatter", + "school-2": "university", + "scissors-square": "square-scissors", + "scissors-square-dashed-bottom": "square-bottom-dashed-scissors", + "send-horizonal": "send-horizontal", + "shield-close": "shield-x", + "shield-question": "shield-question-mark", + "sidebar": "panel-left", + "sidebar-close": "panel-left-close", + "sidebar-open": "panel-left-open", + "sigma-square": "square-sigma", + "slash-square": "square-slash", + "sliders": "sliders-vertical", + "sort-asc": "arrow-up-narrow-wide", + "sort-desc": "arrow-down-wide-narrow", + "split-square-horizontal": "square-split-horizontal", + "split-square-vertical": "square-split-vertical", + "square-gantt-chart": "square-chart-gantt", + "stars": "sparkles", + "stop-circle": "circle-stop", + "subtitles": "captions", + "table-config": "columns-3-cog", + "terminal-square": "square-terminal", + "test-tube-2": "test-tube-diagonal", + "text": "text-align-start", + "text-selection": "text-select", + "train": "tram-front", + "tv-2": "tv-minimal", + "unlock": "lock-open", + "unlock-keyhole": "lock-keyhole-open", + "upload-cloud": "cloud-upload", + "user-2": "user-round", + "user-check-2": "user-round-check", + "user-circle": "circle-user", + "user-circle-2": "circle-user-round", + "user-cog-2": "user-round-cog", + "user-minus-2": "user-round-minus", + "user-plus-2": "user-round-plus", + "user-square": "square-user", + "user-square-2": "square-user-round", + "user-x-2": "user-round-x", + "users-2": "users-round", + "verified": "badge-check", + "wallet-2": "wallet-minimal", + "wand-2": "wand-sparkles", + "wrap-text": "text-wrap", + "x-circle": "circle-x", + "x-octagon": "octagon-x", + "x-square": "square-x" + } +} diff --git a/apps/mobile/src/lib/projectIcon.test.ts b/apps/mobile/src/lib/projectIcon.test.ts new file mode 100644 index 0000000000000..58107f2620adb --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { lookupLucideIconNodes } from "./lucideIcon"; +import { resolveProjectIconGlyph } from "./projectIcon"; + +const IMAGE_DATA_URL = "data:image/png;base64,iVBORw0KGgo="; + +describe("resolveProjectIconGlyph", () => { + it("returns null without an assigned icon", () => { + expect(resolveProjectIconGlyph(null, "Control Plane")).toBeNull(); + expect(resolveProjectIconGlyph(undefined, "Control Plane")).toBeNull(); + }); + + it("draws a known Lucide name as its glyph nodes in the icon color", () => { + const glyph = resolveProjectIconGlyph( + { kind: "lucide", name: "folder-code", color: "sky" }, + "Control Plane", + ); + expect(glyph).toMatchObject({ kind: "lucide", name: "folder-code", color: "sky" }); + expect(glyph?.kind === "lucide" ? glyph.nodes : null).toEqual([ + ["path", { d: "M10 10.5 8 13l2 2.5" }], + ["path", { d: "m14 10.5 2 2.5-2 2.5" }], + [ + "path", + { + d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z", + }, + ], + ]); + }); + + it("resolves a Lucide alias to its canonical icon's nodes", () => { + const alias = resolveProjectIconGlyph( + { kind: "lucide", name: "alarm-check", color: "red" }, + "Alarms", + ); + expect(alias).toMatchObject({ kind: "lucide", name: "alarm-check", color: "red" }); + expect(alias?.kind === "lucide" ? alias.nodes : null).toEqual( + lookupLucideIconNodes("alarm-clock-check"), + ); + }); + + it("falls back to the title monogram in the icon color for an unknown Lucide name", () => { + expect( + resolveProjectIconGlyph( + { kind: "lucide", name: "not-a-lucide-icon", color: "violet" }, + "Control Plane", + ), + ).toEqual({ kind: "monogram", text: "CP", color: "violet" }); + expect( + resolveProjectIconGlyph({ kind: "lucide", name: "constructor", color: "gray" }, "api"), + ).toEqual({ kind: "monogram", text: "AI", color: "gray" }); + }); + + it("passes emoji, monogram, and image icons through", () => { + expect(resolveProjectIconGlyph({ kind: "emoji", emoji: "🚀" }, "Rocket")).toEqual({ + kind: "emoji", + emoji: "🚀", + }); + expect( + resolveProjectIconGlyph({ kind: "monogram", text: "HT", color: "emerald" }, "Hometrace"), + ).toEqual({ kind: "monogram", text: "HT", color: "emerald" }); + expect(resolveProjectIconGlyph({ kind: "image", dataUrl: IMAGE_DATA_URL }, "Photos")).toEqual({ + kind: "image", + dataUrl: IMAGE_DATA_URL, + }); + }); +}); diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts index 6bad062e3600f..b506708961797 100644 --- a/apps/mobile/src/lib/projectIcon.ts +++ b/apps/mobile/src/lib/projectIcon.ts @@ -1,6 +1,14 @@ import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import { lookupLucideIconNodes, type LucideIconNode } from "./lucideIcon"; + export type ProjectIconGlyph = + | { + readonly kind: "lucide"; + readonly name: string; + readonly nodes: ReadonlyArray; + readonly color: ProjectIconColor; + } | { readonly kind: "emoji"; readonly emoji: string } | { readonly kind: "image"; readonly dataUrl: string } | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; @@ -33,9 +41,10 @@ export function projectMonogram(projectName: string): string { } /** - * Picks what mobile draws for an assigned project icon. Mobile does not bundle - * the Lucide set, so a Lucide override keeps its color and falls back to the - * project's monogram instead of the folder glyph. + * Picks what mobile draws for an assigned project icon. A Lucide override draws + * the same glyph as web from generated node data; a name that data lacks (a newer + * Lucide on web) keeps its color and falls back to the project's monogram + * instead of the folder glyph. */ export function resolveProjectIconGlyph( projectIcon: ProjectIconOverride | null | undefined, @@ -48,8 +57,12 @@ export function resolveProjectIconGlyph( return { kind: "image", dataUrl: projectIcon.dataUrl }; case "monogram": return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; - case "lucide": - return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; + case "lucide": { + const nodes = lookupLucideIconNodes(projectIcon.name); + return nodes === null + ? { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color } + : { kind: "lucide", name: projectIcon.name, nodes, color: projectIcon.color }; + } case undefined: return null; } @@ -82,3 +95,29 @@ const PROJECT_ICON_COLOR_CLASSES: Record< export function projectIconColorClassNames(color: ProjectIconColor) { return PROJECT_ICON_COLOR_CLASSES[color]; } + +/** Tailwind's 500 shades as hex, for SVG strokes that cannot take a className. */ +const PROJECT_ICON_COLOR_HEX: Record = { + gray: "#6b7280", + red: "#ef4444", + orange: "#f97316", + amber: "#f59e0b", + yellow: "#eab308", + lime: "#84cc16", + green: "#22c55e", + emerald: "#10b981", + teal: "#14b8a6", + cyan: "#06b6d4", + sky: "#0ea5e9", + blue: "#3b82f6", + indigo: "#6366f1", + violet: "#8b5cf6", + purple: "#a855f7", + fuchsia: "#d946ef", + pink: "#ec4899", + rose: "#f43f5e", +}; + +export function projectIconColorHex(color: ProjectIconColor): string { + return PROJECT_ICON_COLOR_HEX[color]; +} diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 1ef36f43fff68..12188676cd5da 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -175,6 +175,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); + expect(second.capabilities.projectReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequests).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c3c93a18e73dc..42bc5d559ece5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -241,6 +241,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + projectReorder: true, assistants: true, projectSchedules: { scheduler: scheduleBackend.scheduler, timeZone: hostTimeZone.zone }, threadTitleRegeneration: true, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index e215dd3f78727..c28a20e630f9d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -68,6 +68,7 @@ function commandToAggregateRef(command: OrchestrationCommand): { switch (command.type) { case "project.create": case "project.meta.update": + case "project.reorder": case "project.delete": case "project.schedule.record": case "project.schedule.agent-change": diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 4995d0c6a4118..b80e2a97474b7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4566,6 +4566,58 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }), ); + it.effect("project order keys persist through snapshot and shell reads, and survive edits", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make("project-order-key"); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-order-key-create"), + projectId, + title: "Ordered", + workspaceRoot: "/tmp/project-order-key", + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + }); + const readProject = () => + snapshotQuery + .getSnapshot() + .pipe(Effect.map((snapshot) => snapshot.projects.find((p) => p.id === projectId))); + const before = yield* readProject(); + assert.strictEqual(before?.orderKey, null); + + yield* engine.dispatch({ + type: "project.reorder", + commandId: CommandId.make("cmd-order-key-reorder"), + projectId, + orderKey: "m", + }); + const reordered = yield* readProject(); + assert.strictEqual(reordered?.orderKey, "m"); + // Arranging the list is not project activity. + assert.strictEqual(reordered?.updatedAt, before?.updatedAt); + const shell = yield* snapshotQuery.getProjectShellById(projectId); + assert.strictEqual(Option.getOrNull(shell)?.orderKey, "m"); + + // Every later write spreads the stored row; a select that missed the column would null it. + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("cmd-order-key-rename"), + projectId, + title: "Renamed", + }); + const renamed = yield* readProject(); + assert.strictEqual(renamed?.title, "Renamed"); + assert.strictEqual(renamed?.orderKey, "m"); + const rows = yield* sql<{ + readonly key: string | null; + }>`SELECT order_key AS key FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(rows, [{ key: "m" }]); + }), + ); + it.effect("re-creating a deleted thread id starts from an empty projection", () => Effect.gen(function* () { const engine = yield* OrchestrationEngineService; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7aad0a703689f..5b5037829398d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -510,6 +510,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti autoPull: false, faviconPath: event.payload.faviconPath ?? null, projectIcon: event.payload.projectIcon ?? null, + orderKey: null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -543,6 +544,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.projectIcon !== undefined ? { projectIcon: event.payload.projectIcon } : {}), + ...(event.payload.orderKey !== undefined ? { orderKey: event.payload.orderKey } : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), ...(event.payload.assistant !== undefined ? { assistant: event.payload.assistant } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4559c99ce649d..40269ea0bee70 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -407,6 +407,7 @@ function mapProjectShellRow( autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, + orderKey: row.orderKey ?? null, // Omitted for workspaces so they stay off the wire. Schedule prompts // never ride the shell; `schedules.status` returns them. ...(row.assistant ? { assistant: withoutSchedulePrompts(row.assistant) } : {}), @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", + order_key AS "orderKey", assistant_json AS "assistant", scripts_json AS "scripts", created_at AS "createdAt", @@ -1151,6 +1153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", + order_key AS "orderKey", assistant_json AS "assistant", scripts_json AS "scripts", created_at AS "createdAt", @@ -1178,6 +1181,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", + order_key AS "orderKey", assistant_json AS "assistant", scripts_json AS "scripts", created_at AS "createdAt", @@ -2329,6 +2333,7 @@ pending_approval_requests AS ( autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, + orderKey: row.orderKey ?? null, ...(row.assistant ? { assistant: row.assistant } : {}), scripts: row.scripts, createdAt: row.createdAt, @@ -2498,6 +2503,7 @@ pending_approval_requests AS ( autoPull: row.autoPull === 1, faviconPath: row.faviconPath ?? null, projectIcon: row.projectIcon ?? null, + orderKey: row.orderKey ?? null, ...(row.assistant ? { assistant: row.assistant } : {}), scripts: row.scripts, createdAt: row.createdAt, @@ -3053,6 +3059,7 @@ pending_approval_requests AS ( autoPull: option.value.autoPull === 1, faviconPath: option.value.faviconPath ?? null, projectIcon: option.value.projectIcon ?? null, + orderKey: option.value.orderKey ?? null, ...(option.value.assistant ? { assistant: option.value.assistant } : {}), scripts: option.value.scripts, createdAt: option.value.createdAt, diff --git a/apps/server/src/orchestration/decider.project-reorder.test.ts b/apps/server/src/orchestration/decider.project-reorder.test.ts new file mode 100644 index 0000000000000..d598def8dc75a --- /dev/null +++ b/apps/server/src/orchestration/decider.project-reorder.test.ts @@ -0,0 +1,159 @@ +import { + CommandId, + ProjectId, + type OrchestrationCommand, + type OrchestrationProject, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { isProjectReorderOnlyPayload } from "./projectOrderEvents.ts"; +import { projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const PROJECT_ID = ProjectId.make("project-1"); + +function makeProject(overrides: Partial = {}): OrchestrationProject { + return { + id: PROJECT_ID, + title: "Personal", + workspaceRoot: "/tmp/personal", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeReadModel(overrides: Partial = {}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [makeProject(overrides)], + threads: [], + updatedAt: NOW, + }; +} + +const reorder = ( + orderKey: string, + extra: { readonly ifKeyless?: true } = {}, +): Extract => ({ + type: "project.reorder", + commandId: CommandId.make(`cmd-reorder-${orderKey}`), + projectId: PROJECT_ID, + orderKey, + ...extra, +}); + +it.layer(NodeServices.layer)("project ordering", (it) => { + it.effect("stores the key without changing the project's updatedAt", () => + Effect.gen(function* () { + let readModel = makeReadModel({ updatedAt: "2025-06-01T00:00:00.000Z" }); + for (const orderKey of ["m", "m", "g"]) { + const decided = yield* decideOrchestrationCommand({ + command: reorder(orderKey), + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "project.meta-updated", + payload: { projectId: PROJECT_ID, orderKey, updatedAt: "2025-06-01T00:00:00.000Z" }, + }); + expect(isProjectReorderOnlyPayload(events[0]!.payload)).toBe(true); + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.projects[0]).toMatchObject({ + orderKey, + updatedAt: "2025-06-01T00:00:00.000Z", + }); + } + }), + ); + + it.effect("ifKeyless refuses to overwrite an existing key but seeds a keyless project", () => + Effect.gen(function* () { + const keyed = yield* decideOrchestrationCommand({ + command: reorder("m", { ifKeyless: true }), + readModel: makeReadModel({ orderKey: "g" }), + }).pipe(Effect.flip); + expect(keyed._tag).toBe("OrchestrationCommandInvariantError"); + + const decided = yield* decideOrchestrationCommand({ + command: reorder("m", { ifKeyless: true }), + readModel: makeReadModel({ orderKey: null }), + }); + expect(Array.isArray(decided) ? decided[0] : decided).toMatchObject({ + payload: { orderKey: "m" }, + }); + }), + ); + + it.effect("rejects a missing or deleted project", () => + Effect.gen(function* () { + const missing = yield* decideOrchestrationCommand({ + command: { ...reorder("m"), projectId: ProjectId.make("nope") }, + readModel: makeReadModel(), + }).pipe(Effect.flip); + expect(missing._tag).toBe("OrchestrationCommandInvariantError"); + const deleted = yield* decideOrchestrationCommand({ + command: reorder("m"), + readModel: makeReadModel({ deletedAt: NOW }), + }).pipe(Effect.flip); + expect(deleted._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("a rename after a reorder keeps the key and only then bumps updatedAt", () => + Effect.gen(function* () { + let readModel = makeReadModel({ orderKey: "m" }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-rename"), + projectId: PROJECT_ID, + title: "Renamed", + }, + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(isProjectReorderOnlyPayload(events[0]!.payload)).toBe(false); + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.projects[0]).toMatchObject({ title: "Renamed", orderKey: "m" }); + }), + ); + + it.effect("clears the key when a Project converts back to a workspace", () => + Effect.gen(function* () { + const readModel = makeReadModel({ + orderKey: "m", + assistant: { coordinatorThreadId: "thread-1" as never }, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-convert"), + projectId: PROJECT_ID, + assistant: null, + }, + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.at(-1)).toMatchObject({ payload: { assistant: null, orderKey: null } }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3a82de4b8a7c6..764fdfce75a28 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -307,6 +307,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), ...(assistant !== undefined ? { assistant } : {}), + // Projects and Tasks folders are separate lists, so a key from one would drop the + // project at an arbitrary place in the other. + ...(assistant !== undefined && (assistant === null) !== (project.assistant == null) + ? { orderKey: null } + : {}), updatedAt: occurredAt, }, }; @@ -315,6 +320,46 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return companions.length > 0 ? [...companions, metaUpdatedEvent] : metaUpdatedEvent; } + case "project.reorder": { + const project = yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + if (project.deletedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' is deleted and cannot be reordered.`, + }); + } + // A seed from a client's saved local order must not overwrite an arrangement that + // another client already published. Rejecting is the no-op: a command has to produce + // an event, and the seeding client ignores this failure. + if (command.ifKeyless === true && project.orderKey != null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' already has an order key.`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + })), + type: "project.meta-updated" as const, + payload: { + projectId: command.projectId, + orderKey: command.orderKey, + // Arranging the list is not project activity: keep activity sorts and the + // duplicate-workspace winner where they were. + updatedAt: project.updatedAt, + }, + }; + } + case "project.delete": { const project = yield* requireProject({ readModel, diff --git a/apps/server/src/orchestration/projectOrderEvents.ts b/apps/server/src/orchestration/projectOrderEvents.ts new file mode 100644 index 0000000000000..7b0e2e412f1ab --- /dev/null +++ b/apps/server/src/orchestration/projectOrderEvents.ts @@ -0,0 +1,10 @@ +/** + * A `project.meta-updated` payload that only arranges the sidebar. Reactors that reconcile + * schedules or held runs on project edits skip these: a drag must not re-apply OS schedule + * entries, and a seed writes one per project. + */ +export function isProjectReorderOnlyPayload(payload: object): boolean { + return Object.keys(payload).every( + (key) => key === "projectId" || key === "orderKey" || key === "updatedAt", + ); +} diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0a58b81d4ceac..8544ae1ba658f 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -346,6 +346,7 @@ export function projectEvent( autoPull: false, faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, + orderKey: null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -388,6 +389,7 @@ export function projectEvent( ...(payload.projectIcon !== undefined ? { projectIcon: payload.projectIcon } : {}), + ...(payload.orderKey !== undefined ? { orderKey: payload.orderKey } : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), ...(payload.assistant !== undefined ? { assistant: payload.assistant } : {}), updatedAt: payload.updatedAt, diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index 1e7397bcd7a82..75c21a6f82423 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -48,6 +48,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { auto_pull, favicon_path, project_icon_json, + order_key, assistant_json, scripts_json, created_at, @@ -63,6 +64,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.autoPull ? 1 : 0}, ${row.faviconPath ?? null}, ${row.projectIcon ? JSON.stringify(row.projectIcon) : null}, + ${row.orderKey ?? null}, ${row.assistant ? JSON.stringify(row.assistant) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -78,6 +80,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { auto_pull = excluded.auto_pull, favicon_path = excluded.favicon_path, project_icon_json = excluded.project_icon_json, + order_key = excluded.order_key, assistant_json = excluded.assistant_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -100,6 +103,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", + order_key AS "orderKey", assistant_json AS "assistant", scripts_json AS "scripts", created_at AS "createdAt", @@ -124,6 +128,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { auto_pull AS "autoPull", favicon_path AS "faviconPath", project_icon_json AS "projectIcon", + order_key AS "orderKey", assistant_json AS "assistant", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 27dea8f7baab4..fd2f15b6c4bb1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -67,6 +67,7 @@ import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; import Migration0054 from "./Migrations/054_ProjectionThreadMessageSource.ts"; import Migration0055 from "./Migrations/055_ProjectionProjectsAssistant.ts"; +import Migration0056 from "./Migrations/056_ProjectionProjectsOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -134,6 +135,7 @@ const migrationEntries = [ [53, "PullRequestFilesViewed", Migration0053], [54, "ProjectionThreadMessageSource", Migration0054], [55, "ProjectionProjectsAssistant", Migration0055], + [56, "ProjectionProjectsOrderKey", Migration0056], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.test.ts b/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.test.ts new file mode 100644 index 0000000000000..bfc711c417ca7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.test.ts @@ -0,0 +1,30 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import Migration0056 from "./056_ProjectionProjectsOrderKey.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("056_ProjectionProjectsOrderKey", (it) => { + it.effect("adds a nullable order key column, and a rerun is a no-op", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 55 }); + yield* runMigrations({ toMigrationInclusive: 56 }); + yield* Migration0056; + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const orderKey = columns.filter((column) => column.name === "order_key"); + + assert.equal(orderKey.length, 1); + assert.equal(orderKey[0]?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.ts b/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.ts new file mode 100644 index 0000000000000..0096fd3c84b66 --- /dev/null +++ b/apps/server/src/persistence/Migrations/056_ProjectionProjectsOrderKey.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Fork migration. Once shipped, this id never moves (see 055). PRAGMA-guarded so a +// renumbered copy can run again. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "order_key")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 9dcc187cd2584..f4b76c3649d32 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -12,6 +12,7 @@ import { ProjectAssistant, ProjectIconOverride, ProjectId, + ProjectOrderKey, ProjectScript, ThreadEnvMode, } from "@t3tools/contracts"; @@ -31,6 +32,7 @@ export const ProjectionProject = Schema.Struct({ autoPull: Schema.Boolean, faviconPath: Schema.optional(Schema.NullOr(Schema.String)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + orderKey: Schema.optional(Schema.NullOr(ProjectOrderKey)), assistant: Schema.optional(Schema.NullOr(ProjectAssistant)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/schedules/ScheduleHost.ts b/apps/server/src/schedules/ScheduleHost.ts index 90a35fdddf373..6314c739d5ac3 100644 --- a/apps/server/src/schedules/ScheduleHost.ts +++ b/apps/server/src/schedules/ScheduleHost.ts @@ -73,6 +73,7 @@ import { resolveScheduleBackend, type ScheduleBackendMode, } from "./scheduleBackend.ts"; +import { isProjectReorderOnlyPayload } from "../orchestration/projectOrderEvents.ts"; import { makeSystemdBackend, renderScheduleUnits, systemdUnitDir } from "./systemdBackend.ts"; import { ScheduleRunner, @@ -516,7 +517,8 @@ export const make = Effect.gen(function* () { yield* worker.enqueue("startup"); yield* noteSeen(head); yield* Stream.runForEach(events, (event) => - (event.type === "project.meta-updated" || event.type === "project.deleted" + ((event.type === "project.meta-updated" && !isProjectReorderOnlyPayload(event.payload)) || + event.type === "project.deleted" ? enqueueReconcile : Effect.void ).pipe(Effect.andThen(noteSeen(event.sequence))), diff --git a/apps/server/src/schedules/ScheduleRunner.ts b/apps/server/src/schedules/ScheduleRunner.ts index dc8fd86b2df67..299b4f951c92f 100644 --- a/apps/server/src/schedules/ScheduleRunner.ts +++ b/apps/server/src/schedules/ScheduleRunner.ts @@ -77,6 +77,7 @@ import { } from "../persistence/Services/ProjectionThreads.ts"; import { ProjectionThreadSessionRepository } from "../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionTurnRepository } from "../persistence/Services/ProjectionTurns.ts"; +import { isProjectReorderOnlyPayload } from "../orchestration/projectOrderEvents.ts"; import { forkParked } from "../serverActivation.ts"; import { HostTimeZoneSource } from "./hostZone.ts"; import { makeScheduleQueries } from "./scheduleQueries.ts"; @@ -669,6 +670,9 @@ export const make = Effect.gen(function* () { ); // A pause, edit, delete or archive drops the Project's held runs. case "project.meta-updated": + return !isProjectReorderOnlyPayload(event.payload) && hasHoldIn(event.payload.projectId) + ? enqueueRelease + : Effect.void; case "project.deleted": return hasHoldIn(event.payload.projectId) ? enqueueRelease : Effect.void; default: diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6619c182ac1cf..96f265c527ac1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +import { orderProjectsByKey } from "@t3tools/client-runtime/state/project-order"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -878,15 +879,17 @@ function OpenCommandPaletteDialog(props: { ); const orderedProjects = useMemo( () => - orderItemsByPreferredIds({ - items: workspaceProjects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }), + orderProjectsByKey(workspaceProjects, (keyless) => + orderItemsByPreferredIds({ + items: keyless, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + ), [projectOrder, workspaceProjects], ); const unsortedProjectGroups = useMemo( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8449e1dcb469b..e9afc567ff282 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,6 +30,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import { partitionAssistants } from "@t3tools/client-runtime/state/assistants"; +import { orderProjectsByKey } from "@t3tools/client-runtime/state/project-order"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, @@ -43,6 +44,7 @@ import { resolveEnvironmentMachineKind, type EnvironmentMachineKind, type ProjectIconOverride, + type ScopedProjectRef, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; @@ -148,10 +150,12 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { readThreadShell, + readEnvironmentSupportsProjectReorder, useAllEnvironmentProjectSnapshotsReady, - useProjects, useThreadShells, } from "../state/entities"; +import { useProjectsWithOrderOverrides } from "../projectOrderOverrides"; +import { useProjectOrderWriter } from "../hooks/useProjectOrderWriter"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; @@ -248,7 +252,9 @@ import { useSidebarAssistants, useThreadGroupRollup, } from "./sidebar/SidebarAssistantsSection"; +import { planEntryArrange, planEntrySeed, type ArrangeEntry } from "./sidebar/projectArrange"; import { + assistantExpansionKey, flattenAssistantJumpOrder, selectableThreadKeys, settleableSelection, @@ -2393,7 +2399,8 @@ const SidebarProjectFolderBlock = memo(function SidebarProjectFolderBlock({ }); export default function Sidebar() { - const projects = useProjects(); + const projects = useProjectsWithOrderOverrides(); + const writeProjectOrder = useProjectOrderWriter(); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const reorderAssistants = useUiStateStore((store) => store.reorderAssistants); @@ -2549,17 +2556,21 @@ export default function Sidebar() { [primaryEnvironmentId, projects, threads], ); const { workspaceProjects, workspaceThreads } = assistantPartition; + // Folders arranged on any client (synced `orderKey`) lead; the rest keep this client's + // saved order, which is also what seeds the synced order the first time. const orderedProjects = useMemo( () => - orderItemsByPreferredIds({ - items: workspaceProjects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }), + orderProjectsByKey(workspaceProjects, (keyless) => + orderItemsByPreferredIds({ + items: keyless, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + ), [projectOrder, workspaceProjects], ); const useManualProjectOrder = sidebarProjectSortOrder === "manual" || folderOrderLocked; @@ -3132,6 +3143,49 @@ export default function Sidebar() { [projectFolders, settledViewOpen, tasksSectionExpanded], ); // Projects reorder among themselves. Two or more, or there is nothing to move. + // Publish this client's saved order once, the first time it meets a server without one. + // Only after every environment is live (a cached snapshot could hide keys another client + // already wrote), and never over an existing arrangement: seed writes are `ifKeyless`. + const savedAssistantOrder = useUiStateStore((store) => store.assistantOrder); + const seededProjectOrderRef = useRef(false); + useEffect(() => { + if (seededProjectOrderRef.current || !allProjectSnapshotsReady) return; + if (savedAssistantOrder.length === 0 && projectOrder.length === 0) return; + if (projects.some((project) => !serverConfigs.has(project.environmentId))) return; + const toEntry = (project: EnvironmentProject): ArrangeEntry => ({ + id: `${project.environmentId}:${project.id}`, + members: [ + { + environmentId: project.environmentId, + projectId: project.id, + orderKey: project.orderKey, + writable: readEnvironmentSupportsProjectReorder(project.environmentId), + }, + ], + }); + const writes = [ + ...(savedAssistantOrder.length > 0 + ? (planEntrySeed({ + entries: assistantModels.map((model) => toEntry(model.entry.project)), + }) ?? []) + : []), + ...(projectOrder.length > 0 + ? (planEntrySeed({ entries: orderedProjects.map(toEntry) }) ?? []) + : []), + ]; + if (writes.length === 0) return; + seededProjectOrderRef.current = true; + void writeProjectOrder(writes, { ifKeyless: true }); + }, [ + allProjectSnapshotsReady, + assistantModels, + orderedProjects, + projectOrder.length, + projects, + savedAssistantOrder.length, + serverConfigs, + writeProjectOrder, + ]); const assistantSortableIds = useMemo( () => settledViewOpen || !showAssistantsSection || !assistantsSectionExpanded @@ -4850,6 +4904,53 @@ export default function Sidebar() { if (sidebarProjectSortOrder !== "manual") { updateSettings({ sidebarProjectSortOrder: "manual" }); } + // Synced order first: the drop is one key per folder, written to the servers that own + // its projects. Folders on servers that predate it keep the local order below. + const toEntry = (id: string, refs: readonly ScopedProjectRef[]): ArrangeEntry => ({ + id, + members: refs.flatMap((ref) => { + const project = projectByKey.get(`${ref.environmentId}:${ref.projectId}`); + return project === undefined + ? [] + : [ + { + environmentId: project.environmentId, + projectId: project.id, + orderKey: project.orderKey, + writable: readEnvironmentSupportsProjectReorder(project.environmentId), + }, + ]; + }), + }); + const visibleEntries = folders.flatMap((folder) => + folder.project === null + ? [] + : [toEntry(folder.projectKey, folder.project.memberProjectRefs)], + ); + const shownRefKeys = new Set( + folders.flatMap((folder) => + (folder.project?.memberProjectRefs ?? []).map( + (ref) => `${ref.environmentId}:${ref.projectId}`, + ), + ), + ); + const hiddenEntries = orderedProjects + .filter((project) => !shownRefKeys.has(`${project.environmentId}:${project.id}`)) + .map((project) => + toEntry(`hidden:${project.environmentId}:${project.id}`, [ + scopeProjectRef(project.environmentId, project.id), + ]), + ); + const writes = planEntryArrange({ + visible: visibleEntries, + hidden: hiddenEntries, + activeId: activeFolder.projectKey, + overId: overFolder.projectKey, + }); + if (writes !== null) { + void writeProjectOrder(writes); + return; + } reorderProjects( [...visualOrder, ...rest], activeFolder.project.memberProjects.map((member) => member.physicalProjectKey), @@ -4858,35 +4959,73 @@ export default function Sidebar() { }, [ orderedProjects, + projectByKey, projectFolders, reorderProjects, settledProjectFolders, settledViewOpen, sidebarProjectSortOrder, updateSettings, + writeProjectOrder, ], ); + // Projects arrange one project per row. A drop is one key written to the owning server; + // Projects on a server that predates synced order keep the local order. + const arrangeAssistant = useCallback( + (activeKey: string, overKey: string) => { + const toEntry = (project: EnvironmentProject): ArrangeEntry => ({ + id: assistantExpansionKey(project.environmentId, project.id), + members: [ + { + environmentId: project.environmentId, + projectId: project.id, + orderKey: project.orderKey, + writable: readEnvironmentSupportsProjectReorder(project.environmentId), + }, + ], + }); + const shown = new Set(assistantModels.map((model) => model.key)); + const writes = planEntryArrange({ + visible: assistantModels.map((model) => toEntry(model.entry.project)), + hidden: projects + .filter( + (project) => + project.assistant != null && + !shown.has(assistantExpansionKey(project.environmentId, project.id)), + ) + .map(toEntry), + activeId: activeKey, + overId: overKey, + }); + if (writes !== null) { + void writeProjectOrder(writes); + return; + } + reorderAssistants( + assistantModels.map((model) => model.key), + activeKey, + overKey, + ); + }, + [assistantModels, projects, reorderAssistants, writeProjectOrder], + ); const handleAssistantDragEnd = useCallback( (event: DragEndEvent) => { setFolderDragActive(false); const { active, over } = event; if (!over || active.id === over.id) return; - reorderAssistants( - assistantModels.map((model) => model.key), - String(active.id), - String(over.id), - ); + arrangeAssistant(String(active.id), String(over.id)); }, - [assistantModels, reorderAssistants], + [arrangeAssistant], ); const handleAssistantMove = useCallback( (key: string, direction: -1 | 1) => { const order = assistantModels.map((model) => model.key); const neighbor = order[order.indexOf(key) + direction]; - if (neighbor !== undefined) reorderAssistants(order, key, neighbor); + if (neighbor !== undefined) arrangeAssistant(key, neighbor); }, - [assistantModels, reorderAssistants], + [arrangeAssistant, assistantModels], ); const newThreadShortcutLabel = diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 79bfd994d0f89..c815bea09725c 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,3 +1,4 @@ +import { orderProjectsByKey } from "@t3tools/client-runtime/state/project-order"; import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import type { ScopedProjectRef } from "@t3tools/contracts"; @@ -66,7 +67,8 @@ export function DraftHeroHeadline({ () => sortLogicalProjectsForSidebar( buildSidebarProjectSnapshots({ - projects, + // Manual keeps the synced folder order the sidebar shows. + projects: projectSortOrder === "manual" ? orderProjectsByKey(projects) : projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => diff --git a/apps/web/src/components/sidebar/SidebarAssistantsSection.tsx b/apps/web/src/components/sidebar/SidebarAssistantsSection.tsx index 11d0609c02264..41cf462f78624 100644 --- a/apps/web/src/components/sidebar/SidebarAssistantsSection.tsx +++ b/apps/web/src/components/sidebar/SidebarAssistantsSection.tsx @@ -42,7 +42,7 @@ import { animateSidebarLayoutChanges, type SidebarSection } from "../Sidebar.log import { assistantExpansionKey, isAssistantExpanded, - orderAssistantsByPreference, + orderAssistantModels, rollupAssistantsStatus, rollupThreadGroupStatus, visibleAssistantAgentRows, @@ -135,7 +135,7 @@ export function useSidebarAssistants(input: { }); }, [entries, nowMinute, serverConfigs, snoozeWakeTick]); const models = useMemo( - () => orderAssistantsByPreference(unorderedModels, assistantOrder), + () => orderAssistantModels(unorderedModels, assistantOrder), [assistantOrder, unorderedModels], ); const coordinatorKeys = useMemo( diff --git a/apps/web/src/components/sidebar/projectArrange.test.ts b/apps/web/src/components/sidebar/projectArrange.test.ts new file mode 100644 index 0000000000000..641a64a699c12 --- /dev/null +++ b/apps/web/src/components/sidebar/projectArrange.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { planEntryArrange, planEntrySeed, type ArrangeEntry } from "./projectArrange"; + +const entry = ( + id: string, + members: ReadonlyArray<{ + env: string; + key?: string | null; + writable?: boolean; + }>, +): ArrangeEntry => ({ + id, + members: members.map((member) => ({ + environmentId: member.env, + projectId: `${id}-${member.env}`, + orderKey: member.key ?? null, + writable: member.writable ?? true, + })), +}); + +describe("planEntryArrange", () => { + it("writes one key to a single-project entry between its keyed neighbours", () => { + const writes = planEntryArrange({ + visible: [ + entry("a", [{ env: "e1", key: "d" }]), + entry("b", [{ env: "e1", key: "h" }]), + entry("c", [{ env: "e1", key: "m" }]), + ], + hidden: [], + activeId: "c", + overId: "b", + }); + expect(writes).toHaveLength(1); + expect(writes![0]).toMatchObject({ environmentId: "e1", projectId: "c-e1" }); + expect(writes![0]!.orderKey > "d" && writes![0]!.orderKey < "h").toBe(true); + }); + + it("writes the same key to every writable member of a moved folder", () => { + const writes = planEntryArrange({ + visible: [ + entry("a", [{ env: "e1", key: "d" }]), + entry("folder", [ + { env: "e1", key: "m" }, + { env: "e2", key: "q" }, + { env: "old", writable: false }, + ]), + ], + hidden: [], + activeId: "folder", + overId: "a", + }); + expect(writes!.map((write) => write.environmentId).sort()).toEqual(["e1", "e2"]); + expect(new Set(writes!.map((write) => write.orderKey)).size).toBe(1); + expect(writes![0]!.orderKey < "d").toBe(true); + }); + + it("returns null when the moved entry lives only on servers without the capability", () => { + expect( + planEntryArrange({ + visible: [entry("a", [{ env: "e1" }]), entry("old", [{ env: "old", writable: false }])], + hidden: [], + activeId: "old", + overId: "a", + }), + ).toBeNull(); + }); + + it("never reuses a hidden entry's key", () => { + const writes = planEntryArrange({ + visible: [entry("a", [{ env: "e1", key: "d" }]), entry("b", [{ env: "e1", key: "m" }])], + hidden: [entry("hidden", [{ env: "e1", key: "h" }])], + activeId: "b", + overId: "a", + }); + for (const write of writes!) expect(write.orderKey).not.toBe("h"); + }); + + it("ignores a drop onto itself or an unknown row", () => { + const visible = [entry("a", [{ env: "e1" }]), entry("b", [{ env: "e1" }])]; + expect(planEntryArrange({ visible, hidden: [], activeId: "a", overId: "a" })).toBeNull(); + expect(planEntryArrange({ visible, hidden: [], activeId: "a", overId: "zzz" })).toBeNull(); + }); +}); + +describe("planEntrySeed", () => { + it("seeds keys in saved order, skipping servers that cannot store them", () => { + const writes = planEntrySeed({ + entries: [ + entry("b", [{ env: "e1" }]), + entry("old", [{ env: "old", writable: false }]), + entry("a", [{ env: "e2" }]), + ], + }); + expect(writes!.map((write) => write.projectId)).toEqual(["b-e1", "a-e2"]); + expect(writes![0]!.orderKey < writes![1]!.orderKey).toBe(true); + }); + + it("does nothing once any entry already has a key", () => { + expect( + planEntrySeed({ + entries: [entry("a", [{ env: "e1", key: "m" }]), entry("b", [{ env: "e1" }])], + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/projectArrange.ts b/apps/web/src/components/sidebar/projectArrange.ts new file mode 100644 index 0000000000000..dc100dabc3622 --- /dev/null +++ b/apps/web/src/components/sidebar/projectArrange.ts @@ -0,0 +1,101 @@ +import { + minProjectOrderKey, + moveIdToTarget, + planProjectOrderSeed, + planProjectReorder, +} from "@t3tools/client-runtime/state/project-order"; + +// Web glue for the synced order of Projects and Tasks folders. An "entry" is one row of a +// list: a Project (one member) or a Tasks folder (one member per project it groups, possibly +// on several environments). Entries are planned as a unit; the chosen key is then written to +// every member whose environment can store it, so members stay adjacent under any client's +// grouping (equal keys tie-break by environment id and project id). + +export interface ArrangeMember { + readonly environmentId: string; + readonly projectId: string; + readonly orderKey: string | null | undefined; + /** The member's environment understands `project.reorder`. */ + readonly writable: boolean; +} + +export interface ArrangeEntry { + readonly id: string; + readonly members: readonly ArrangeMember[]; +} + +export interface ProjectOrderWrite { + readonly environmentId: string; + readonly projectId: string; + readonly orderKey: string; +} + +function entryKey(entry: ArrangeEntry): string | null { + return minProjectOrderKey(entry.members.map((member) => member.orderKey)); +} + +function isWritable(entry: ArrangeEntry): boolean { + return entry.members.some((member) => member.writable); +} + +function fanOut( + entries: ReadonlyMap, + assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>, +): ProjectOrderWrite[] { + return assignments.flatMap(({ id, orderKey }) => + (entries.get(id)?.members ?? []) + .filter((member) => member.writable) + .map((member) => ({ + environmentId: member.environmentId, + projectId: member.projectId, + orderKey, + })), + ); +} + +/** + * Writes that realize dropping `activeId` at `overId`. `visible` is the list in displayed + * order; `hidden` are entries outside it (filtered, archived) whose keys stay reserved. Null + * when nothing can be written, so the caller falls back to the local order. + */ +export function planEntryArrange(input: { + readonly visible: readonly ArrangeEntry[]; + readonly hidden: readonly ArrangeEntry[]; + readonly activeId: string; + readonly overId: string; +}): ProjectOrderWrite[] | null { + const orderedIds = moveIdToTarget( + input.visible.map((entry) => entry.id), + input.activeId, + input.overId, + ); + if (orderedIds === null) return null; + const all = [...input.visible, ...input.hidden]; + const entries = new Map(all.map((entry) => [entry.id, entry] as const)); + const assignments = planProjectReorder({ + orderedIds, + movedId: input.activeId, + keysById: new Map(all.map((entry) => [entry.id, entryKey(entry)] as const)), + reorderableIds: new Set(all.filter(isWritable).map((entry) => entry.id)), + }); + if (assignments === null) return null; + const writes = fanOut(entries, assignments); + return writes.length === 0 ? null : writes; +} + +/** Writes that publish a client's saved order; null once anyone has arranged the list. */ +export function planEntrySeed(input: { + readonly entries: readonly ArrangeEntry[]; +}): ProjectOrderWrite[] | null { + const entries = new Map(input.entries.map((entry) => [entry.id, entry] as const)); + const assignments = planProjectOrderSeed({ + items: input.entries.map((entry) => ({ + id: entry.id, + orderKey: entryKey(entry), + writable: isWritable(entry), + })), + }); + if (assignments === null) return null; + const writes = fanOut(entries, assignments); + return writes.length === 0 ? null : writes; +} diff --git a/apps/web/src/components/sidebar/sidebarAssistants.logic.test.ts b/apps/web/src/components/sidebar/sidebarAssistants.logic.test.ts index 2ba602ae02420..75fedc3db0cd2 100644 --- a/apps/web/src/components/sidebar/sidebarAssistants.logic.test.ts +++ b/apps/web/src/components/sidebar/sidebarAssistants.logic.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test"; import { assistantExpansionKey, flattenAssistantJumpOrder, + orderAssistantModels, orderAssistantsByPreference, selectableThreadKeys, settleableSelection, @@ -167,3 +168,22 @@ describe("orderAssistantsByPreference", () => { expect(keys(orderAssistantsByPreference(rows, ["c", "b"]))).toEqual(["c", "b", "a"]); }); }); + +describe("orderAssistantModels", () => { + const model = (key: string, orderKey?: string) => ({ + key, + entry: { project: { orderKey } }, + }); + const keys = (list: readonly { key: string }[]) => list.map((row) => row.key); + + it("applies the saved order when nothing is arranged", () => { + const rows = [model("a"), model("b"), model("c")]; + expect(keys(orderAssistantModels(rows, ["c", "a"]))).toEqual(["c", "a", "b"]); + }); + + it("lets arranged Projects lead and the saved order arrange only the rest", () => { + // The partition hands arranged Projects over first and already in key order. + const rows = [model("x", "d"), model("y", "m"), model("a"), model("b"), model("c")]; + expect(keys(orderAssistantModels(rows, ["c", "y", "a"]))).toEqual(["x", "y", "c", "a", "b"]); + }); +}); diff --git a/apps/web/src/components/sidebar/sidebarAssistants.logic.ts b/apps/web/src/components/sidebar/sidebarAssistants.logic.ts index 39fad5f2a6a2d..e905eb93eade1 100644 --- a/apps/web/src/components/sidebar/sidebarAssistants.logic.ts +++ b/apps/web/src/components/sidebar/sidebarAssistants.logic.ts @@ -36,6 +36,23 @@ export function orderAssistantsByPreference( : orderItemsByPreferredIds({ items: models, preferredIds: order, getId: (model) => model.key }); } +/** + * The Projects section order: arranged Projects (synced `orderKey`, already first and in key + * order) lead, then this client's saved order applies to the rest, so a client that has not + * seeded the synced order yet (or talks to an older server) still shows its own arrangement. + */ +export function orderAssistantModels< + T extends { + readonly key: string; + readonly entry: { readonly project: { readonly orderKey?: string | null | undefined } }; + }, +>(models: readonly T[], legacyOrder: readonly string[]): readonly T[] { + const arranged = models.filter((model) => model.entry.project.orderKey != null); + if (arranged.length === 0) return orderAssistantsByPreference(models, legacyOrder); + const rest = models.filter((model) => model.entry.project.orderKey == null); + return [...arranged, ...orderAssistantsByPreference(rest, legacyOrder)]; +} + interface ScopedThreadLike { readonly environmentId: EnvironmentId; readonly id: ThreadId; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 6e7d6742749ba..d43ef75d9b99b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,3 +1,4 @@ +import { orderProjectsByKey } from "@t3tools/client-runtime/state/project-order"; import { useAtomValue } from "@effect/atom-react"; import { scopedProjectKey, @@ -465,15 +466,17 @@ export function useHandleNewThread() { // The fallback target for a new thread is a plain workspace, never a Project. const projects = useWorkspaceProjects(); const orderedProjects = useMemo(() => { - return orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); + return orderProjectsByKey(projects, (keyless) => + orderItemsByPreferredIds({ + items: keyless, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + ); }, [projectOrder, projects]); const handleNewThread = useNewThreadHandler(); diff --git a/apps/web/src/hooks/useProjectOrderWriter.ts b/apps/web/src/hooks/useProjectOrderWriter.ts new file mode 100644 index 0000000000000..b5d17132113e4 --- /dev/null +++ b/apps/web/src/hooks/useProjectOrderWriter.ts @@ -0,0 +1,62 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import type { ProjectOrderWrite } from "../components/sidebar/projectArrange"; +import { toastManager } from "../components/ui/toast"; +import { holdProjectOrder } from "../projectOrderOverrides"; +import { readEnvironmentSupportsProjectReorder } from "../state/entities"; +import { projectEnvironment } from "../state/projects"; +import { useAtomCommand } from "../state/use-atom-command"; + +/** + * Writes order keys to the servers that own the projects. A drop shows at once (see + * `holdProjectOrder`) and rolls back if a write fails. A seed (`ifKeyless`) is silent: a + * server that already holds an arrangement refuses it, which is the expected outcome. + */ +export function useProjectOrderWriter() { + const reorderProject = useAtomCommand(projectEnvironment.reorder, { reportFailure: false }); + return useCallback( + async ( + writes: readonly ProjectOrderWrite[], + options: { readonly ifKeyless?: boolean } = {}, + ): Promise => { + const supported = writes.filter((write) => + readEnvironmentSupportsProjectReorder(write.environmentId as EnvironmentId), + ); + if (supported.length === 0) return false; + const seeding = options.ifKeyless === true; + const hold = seeding ? null : holdProjectOrder(supported); + let succeeded = true; + for (const write of supported) { + const result = await reorderProject({ + environmentId: write.environmentId as EnvironmentId, + input: { + projectId: ProjectId.make(write.projectId), + orderKey: write.orderKey, + ...(seeding ? { ifKeyless: true as const } : {}), + }, + }); + if (result._tag === "Success") continue; + succeeded = false; + if (seeding) continue; + hold?.release(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to reorder", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return false; + } + hold?.releaseSoon(); + return succeeded; + }, + [reorderProject], + ); +} diff --git a/apps/web/src/projectOrderOverrides.ts b/apps/web/src/projectOrderOverrides.ts new file mode 100644 index 0000000000000..23d43875e987d --- /dev/null +++ b/apps/web/src/projectOrderOverrides.ts @@ -0,0 +1,72 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { create } from "zustand"; +import { useEffect, useMemo } from "react"; + +import { useProjects } from "./state/entities"; + +// A drop shows at once: the keys we just wrote stand in for the project records' `orderKey` +// until the shell stream delivers them. A failed write drops its override immediately; a +// successful one is dropped once the server value matches, or after a grace period if another +// client's later write superseded it. +const OVERRIDE_GRACE_MS = 5_000; + +const overrideKey = (environmentId: string, projectId: string) => `${environmentId}\0${projectId}`; + +interface ProjectOrderOverrideStore { + readonly overrides: Readonly>; + readonly set: (entries: ReadonlyArray) => void; + readonly clear: (keys: ReadonlyArray) => void; +} + +export const useProjectOrderOverrideStore = create((set) => ({ + overrides: {}, + set: (entries) => + set((state) => ({ overrides: { ...state.overrides, ...Object.fromEntries(entries) } })), + clear: (keys) => + set((state) => { + if (!keys.some((key) => key in state.overrides)) return state; + const overrides = { ...state.overrides }; + for (const key of keys) delete overrides[key]; + return { overrides }; + }), +})); + +export function holdProjectOrder( + writes: ReadonlyArray<{ + readonly environmentId: string; + readonly projectId: string; + readonly orderKey: string; + }>, +): { readonly release: () => void; readonly releaseSoon: () => void } { + const keys = writes.map((write) => overrideKey(write.environmentId, write.projectId)); + useProjectOrderOverrideStore + .getState() + .set(writes.map((write, index) => [keys[index]!, write.orderKey] as const)); + const release = () => useProjectOrderOverrideStore.getState().clear(keys); + return { release, releaseSoon: () => void setTimeout(release, OVERRIDE_GRACE_MS) }; +} + +/** `useProjects()` with pending drops applied. Same array while nothing is pending. */ +export function useProjectsWithOrderOverrides(): ReadonlyArray { + const projects = useProjects(); + const overrides = useProjectOrderOverrideStore((state) => state.overrides); + const clear = useProjectOrderOverrideStore((state) => state.clear); + const hasOverrides = Object.keys(overrides).length > 0; + + useEffect(() => { + if (!hasOverrides) return; + const settled = projects.flatMap((project) => { + const key = overrideKey(project.environmentId, project.id); + return overrides[key] !== undefined && overrides[key] === project.orderKey ? [key] : []; + }); + if (settled.length > 0) clear(settled); + }, [clear, hasOverrides, overrides, projects]); + + return useMemo(() => { + if (!hasOverrides) return projects; + return projects.map((project) => { + const orderKey = overrides[overrideKey(project.environmentId, project.id)]; + return orderKey === undefined ? project : { ...project, orderKey }; + }); + }, [hasOverrides, overrides, projects]); +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 91354ecc0f74d..2a3f09d1853aa 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -268,6 +268,15 @@ export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentI ); } +/** Whether the environment's server stores a manual order for Projects and Tasks folders + (`project.reorder`). Same version-skew contract as thread reordering. */ +export function readEnvironmentSupportsProjectReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .projectReorder === true + ); +} + export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index a438ee2f143b6..751c0d8feabef 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -239,6 +239,10 @@ "types": "./src/state/schedules.ts", "default": "./src/state/schedules.ts" }, + "./state/project-order": { + "types": "./src/state/projectOrder.ts", + "default": "./src/state/projectOrder.ts" + }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 8f313c1632ff8..ef72348eea195 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -30,6 +30,7 @@ type CommandInput = Omit< export type CreateProjectInput = CommandInput<"project.create">; export type UpdateProjectInput = CommandInput<"project.meta.update">; +export type ReorderProjectInput = CommandInput<"project.reorder">; export type DeleteProjectInput = CommandInput<"project.delete">; export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; @@ -114,6 +115,16 @@ export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effec }); }); +export const reorderProject: (input: ReorderProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.reorder", + commandId: yield* commandId(input), + }); +}); + export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( "EnvironmentCommands.deleteProject", )(function* (input) { diff --git a/packages/client-runtime/src/state/assistants.test.ts b/packages/client-runtime/src/state/assistants.test.ts index 00954d900f228..7a7c5f066e80f 100644 --- a/packages/client-runtime/src/state/assistants.test.ts +++ b/packages/client-runtime/src/state/assistants.test.ts @@ -93,6 +93,24 @@ describe("partitionAssistants", () => { expect(selectWorkspaceProjects(projects)).toBe(projects); }); + it("lists arranged Projects by synced key ahead of the default order", () => { + const alpha = project("alpha", { coordinator: "a" }); + const beta = project("beta", { coordinator: "b", orderKey: "n" }); + const gamma = project("gamma", { coordinator: "g", orderKey: "d" }); + const remoteDelta = project("delta", { + coordinator: "x", + environmentId: remote, + orderKey: "h", + }); + const partition = partitionAssistants([alpha, beta, gamma, remoteDelta], [], primary); + expect(partition.assistants.map((entry) => entry.project.id)).toEqual([ + "gamma", + "delta", + "beta", + "alpha", + ]); + }); + it("routes Project threads to their Project and hides archived Projects", () => { const personal = project("personal", { coordinator: "coord" }); const archived = project("old", { coordinator: "old-coord", archivedAt: NOW }); diff --git a/packages/client-runtime/src/state/assistants.ts b/packages/client-runtime/src/state/assistants.ts index 81e941a39f449..11feb769eab23 100644 --- a/packages/client-runtime/src/state/assistants.ts +++ b/packages/client-runtime/src/state/assistants.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import { derivePhysicalProjectKey } from "./projectGrouping.ts"; +import { compareProjectOrder } from "./projectOrder.ts"; import { effectiveSnoozed, type ThreadSnoozeShell } from "./threadSettled.ts"; import { resolveSettledThreadTimestamp, @@ -30,6 +31,7 @@ export interface AssistantProjectInput { readonly title: string; readonly workspaceRoot: string; readonly assistant?: ProjectAssistant | null | undefined; + readonly orderKey?: string | null | undefined; } export interface AssistantThreadInput { @@ -53,7 +55,7 @@ export interface AssistantEntry { } export interface AssistantPartition { - /** Unarchived Projects, primary environment first, then by title. */ + /** Unarchived Projects: arranged ones by synced key, then primary environment first, then by title. */ readonly assistants: readonly AssistantEntry[]; readonly archivedAssistants: readonly AssistantEntry[]; readonly workspaceProjects: readonly P[]; @@ -133,6 +135,9 @@ export function partitionAssistants< (isArchivedAssistant(draft.project) ? archivedAssistants : assistants).push(entry); } const compare = (left: AssistantEntry, right: AssistantEntry) => { + // Arranged Projects (synced `orderKey`) lead; the rest keep the default order below. + const arranged = compareProjectOrder(left.project, right.project); + if (arranged !== 0) return arranged; const leftPrimary = left.project.environmentId === primaryEnvironmentId ? 0 : 1; const rightPrimary = right.project.environmentId === primaryEnvironmentId ? 0 : 1; return ( diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index ea4d5ed95c152..7de6da34aa93a 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -11,9 +11,11 @@ import { import { type CreateProjectInput, type DeleteProjectInput, + type ReorderProjectInput, type UpdateProjectInput, createProject, deleteProject, + reorderProject, updateProject, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -86,6 +88,12 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), + reorder: createEnvironmentCommand(runtime, { + label: "environment-data:commands:project:reorder", + execute: (input: ReorderProjectInput) => reorderProject(input), + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), delete: createEnvironmentCommand(runtime, { label: "environment-data:commands:project:delete", execute: (input: DeleteProjectInput) => deleteProject(input), diff --git a/packages/client-runtime/src/state/projectOrder.test.ts b/packages/client-runtime/src/state/projectOrder.test.ts new file mode 100644 index 0000000000000..bd0c8fc800ef5 --- /dev/null +++ b/packages/client-runtime/src/state/projectOrder.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + compareProjectOrder, + minProjectOrderKey, + moveIdToTarget, + orderProjectsByKey, + planProjectOrderSeed, + planProjectReorder, +} from "./projectOrder.ts"; + +const p = (environmentId: string, id: string, orderKey?: string | null) => ({ + environmentId, + id, + orderKey, +}); + +describe("orderProjectsByKey", () => { + it("puts keyed projects first by key, across environments, keyless after in input order", () => { + const ordered = orderProjectsByKey([ + p("a", "z"), + p("b", "k2", "m"), + p("a", "k1", "c"), + p("a", "y", null), + ]); + expect(ordered.map((project) => project.id)).toEqual(["k1", "k2", "z", "y"]); + }); + + it("breaks equal keys by environment id then project id, on every client alike", () => { + const projects = [p("b", "one", "m"), p("a", "two", "m"), p("a", "one", "m")]; + const forward = orderProjectsByKey(projects).map((x) => `${x.environmentId}:${x.id}`); + const reversed = orderProjectsByKey([...projects].reverse()).map( + (x) => `${x.environmentId}:${x.id}`, + ); + expect(forward).toEqual(["a:one", "a:two", "b:one"]); + expect(reversed).toEqual(forward); + }); + + it("lets a caller order the keyless tail", () => { + const ordered = orderProjectsByKey([p("a", "x"), p("a", "y"), p("a", "z", "m")], (rest) => + [...rest].reverse(), + ); + expect(ordered.map((project) => project.id)).toEqual(["z", "y", "x"]); + }); + + it("keeps compareProjectOrder neutral for two keyless projects", () => { + expect(compareProjectOrder(p("a", "x"), p("a", "y", null))).toBe(0); + }); +}); + +describe("minProjectOrderKey", () => { + it("ignores keyless members and returns the lowest key", () => { + expect(minProjectOrderKey([null, "m", undefined, "c"])).toBe("c"); + expect(minProjectOrderKey([null, undefined])).toBeNull(); + }); +}); + +describe("planProjectReorder", () => { + const all = new Set(["a", "b", "c", "d"]); + + it("writes a single key between arranged neighbours", () => { + const keys = new Map([ + ["a", "d"], + ["b", "h"], + ["c", "m"], + ["d", "t"], + ]); + const plan = planProjectReorder({ + orderedIds: ["a", "c", "b", "d"], + movedId: "c", + keysById: keys, + reorderableIds: all, + }); + expect(plan).toHaveLength(1); + expect(plan![0]!.id).toBe("c"); + expect(plan![0]!.orderKey > "d" && plan![0]!.orderKey < "h").toBe(true); + }); + + it("materializes the visible list when a neighbour has no key yet", () => { + const plan = planProjectReorder({ + orderedIds: ["b", "a", "c"], + movedId: "b", + keysById: new Map(), + reorderableIds: new Set(["a", "b", "c"]), + }); + expect(plan!.map((assignment) => assignment.id)).toEqual(["b", "a", "c"]); + const written = plan!.map((assignment) => assignment.orderKey); + expect([...written].sort()).toEqual(written); + }); + + it("does not write to entries whose environment cannot store keys", () => { + const plan = planProjectReorder({ + orderedIds: ["b", "old", "a"], + movedId: "b", + keysById: new Map(), + reorderableIds: new Set(["a", "b"]), + }); + expect(plan!.map((assignment) => assignment.id).sort()).toEqual(["a", "b"]); + expect( + planProjectReorder({ + orderedIds: ["b", "old"], + movedId: "old", + keysById: new Map(), + reorderableIds: new Set(["b"]), + }), + ).toBeNull(); + }); + + it("keeps hidden entries' keys reserved", () => { + const keys = new Map([ + ["a", "d"], + ["hidden", "h"], + ["b", "m"], + ]); + const plan = planProjectReorder({ + orderedIds: ["b", "a"], + movedId: "b", + keysById: keys, + reorderableIds: new Set(["a", "b"]), + }); + for (const assignment of plan!) expect(assignment.orderKey).not.toBe("h"); + }); + + it("rewrites over a non-monotone visible order instead of failing", () => { + // An activity-sorted view whose order disagrees with the stored keys. + const keys = new Map([ + ["a", "t"], + ["b", "d"], + ]); + const plan = planProjectReorder({ + orderedIds: ["a", "b"], + movedId: "b", + keysById: keys, + reorderableIds: new Set(["a", "b"]), + }); + expect(plan).not.toBeNull(); + const byId = new Map(plan!.map((assignment) => [assignment.id, assignment.orderKey])); + expect((byId.get("a") ?? "t") < (byId.get("b") ?? "d")).toBe(true); + }); +}); + +describe("moveIdToTarget", () => { + it("moves in either direction and refuses no-ops", () => { + expect(moveIdToTarget(["a", "b", "c"], "a", "c")).toEqual(["b", "c", "a"]); + expect(moveIdToTarget(["a", "b", "c"], "c", "a")).toEqual(["c", "a", "b"]); + expect(moveIdToTarget(["a", "b"], "a", "a")).toBeNull(); + expect(moveIdToTarget(["a", "b"], "a", "zzz")).toBeNull(); + }); +}); + +describe("planProjectOrderSeed", () => { + const item = (id: string, orderKey: string | null, writable = true) => ({ + id, + orderKey, + writable, + }); + + it("publishes the saved order as ascending keys", () => { + const plan = planProjectOrderSeed({ + items: [item("c", null), item("a", null), item("b", null)], + }); + expect(plan!.map((assignment) => assignment.id)).toEqual(["c", "a", "b"]); + const keys = plan!.map((assignment) => assignment.orderKey); + expect([...keys].sort()).toEqual(keys); + expect(new Set(keys).size).toBe(3); + }); + + it("does nothing once anyone has arranged the list", () => { + expect(planProjectOrderSeed({ items: [item("a", null), item("b", "m")] })).toBeNull(); + }); + + it("skips entries that cannot store keys and lists too short to order", () => { + const plan = planProjectOrderSeed({ + items: [item("a", null), item("old", null, false), item("b", null)], + }); + expect(plan!.map((assignment) => assignment.id)).toEqual(["a", "b"]); + expect(planProjectOrderSeed({ items: [item("a", null)] })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/projectOrder.ts b/packages/client-runtime/src/state/projectOrder.ts new file mode 100644 index 0000000000000..9ecbb1e433629 --- /dev/null +++ b/packages/client-runtime/src/state/projectOrder.ts @@ -0,0 +1,114 @@ +import { planPinnedReorder, generateSpreadPinOrderKeys } from "./threadSort.ts"; + +// Shared, Hermes-safe helpers for the synced order of Projects and Tasks folders. +// `orderKey` lives on each project record (fractional index, same alphabet as thread order +// keys), so it reaches every client through the shell like `projectIcon`. Keys from different +// environments compare directly; ties break by environment id, then project id. + +export interface OrderKeyedProject { + readonly environmentId: string; + readonly id: string; + readonly orderKey?: string | null | undefined; +} + +/** Keyed before keyless; keyed by key, then environment id, then project id. Keyless: 0. */ +export function compareProjectOrder(left: OrderKeyedProject, right: OrderKeyedProject): number { + const leftKey = left.orderKey ?? null; + const rightKey = right.orderKey ?? null; + if (leftKey === null || rightKey === null) { + return leftKey === null ? (rightKey === null ? 0 : 1) : -1; + } + if (leftKey !== rightKey) return leftKey < rightKey ? -1 : 1; + return left.environmentId.localeCompare(right.environmentId) || left.id.localeCompare(right.id); +} + +/** The lowest key among several members (a folder holds one project per environment). */ +export function minProjectOrderKey(keys: ReadonlyArray): string | null { + let min: string | null = null; + for (const key of keys) { + if (key != null && (min === null || key < min)) min = key; + } + return min; +} + +/** + * Arranged projects first in key order, then the rest. `orderRest` lets a caller keep an + * older ordering (a client's saved local order, or its default sort) for keyless projects. + */ +export function orderProjectsByKey( + projects: readonly T[], + orderRest?: (keyless: readonly T[]) => readonly T[], +): T[] { + const keyed: T[] = []; + const keyless: T[] = []; + for (const project of projects) { + (project.orderKey != null ? keyed : keyless).push(project); + } + keyed.sort(compareProjectOrder); + return [...keyed, ...(orderRest ? orderRest(keyless) : keyless)]; +} + +export interface ProjectOrderAssignment { + readonly id: string; + readonly orderKey: string; +} + +/** + * Keys needed to realize a drop. `orderedIds` is the visible order after the move; `keysById` + * also carries hidden entries (filtered folders, archived Projects) so their keys stay + * reserved. Entries whose environment cannot store keys are left out of `reorderableIds`: + * they sit after the arranged ones and never take part in the plan. Null when the moved entry + * cannot be arranged. + */ +export function planProjectReorder(input: { + readonly orderedIds: readonly string[]; + readonly movedId: string; + readonly keysById: ReadonlyMap; + readonly reorderableIds: ReadonlySet; +}): ReadonlyArray | null { + if (!input.reorderableIds.has(input.movedId)) return null; + const orderedIds = input.orderedIds.filter((id) => input.reorderableIds.has(id)); + const assignments = planPinnedReorder({ + orderedIds, + keysById: input.keysById, + movedId: input.movedId, + }); + return assignments.length === 0 ? null : assignments; +} + +/** + * Moves one entry to the slot of `targetId` in `orderedIds`, dropping it where the target was + * (before it when moving up, after it when moving down), like a drag. + */ +export function moveIdToTarget( + orderedIds: readonly string[], + movedId: string, + targetId: string, +): string[] | null { + const from = orderedIds.indexOf(movedId); + const to = orderedIds.indexOf(targetId); + if (from < 0 || to < 0 || from === to) return null; + const next = [...orderedIds]; + next.splice(from, 1); + next.splice(to, 0, movedId); + return next; +} + +/** + * Keys that publish a client's saved local order. `items` are in that saved order. Null unless + * nobody has arranged the list yet (no item holds a key) and something can be written: the + * first client to seed wins, and the server ignores writes to keyed projects (`ifKeyless`). + */ +export function planProjectOrderSeed(input: { + readonly items: ReadonlyArray<{ + readonly id: string; + readonly orderKey: string | null | undefined; + readonly writable: boolean; + }>; +}): ReadonlyArray | null { + if (input.items.some((item) => item.orderKey != null)) return null; + const writable = input.items.filter((item) => item.writable); + if (writable.length < 2) return null; + const keys = generateSpreadPinOrderKeys(writable.length); + return writable.map((item, index) => ({ id: item.id, orderKey: keys[index]! })); +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7992bb94299a2..9b964550db3c9 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -145,6 +145,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), /** Server persists manual Active order through thread.active.reorder. */ threadActiveReorder: Schema.optionalKey(Schema.Boolean), + /** Server stores a manual order for Projects and Tasks folders (`project.reorder`, `orderKey` + on the project record). Absent on older servers, so clients keep that order locally. */ + projectReorder: Schema.optionalKey(Schema.Boolean), /** Server understands the project `assistant` marker (Projects: a coordinator plus agents) and enforces its invariants. Absent on older and upstream servers, so clients hide Projects rather than send commands those servers would drop. */ diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f12b46280b35b..5e6aa71c54328 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1779,3 +1779,63 @@ it.effect("encodes compatible icons inside snapshots and client commands", () => assert.deepEqual(yield* decodeNightlyIcon(command.projectIcon), fallback); }), ); + +it.effect("project.reorder accepts fractional order keys and rejects anything else", () => + Effect.gen(function* () { + for (const orderKey of ["m", "nb", "abcz"]) { + const command = yield* decodeOrchestrationCommand({ + type: "project.reorder", + commandId: "cmd-reorder", + projectId: "project-1", + orderKey, + ifKeyless: true, + }); + assert.strictEqual(command.type, "project.reorder"); + } + // A trailing minimum digit leaves no room to sort before the key; other characters and + // oversized keys never come from the planner. + for (const orderKey of ["", "a", "ma", "M", "m-1", "b".repeat(65)]) { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "project.reorder", + commandId: "cmd-reorder-invalid", + projectId: "project-1", + orderKey, + }), + ); + assert.strictEqual(result._tag, "Failure", `key ${JSON.stringify(orderKey)}`); + } + const dispatchable = yield* Schema.decodeUnknownEffect(ClientOrchestrationCommand)({ + type: "project.reorder", + commandId: "cmd-reorder-client", + projectId: "project-1", + orderKey: "m", + }); + assert.strictEqual(dispatchable.type, "project.reorder"); + }), +); + +it.effect("project shells and meta events carry an optional order key", () => + Effect.gen(function* () { + const shell = { + id: "project-1", + title: "Ordered", + workspaceRoot: "/tmp/ordered", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const decodeShell = Schema.decodeUnknownEffect(OrchestrationProjectShell); + // Cached snapshots from older servers have no key. + assert.strictEqual((yield* decodeShell(shell)).orderKey, undefined); + assert.strictEqual((yield* decodeShell({ ...shell, orderKey: "m" })).orderKey, "m"); + assert.strictEqual((yield* decodeShell({ ...shell, orderKey: null })).orderKey, null); + const payload = yield* Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload)({ + projectId: "project-1", + orderKey: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(payload.orderKey, null); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 36dda008d0ee4..ef69a2a5f198a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -552,6 +552,16 @@ export const ProjectIconOverride = Schema.Union([ ); export type ProjectIconOverride = typeof ProjectIconOverride.Type; +// Fractional index key for the user-arranged order of Projects and Tasks folders. Same +// alphabet as thread order keys: a plain string comparison sorts, and a drag writes one key +// to one project on its own server. A trailing "a" is never generated (no room before it). +export const PROJECT_ORDER_KEY_MAX_LENGTH = 64; +export const ProjectOrderKey = TrimmedNonEmptyString.check( + Schema.isMaxLength(PROJECT_ORDER_KEY_MAX_LENGTH), + Schema.isPattern(/^[a-z]*[b-z]$/), +); +export type ProjectOrderKey = typeof ProjectOrderKey.Type; + export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, @@ -567,6 +577,9 @@ export const OrchestrationProject = Schema.Struct({ // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + // Optional on the wire so cached snapshots from older servers still decode. Null or absent + // means "not arranged": clients place the project after arranged ones. + orderKey: Schema.optional(Schema.NullOr(ProjectOrderKey)), assistant: Schema.optional(Schema.NullOr(ProjectAssistant)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -966,6 +979,9 @@ export const OrchestrationProjectShell = Schema.Struct({ // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + // Optional on the wire so cached snapshots from older servers still decode. Null or absent + // means "not arranged": clients place the project after arranged ones. + orderKey: Schema.optional(Schema.NullOr(ProjectOrderKey)), assistant: Schema.optional(Schema.NullOr(ProjectAssistant)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -1202,6 +1218,16 @@ const ProjectMetaUpdateCommand = Schema.Struct({ assistant: Schema.optional(Schema.NullOr(ProjectAssistantPatch)), }); +const ProjectReorderCommand = Schema.Struct({ + type: Schema.Literal("project.reorder"), + commandId: CommandId, + projectId: ProjectId, + orderKey: ProjectOrderKey, + // Seeding from a client's saved local order must not overwrite an arrangement another client + // already published: the server ignores the write when the project already has a key. + ifKeyless: Schema.optional(Schema.Literal(true)), +}); + const ProjectDeleteCommand = Schema.Struct({ type: Schema.Literal("project.delete"), commandId: CommandId, @@ -1516,6 +1542,7 @@ const ThreadSessionStopCommand = Schema.Struct({ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, + ProjectReorderCommand, ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, @@ -1549,6 +1576,7 @@ export type DispatchableClientOrchestrationCommand = export const ClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, + ProjectReorderCommand, ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, @@ -1862,6 +1890,8 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ autoPull: Schema.optional(Schema.Boolean), faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), + // Null clears the key (the project changed between Projects and workspaces). + orderKey: Schema.optional(Schema.NullOr(ProjectOrderKey)), scripts: Schema.optional(Schema.Array(ProjectScript)), // The full resolved marker, never the command's patch. assistant: Schema.optional(Schema.NullOr(ProjectAssistant)), From a632207f38678c40ca775468299f9d9244b10744 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:57:23 -0600 Subject: [PATCH 20/28] test: cover project order key in snapshot fixtures and contracts; document ordering Co-Authored-By: Claude Sonnet 5.5 --- .../Layers/ProjectionSnapshotQuery.test.ts | 2 ++ docs/user/thread-sidebar.md | 13 +++++++++++++ packages/contracts/src/orchestration.test.ts | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 843eb8343d842..97e7b8b9928f3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -432,6 +432,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { autoPull: false, faviconPath: null, projectIcon: null, + orderKey: null, scripts: [ { id: "script-1", @@ -559,6 +560,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { autoPull: false, faviconPath: null, projectIcon: null, + orderKey: null, scripts: [ { id: "script-1", diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 25d6bf1aa5c34..054100cb8dc01 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -95,6 +95,19 @@ If dragging is unavailable for one environment, update the T3 Code server runnin environment. Pinned and active reordering require server support. Threads from older servers keep their default order until the server is updated. +## Order of Projects and workspace folders + +Drag a Project or a workspace folder (or press Alt+Up or Alt+Down on a focused Project row) to +change its place. The order is saved on the server that owns the Project or workspace, so it +appears on your other devices, mobile included. Mobile shows arranged folders first in that +order, then the rest by recent activity; it does not reorder yet. + +New Projects and workspaces start after the arranged ones. The first time a device sees a +server with no saved order, it publishes its own saved order once; a device that connects later +adopts what is already there. The last drag wins. Dragging while the folder list is sorted by +activity adopts the order you see as the shared order. The sort setting itself stays per +device. Servers that predate this keep the order on each device. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 5e6aa71c54328..58d947c158733 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1803,7 +1803,7 @@ it.effect("project.reorder accepts fractional order keys and rejects anything el orderKey, }), ); - assert.strictEqual(result._tag, "Failure", `key ${JSON.stringify(orderKey)}`); + assert.strictEqual(result._tag, "Failure", `key "${orderKey}"`); } const dispatchable = yield* Schema.decodeUnknownEffect(ClientOrchestrationCommand)({ type: "project.reorder", From 719e07edaac69381580e4eb3204bf437419b62e5 Mon Sep 17 00:00:00 2001 From: Nick Roberts <9039456+nickrroberts@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:14:43 -0600 Subject: [PATCH 21/28] fix(mobile): let wide markdown tables and code scroll horizontally The assistant message is wrapped in a long-press Pressable, which takes the JS responder on every touch. Fabric's ScrollView refuses to cancel content touches while an ancestor is the JS responder, so the horizontal scrollers around tables and code blocks never started a drag and wide content was clipped at the message edge. While the content overflows, the scroller's content view now claims the responder itself so the Pressable never does. Content that fits still leaves the long press to the message. Wide blocks also fade into the surface at any edge that hides content. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/NativeMarkdownBlock.tsx | 131 +++++++++++++++--- .../src/SelectableMarkdownText.types.ts | 4 + .../src/horizontalOverflow.test.ts | 51 +++++++ .../src/horizontalOverflow.ts | 39 ++++++ .../src/features/threads/ThreadFeed.tsx | 8 ++ 5 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.test.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index b57a182dbc56c..763d3335d9479 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -1,8 +1,19 @@ -import { createContext, memo, useContext, useMemo } from "react"; -import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; +import { createContext, memo, type ReactNode, useContext, useMemo, useState } from "react"; +import { + Image, + Platform, + ScrollView, + Text, + useColorScheme, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ViewStyle, +} from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; +import { horizontalOverflowEdges, horizontalOverflowFadeGradient } from "./horizontalOverflow"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { nativeMarkdownDocumentRuns, @@ -145,6 +156,102 @@ function HighlightedCodeText(props: { ); } +const OVERFLOW_FADE_WIDTH = 28; + +/** + * Horizontal scroller for tables and code wider than the message. + * + * Fabric's ScrollView will not start a drag while any ancestor is the JS + * responder (`touchesShouldCancelInContentView` returns NO), and a message + * wrapped in a long-press Pressable takes the responder on every touch. While + * the content overflows, the content view claims the responder itself so the + * ancestor never does and the pan can scroll. Content that fits leaves touches + * to the ancestor, so its long press still works there. + */ +function HorizontalOverflowScroll(props: { + readonly children: ReactNode; + readonly contentStyle?: ViewStyle; + /** Opaque colour behind the content, faded in over any edge that hides some of it. */ + readonly fadeColor?: string; +}) { + const [viewportWidth, setViewportWidth] = useState(0); + const [contentWidth, setContentWidth] = useState(0); + // Only whether an edge is hidden matters, so scroll events re-render when + // that flips rather than on every frame. + const [scrolled, setScrolled] = useState({ leading: false, trailing: true }); + const { overflows } = horizontalOverflowEdges({ viewportWidth, contentWidth, offsetX: 0 }); + const leadingFade = + overflows && scrolled.leading + ? horizontalOverflowFadeGradient(props.fadeColor, "leading") + : undefined; + const trailingFade = + overflows && scrolled.trailing + ? horizontalOverflowFadeGradient(props.fadeColor, "trailing") + : undefined; + const onScroll = overflows + ? (event: NativeSyntheticEvent) => { + const { leading, trailing } = horizontalOverflowEdges({ + viewportWidth, + contentWidth, + offsetX: event.nativeEvent.contentOffset.x, + }); + setScrolled((previous) => + previous.leading === leading && previous.trailing === trailing + ? previous + : { leading, trailing }, + ); + } + : undefined; + + return ( + + setViewportWidth(event.nativeEvent.layout.width)} + onContentSizeChange={(width) => setContentWidth(width)} + > + overflows}> + {props.children} + + + {leadingFade ? ( + + ) : null} + {trailingFade ? ( + + ) : null} + + ); +} + function NativeCodeBlock(props: { readonly node: MarkdownNode; readonly textStyle: NativeMarkdownTextStyle; @@ -204,19 +311,16 @@ function NativeCodeBlock(props: { iconSize={14} /> - - + ); } @@ -244,12 +348,7 @@ function NativeTable(props: { }) { const rows = collectTableRows(props.node); return ( - + ))} - + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index adfa34365af61..3086ae9f2a11b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -14,6 +14,10 @@ export interface NativeMarkdownTextStyle { readonly quoteMarkerColor: string; readonly dividerColor: string; readonly contextChipBorderColor?: string; + /** Opaque colour the markdown sits on; wide tables fade into it at a scrollable edge. */ + readonly surfaceColor?: string; + /** Opaque colour behind code block text; wide code fades into it at a scrollable edge. */ + readonly codeBlockSurfaceColor?: string; readonly fontSize: number; readonly lineHeight: number; readonly fontFamily: string; diff --git a/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.test.ts b/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.test.ts new file mode 100644 index 0000000000000..b9f5dedc0c647 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "vite-plus/test"; + +import { horizontalOverflowEdges, horizontalOverflowFadeGradient } from "./horizontalOverflow"; + +it("leaves a table that fits its viewport static", () => { + expect(horizontalOverflowEdges({ viewportWidth: 363, contentWidth: 322, offsetX: 0 })).toEqual({ + overflows: false, + leading: false, + trailing: false, + }); + // Rounding in the measured widths is not scrollable content. + expect( + horizontalOverflowEdges({ viewportWidth: 363, contentWidth: 363.5, offsetX: 0 }).overflows, + ).toBe(false); +}); + +it("marks the hidden edges of a wide table as it scrolls", () => { + const wide = { viewportWidth: 363, contentWidth: 802 }; + expect(horizontalOverflowEdges({ ...wide, offsetX: 0 })).toEqual({ + overflows: true, + leading: false, + trailing: true, + }); + expect(horizontalOverflowEdges({ ...wide, offsetX: 200 })).toEqual({ + overflows: true, + leading: true, + trailing: true, + }); + expect(horizontalOverflowEdges({ ...wide, offsetX: 439 })).toEqual({ + overflows: true, + leading: true, + trailing: false, + }); +}); + +it("does not treat an unmeasured viewport as overflowing", () => { + expect( + horizontalOverflowEdges({ viewportWidth: 0, contentWidth: 802, offsetX: 0 }).overflows, + ).toBe(false); +}); + +it("fades toward the hidden edge only for opaque hex surfaces", () => { + expect(horizontalOverflowFadeGradient("#f2f2f7", "trailing")).toBe( + "linear-gradient(to right, #f2f2f700 0%, #f2f2f7 100%)", + ); + expect(horizontalOverflowFadeGradient("#0A0A0A", "leading")).toBe( + "linear-gradient(to left, #0A0A0A00 0%, #0A0A0A 100%)", + ); + expect(horizontalOverflowFadeGradient("rgba(0, 0, 0, 0.04)", "trailing")).toBeUndefined(); + expect(horizontalOverflowFadeGradient(undefined, "trailing")).toBeUndefined(); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.ts b/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.ts new file mode 100644 index 0000000000000..7ca40c40f10c6 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/horizontalOverflow.ts @@ -0,0 +1,39 @@ +/** Sub-point differences are layout rounding, not scrollable content. */ +const OVERFLOW_TOLERANCE = 1; + +export interface HorizontalOverflowEdges { + /** Content is wider than the viewport, so the block scrolls. */ + readonly overflows: boolean; + /** Content is hidden past the leading edge. */ + readonly leading: boolean; + /** Content is hidden past the trailing edge. */ + readonly trailing: boolean; +} + +export function horizontalOverflowEdges(input: { + readonly viewportWidth: number; + readonly contentWidth: number; + readonly offsetX: number; +}): HorizontalOverflowEdges { + const maxOffset = input.contentWidth - input.viewportWidth; + const overflows = input.viewportWidth > 0 && maxOffset > OVERFLOW_TOLERANCE; + return { + overflows, + leading: overflows && input.offsetX > OVERFLOW_TOLERANCE, + trailing: overflows && input.offsetX < maxOffset - OVERFLOW_TOLERANCE, + }; +} + +/** + * CSS gradient that fades content into an opaque surface at one edge. Only + * six-digit hex colours can take the transparent `00` alpha suffix, so other + * formats get no fade rather than a gradient through transparent black. + */ +export function horizontalOverflowFadeGradient( + color: string | undefined, + edge: "leading" | "trailing", +): string | undefined { + if (color === undefined || !/^#[0-9a-f]{6}$/i.test(color)) return undefined; + const direction = edge === "leading" ? "to left" : "to right"; + return `linear-gradient(${direction}, ${color}00 0%, ${color} 100%)`; +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 03a4d0a8120e8..99e2cc1cb4485 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -980,6 +980,8 @@ function useMarkdownStyles( theme["--color-border"], theme["--color-user-bubble"], ); + const screenColor = theme["--color-screen"]; + const userBubbleColor = theme["--color-user-bubble"]; const markdownUserBodyColor = theme["--color-user-bubble-foreground"]; const markdownUserCodeBg = theme["--color-md-user-code-bg"]; const markdownUserCodeText = theme["--color-md-user-code-text"]; @@ -1277,6 +1279,8 @@ function useMarkdownStyles( codeColor: markdownUserCodeText, codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, + surfaceColor: userBubbleColor, + codeBlockSurfaceColor: flattenThemeColor(markdownUserFenceBg, userBubbleColor), fileTextColor: markdownUserBodyColor, skillTextColor: userBubbleSkillForeground, quoteMarkerColor: markdownUserBodyColor, @@ -1311,6 +1315,8 @@ function useMarkdownStyles( codeColor: markdownCodeText, codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, + surfaceColor: screenColor, + codeBlockSurfaceColor: flattenThemeColor(markdownCodeBg, screenColor), fileTextColor: markdownCodeText, skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, @@ -1350,7 +1356,9 @@ function useMarkdownStyles( onLinkPress, regularFontFamily, renderImage, + screenColor, themeMode, + userBubbleColor, userBubbleForegroundMuted, userBubbleSkillForeground, ]); From a3533ab022b0b686c0e8c8a92989a5494c47871d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 22:36:51 -0700 Subject: [PATCH 22/28] fix(clients): a preview app no longer knocks the desktop's own server offline (#13577) Co-authored-by: Claude Opus 5.5 (1M context) (cherry picked from commit a107f8a0792c3add90d398602998c55c3ebaef27) --- .../client-runtime/src/connection/layer.ts | 7 ++ .../src/connection/registry.test.ts | 96 ++++++++++++++++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 46dcdcf569b9e..b64e58c4fef17 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -32,11 +32,18 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere if (!current.environments.has(environmentId)) seenChecks.delete(environmentId); } } + const registered = yield* SubscriptionRef.get(registry.entries); for (const entry of current.environments.values()) { const status = Option.getOrNull(entry.status); const descriptor = status?.descriptor; if (status === null || descriptor === undefined) continue; const environmentId = entry.environment.environmentId; + // Discovery describes the server behind the relay route. A direct + // connection (the desktop's own server, a saved URL, SSH) can reach + // a different server with the same environment id, such as a + // preview app that shares the home directory. Its socket handshake + // already checks the protocol. + if (registered.get(environmentId)?.target._tag !== "RelayConnectionTarget") continue; const previous = seenChecks.get(environmentId); const fresh = previous?.checkedAt !== status.checkedAt || diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 729d270601874..e098cd3f54c3c 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -683,7 +683,7 @@ describe("EnvironmentRegistry", () => { it.effect("only a fresh health check for the rejected environment unlocks it", () => Effect.gen(function* () { - const harness = yield* makeHarness([RELAY_TARGET], [], [], { + const harness = yield* makeHarness([RELAY_TARGET, SECOND_RELAY_TARGET], [], [], { initialDisabled: [RELAY_TARGET.environmentId], }); const descriptor = (environmentId: EnvironmentId): ExecutionEnvironmentDescriptor => ({ @@ -778,8 +778,8 @@ describe("EnvironmentRegistry", () => { yield* SubscriptionRef.update(discoveryState, (state) => ({ ...state, environments: new Map(state.environments).set( - SECOND_TARGET.environmentId, - discovered(descriptor(SECOND_TARGET.environmentId)), + SECOND_RELAY_TARGET.environmentId, + discovered(descriptor(SECOND_RELAY_TARGET.environmentId)), ), })); yield* Deferred.await(unrelated); @@ -798,8 +798,8 @@ describe("EnvironmentRegistry", () => { environments: new Map([ [RELAY_TARGET.environmentId, discovered(descriptor(RELAY_TARGET.environmentId))], [ - SECOND_TARGET.environmentId, - discovered(descriptor(SECOND_TARGET.environmentId), "2026-09-15T00:01:00Z"), + SECOND_RELAY_TARGET.environmentId, + discovered(descriptor(SECOND_RELAY_TARGET.environmentId), "2026-09-15T00:01:00Z"), ], ]), })); @@ -827,6 +827,92 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("discovery leaves direct connections that share an environment id alone", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET, RELAY_TARGET]); + const discovered = (environmentId: EnvironmentId) => { + const endpoint = { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + providerKind: "manual" as const, + }; + return { + environment: { + environmentId, + label: "Preview server", + endpoint, + linkedAt: "2026-09-25T00:00:00Z", + }, + availability: "online" as const, + status: Option.some({ + environmentId, + endpoint, + status: "online", + checkedAt: "2026-09-25T00:00:00Z", + descriptor: { + environmentId, + label: "Preview server", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "2.0.0", + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, + capabilities: { repositoryIdentity: true }, + }, + }), + error: Option.none(), + }; + }; + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const relayChecked = yield* Deferred.make(); + yield* watchDiscoveredCompatibility().pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, { + ...registry, + setCompatibility: (environmentId, error) => + registry + .setCompatibility(environmentId, error) + .pipe( + Effect.andThen( + environmentId === RELAY_TARGET.environmentId + ? Deferred.succeed(relayChecked, undefined) + : Effect.void, + ), + ), + }), + Effect.provideService( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: + yield* SubscriptionRef.make( + { + ...RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + environments: new Map([ + [TARGET.environmentId, discovered(TARGET.environmentId)], + [RELAY_TARGET.environmentId, discovered(RELAY_TARGET.environmentId)], + ]), + }, + ), + refresh: Effect.void, + }), + ), + Effect.forkScoped, + ); + yield* Deferred.await(relayChecked); + + const entries = yield* SubscriptionRef.get(registry.entries); + expect(entries.get(RELAY_TARGET.environmentId)).toMatchObject({ enabled: false }); + expect(entries.get(TARGET.environmentId)).toMatchObject({ enabled: true }); + expect(entries.get(TARGET.environmentId)?.unsupportedReason).toBeUndefined(); + expect((yield* registry.state(TARGET.environmentId)).phase).toBe("connected"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("discovery keeps unsupported environments off until compatibility changes", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET], [], [], { From edf109568b82741395037b05a9daca58e0856f01 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 19:28:02 -0700 Subject: [PATCH 23/28] fix(server): the SQLite WAL file shrinks back after large writes (#13684) Co-authored-by: Claude Opus 5.5 (1M context) (cherry picked from commit 8aa5be2f02c3b4b9b5b3b86acbf5f2b0467438f4) --- .../src/persistence/Layers/Sqlite.test.ts | 32 ++++++++++++++++++- apps/server/src/persistence/Layers/Sqlite.ts | 6 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts index 0b64e4f7fdcba..5bcbd35e918c0 100644 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { + SqlitePersistenceMemory, + WAL_SIZE_LIMIT_BYTES, + makeSqlitePersistenceLive, +} from "./Sqlite.ts"; const lockHolderSource = ` const { DatabaseSync } = require("node:sqlite"); @@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", ( ); }); +it.effect("shrinks the WAL file back to the size limit after a large write", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; + // About 25% more 4 KB rows than the limit holds, in one transaction. + const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE wal_probe(payload BLOB)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) + INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n + `; + assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); + + // The auto-checkpoint after the large commit copied every frame into the + // database, so the next commit restarts the WAL and cuts the file back. + yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; + assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + it.effect("applies busy_timeout in the shared persistence setup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 88342cbf1fad1..4d37d60a23ad9 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +// Size the -wal file is cut back to on the first commit after a WAL reset. +export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; + const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -15,6 +18,9 @@ const setup = Layer.effectDiscard( yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; + // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its + // largest size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); }), ); From 67a974d2824bc761fa1f3e6d00128d9b8a3216ea Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 02:50:06 -0700 Subject: [PATCH 24/28] fix(server): background git status fetches no longer fill the disk with failed repacks (#13812) Co-authored-by: Claude Opus 5.5 (1M context) (cherry picked from commit 95030dc674883f0f2a7fd034b32ce742c8cf55d0) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 6 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 045f6d15cfe64..5de78ab5f905b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1834,6 +1834,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not start Git auto-maintenance from background upstream fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["repack", "-d"]); + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push"]); + yield* git(cwd, ["repack", "-d"]); + // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. + yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); + yield* git(cwd, ["config", "gc.autoDetach", "false"]); + yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); + const packCount = git(cwd, ["count-objects", "-v"]).pipe( + Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]), + ); + assert.equal(yield* packCount, "2"); + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(yield* packCount, "2"); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 16727afd0ea27..e455ccd75312d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1129,10 +1129,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps + // this poll from starting `git gc --auto`. When that gc fails, for example on a repository + // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` + // behind each time, so a background poll could fill the disk. return executeGit( "GitVcsDriver.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", From 14f4d28923c6bfc94570af6e167558cf8d1988e9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 26 Sep 2026 01:35:22 -0700 Subject: [PATCH 25/28] perf(clients): saving the thread list cache no longer freezes the UI (#13767) Co-authored-by: Claude Opus 5.5 (1M context) (cherry picked from commit 9151ea407accd1b7af575dcdd514e2b2e3fe6cbb) --- .../src/connection/environment-cache-store.ts | 19 +++++-- apps/web/src/connection/storage.test.ts | 23 ++++++++ apps/web/src/connection/storage.ts | 22 +++++--- .../src/platform/persistence.test.ts | 55 +++++++++++++++++++ .../src/platform/persistence.ts | 16 ++++++ 5 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 packages/client-runtime/src/platform/persistence.test.ts diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce063..aeff4fe86c73c 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,7 @@ import { ConnectionPersistenceError, EnvironmentCacheStore, + encodeShellSnapshotForCache, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, @@ -53,7 +54,6 @@ const StoredVcsRefs = Schema.Struct({ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredShellSnapshot), ); -const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredThreadSnapshot), ); @@ -133,11 +133,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + const payload = yield* Effect.try({ + try: () => + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 773d20c3b8825..206220768e9a7 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); }), ); + + it.effect("fails IndexedDB writes whose commit aborts", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const transaction = Object.assign(new EventTarget(), { + error: null as DOMException | null, + objectStore: () => ({ + put: () => { + // A failed commit aborts the transaction without an "error" event. + queueMicrotask(() => { + transaction.error = new DOMException("Quota exceeded", "QuotaExceededError"); + transaction.dispatchEvent(new Event("abort")); + }); + }, + }), + }); + const backend = makeCatalogBackend({ transaction: () => transaction } as never); + + const error = yield* backend.write("{}").pipe(Effect.flip); + + expect(error.message).toContain("QuotaExceededError"); + }), + ); }); describe("browser GitHub routing permissions", () => { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 6e777c6631995..b73aed5c9d294 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -6,6 +6,7 @@ import { ConnectionTargetStore, EMPTY_CONNECTION_CATALOG_DOCUMENT, EnvironmentCacheStore, + encodeShellSnapshotForCache, putRemoteDpopTokenInCatalog, registerConnectionInCatalog, removeCatalogValue, @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); @@ -188,7 +188,9 @@ function writeDatabaseValue( ) { return Effect.callback((resume) => { const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { + // Every failed write fires "abort". A failed commit, such as + // QuotaExceededError, fires only "abort" and no "error". + transaction.addEventListener("abort", () => { resume( Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), ); @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( ), saveShell: (environmentId, snapshot) => Effect.gen(function* () { - const encoded = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot); + const encoded = yield* Effect.try({ + try: () => + // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded. + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); }).pipe( Effect.mapError((cause) => diff --git a/packages/client-runtime/src/platform/persistence.test.ts b/packages/client-runtime/src/platform/persistence.test.ts new file mode 100644 index 0000000000000..3e856e8479760 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.test.ts @@ -0,0 +1,55 @@ +import { + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Arr from "effect/Array"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"; + +import { encodeShellSnapshotForCache } from "./persistence.ts"; + +// Generated values can hold untrimmed strings, which a decoded value never +// has. One encode and decode gives a value a client can hold; values that +// fail are dropped. Size 30 makes the generator fill optional fields. +const sampleDecoded = (schema: S) => + Effect.gen(function* () { + const encode = Schema.encodeEffect(schema); + const decode = Schema.decodeEffect(schema); + const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), { + count: 1000, + size: 30, + }); + const decoded = yield* Effect.forEach(generated, (value) => + encode(value).pipe(Effect.flatMap(decode), Effect.option), + ); + return Arr.getSomes(decoded); + }); +const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot); + +describe("encodeShellSnapshotForCache", () => { + it.effect("matches the Schema encoding of a generated snapshot", () => + Effect.gen(function* () { + const threads = yield* sampleDecoded(OrchestrationThreadShell); + const projects = yield* sampleDecoded(OrchestrationProjectShell); + const snapshot: OrchestrationShellSnapshot = { + snapshotSequence: 1, + // The generator rarely makes monogram icons, and they are the one + // project field whose encoding differs from the decoded value. + projects: projects.map((project, index) => + index % 2 === 0 + ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } } + : project, + ), + threads, + updatedAt: "2026-09-25T00:00:00.000Z", + }; + + expect(threads.length).toBeGreaterThan(0); + expect(projects.length).toBeGreaterThan(0); + expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot)); + }), + ); +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 99469081dc10e..b2bc18222250d 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadDetailSnapshot, type ServerConfig, @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< } >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} +const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell)); + +/** + * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result + * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does + * not change. Walking thousands of threads through Schema blocks the UI + * thread, and a decoded thread shell is already in its encoded form, so only + * the projects go through Schema: their icon has a real encode transform. + */ +export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) => + Effect.map( + encodeProjectShells(snapshot.projects), + (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded, + ); + export class EnvironmentOwnedDataCleanup extends Context.Reference<{ readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { From bfb89701c67ddf6e364fe31b74fd90dacdefd979 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:12:40 -0400 Subject: [PATCH 26/28] fix(server): OpenCode stop no longer hangs when a turn is interrupted before submission (#12003) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit e518866d28603da273a9357516dd1aef919f1353) --- .../provider/Layers/OpenCodeAdapter.test.ts | 51 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 13 ++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d38397..706f17ce6069c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -781,6 +782,56 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "stopSession completes after sendTurn is interrupted before the prompt is submitted", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupted-before-submit"); + runtimeMock.state.createdSessionIds.push("ses_interrupted_before_submit"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const baseClock = yield* Clock.clockWith(Effect.succeed); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const gatedClock: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: () => baseClock.currentTimeMillisUnsafe(), + currentTimeMillis: Effect.suspend(() => { + calls += 1; + return calls === 2 + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(baseClock.currentTimeMillis), + ) + : baseClock.currentTimeMillis; + }), + }; + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt must not be submitted", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.provideService(Clock.Clock, gatedClock), Effect.exit, Effect.forkChild); + yield* Deferred.await(reached); + yield* Fiber.interrupt(sendFiber); + + const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit); + NodeAssert.equal(Exit.isSuccess(stopExit), true); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("aborts a held teardown request before closing the session scope", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 42fefe7115ab9..6cb48e8d6aac2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3531,7 +3531,18 @@ export function makeOpenCodeAdapter( ? { resumeCursor: context.session.resumeCursor } : {}), }; - }), + }).pipe( + // stopSession waits on submissionSettled; an interrupted sendTurn + // must not leave it pending. + Effect.ensuring( + Effect.suspend(() => { + const admission = context.promptAdmission; + return admission + ? Deferred.succeed(admission.submissionSettled, undefined).pipe(Effect.ignore) + : Effect.void; + }), + ), + ), ); }); From ef2dcd9086ba9967a8551e1c9a129006ab92282c Mon Sep 17 00:00:00 2001 From: Morgana Allen Date: Mon, 28 Sep 2026 11:10:54 -0700 Subject: [PATCH 27/28] fix(web): skip unchanged shortcut modifier updates - 195x typing latency improvement (#13884) Co-authored-by: GPT-6 Astra (cherry picked from commit ed57bed8e71b2e89a9350c94bda0dacc71cd4817) --- apps/web/src/shortcutModifierState.test.ts | 68 +++++++++++++++++++++- apps/web/src/shortcutModifierState.ts | 18 +++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index 4ef4b3b6bf2dc..20b63220072d6 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { areShortcutModifierStatesEqual, shortcutModifierStateAfterKeyboardEvent, + useShortcutModifierState, type ShortcutModifierState, } from "./shortcutModifierState"; @@ -13,6 +18,67 @@ const emptyState = (): ShortcutModifierState => ({ shiftKey: false, }); +describe("useShortcutModifierState", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it.each(["keyup", "paste", "blur"] as const)( + "does not render for unchanged modifiers after %s resets the state", + async (reset) => { + const render = vi.fn(); + function Consumer() { + render(useShortcutModifierState()); + return null; + } + await act(async () => { + root.render(createElement(Consumer)); + }); + const press = async (type: "keydown" | "keyup", key: string, shiftKey = false) => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent(type, { key, shiftKey })); + }); + }; + + await press("keydown", "Shift", true); + expect(render).toHaveBeenLastCalledWith({ ...emptyState(), shiftKey: true }); + if (reset === "keyup") { + await press("keyup", "Shift"); + } else { + await act(async () => { + window.dispatchEvent(new Event(reset)); + }); + } + expect(render).toHaveBeenLastCalledWith(emptyState()); + render.mockClear(); + + for (const key of "typing") { + await press("keydown", key); + await press("keyup", key); + } + await act(async () => { + window.dispatchEvent(new Event("paste")); + window.dispatchEvent(new Event("blur")); + }); + expect(render).not.toHaveBeenCalled(); + await press("keydown", "Shift", true); + expect(render).toHaveBeenCalledExactlyOnceWith({ ...emptyState(), shiftKey: true }); + }, + ); +}); + function keyboardEventLike(type: "keydown" | "keyup", init: Partial): KeyboardEvent { return { type, diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index 3abeeaa3e8aa7..15d6e0a1bcae7 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; export interface ShortcutModifierState { metaKey: boolean; @@ -28,10 +28,18 @@ export function areShortcutModifierStatesEqual( export function useShortcutModifierState(): ShortcutModifierState { const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); + const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE); useEffect(() => { + const updateState = (next: ShortcutModifierState) => { + // Even a no-op state dispatch can cost work in the sidebar's large tree. + // Ordinary typing must return before dispatching a React update. + if (areShortcutModifierStatesEqual(stateRef.current, next)) return; + stateRef.current = next; + setState(next); + }; const onKeyboardEvent = (event: KeyboardEvent) => { - setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); + updateState(shortcutModifierStateAfterKeyboardEvent(stateRef.current, event)); }; // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup // never reaches the page, so the tracked state stays "⌘ held" forever and @@ -39,11 +47,7 @@ export function useShortcutModifierState(): ShortcutModifierState { // treat it like a blur and reset. A physically held modifier re-registers // on the next real key event. const onResetEvent = () => { - setState((current) => - areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) - ? current - : EMPTY_SHORTCUT_MODIFIER_STATE, - ); + updateState(EMPTY_SHORTCUT_MODIFIER_STATE); }; window.addEventListener("keydown", onKeyboardEvent, true); From b3e32661cc3f72304ffc4d23a7c09888dd8320a7 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 30 Sep 2026 10:09:03 +1000 Subject: [PATCH 28/28] fix(grok): recover from crashed provider sessions (#10607) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: SAPHID (cherry picked from commit 050cfad04f673fb092630614b8f7e82000b43ea5) --- apps/server/scripts/acp-mock-agent.ts | 6 ++ .../src/provider/Layers/GrokAdapter.test.ts | 60 +++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 37 ++++++++++-- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9fbdeee03c86c..621a518b0ae98 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -619,6 +619,12 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if ( + process.env.T3_ACP_CRASH_PROMPT === "1" && + request.prompt.some((part) => part.type === "text" && part.text === "crash now") + ) { + return yield* Effect.sync(() => process.exit(23)); + } if (completeFirstPromptOnCancel && promptCount === 1) { yield* agent.client.sessionUpdate({ diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbee..fb17d6839fda6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -363,6 +363,66 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("retires a crashed process so a deliberate retry can resume", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-crash-recovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-crash-recovery-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapper = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_CRASH_PROMPT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapper); + const exited = + yield* Deferred.make>(); + const events = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.exited" + ? Deferred.succeed(exited, event).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + const input = { + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access" as const, + }; + const session = yield* adapter.startSession(input); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "crash now", attachments: [] }), + ); + assert.isDefined(failure); + assert.isFalse(yield* adapter.hasSession(threadId)); + const retryDuringTeardown = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "retry during teardown", attachments: [] }), + ); + assert.equal(retryDuringTeardown._tag, "ProviderAdapterSessionNotFoundError"); + assert.equal((yield* Deferred.await(exited)).payload.exitKind, "error"); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + yield* Fiber.interrupt(events); + yield* adapter.startSession({ ...input, resumeCursor: session.resumeCursor }); + const turn = yield* adapter.sendTurn({ threadId, input: "retry now", attachments: [] }); + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(requests.filter((request) => request.method === "session/new").length, 1); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + const resumes = requests.filter((request) => request.method === "session/load"); + assert.equal(resumes.length, 1); + assert.deepStrictEqual(resumes[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 6ea10bfe141fa..7236748f8887e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -175,6 +175,7 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + terminated: boolean; /** Live monitor/shell identities and their originating turns. */ readonly backgroundTasks: Map; } @@ -345,6 +346,7 @@ export function grokPromptSettlementBelongsToContext(input: { export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { + const ownerScope = yield* Effect.scope; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -923,7 +925,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { + if (!ctx || ctx.stopped || ctx.terminated) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); @@ -947,7 +949,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, - payload: { exitKind: "graceful" }, + payload: { exitKind: ctx.terminated ? "error" : "graceful" }, }); }); @@ -1317,12 +1319,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + terminated: false, backgroundTasks: new Map(), }; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if (event._tag === "ConnectionTerminated") { + ctx.terminated = true; + yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + if (sessions.get(ctx.threadId) !== ctx) return; + if (ctx.activeTurnId) { + yield* settlePromptInFlight( + ctx.threadId, + ctx.activeTurnId, + ctx.acpSessionId, + { + errorMessage: "Grok connection terminated.", + settleAllPrompts: true, + }, + ); + } + yield* stopSessionInternal(ctx); + }), + ).pipe(Effect.forkIn(ownerScope)); + return; + } if (event._tag === "EventStreamBarrier") { yield* Deferred.succeed(event.acknowledge, undefined); return; @@ -2168,12 +2193,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const listSessions: GrokAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + Effect.sync(() => + Array.from(sessions.values()) + .filter((c) => !c.terminated) + .map((c) => ({ ...c.session })), + ); const hasSession: GrokAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const c = sessions.get(threadId); - return c !== undefined && !c.stopped; + return c !== undefined && !c.stopped && !c.terminated; }); const stopAll: GrokAdapterShape["stopAll"] = () =>