From 8e0788dc2a840b638de1290599faaf1dcff722e4 Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Mon, 29 Jun 2026 14:23:45 -0400 Subject: [PATCH] feat: show queued dashboard follow-ups (#1631) * feat: show queued dashboard follow-ups Co-authored-by: open-swe[bot] * fix: de-dupe queued follow-ups while streaming --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] Co-authored-by: Johannes du Plessis --- tests/e2e/fake_llm.py | 6 + .../screenshots/queued-messages-dashboard.png | Bin 0 -> 45021 bytes tests/e2e/tests/dashboard.spec.ts | 81 ++++++++-- ui/src/components/agents/AgentThreadView.tsx | 56 ++++++- .../components/agents/messages/Messages.tsx | 40 ++++- ui/src/components/agents/messages/types.ts | 3 +- .../agents/provider/useSubmitAgentMessage.ts | 150 ++++++++++++------ ui/src/lib/agents/types.ts | 8 + 8 files changed, 278 insertions(+), 66 deletions(-) create mode 100644 tests/e2e/screenshots/queued-messages-dashboard.png diff --git a/tests/e2e/fake_llm.py b/tests/e2e/fake_llm.py index 9ea10e99..3f491bdd 100644 --- a/tests/e2e/fake_llm.py +++ b/tests/e2e/fake_llm.py @@ -10,6 +10,7 @@ the preceding tool result, exactly as a real model would. from __future__ import annotations import re +import time from typing import Any from e2e_env import ( @@ -239,6 +240,11 @@ def _latest_attribution(messages: list[BaseMessage]) -> str | None: def _step_followup(messages: list[BaseMessage]) -> AIMessage: + if any( + isinstance(msg, HumanMessage) and "Please queue this follow-up" in _text(msg.content) + for msg in messages + ): + time.sleep(2) attribution = _latest_attribution(messages) suffix = f" I saw this follow-up was from {attribution}." if attribution else "" return AIMessage(content=f"{FOLLOW_UP_REPLY}{suffix}") diff --git a/tests/e2e/screenshots/queued-messages-dashboard.png b/tests/e2e/screenshots/queued-messages-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..956baabecce43a1810c62915a3564ca62b137618 GIT binary patch literal 45021 zcmcG$by$@9*DpMXfFiL~LXc1q6%ddPDFaYSx=W;M=q^P>NrP_b?ruc{K^lfGX^`$d z>(>2yp7;6Zecy9k=lBn9_b@Z}J?p#Hr`Eial@TYzr^H90P=rsPJeEhHaNw_JI?tVf zA0o68SSZv#sHcw~C_2Ub9>sGanHl=N{`BSROQ!QmE~}ys{2GdHe1S-n9`lFW1Q$u*HP3o})tUHd!ct*NM|v!^{w{lBX?RztKL zX5)+;xOo{EexS@*Z`??fie$%ySN5uA>VzLCR6Xvw`agfsxQ4<;{$l*D;={l=aXD zpN}V#DNCc_Hp{>7*!e1t<@!3E_edRYy_3J;r|MY%f@1Mp+_>7R? z&xeIt^Hux2t(+$P{1tCpj(g z!7&xZ735dmU(nyXG_RO8Ij#6&_GN;QN=h&5my*u%n=IPZO%>gqckCDcV4Pd2lp@OJW7Ye+MHBU4QCvjiG^bBI|4SPV8y?KQSLofAUfFrs%aSetK}&j+1<(Krr^t#qw=C-A`-1 z4h9Mr42GXkHE3VDbP4V$ro>F8;i6X?7P&t;>y85*FY?aw^?6ZccVk`d@!y&8WOWwu zSkF6rNE<+8Wuk(!w`==Sy5ShVzBQaxFcx6=-TDd;g6p_ zc^)j>#5^5?oniK+eR@h!K@Cc4|4g7|sC;y|1fd=N7s}oS^~UZ>6`Awr531Pi)>$irPFo3g z=gH6G(YmeWc^;e1w1iH54^*g7GTYwn*NN`9b@OJnc8&UZwRfBX0-D$@-zk^h-Hwwz zaXa4buiN|O?{k4X>bmmJ`NJ{SRrl>a6~BxnOpa~jXoc;Z2RsCM+CZMMy{(>S!ly@@ zEul!5JKxYPIpn^NiZV18q&C*xpdvAjAm{66iX)?X&%SH*z8gJm`h))A@uKXE zGJRjwYVEMwK&|^BE+OTel`+S0cb9=8qmJ$rNwWm`6bYUYtA?ZfO&*J(@$VIkAr z^g0zfUZasDHtT)5OCNH=#xmp^YZTME9j7r&qf+u@Zi#k1GVq&J+nT%Q0**jid zC<}w4Guiw_nN3JX>sWjcxqq;fr}CdGsg|2%KZOPiP5CSi4xg|_y6orn*!x#0(sIvG zWve-TibUPr_YR;@I;mW7Y+a}`Z6@?fBRj|aOCg|KKS|$s$evU|g8bf*RNbY^my_yJ zBti?-IjN|rEr@)5eAJdHYpo_<@tx#qRRvM;>6Pl(wsFCoyD5?@AKa zi0#c%$#YyCGkm(A6f7i<<$koc&NJ>-6t~!&(z>Ta zIg?n!f6)Si6qc7ALpD7_8gYZtsNFze@ zlKJqz&furo=Y`1k)|nNBJ|PshE`eS}DLV?qa1Dct`>ez8k+1SB)6K&`dUn1(vhf7+H zqXt4*dcV`uT3@>b;MEMcQ8Lb*dDE0S_(Am%2Z;a z*RZfNnAqFfgH{yrjFefu*!^$^4pr4k zsK?%<@9v0gheg?F>Lir5=T?_Erh|GEsvU+Ka!QrF`|`y&nmi|7;wdI8y;YJ|GPE2n z&6$j6RLT@U&g3xT(QIpYo>_k`x_bT1c6Q>bA1TvM$qwkDLnUTqrhSZaXk8-%O!J-N zhJhN_U6`$Hz8J3}Z#-g5jz&L9_eDlV1_RNXsUfP(R#p^h&ybYBo9d|g4Vjw0<<7)J zUE(~nHi@viCi-m)J!vIND!wO*UZ<67L(Ol49dX3JA3u{EUu59&Ph}RMG#!zp2RkN|LrM-CBE>4MGZy31SME6po<7{dr_3JyWJJ}z*?+CQzEqy)>r|?qB=kk(# zha#BfA9B2P>(++kZXl(RE)jm6EJu@x8^O*u{>A?2&uF16PUY z0s(ER-F)X~=xu;3WJuiGhP41k)R3fyw2*u!@g~%yob9+~LdR5YDsuJc_h1o4C0(&g z=LavBJBsP=wHbPd6b=3>< zxw1p<_JzybIwCuXX9%Xr_>Ho+q62pW&!Xz-HpQe*^cnSooav0^Lby&Be|rn-m=$gm zu@~*D9J!^i+rD&N|7R*F>c{<#vnv8uoen%Dzr4iyz8xxWMk=X5z4t^)L_YH(iSBFi zdz9f)IXW>}y(pAg;<4VthiAt3a8R1Kh776{5v`wDFN!K}j=eZA6VtIJUv(8n<7#bs zsJtXP897`$z1{H`=Ss6{ItshuRX1yi4xrs+H&RMU(+T+vjst$%pf6%#R{k6)k(K(; z4wP4GNA=u|alG1OO`B|C(g`U#0PHqco4c0)AFx_+c1@%5DQuvZF3S}i~ zV)SiwZ3YW@OwjDJ1FagD?bqio$7lea8Us)>cg?lOad0wi)E)zX?=@fcopQ5&mT-cFoXd@k zvG*PM$CDL_`=W9E$e0fr$#r_uGY2_-pY2?`J%xDB%0NLw4PH)AX($#doF-gk?$7Z82O56LV*&KM(#^HtG=lF_h1v!e%C9>F4uMDm+5U>F!At9rwN>qi43N zYBE#N&d6L;%2KX@Y5S(kd=UCUOJRfVGwJLalUYQ7Mss8yO@)*idS>8rL8T3=re1Kvb`?qaG`WOuPiPg=Z3C4fx+ zp6A?_qm_M{u|(ATym>(;@Yx1Za;aB2@kPb=N5iyT?NiYK*!!^% zv$H&`pbSpT&){)*K_`(1>DC*6`uc3fzW)8N^W_R2LpL3SXle>L!5>W4?}f*iD_x5 zsHn)uQnBdoWOdI+U&?EV#xHpK^l3D=h5S=xHq~WQIl127n&J-g8uQpTKaW0T4h60-1VXQV= zfnnr%vY*zvc!x%KtMk$9UP}Yxg6GkCQ?d?lL0xQ+A$|cOj|GwfUByU9z4lC6a8u>ipwnIwUV?Fzx&~G+0@ijUt(o$oS^%G9sB_-mb1>qYR2n% zay09;+7*Ly_G~$Us+*%;PQs;(;QJv5W0WhgPSF_$8JZP^T(zaaA80(o@=LsBw9|hb z$Dbah0oQUIb57e2F>QbGJs?uR*#;V3OBi$3@~5z{*+TwyZO>!-oG85_EL8pJ4QRU? zM5Nd`b&2_QzrLL}xXy8ln$PAj_T=Q`8@hLq<2j+_<-EZ)_RB-tLHAc=ub06Gz-S)^ ztq8DwmPT1_Tlpu6YXv{kp?;DAe<>~`gXLXhC=T)unXKe-x9Xv63x;>JuRs_wH z5GsF)eUC%ElIdfY%p1b1+(zwGb5|B#nCTm*GUhKmt_OydXFcDO7W{#0md6R9^`V&7 zP^R6r2I8f@g|V6{u2K^nO;eB$F`ufw*G*?HK2MS);znUuP*X~&x zM%M{i3~^$kbX~FFEaK|AUc7km#4V7peun(&RWtoa(>L^zVbzaS8fc)=e|vWsaQD5u zy0%}&gY2y1?3G;AZFVrsGQU788*IJ>sP2F>S0OFvP=RrC@wLxlALCJ_KuTYiUc~lu zRC#ZCortA43isq83fCsv{CqtMrGMd!m1PtCUvck$U(@r^r8{&x*`GUb1@V9aVJ)cX zdxL6NLNmJhntFPAt7A1tC(fDyqND_OAo&Bew#x$jw!?hq^*^LoSB3A_rTO08UTDKe zhiDCB=Fos&>`l})*B{*9oD&9mnx3A%T6@^uECfnK6t_jxA7!AK-lo&h3ntOt^rvUv z8yhQ%o4$XSiQ-%U8O39{q(9_EU!ieVA|UA@H)V!iiBd}GfFwJtekky1ps1=h^W^#Z_hv05+rj3 zHlz0a(DsJ@-eGRF%JZ1Hc+ZuilyNRI%={`G`R64e3<}>*UG+d2tvy_+;u&+AYU3F< z=gQS6lNntZD#@~2=w>A1G42#$DAp*m3>MgE2BP!m;lr;D4KMWch^Tn2rkgJcR__d% z#U36(rG~TWoOC`S?gJ7D4Ip!?)VS;6G90Ue{kizQoUnU*d=+v@Vq>#PIqF=3DaP(_ zv&6;3Fc}Jerj;*r#)`=|R}Z!pUi^92Hr{t}CP1|`vOa6XSXOE=ywiSvRdML3!}H_? zH;7w2I<;;jfR8V)o2y!o*qs+gNJONJ+2&^DK&fQNi+V~T<3W{e*T48r&G6}jomqpixN_8) zeJlhFQ;e+oXrmQiVjPE2&^|QFSI?h6$2{_9Ujw!nqJ>99l^&7PvJ3-irDj((S}$?whiweliZu+(MSwC{DB&XWyp)?kv*q9v86KleBMLC}Tz?mAD; zP05N&G&Cnb_?a$+-;qm%zRg8N(3JO&638@9Ki$GlG-wH_Sj^1ItvxW^{Akz;tXc&W zN4TRJ%w;1st=QE%OeUJu}PvYG+2}6eQeAYF$ce6e*&sOomFW*7gyd4-& zg?S$H8E!eQP0?LN#3|2q$G{su$>L5M`ba0#Oh ztw!P^kA<2y?pN9K~wP{){Yg*TuaJY^2>SW~8Z?npap1bB}LE-FM9WP9f-mjF&)yZbkUfQxDou z+duOb&c*c|Abc*Djnj~m8)@owb#)nb@02R4edZb}G77duDhPm_QMj6`Ed$wklDoOi*3ETx8HncFaXf#;m^O&sq!W*PEu4jREh;bGbvMVi?|z zp=9Pf1FNx*Lfj-M6vf9_EY>i8+l3*=h3?&*Dds{?rF zS z{vVtUVJNPe7vDD1dt~r3`Ew_GA)OAXTgA%DMWJWFBE$q&7 zka_Un0W?&0b^~B2LrhmyhSsN=Xq~4oj_Y)uK}}NuiBo!ifruy$CMzyUWSq-lMu*Vh zPYu^fY8W1RPhJFC|burYl%v<1QY&%eGF^(QkC z=LUQq9I8sb_?SySMSSV-2Mr8V2OuE8G zTIXV5KIR>!b?RVIzcS2XbG8kzUksO7*y&X;WIgqtp)g~L3uQosN`UL@)K(L51q4V+ zNoiI)Sp&L*{^K6Z2VxaqmWF0aiLh8te#KdK$0ujWo`jjE7S8W# zwktg)E-ubAm6Dojx|%>!Y<5M!DPN7uK`vkSL8zdTOJ6lfG(c=9w`X)_h+uL;w5V(^ zCn_<1VH4!h&^vNBU}{qG0v^XGPML-CyX{#8(jp{pX7lyyvoixXME#Hxao=%8Q5exd z%M8{q-uu5nFjog=NEmWgtq_X9kY0^%A0%_Y3`wqN1aTe8R4*^Jt!X06&bd|&2i5El z`81P-M04D2vkh#itfMOP!9vhX&4_c3vpzf+GE;_ed%Hpe`uq)~b?utubmUwfWD37cRebZl`fY-RipdapwT)r7r@y zVCQ+K*lx?A;tYH+A}4I*Vh#bVY*z>x{=6dCYg8+gh<)aaoqErUDxq|D$8>_hROzb* ze2uy(vRPV(47(pn(qvCR-F}v(>pdKGr!{WacdjY>!mfg4L3RgP}S4aXWs?L3$5HVfA)`FF@I{ z@4tu3@_rQ*mN718$}!be?xxoNc1tO4#hD~CbAYqO_~0ra@~muJ-Q^h2&9s(D+9F_j z_d&TMe0a#B%wkxXPq_)p!y!d~xX?h-=Najz)rvWOvSG#lXaO`n^2-r(KO11`X}GWIV8Q9-e=L%bS;|Kos~)y5 zZ!gk?yf1mpZ`d7yjZ0v)f^n`LI}o;+ejBFTdep*{7eFJZE-rkKm7gP(n~=L`?N7!` zfT}n6tndl0o}R!WoU^09(na0^z zjk==B9P*|qoVsI$lCDOf*@R8+u$4Zd%O`P*!j>LpP37Q`1G2`*n+O>xsGAo4P{lHvAn z*7zWTmJkNpwTTDI!8%TpK7cxeHh*&X#m;uzyc2{docw1i5R3QY$6_b^X-uP{dMUv3 zlb3ijvQq%5689_Z%-UKtPFw0{1)qT2e?Int%4;FdlWHPM&LNPkj(ZiJG2mVW%~q83 z$tF9k``snY?ovB%<|HF-lCcH(1c;!L{NAr;Jr(4q9GyMPzAQ3ZRR$Goe&zhy_t{v>U)zl6=;+|kD^TMuJTAFpbL)V|wy=H0Y&+D~ zo-6@+^fUOMFs#7pTei7}1uPbZF#04UXgf)~c36vmg8i|Qf)7th3vlVt>Q6;N)An&H zDWl53O3X0kV8myJS3b*9O=l|!F5kvzgfnX>1Hvjg7CznYfKJwzCR5>ZR51b4bXz1x z({8OiLou{O=s=aLJ`24WpVZj2p-_OcxB2w5JZ()n;d&||(UO%{Ys$+Kqg=h(mE>F5 zr>47Yt|hcws5s`b_GFGy@dRY<1ncwvxlb%FF)P&&)nMEVy26$Sxs;*LQNmuFt~=(U zdZZ7JPjBVR1omgCbWmZuKIY4Co7sqBARDu3!@9eAhV+i?HGcjfm-Oc;^LBG3AhN(f z7lc{Be?v|+R=~NijE$rB6o6v}eJ@s+%*)l$DiwNTY=A3}-D#wfb=yuGjeM9?mPVoj zAGIB*-@){Zoz4~1{@RqvKubwHR3&F;iE89fQDs)d)z>cE=-0P*wd7(U zHKY$-ym*nb$1EMi*_C0cl&rMC3?@=vZgE~`u1xXeJoS>y;FJxFH&>zPZGZdj!C~TS z73k+?DKW0L&^a!+4$DGYQw(FQZ%G0Fy8-}=-5XW4uIhTHA0O$5%g~6#7wh=57OrLk zK54(C;}f|mhh-SDi|-%$Xl|!kE1H%Trkhq64weEok{FsLrlA1|4Ud*zGvf&2%Cb085t$2rU4S% z)hPW9n*P=v^>wtJo~ltv>}|O>itlTI?%I7l@g^3!qfwf@vozZ#`WTM_bnF@s=e6}AYCjjgIVIaq2VYeWklva009F!nLJWIfKl zcl48^11O3n8Nk>lyY1THGB!3FAO{12(po+XEYqeGMLX`b9q-@)d^T7<-r=Q0-HE1e zOrX4if>Kpy&aTR6|Wm4Ju$EN{FoN`}o~%l+889dXXSe9E5twVzn<9rk&BOak2j+{&-Q{ zl@#_qi~O31<%7TBsQ-V2>pl6+TepZP*ps0gATP4Z-3|#A$f5+arbC|q)7Rc4;O$#D zB*b8LSq=VC2fx5DV{PIK%%aLNI6%!Wx_J5dT|iaFG=R|+FX)Of zh=Jx`;k-Eu*g&}zT$%QG;nmZTju<{$0J*lj?CkQN(o$0Pi=5!4=pf^Dt>0|8bh-Hh z9Sm-TT+Ir&95bPQj7|yb{vGItCH%qc043}WB45c(kRxQj1_uZ479j5&@6S6|sCJw5 zA$Qqb$#Kcp&^I^#4Y>Z6R75^#Z-|>0%dbSLZl<3yj>v2f(ZJ6I4)Ev=0eI%YE}b_S z7#L<7e^AO+{aQNMUgGl5)tvy^V!WNO21Ka^y1&VWIIVjz7zayKWUSi1s}W&W{s`kG z=8709I$S~WyCC|vU94^!O_neHdPFRIv<3irAAc@`&OU35UjUk4jF^J9(z&v*$@#9N%oa?Zj!uL68*oBG zle}NO;tU6pkD+QB9+8gax9=W@8;RJgwecpZ2*NRwr$Ir1rflY3$XxY>ED7;r)qVu66c zlyQjwb)ZP=B9dLi6hl!JzX4i4$89lWj4?!qGO3ihFe#)4tX4$-G>OH#TT4!iLODG` zDxd2D+1&^}+gb1gLp`=T4{qd2#qe4Kwj&nWOZYD5pfsrbd=Cb${rSkKHIStxLJgY* z03PCI51Xlx@M?(_#o3&k$-k7hX$-;w_i#;{pq2*pYG@_5j5R=rJcJCo-^RV^g%PI}u$7a3()Qnm{j(h=}+=Ck|w= zjqyl53es-C7yjDIBF-8HMnzgUfHwN0bx_={9CL|$nTuwDvlc508@5O(`2WP4m?8ol zf-j(3S5{UQ&IGz<8w1rJsq@XA+u`>BobFmsLMfM#Y>|Tnh@b$Jfy{F|t?33P>;UgI z8-n{d&w$g9n5t9NLWjROl&pbci5aN)m=>*rU85jZ1~oj}FflO!=e8|g_>|HY#I%Uz zvDrA5Q{-mzUU(uQ0dyJ^n}e(u7wGO+145bIN5@9z0gVFelEZ4eKLs?4|MQ9su~cy2 zVB=8%;b!j5W^5$xfu6x?meYIjA}~O~#18HwqhF@#V#gkc(SndcgnJdj96PKe$O7rq zO>%dw^h7f)*J?lyAP=MAvqYqAXinJ@c?O7wFrxm2ceEmnX|xd=rJqOOLozKgcuGA1 zW#R$znX*$zdoJxb#uOxdxVEb9)z6Q7?Ic|HH)im2!MO8~$^A4Aa^ZR!Th$S8t`jKb z3HA#F1i%whPN!5Htk8LCMX9UjOEyiR5dn}5kBP~y(S{o6RRxBeDji)JJd*#sfdEQn z;ZLcA9b=V4>&Z-BDBcD7#KHM|d$A9oWTLk>h$0ZAQYW3gzf%5a8H6#oSosNaD1Hzb zyuDA=XQ3(Hxe3i!S^Uau*PHpRebA&Xkh4HA*0B*?v<^xZ*zC;m$!~*XATp%STYI%s zuMt1?b3fI6`^Drmfp$^S1u!S)_nv;*UF?$-=g53`CkC!v=mtDZtrigszs? zoHN=axI2>c{iFEh z74V4`#2%bS?IF^8$p*lXefsA+RVF>R8mvo(;ef-@hlx{w$Y#WC-``!n4eGl1=iQWW zZGQKIVoD?q0jHW1J>~|Q*`j8Rb95^tV8D?}sk8${w|4_HX@} zE6{z{V1WIrHq&KcIl(y2+!fkrrjz(g`9xCEbvDxIXZ;|2uyjR=aL45?-hPNW5>#PohQ5tU>PMI@?U+r%|Y&^ zFb0UtC7U3Em11Ugmczs~+P?^5C!w)9zA&c#hP5li!)mdQpyGsg23}xTr4)hU_%En9 z+r#}rgh&{muG=lBHh7DFcLWww!Z?iD({s(nW^BM!m^E$+rd3%{8h}IZ0L`T%PH-uy z1Aq@jkbflbA~-!o(1Bz4?OT2?!rYAJK`UboPHAcyKMeHTTYGMc<#D*o^9Q-Tch%20 zGm<1ujINu1e;yfpd4915Oc*XwU>g8z*+P;Q9n39r$1e6)a82wH4 z(e9b%SyqSLlB|v2zV&7*Ek&8-%9$&pftn~XsQ#!xkJm!Wy+BOt81L^7lLG4Kc(r;n zNK9E4Jyu=uN1|^QJOhL~Kq?aE6llUgEtg!2p(iV*%YJEo_3hi^P#-02hl-L}P}~&>YmNY7s7&Ir92uxCzS8{T90G2UiAi;KVuWQA>q@ia|NJLO zo-cn4i)&^7ZZrH@m}tHL^P&jg;{CFppj5!MitYTHeUhAlUgt}A)nTF@YwFS(uef7- z>-&fZUXX&}!;y*?5?m5=O>%bsuh^6|>_ns<{`{pkEfxNY8Q%Ijgix>WMU<7ahtYR@ zyDwmyowCi{B+0)UGxgCU8%u=>@j=L?pdg_F+L;l(4 zW`2GicqqIJZgJYygn>Gkf6@bBJ%n2ysgZl$Rn1QTClNy2U?(6l<7)X-(CgQ)e{M&H zHa45Y@SscU=t+Kp#$Yr}&~CQ)b4uOF;D?~4JniaQwym~$ux)j+ZJE>X*YHmz2Yk<; zd;Lc4d6w)#_!fu=ws*y8R3zz*nnY9ui~O^o!_PhTk7<6r))00kypS=u2T zG+pp}p}XibCVa9tLjiaynDDMnPURoI*ca@L-9pkZ0B?7}6AQk=rU!-ILLh;|GL_x| zIoI;tY~nw*TFlU-04h+N&IM^!q9s#(zQ?y@YxnNbX~MekzLnz%OR zEEX)Dj~Gp#h_MUgl$6o$mbQ(O*H7KrUMJf9K3vrm+1l+AozzruxXIEo;^#VTROZsB z$>PW6>^4_k{U}Cd-2HiV&8{XHL306Y%Xn3<#yXCf3>7c@w_+a|$Um_JL1r&3?4eSN z`@pAmAMuxD4RQQ+1frPXVE+bS_&0EsJtb3Apjlxvo8MHytAPHO2)H0?2PD)vG>VQk ztAm^#M|*HnG>Db#YW8QNwOqKvAB;l4+6L&eOF4v_q_QO|haUrLZ2OpZ78jRAqf{!E zH~5}G3yFpAzkw{)x}(Cua{DD^+ffkhCaPCb!3pIy?QO2nTeZ$4unmDEJNGR^JmE>%hF)-%LcQ{BiwYL^Up)L;trC5H#5v0r(@S5&+r!nj<8> zfQhV-n=Ud?Z!9`lj#WDw`kOvpH2UiS908-~j`ingduXfpUIXOPjkv_5Al@7!r56P* z%4&Vi52-CdbpFAZgJhFU*UMAYC&;y(9*D=^mWk>7U@g|)7;kX{Ni+kT%zXTL#x75x z@%wkvq5l#1QlgO15`=R!TZzo%9?Z&Ic$h56DOPqic0lobkXRoabz^wN0zwT}sg|nuDXV}Otx1$a!n8U`| zH|_9Q!<7F_N4HB%Qfb`g$gvx_L93rpu}02YZC*ip@LmOA%m``Aj<5bR=d*t7QA+mn zw1a6v;yMy&;-yX}WssltrS(Ai0YG<%8xS^Fm#0e1c7A^3?d{#i3rLx|CS|yf+HvG_ zoew@~_wy9VzV<227?Yq6A3lJk+fN0vnlr)MCM(30hAv8wR>*C@`77+jKxTb3`{#W5 z=D(iA2ZNQRUvu2f|0c+k;0V8NQT99Au7W0m^)D{kzE~EJK%oXYJ3C=i_ZW!f&6##}=@a&SWd%+lmF2o7Esq-3sSefs2-!<*M3jEz1e|6NU&jhxQ=HaIzVH+6$g4c8R5{JRxr@x;78Yh`C_uPuiXQKWQBWM#NpT_N0Oy=G0wNyBD*cw`r|_GMQ%&`#$r zT5^e4cwBU_>X(Ou2E++$MJ54c`!T95k$9Dn_r^QE{V=BSRsG&nx0^QB;9Df=d%>XBl_|7g;?U7SLy>FL2Qq)<}?8I>g_=OI)HHjr{`oL^elZ>nknY=yP!#tSDy4qNi0ZBYdQp zBw75cwhD9GKmcUqLXRY3mjKL(7*~e1LwzA%=zx%mLH0@@bibU(Tkfb)ZuMMA{YVWJY->&P z8MtQ)8rZRfv~=H>dBAm5_Di#x-0RnQd7bLLun<+BEj?_cIm8a>Qi+|1@4VHX_OPvh zbzJ-lb1t*OM9dIN;#uV2fb*!*tP8viB_8f3R;A2dNGCxaL|DK1pK$z$rVRE1ir6x3 z;K@gt3JtAhi`F=TmSfq~zoIaj4|B>Zl_+`~8M}s}Kt}Zgh9dBA2-xP<((eYS3R4bGCP0r5+L$kbOVhEjsj1?v0&5_Rw7|YgAEXfhBaYj&G9wDgkdlYu z6J?WhAldk$fP@tm8fu3BbpZG#oTsc=+Ubmg{>>7)^hnv)Bl}&H9!ucgwJ-p+w|1`EF>oHbAS7!VLsh-qlfMLVf%4`jbfgLVa2@i8{f*Ur?&ibraIG}(ZZ6K zMv~ol*w~(`(>VGj^r8-K$Jx0$A=*>U>KfGrmj5oTWxzl8ec{3uXu~Iuain*t^_ZpJ zjCHWEA_mFwI?;4J>BaXUA^NCN@{1Qgd`6$3%FRJTIHpxI_yK@)3Dti5wHkt05D!g+ z775Q}Zz=4ul|H0VZ2VmNL$v7F>@O*UJ4))BEW6U-La9KSYJlp&xuyt@5FUQKP0uV3 z^43Va{7Yj*lOW8{pWX<;Xo=nw=7?C$U@xQ4+AB(3$;${P-MYi%Uuiohlj904<@k5c z6y^SA>DH|tco5)gN*yXZKVIsu*!%=89zq}WWzuUb9bsV@(5-%elvFcq?(>( zZmw@;ZaogCS@c|ey$%AwLqDTpR3Ox+3(uXYT+#akD|#ya&3y=l0TV5MxN+qF5Y@h1 zewGsDMa9g=w-24-E^Lf!*QlXz;jTg@J%UA*0H)XEIDE9Ex)ZF5UN5Qq@jhOtw;p&H=7Gn{#kCDsXCFRc2(g|v(*={cVn;gLcE{|^uF$0Q~|;`sA= zs5oGrw{PCOa{&8yi+O#jK%bFOYPVL$WH1Z44Wz^wZvn7B1dEEDJrQIzP`6q?IjaA8 z1N#1VadbDy`*4YAG+{*=46x`x@MN4fe%x*fyjlk0P=KAGB0MRKO;}CH;n##T?I4J4 zd$8+RbG$PQxKpu2jTyM%SfzayS7@A|!*5kn)6DMj#PX)64l5%WdL?`(klD)q0n!eD z5jjFg7EZu#Q$yE-;SPd4$o<+ODJV^Fp~`A|*|kg!S4xTOdL-4d2`VWqW}^DYFht6v*djH$a+J z$7A0B5Fb}gA{wL>rB~VodZJ>s_mS7XrqVoHC8D7s#Don@hviXECc)uSUY#Y3M9`=q zlcd}V0j0hl(BWVXIrnQhGBiPkQZmJ`&we{(^3L4i^Un``vSkEhxid1SaXADD1^IlX5q6?uh3EgTMqubpVMDp=^T_?P^7Sj6M|S`3#rir zO*uIshDwcG;^nZnt*@`ItDEq35q3+>deaLa^OvGI2QPPtiYmKu3nT_mI{GU7YU=nR z!Y#rT&JwICZA1wfR+35t-2<$e88Y$&abD8$Sb{*N@P5G?c z%0<{WwxuY7Y|Ed;-%_}LExGN_oTHPWCaN4)mAPv-+wVhItrWO9+#n(dNxSVp#zE@o zQ@L#?&@)WyKv(=5dmQ_iBAM{%(>Vfn+j{32EBxd*~tk^LqXGh$~eRF}7k%W>mGb*3&DL4_`-Q7rb5C{kM1lc1;y<`!bixn?bk`7Zufl4djJV zBN<3Wf=PK4To@VscnWefURxKCYI0TrV^t*X0j!1G9`XbyG5DVh?1YkPa4ND_u_zDk_6WQdQ>xif2HXnctT>ul~jT^tfT(v^? zpMl!~Y4dA>h^0eQ01D;uaGBXPc6J+(Wfw}TfRoIDEI+e6_^*QDCy%1xv5av&1Yf2j z_P!B#09$7vY%_iSaCa3AdzZL7E2A1n#%NfYkcx9{CR_*PkqrnWT6ri}s1y`|>jrYK zkL@9#2$0e=An_GGR$Y>XuLV&p!I^H$?N!%e;N6h1{j&B{bOO#%AF@V@LTSswq?mTW z&jqXI6taTAPizSh0T>{64g5n*$zhKXl-Mnqm;n&rV2pfoOSj8>iCBp6gb>bk1ZfMA zdSPKca>~_g9H{gENEfR2GgfV_S`;HL-VE%=SPT}7=qn2 z*wwxgboD;a3E^wkCIGFi4U83=D0o(arBf#<`S|f`o78RD>phNDdPe{57o}w?X8anf zE9-&)j}m;iAmtVnKpx`_Ik>jmc+h3!!SevM^Ufk%qT}A$q~AA2udHe$aQ9!INeygU zgZ}|D<%d;>Tc{K14nE19iJZ~qY#@J38t^3s5udjJ&({ts5wfV}Pk`Hy)w`Ueflgvr z=XZ1k#TG3H*{NdaH)R=0H~jLnV=@ZTzfurCo-a>P}a#ye;^qaGFRPx7Q$rWLXo4K_a7q z^z@=idRZEdKsfyT{3K*#Jf;G9NcP&8YCsf#Z-KaH(w$sr(0ZV@g$pJpQhOV(;ABJ# z-%7>`9~apeh%-UzF0%v)BeE~TnvYuQKO4?;Zxe)&C?R@N1w3$P{a zV3~^uQl;yqitzmhkN&{lK+6!rDPhK?0`kKzA94BSCK2ee2t07{+d)eYM5PIvv_uF8B3kHN zgqJKP4&@&OgAeECRzXxxN&@>j(>D*&yDVF&pgejY6;=irN8067;2x$Oeeks=w#4&+ zWnF-g)ByRwi8_qf0hBqU3|!UGve#Dw?m0L_>aehYje2ju-*0k_r)qi}YYbLS;Dk_; zlarH@8sH9r-X*yj4R{d|U;*F3_XbEwN?wK0MxjF*aa%eH{*As-OAuMhitXU8>R$f_ zT&@y^B}g@bT!1$Kbukg>080e0!{2*PR|R(6X+4h$WRj*~j}m$ZY=^u(%rQe<(-;BG ze~1kzls)+NLx-?L(&$)~QTV7b*7YhaRhO^zWf^!Zf-~9I&Rj(07>k6 zq=AX=!2$}hftW5w7f4G@owGRtjd2>HazMB@A)}}4Y8ShMQ2?rsbUQfjz~VH(f{LXZ z2Qzt=7j919N_Gsv)$ZsAtBFXo0k8z*BjgX-=4Vj+DS9Uo_+Bxy@;VtIahI+Rnqqy9P(0@9(g&$ykq=T@!E*gk1Oa>9{yKY6Hz8qyWI<4c%nPj ztuVO{T81{fEkOS9E{hD<+^KZcU}-j3#l*^8;_iq*)u$vVR5nl@nb*k|i(>{&p^|xj zOiSm(F~}&->Wu#QQ7=X36wP7-;bQ7Ls>Dj|O1EcoShYx)l%?p*bYWD1NKAb09CD6j zav3aj2BzVPh=iYH1H2uKj|>b9APe14pesoki-+KFF?ArJ&kDzH zZ!bbF)~^j_72@$|S=ybC?x~uGl}4}@>;Bk4ar)aVorbvW>7c3s!_{gWhLe$gPBTf2 zP{|Gy+6i}9v{k&E3$b?CDUgLPMFLX_Das@1bAfpuSK&iEU!VVbv}zY3o0xjflTF&= zWeAbC=RoWV7T|AQOB>a?Tz+GZTK8I0i%A_e0V&~}qx)}g187oL1pzZ-Y|b0g0Epq#m9+;#=MZvSiNOW1-_zaw z=Rp*E)Ds~gA-%nd)cg^igxAFJ&z(C*K)`v5i-WVzM+$FmTJ_|VD1XNo2@gT<6$_%; zhr_gFTW=1zvJr54vXruB*-yZ}hb+2grXs`KccA+qq~=2%KoMBND~z<~;pbmMKYaKQ zcJXX%3ia+Skn=^3#k+(r)?_+nkCnOXQlfr8U+7N3F9s~2cu5NT~eX zjpl~M178}KCxQG1+yL9T!WflIA?XDh9h}Hpz4UvEj~X9>XOtoUFOk5&94h8R8VH`%bW=7lXa#AtFvBKn*uh1!CMI%W7%PF zYPn3h^&zmbLI-0xkXG0OI5Xh54A?*UyWyJm9a*Q1R6FMUo4@tzgraKJs6HXG($l-K zOZbo`33~)oWmh-9ab!{J)Dw?43A%2YycN__>{Vz?nJzX!WRFg-Tcf<(*Y4L>#P*}8 zXlXmP6PsV0IdkUWqep^5;1$|rOOktGp_=c&4(r^~E!FQSyAa-jeVQZ?N@uMgPQ4K&EBTW)wf}rmagjgAEJwmx`RcAHJ6B*xo~8 zcny|tHq$;)6_z0VsowL87}-(z^0yeBAi<#;hZ;XoZ0rEK59B)W#~O3E28U%izm15I zGjxD_iyVb}k$i%rj10)Afz~O=mt`DRo(m2Pmj-Nr?2m%{Z>svv5oiunCDeyJ3@`X* z9Z%TDQ-5(#M`+uO`P0Ww4TMmq7fH)zci#r%l@+RIviTTE|3?c5VdUHyoAthBZY*12 zeC#LcE_DcTR_M6BRFHNbm*yGO7bLxS5otwGcw<04#%=`%1|qIVE`SdRKQRepk*L9< z$J|AdO+%QQpidZ7BR(7?%}ik}Kg2~D(vkqly4?E8!Iy#RU&;UzH=15LxJO;U0lxPM zZrnKRUb2EZRrGypV7`jB6~ZC4NC-BXXGx7&1irouE;>s|Sw(#Y8VLrzL1W6Mk_SM2 z8U)`IQl*gfQfOzp(A}WlXe_AEnT5h;$t0DRG^854tHJtkfM~*NU*E&ATVvW6cAx%a zi&gp3gz0Ps(R&fdN$W?X4n$H7PA<)(F*F{zj_%>#JKY{6t(udO%x4Yn?Kv?WOUTQ2 z17Lzwk>4)~AxdQjd0g1}*}`~>InBj7#{Dn$-aDqMzHJxWh*%Isr3okq3Mi-)1qBh6 zCZhBz9R);CIwBn_N)b_zE=_usBBC@!0clFN&^w!=^bY4*zWI`K{+MKDPBN33eCBzR zH;+89S?gEs`)arIU>N#Aiz!^;*bME?4}fmqKG$tf{0VcC2g?w4Fv~dBJN55s9dOXE zeg-@zS^_JyUthW*-?%CRo32d5^1};(P^=0*e&@iV?9+SnnA+tM2uZy4H}&9m`d`c(K7x{lpq-8{*tq zvw9YAt9Lx#Ci=5GgWJvR{%E(g=edYQ>UFusjcVar@{dokY~1|1N7UDsLj_KpP*14B zHYj&9ugC>Z2}6;_Hpm#!+Nc)VnOdLKy4F|)W}R^Atm$}n8wCd;+l^!T@Ru*ZF0x9N zepvt@=#Ey12 zcG1q>5Tt(%IaYs0L+=}Q-I~rQ`(5DOxu z!lc6Bg?Om2EsjW^x>~2t{~2O0VLPaXX6?*ZP%#!dr6su)G5Sof@z@$XbSQXaz!Jh` zyY^6tSPWIkwVS(1t05ydDA&T@;-p2{n@Sh%&pUazO*EkU{=JuB?_fgYhRpQ-Y%_2P z5I8B}v5U$HGA#!wDJdBW(cn)LB}i3DdU6uN@qVBh#L`bwOe@7&pFWk*l3S@r?*MGr zS~FYtUZFZ0tNVOk(MoIHRfRN0J~0XX#j)suzxZBhzeui%u#`A5Q9Ty`PJO34l&-8_$ zI9$7x207))kl!MeTnQ4#9d%Q`xr(NB{ytT-Sm8X95JJBJX*53} z4FX={jD7g?8c(+RA>B6x;C*Ba75Jdu6ugs76_=zGw766sMxObd6mQ?Vt?PXqx za)!_tUT$3v0YK$$-jq1a_P1C;yo7P>a_>;9jUdpJT)vhcb%rnHFI|FbQwR2&e`TwL zX~_n?Sn0b@XXVeX{H*YP-+EKKP}|6BayeQserST+5+{kP!cEB)C7jq%5g z_-Ih1m9@XP}1&>ERXiGz>)!J?j*1x8-icDO@O{%*A?iOVnluUteE9iG zuoJJTKF{;8Fz+tAi%a>0XWZcq#}jVHiirA#_baaj;O{kpS^2i%b&YO;gn_R^$md>H zOB4S2womlCcE!H4B2_tqPa~Wen}*Uf16fUlN!J_x<2^_WkF9=bHNO{SFacW4D7SuV#0YjdbqaQ{{L^3)L&> z_8_`0X)2;ZB$o5(X$^`UTeb*z6-oZN4qE`;=XHZ=uSQ7_Alvt6eP0f<7sJ2widy7- zoTB%V-%x_GYIR02afN`ef9Qz??mu-ZR7}9`bb=vKvE$F&OAp>H)YQ~lx594Ks}=Zc zG?`q3STp>&>=FE>)=){o5#rBFxZ?~ds{-kr99t`j1{gxl5T7pFr2D-xpW@#yM#R^j z%an3QQH#&?f8FK(>@I55V<`}vBVzzsxC3gaKkwbYUl;wKe%a|YRsRl z0kW^gb9K~k)3F3dgr%jW>G=)Bxw#XeovcWRWtstghXQHGwrwz|pwaOz+z@h({fx^A z4vrqD&D+P?3!AFa5m*aJSL3@zA_D-S5}qtsK@g5W&vZq18R&_^<;y3I`J(~842c{e z!$QUs+Zqvua>@;5h3s1~7W)ZoXF_Xw1^ zsm%NZrw0v$T7Fkm`h%d)#Y1za3&abTzWm%gxN2k55YPAO;_-Lg%w_9MTU%lDjv!bz z#expgUqlEOxVqNOiMNlL^`U9^AQC1IxGCj;nflgI8*%bmRdbP0KR^N$iFh5^1(yvr zWV{K8*sG$4S6aZL| zMnoS2)(6~BAx!t++WN)sU46y-44X3<{SY9#+BBQ^xHtq0ygrh;6=e-i*|3lB9i2(fN{ zb@em6r=blstAcZVFcSOp8Be3w84c@7}#jZnZhs z15Pl!izMWFF zo)O2JCYe)UTySBSTJZ(cjC5*)FWjf7k!7GyXR+}i8NEe>eM4&#M~Ll>JQ)7P#ftzk zgy*xt>z;rY4=)U1Y1hyZ6gtFp)U+=p}T)+fB)*`%S5B}-3g81I~3H*PJHuS4jm!X zhL=rGDaIiH<}cz_gra{z63Od`8tE0Z69`8bA=4?_=(R5VbrUvLW z;r!9i${Vc07AAA)GuXd(TuCFs|#CsiIBkdBWxjR z9WHuzsLb~D2On0G9Zz*_&RBK|Q{NUtDZGy@?1|X#k>GsZ->qN*vtyr19Cw+{XwGXs z8u*)^yQ1Qt{&z3xQehth(=*HDM+KXkC)6^PnJGy$Qqecr-Dd(7_w!PaP2wcTj*7}E zb10Cee@sm6%J_C{RO@rTXG78(QG#_j(_Dt42;Eeoy<526GJo#V)PMn4kJW#w`Y%H3^}CJ7ku5$L$P_+_wcG#wdTO{h}5cR>%x0JL}wv8_|#BF zg({a2>!NXi3i?J%YH;O^sO#j;C!bQ0_m_t73)HkV#V70j3kx_~Ut_JnXO{5E=Dl|P zZ!z9mOWJo{)C*~QUl|d5TDKiq}4U_45|@G9o!kY$Wk>a#B(L>CuMxO0-P$&mFG<(DDm<)OTFX zUP(?)Sz``@>^3z3@6MB7m~dpIPd}lKgJbc-z)M{`6~Y7p{QZzg_=`h{Pub6|;SeZr zTx)nJ@mIfo(L!=IzCDfh7MIOgOAp-iRvD2-1il1XjWasI0fIBUcmj()_yz zsREubv5KXna;cNpj+@ai7?G+tr|Eg@@-HKAp)e|o?k!8Pf0p|+V1pAewdl~RlamjP zKbqatxYf=w5~_q7NfQS`51%Awm-#&vD<6;KVE9YV674!6Iy4CiF}bOSI1q4gWV_B= zjaKl8?QRr&P~Sdka~;*Ln3!#Js}oNu!yzf1zTrckb+5hOcC9h5zAI(!j$mP>WAo(8 zfQKhjQe)~&cUW$9G||LK*Vp)c+qlPURHf`$2I^x!GUTrhy_|omaEfKRGIWR5+C&%W z8148GH$7vMh8>cwjP`9YJCmm~kfn222`9HZ+87+EYdG%2z^kJGj52QCoHdvkFpNt$ zLLYLR6Y%L@XJgQu?MZCN`_p7jduHa@&)`SFG7vFrZZUJrZ2jGsY9DeuHe5NS#YXRV z0$sk?$c=nlRca$z<zgZ_?)UYHhH?tH1tvxBgLYJ9K4l~2TofiYoxt@}o* zKIE(Yo@#Xuk?=fgLS}=19PK(*;LJw-Qv3Pm{;{K9Q# zGxkGp+UC5mb^ANp>w6))yvF8er7?M{|erkACEOH*&>vrU{@{q5)8Re4bVj>(t5 z95vB;jw`Ai4ZwuszA?8*GB|{s8a@uMt-jB_&R-p_M9dIxCeef~d|kTj(Blp2a7Y>=MP^cB@x) zEB)-LyR{%Ou!-ES=(H8~nnAk;VbcEiEoIKv}t?X(IN5r5l|$aQAe0u@Op=vy_U(Yx{3k*VjThioo>aU+|nq%(W+y4%f98L`U9s4&FgeT z_YTn&h^LFQCi@ig!Gva|TkIz{nI3gh%huW|IYf_FZKh8#@kX$T{!3|1|<&UZMCMxxR=4%60oYps2JA@8-kGIC$IwEjvEE3l-< zqid)vso52J_ENHNFG%w$&J8wf&ldlXyie_6zCKYkJE4-I=6rd1>;jwf=WjRupajAL zO$oA?=$q9Nt#gwXRBD(2<*UOx?f32#kxeS>0q;+SYVKgBqPl-GhYBj{fChpCAlthw`Sn=@Wwrk{%rtB8=HG4Hp@^5q2k1vV0T{Ta{5o`;@ zG*Q<5y^F4-sgZyBO?D!mQDq9t2NSC0Dl>?Q5B!e|E#-LOzK8rMVeX*^#k55BP z+9pMiOp;9b=6-oz_4RVz$n7{!60hNM6R?VM+pm*^&<^ak%rN?U@}aul7b#HK0ZjAB zCt!S)pr^a)e)||ugU@J8i`jdf?Ce>Ry+piHze=jg5yKL3 zQPC?rV+{8*T%Y}V;GD~Npk^kef4MTR2x>$DPhskFJ6?a2Wj{+Ft)CZ<9%S~NR^#R% z2`-0WIySb6si4~?KHnuK?%5tr4$6>eNqXscXz3 zBTVP&L#928_mZA@n@+Gu^`CR|XBB;z!xw#oRJ0X6{*E2D*SESLm0Wn-D>J;7b1E1hvXs=%BDS$-uQ;hYr)T$-xzIfxvRFf`T3v` zSh827&E>Xf*JH_+n+fhVlW91nX%1DKms4av(Lj^_&xKjw3;%B*6aV`Gg*$KJcc22v zkB?la0A~utGU4r6F*Va4Dg{G%+idskHD7T*;7xLdzs>?30?7}{on>IRW(w_&?`Dkn7a7ssfM`WYmQy=D~FWqXONvO|Xs@$&Lw>V_{Z zxEhxDNb20}q7Bpz^vU&s*iJ2|bzG7Q zu7G;gWYalwJs}OTkMR5u`s9U$F?a2EHa)<$(I%c>&k=xQb zSu`)b|Ibg$&(Sf>NY@ngrOmI)A0YmhyP#}MQ+#HoLg4JG(4+wCha5Jtq5s|pM64z($#s@^zo@w43B{4Ch9PPR^WAAM4KeiluNvj@W zkniNtjOXN-0on!cyW+fM?bZ|`(A=veUS_8BV&z*RKUTPK*P_8|$1aCEA15imT`O}7 zDQzC6_q%=jbGNu3wB}`ex&3L>_frMME?BWqCud&88}lCiY=viB{b^9tP$NL&&<+}! zgOo#r6w>SZ;H;6@j_SrwRUmo`H>|cS73;%nEhE zulcmOnhAE25j&a^6$NcUZDHeSk)2h?2`#DKVN!%sLE~3z;6clWp*HII!J!i(#acyo zhl7H&E|v7Yj;?dC*aC>%n7nH_R)nb$xy*SJB&UDZYki|9OEDTjPyXm3A^d(RHi_vGX@4__p_(?d>Iz)~*_?6If1mwgCS2)K^^pLIN_~ z;$G3`H;`7o99bt@!`ub9GSYk~S>HrebvM(+f!jqMB(KsePmyo8pa(5ec(R+TcF*Nq zr?d!Z+S*l3FMu!B*r6v=SM9bs{O1GeQ)n)2-!Yg5Z0SzSKGVyWX&AcOpHl84l^Hr{ zo<4D+8DIZuhJhi+);$S7KcvhhE(FW;&n$_Xh3RGwo;FwW`f<-t< z_*u)nnfNqo_l|>+C14n+zl|kK6%61EKQnM^1;b*j53oMv`wo4%GEiig$S~&Qn{!I7 zE!w@tEW@+8rVt$gbGp@>9O&u|$2VBX6?pJek`(s|VQoMka0oQ2 zt7x)dbbJSacn6Pow=(j5y7uwPz>Q-J{RU{7(_oUpT(hC7IV~Z+(KcG^2|(rAH)R{*1@9RaKcouPM)Us)Ox{+92{QVsf7m9`4H7=!|}6;3UX_ zO+lgJ6ORJP5se0_ZA(2ytP5T;uPeH(WtqsFei6v;N(KghYuezKh?jEv-hsvW9;{C>tlu~vm63{J#VvX^(XGv*%Xk} z(~?Zi>&?99@;+)TZ|Y)}p+~G%%3to_tp4YGng|y&Wwbht3}xIADr?b)W&g!H{XH2hwaTpUW*SQx zijwdgOF6kJNGCl}<)T(%q5T7o- zyPkh_9EB9dPxQp6K@5ap7g(s8L^3hxR_vUsChbkdtTQ(a-k4}VUTgDWgM~-DdRBNoEZdKjyLrP<^SYwp>>NZ5f1AN08nJ!(hwc#bK;*lz0_LZ^V~!;F zJSg#Swa4zE^muhxR*|tRZHpWP!8=N}m%XgD{+pVUdNb^a?*|NjBUJ4KImz2;@Xm%q zLP7%BhTqtP#3V>0Eos<2w6#O)-@w#kN0+YWL!w!ENTNBNyTQi%&{-oKgIXJLj{PBy zB*;faalGC6sE|EH`nOm0=r)qPXxlgP(RTkwk@ifYo_>fzoj7;;KEqf5xFlBG>)-Nn z%ioH(cI$uoJDbE)v_v$)T7^~w!iVnup&p>n+whNr8R@%h%XSXYU4Kt1-lQaTQ+6>_ zQxf;Wf4=ryr|kdqch`NU|MPMF{RsAjOl7t*E-Vo&)c@U&Lljq!++k6Q-%Ff7@Xf-t z8a8eLV~EgLbsrfE-=pdo2b2cWN#2M}CNxy=1R$%_|LpNqo(FSZJ3ZI(BOoYOYlpHXtmWSiFQ29nArI{FVRd=a~{eZ;|a|wBzK^Y9Foj`oZFS z%BsC3d_n)_mUz~D*NRx1&GOvNF0q1>)&O-5B*tenkI=#~1P;9uzs7#=bVL>aBUsl?3NMDAN(L5t4d zJc49Hvk$P(>Jgfyvu24Y>o~QUuk^XniPjAJntp9hwCP-%xD;Hd)oRMiG*aSuWWp=+RfI&X#nNdV?;0~KEj`YCb~xvMW+Y(6na2x4FVtn@fW$4=;k zpu;(d?17iMR-(`$^*pQ;1dMp|=nn+6Bw7(YjdAcs@GGtm%m(N?|7NiOhvoq347BOr zp<{S>CS>bsWYix8lZ#G$o^YJQwU*6hp~9f&e)EL&sHtt&TI)fUI}q{+O;)?;T^SNx znqHe6>bkdW-t&3MZ;FK{5k~(eA(eFrt+pgW>z#5{;cn+LGuvgTlJ+>F3d4FERJ}=n<(I*bR?W6ZZqAN{fXEup@0e%NoDU6rp9QA z<6rr1{ z$(^&1J4s~A-KrqC9$LuJ`0^J@L2cjk47tv5I^)YL!E2AE)g%nAbn%UTTjq)Xqx+)f z?8O(Ym!gRAN2F(0h%<^fYlN=hB7pM^%0fCuMiD6VM-NkZLDL%S43_|Y_8EAJO)2v+ z!wDPVNwp;JCf*AO!X(TW5W5~hY*X(jN}m%`SVrUVo3E2O#Ke~1U{kAVI7Rr^{!q;& zeM|SV78>=zEu*@tF`;?TR>7U$D>VdMWf9{!U2RH8o32Sp-dn%M3rSPjxdb&r%v{;TM39>d`uVlU!o;v;zoyI8r_Yqaj{*4_AnBgL}YHaYG z2=J$})^K0#GG|V3xOH?63o$Z!YO;DMH>bY6a(>-?cs#lv8HG!y@*uS)0^@v0ReP=O z1#c#7NT*CDKHo+W1DRIV3Cny8tb#|uU~{`>4G}$l%HoQc7*3{dAYKh2v-k4yf*cES zXJClrJ^JIQ)xL>O*TVi%Jlj164wKNF_76%W@R##=F4a~qa9cXl*J$`VI9IzXxH}{; zq$9Ai#TXa}eLr}%44S_6Q2AFd=QUQ`0AGe^u6f8;c?SqZzpwGB=eh*-;Q`U*_GtRD zT8e>UFlJG32Scx2e_-Ji@i2OaD%x285HmE7MC0zacwOzkW}W9}XK=Ilh5oClIpR5V z4qkm4{bW8COCpNf9Ele7n#o6203V)SSEG8Sg0i$}D>r2!GtoR0_;qd^B?1T+aB?Nk z%iuImcs$y^21xQyGBLUnvB6N7S>zVOU_(r(+62_j9-DG*xS?E^wmv9_zKq_ww$=uj zy`zU8ZcYom(J|e6A)0yL*{1fkJXCU>vaOnS1|Q*_wJcj4)L!#*F5iA~X0%PoUZqEW zG9O+Yg6!V9<@iqCZHLdjpBc2Z3?(ARKoOSPmh;p#xb2Htd=<66(!Lo%{XrD zXCtuJP=jzhNd`W`dFML4K}Uw(-@5bYVR3Qs==%k1k-`WzVz}fVZ>7PdcI&5$yy;hr z9#J|km-1uDpPAj=%)#wCFO~VkBX;r3lz_?nd#PWEf&;z|>PW)DfMN!XOmwUuBE^#O z5$$INLEenACxE}SQ1Z1Bwt`0^AdG!|d{kGbF|0ymu_Iw6qVT74|4;?(jvXa%{t_9z zxr1Y_Y75#~N2-VUMvrvN2Si7FVs~i)XmxeX=8YnA^8TBos{QU`d#VNyY-8kPW_r<6 z|JYdViQr%Gw-FvQc#TnAI88RWy4%?#^(P8uXYwfBBWyBRKU3`NfNI&9Stag^4+)QtZF)&{+NJ21`}c zh6V1E`{Ce#-nlh&5x`|<;+eB^|$6@tfOA^D+x zCN}8n?=P%mptotnY6T?zNHGG8twOA`4I34DRJaX((tPcl=kU4=wT!Q?Z&T?#&H3=JJK{LtD!ViMbws z*BOkWI_DNHT~PUm50+ z7@@*W;mDrho-Za2BqK(#D|CSctf?vDHfA;K6>ZGWk#Vcp@(W zTre>uz4$a}z5d)|n#5a%kAG$UBlY&}3igmiW%J2&0jxqc=asYW(xiGyBK~Rpx;vdV zv$l+psnPgCAtMXP=w#{=@2Z8jHk$sD)rb&cCdsCC=NCVSGV|;rBx+~$DBJBlQ;7Hi7HSd`;;Y;j<0Cm~Oy(dw9(n2zh2@iolR z{KY?VGN9)aV)E}6<`$GNU_!7?3OWi_M&eXcAA6tmBGK{RJk z`KPY;feG5WOrd#YW`m(w{2Du%m_iD@e{C7e;$i}tfz0KfQ_AHQ4cR*F>`P6-1vidQ zelJ?;SdDGf*WEAm52c$`=D%z3I9~$XTvesHO!xO_U`SanH%M{)%-YzrD%nBGBe-sFoQ3IcSyWhGW?`av!sQ} z@p)y|3lVa^<#mtfvXQA;YEr#j&d=tU5rE~O62q{2f>TJ1l%+|C_c8qu_Nc;VgYN8g zm!f7<7{-ib4NKNu%x@g-Yb4#|=j4pTy|amQ^2Yl2^L{({Ro7y@+Yhslo~2zGa=yQ2 z-0{aJdZjLn=)l|r14#4^by6GoXd-xKw_lLN8m_vP?qHA1=5gBl-fsW&t<4UU9y<IS}jM{}ul6|EKrD zM-bJL|6x_jf*E)2G}=E8%%f_gHhpx32{l!c;EU{O!~Szvd7@hXXjMq>0T4q>t-23t zS+7Wr@5p951-2B$R5``Ez_W6cbp6trGYU3xAe**tN32!M+@W9jp&y`gBz4OlSE`E` z8U{Oy5eTRR*?Z5?)Zax7V7E7|pA}jGZ*Y4J$H2qEn~*-0^=KRYp_o#!M^BzUZ8i2| zM|l`Kv7YXX0SIu@_q=# zIYnbek6>5FMs-@a!!Huk9}0tZTyqeJFOL8bIKANpr6>vCQZe?-gWI%XTB-YF5qUxU zetMaxz)-8T^=xN8)aY~G zeX1qFqNxQ@9r3pTjj>O6+}Tn_WP_Fk2Mz9IXJ_BD$9&BJ0rlrzy_Smd@*?e<3#~l6 zDRbin0WZFE=1_Ghz!Is|JU$ zbo4j|3%|i`Po%~GBZ0vb3dO# z|D4cQqUQ;j19X1;*s=P`u@hf0+N+(%ftb1mv@xvQXMo%^;KLno391!OsJT}a3L1V3 znalFtXSFK2wXq3y()_aeqYw|w{RpTja&S!8pKii%EA5 z5mch}Dw;xjNIv7aHG;|kEnDtDZYYu8;`6lH4m?pYSV)97y`F;%c4=ueORHl60JzHn z95Dczp5d z_Jeb7+ae6F=FgEBzJk~k@`WfE$u*Oedoc6aW}S<7XuBhZ)>?dmez-$#9xQMKt34uW z$YesWyees8?iMmWHYqc6Dr0H|Y(tvbK_qy69~CmI4Ta$p5m8$8d{&sRAY;0^O|OcY zSY(H#h9rLHTPBGos%rw>gPfh1cwzIp7qbTuJEwVg27N1B6$Z$lm;XF?Fm;&-=mx0{ z4+P|v*I>jxu?9K;kIMxvG>a9ysSn@e!jwhT!sP8izOW!%X7>%WzIUQh@X_^7wL zi}m9SCO8)1{ehru>>pf!ufJLLyI;vduP=;kiiHi%Zk{4RH47uhi1o3+=@Kb|93aTl zRa;v|r$C{AEiph$@Ma2oT=YLS)t@H<8_ zCZ{2lVAw5ncZyv-`or{#h%>x(%ELMah@*TGi{_$=iabT*UPH=>BOEvngLu}=^BP=8?*3Z1Hy<`x;Ct$4)bH^TAihoYR< z=a1a)ynodHFp|tx5D;j7(CHd1XnfA$yBxjB!E-U4qfv^D%LA`M>INnp+M^ygBrSV9 zY8V?IAD>{j9sOtecjIh+UznIo{5t7?6V>s19B&n@o-q+ZKUa=d%@ZojHQsfYgP1B+`ChZGVs?@u<3dOX== z;QM6KACyDw$B)L4rtd~5B-J$Tn=V|l2<|8;DYd`r96mqgxb%0(O+ zzuFDl5jZ=c6%UkgykKT#M$V*6Z2mB{iSc^B_kQ`5M@fOvpHwX@Ex%-|rnvQrg^I1x zG2c1rvJ=SHquv$JPtYX$MEk(NB|Wo;_AWk~*Fk$YEwS-c=&xCKyblf!|Aq6os!Glz z7L5Fp$N6e?Wo`%(dNwRjob1dden;7fMSP&Y|KKrdN9?Y+IL@!q{uvs^#yxO59m|yS zTRM7`CpDaSY@5^Fa10_#K`%lABsTHjmV;-Jv)zDMG}5v9XmvFq?7mSc=7!9fwXg}<=M(;y7T6sR_+QW*#7*4+us z6-ME(O5bXKC&SE>Kj2fPp zc=h7NaafOb**abjdAoy%f5Gm=P#?c1$Qy$`fsxX;>D~x(+C8xjzzW?#T+Jcj1G9(e zeV_*;61&!7vL6BC-mL`NvK1;C9{qcHixB6mWFOJ=;l`V1R@}*pN(93XT_= z0R~<&4#05hy`|A$7C1UDk$tU4_i+Q6c}qU)ov`btP;(QcOb0E_C!dMSdpz^7F>-?H z<@V(C^do$HQb(Fu0>pSN{E*Qh+o`{wkFTbsCI8K|;v1K+u&_fuB7(a{^wU;WR-QlK zZD?q?QIhh&Y$m18o2isiW@cs_(c$<&Jzto;ipEDqQe3Aw6ngKcW@Z}Xm^^v6{W-IB zA#;*-W*c^B{!3(*TwNa>8}nOb%F=H5w8!fSn=#4C*=(~B>q(?kAoh;9+FM#6wp(0S z!0;Xwm!+Ne{b|auDoyrK+VJ&qe3zDPHNjqla_5xWg1}q|NM>AKK{^|6Hy+B9C)RJP zAA?cc1#OCdrvVOzTUV*xF-*QbjJ1kCIa~jIJK{Ui;R9fKJbqloEkL5fro8U5$${=fPSPZ1AGL0MNfO*O@WJuNFM zMCUM>JQQFG-lpMy&OEWq;IWb*Px7ekf`MABMrA4koJ2NXQ3qPp&gsDb#4YQ0WmxfLs zU+O4ds^zqNT9%3YM+vJlrBT z4-E~CjUB=B4^vD@RVvnXI9*T_8~gEZ(Bn$)V_Hr-=E%ExM*FLBxcB(+<@+!o|{@m7MUVlXjDKPtrI3 z`;q5j2mAUCT;WZR(7uQ-yeW(OL@o2v<6qXXBO~+cjQX1P+-HaZv5bl|o?ED4!oae0VWqyXC<6_^s}$p`4r?=q&ow2vyZePjhQ4 zqA7(WB-jdx)gi^tpM~S(Ql>f<&*U1!Ot8ubf8QdwZ1M>}orWPf|+wtcE*YEbG@4lmce%+4oSX2Aj^T`DZ192WV6DTpsh zoTtrTQdZ|}f#4rP`ZG{m*mgz16NNavN8a8C=D-rQcXX`6KNw;d5g2$^AU(p>6eWFq zeZ6e5NcM(`%lBKVYHGk#$!rO^z`2T$o@?J@&T~_BBaX~>zrKdX?B^Plw*)#XniwaIu3=(5Q^$mb*uG zkg%S8bFvcNT?I=yRt`PSG^MB=?IDPgPIG4r`E0@p|fc@@~LB3T;)-)5y2@#7hj{L{?Bc%xtfiMS6!&Bo1slu`KV@33JY2tF)_h1~K`Y_TjMAULD^!C9vc5X1_M5tYRg#+p zD$ZwgT-&`XuyE)2PNw|&LYYGrH<$2D&CTQ#*U8(WGRIW9 zKve-{@*ajUNYCP5E{Bw^$OutM?#~hIoF!US^h!o=x5RlaHATa4o%~axOcnjMpXLV5 zi)@Q%fjd7q5yKE3DTRL;+&5r8l5$>^!CR{4uRkH3s`|E|{XNOc=>(?HC8!ocM%nyT zsO3dKaGV`Y~fgDetO3jOhKkp3^_dDN!OdEQ!zx40P`P93lrDahrG48YI?SW!+ zqO;hSqFE7YPkq5Dm=WU0zDqof>esHX`0yS4O8Z7%^6UhV0e5%zU3#d>X9GM}u0aR3 zB_TXZ$c!aBH@DpI^QG7F8;x$V!buij!pBbX%QXW{(Ug)kQ@xHIh3s;JL@YIcuw{s!?|8mbkS%{&Z{u;e9YY1+7jQ$7bfYX zX>vE5#cP-CE-MCVs_?__{dsJIMRvIaK0t(q%F&NOoNOnlJi0J&^4t^3Y5Mi;kQj4u zT6}mmn*H%>O%2BQeJ;mDE$I&s9zho~s@R^U#eey`b^vRIrG5hQvS{I3zs3o82r>06 znZe3GOjdQsWDj!ROV?)kgEZbe!W9?JvlJK4V!Q^-=0)XWCv|o8ezcK1m(}<~j}*}K zQ#iZlCEn~RIn#SgNI?GMoyW&0#hb-P-#s)Ij*McD5_~xGbVJsl+K-HG*2+|$WrM2O z+kQ+1S3h-bl)2Gwj0)k`31_EPB?TIG@jEuiuz~EnJa;(ssh{78zD;*#mv)%VIXcx> z@f8&0rWJVkDyvRgsFE~O)T^S+J*$%XnZ4$Gl9G7Hti2`l3bkeC5^QH&=zo5I9&z{& zv?#vV)+`&0alxx-GMxF)+o*~EOP3Tt9nSPo=%TapB5ZmM5!{Uj-fF$?`5Ah{WO$^! zdS#|bDOo1IH+7L-#H-3RoI=~+2;ni>ChU?5zLTsi@Hw^ZUz@OnL>z`aD;Y838-7HojQBg z6Aue=nw?6qpWGlvEN>bXHvb-jL={xW2+lWu8t~-FdEW95JK-4%7e*Y@%jULwl5w5c z#`jMdm|;`CpO*ef=jwE`Iwv<5IQeVtvh=kJ3PGy=4O18Bnds>^ZrelEKNi*uga@OKPiIKatyu&HqPwWfoER&(;o8U1@jvwE_EpWBYMca_&&QQFs# zu#YmEK_GX-5%zC4W%;)ma0+Fk+!l07k!9}3yhY48F+@l$k;aW& zB*bgf-p+?aqGh=#eO{fL)8d8lvP{?Hs6}|6!k*#PLXwFZbcPGMcu!zA5&oYEPzkGl)2FKeeTp`_nBwuUQx~+ivt4#hkC-*ms{>t zAJuVv_3D*ULuc2~qIXD@0m@(&2FJ(N}{k{EY`}cAA+8?crq!tK2V4llj-v%O>NsqrJkLL=M#9LELM0sPt`$IJ#3L>Bs(fZx!o>bzrnn5C(;Y9(qfLK(?$L zf@P@e-5|&}lQyK7wLflA>cuM-e*SVPPDv@`;XHGNh$igCi~F0_WrQ%gg=g;kB^}nX{zB?ltSVC~Y8-(y9H+_$46cJKggC7Cops?UZ&&^-b^cgR;3Z(a@S{VcRkLOsUP1s_zUA!SoH&pd`<6^&4daW?jjj{4diTk{=xRd!Ed0yz`u zqJs!c(+3*f%%aRyw#Ieplr~YEP@Ip6iv!F=_X7#FNt;e5Vm{(Ep|1|#q?cAhmz4jg z&2Qt)@l8YfsNp!faNUS|QYEpY+b72j*g<@*hZh$J>0-k=Xcr)4#0o}m7f6H zfk0OT2Ogz~e_qSVedPl#$apO(!#A247jL5sx~Z9$`QZ|yT;wEYX5?qmO_|83s0KP- zD|4PJ2h&-9UEE})3ij)D!u24}iw`+V(<}Vi-p<>7tnrU#t7B_e3uHU#oy-;Hwky4{ zQPoXJXZtocUkF^;L5hG`Y&gh*5pgTci1J`bBV8i9*hxvf`dqbcp+7VdD( zCTHU-i(&ug*6%yX+1!A8hgD3MxHvhFpDM`pOmLFU4%o^RxOK{Qb^V*8!0cS9`CN~m|#+{SP`aG5F>byigC2Us2eO) zxtusxVDQcekDFpN?fNf|F#62FgqNS64}Vqihd6G(anDOujc<+|*;VtU@y!nH7v0@P zY>U7G&Fr%amaoiruQM_A|5XUWwTEIA278a{@;1?Ez&*01|#dRjEd!e};!2*FBbQ{aRVM`Kwxj zY7f6f_QLP0ZF6M-Nm1cxX?%a~pp35LYVX>&UY#05>yv6HpPCk&$}$i?q0hv#`6N%J z5qsNTk`pb{w3C>8LgSY&4+pwhuiBB|Vm=|ou$9;i`0Vv+$P89$egGJuP&r05MNiMK*pzWszTnlS zfDCGjQVk7_VY-1$cY(tG;yvNG)3d$3U3r7a(ygQ1n~kIm>uOqXfowI5ZG94g;6y}d zou6QsKlwhD`|N2919f&$_o{nmv{f_a_3O_~O~(OpPbrBoS|V(fv!Us(VFPSN=&87J zB;z*t(fCo!JP{%gVHI7jYAm+L=wdQH_Ais9R8)4cq$rfyguSCmW-c5+^#Qnu?S#3q zj?ORi_x^4zIA)Qcd(5inL39WDrvnELBqS-Nk0VOxpbK@PVib@#Z;a=F8|lpCqsC2t zCCW0k=<~`+V9?>Aszx>&OchEY(5@y`*G`ztPLGLwT09tIobH@iW8Gio! z3FcN(z5d&`F3dy_XX6kT7G{XGg;il#8iE1s_YQ|Wv>6x}h??G?$(G2@kbLnzJ)P!> zEp}X}2dF0mhKr+)e`ni=0^s26jar?i_}zq$5*!FBFb-pKCW*1w>5oBB#3PzKZR6-IKh{3}1eckI8r(cyE24jS{QrqZH+(JX@&UuP-4Lgw)seRDyeq z;rm%G!}bU)$*Nzm#`g!evxu6LAOGS9Qz0}hQ8qC#A$QfR>$6;fDo2g8vorH?TewRj zMeUW>KTFcGOQg>p5xRBr<`L@LJx?D$K9?+k#%Xu@DWBcG=O!6TMb%wYq-BoD>{`Gj zgtEIr4OpKcvIl@?pVP29hDoaDmJ~8Ga2tces^-Y()034bVf$T?EHKPL2#sBF*fEP z>&lgy_x~L;ykulXsyONGb4Y$Mj zl%#othv$NUQb)p`JKLuA0Gt;;Q6wEFa?bv~7rUs(+z(}=)8!v(2!dC5cgQE1XbG0% zz2CK7Em>}@UElC`l>?{&^E&V4BP{t9|6|8NYjM86zWyG#Cj@wWSNproz+AGTqw66s z+LHePcRl^6@8ZdTf_~HC?e`(EFf(Dc63B8L+JvP2R9gw|Jx8qA+#7mSg`#-l)6O3;$z& z2Aos;*uo7;=hIqr8rXPd8D^YO=v(?Kj zvO=P|vU28^yWd}4P6uxL1t!KTYi6AS9(`2-TsQ+<-I4fDV(H-K zh!|i$$M6}jd*}h2T>J(+7TA(&L&suZ0uehd@!Ai#nVJa44kcQ z#`h`mAeZUI(3bxe9!Hb*>}cKtF3WGdx;mlsW4O#$&6WIBqSN;Q9PZGzr0Ufb%_-f6woYrVZDyOc=(pGr)A_UO zw&ohK9wNYEQZ31jPqC>fQ}Pe;pl4;?((iQMEK(Lwc@8Q+Av0}s9odd z8#KM+C}VHal~usQK!BSCK4^dW{@wlf8DKvs(09_T*&GLe%Soo4SOuIunALXNg=bwg z5C72$;Mh#Z38N0asz6qeC$D2SzQ1?LB%MKRQykBMX)7P@@7%QE&lE_AJgLzZuQv5e zNt{)}XCQbnSoNEX((6b1zk+90gXW5iI(Mx8IbBBpI6o9=wbxomEfJVPHnE)9b*&XN zO(Rrp1~Mi^E3OArkN^RlCK>+&PqF>~zkA{R9N^+KHc&5_nc=A7`Ae@3Dt-ZqFnGH9 KxvX>;tX< literal 0 HcmV?d00001 diff --git a/tests/e2e/tests/dashboard.spec.ts b/tests/e2e/tests/dashboard.spec.ts index 7b1ebb51..cce7a994 100644 --- a/tests/e2e/tests/dashboard.spec.ts +++ b/tests/e2e/tests/dashboard.spec.ts @@ -10,15 +10,34 @@ async function loginAs(page: Page, user: { login: string; email: string }) { expect(res.ok()).toBeTruthy(); } +async function openRunningThreadViaSlackLink(page: Page) { + await page.goto("/mock/slack"); + await page.locator("#reset").click(); + await expect(page.locator("#thread")).toContainText("No messages yet"); + await page + .locator("#text") + .fill("<@U0BOT> please add a greet() helper and open a PR"); + await page.locator("#send").click(); + + const webLink = page.locator('.msg.bot a[href*="/agents/"]').first(); + await expect(webLink).toBeVisible(); + await webLink.click(); + await expect(page).toHaveURL(/\/agents\//); +} + // Run the Slack flow so a thread + PR exist, then click the bot's real // "Open in Web" link, landing on the actual dashboard app. async function openThreadViaSlackLink(page: Page) { await page.goto("/mock/slack"); await page.locator("#reset").click(); await expect(page.locator("#thread")).toContainText("No messages yet"); - await page.locator("#text").fill("<@U0BOT> please add a greet() helper and open a PR"); + await page + .locator("#text") + .fill("<@U0BOT> please add a greet() helper and open a PR"); await page.locator("#send").click(); - await expect(page.locator(".msg.bot").filter({ hasText: "Add greet() helper" })).toBeVisible(); + await expect( + page.locator(".msg.bot").filter({ hasText: "Add greet() helper" }), + ).toBeVisible(); const webLink = page.locator('.msg.bot a[href*="/agents/"]').first(); await expect(webLink).toBeVisible(); @@ -38,25 +57,61 @@ async function expectTranscriptVisible(page: Page) { } test.describe("Slack → web handoff (real dashboard UI)", () => { - test("the SAME user continues the conversation in the web app", async ({ page }) => { + test("the SAME user continues the conversation in the web app", async ({ + page, + }) => { await loginAs(page, SAME_USER); await openThreadViaSlackLink(page); // The owner sees the composer (either the follow-up bar once the transcript // hydrates, or the empty-state bar before it — both mean they can type). - const composer = page.getByPlaceholder(/Add a follow up|Send the first message/); + const composer = page.getByPlaceholder( + /Add a follow up|Send the first message/, + ); await expect(composer).toBeVisible(); // Continue from the web — a new agent reply streams into the same thread. await composer.fill("Looks good — can you also add a docstring?"); await composer.press("Enter"); - await expect(page.getByText(/anything else you'd like changed/)).toBeVisible(); + await expect( + page.getByText(/anything else you'd like changed/), + ).toBeVisible(); // The transcript that started in Slack is here too (incl. the PR link). - await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible(); + await expect( + page.getByRole("link", { name: "Add greet() helper" }).first(), + ).toBeVisible(); }); - test("a DIFFERENT user can post, and their message is attributed", async ({ page }) => { + test("shows follow-ups queued while the agent is still running", async ({ + page, + }, testInfo) => { + await loginAs(page, SAME_USER); + await openRunningThreadViaSlackLink(page); + + const queuedText = "Please queue this follow-up while you finish the PR."; + const busyComposer = page.getByPlaceholder( + "Send a message to queue next...", + ); + await expect(busyComposer).toBeVisible(); + await busyComposer.fill(queuedText); + await busyComposer.press("Enter"); + + const queuedMessage = page + .getByTestId("queued-message") + .filter({ hasText: queuedText }); + await expect(queuedMessage).toBeVisible(); + const screenshotPath = testInfo.outputPath("queued-messages-dashboard.png"); + await page.screenshot({ path: screenshotPath, fullPage: true }); + await testInfo.attach("queued-messages-dashboard", { + path: screenshotPath, + contentType: "image/png", + }); + }); + + test("a DIFFERENT user can post, and their message is attributed", async ({ + page, + }) => { await loginAs(page, OTHER_USER); await openThreadViaSlackLink(page); @@ -64,16 +119,22 @@ test.describe("Slack → web handoff (real dashboard UI)", () => { await expectTranscriptVisible(page); // …and a non-owner now gets a composer too (owner-only restriction removed). - const composer = page.getByPlaceholder(/Add a follow up|Send the first message/); + const composer = page.getByPlaceholder( + /Add a follow up|Send the first message/, + ); await expect(composer).toBeVisible(); // Posting starts a new run — the agent's follow-up reply streams in. await composer.fill("Can you also add a docstring?"); await composer.press("Enter"); - await expect(page.getByText(/anything else you'd like changed/)).toBeVisible(); + await expect( + page.getByText(/anything else you'd like changed/), + ).toBeVisible(); // The non-owner's message is tagged server-side with their GitHub login, so // the owner can tell who sent it. - await expect(page.getByText(new RegExp(`@${OTHER_USER.login}`)).first()).toBeVisible(); + await expect( + page.getByText(new RegExp(`@${OTHER_USER.login}`)).first(), + ).toBeVisible(); }); }); diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 57c7f792..a6e93c5d 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -3,7 +3,11 @@ import { Link } from "@tanstack/react-router" import { useStreamContext as useAgentThreadStream } from "@langchain/react" import { Map as MapIcon } from "lucide-react" -import type { AgentThread, Message } from "@/lib/agents/types" +import type { + AgentThread, + Message, + QueuedThreadMessage, +} from "@/lib/agents/types" import type { ModelSelection } from "@/lib/agents/provider/useModelOptions" import { AgentGitPanel, @@ -24,6 +28,44 @@ interface AgentThreadViewProps { thread: AgentThread } +function messageText(message: Message): string { + return message.chunks + .map((chunk) => (chunk.kind === "text" ? chunk.text : "")) + .join("\n") + .trim() +} + +function visibleQueuedMessages( + queuedMessages: Array | undefined, + messages: Array +): Array { + const queued = queuedMessages ?? [] + if (queued.length === 0) return queued + + const userMessages = messages + .filter((message) => message.author === "user") + .map((message) => ({ + text: messageText(message), + timestamp: Date.parse(message.timestamp), + consumed: false, + })) + + return queued.filter((queuedMessage) => { + const queuedText = queuedMessage.content.trim() + if (!queuedText) return true + + const match = userMessages.find((message) => { + if (message.consumed || !message.text.includes(queuedText)) return false + if (!Number.isFinite(message.timestamp)) return true + return message.timestamp >= queuedMessage.createdAt - 1000 + }) + if (!match) return true + + match.consumed = true + return false + }) +} + // The stream lives at the `/agents` layout (one persistent provider that // survives the home → thread navigation), so this view only consumes it. export function AgentThreadView({ thread }: AgentThreadViewProps) { @@ -71,8 +113,13 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { return live }, [stream.messages, stream.toolCalls, stream.subagents, thread.messages]) - const hasMessages = baseMessages.length > 0 const isStreaming = thread.status === "running" || stream.isLoading + const queuedMessages = useMemo( + () => visibleQueuedMessages(thread.queuedMessages, baseMessages), + [baseMessages, thread.queuedMessages] + ) + const hasMessages = baseMessages.length > 0 + const hasConversation = hasMessages || queuedMessages.length > 0 const isThinking = stream.isLoading const settingUpSandbox = isThinking && baseMessages.length === 0 // The transcript hydrates from the SDK (`GET …/state` → `stream.messages`). @@ -118,10 +165,11 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) { )} - {hasMessages ? ( + {hasConversation ? (
-
+
; +}) { + if (queuedMessages.length === 0) return null; + + return ( +
+ {queuedMessages.map((message, index) => { + const imageCount = message.images?.length ?? 0; + return ( +
+
+ + {queuedMessages.length > 1 ? `Queued next #${index + 1}` : "Queued next"} + + +
+ {message.content &&
{message.content}
} + {imageCount > 0 && ( +
+ {imageCount} image{imageCount === 1 ? "" : "s"} attached +
+ )} +
+ ); + })} +
+ ); +} + +export const Messages = memo(function MessagesComponent({ messages, + queuedMessages = [], isStreaming, streamIsLoading, isThinking, @@ -212,6 +249,7 @@ export const Messages = memo(function Messages({ /> ); })} + ; + queuedMessages?: Array; isStreaming: boolean; /** Live run signal from `useStream().isLoading` — drives Streamdown token animation. */ streamIsLoading?: boolean; diff --git a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts index 5a33442a..11ff403d 100644 --- a/ui/src/lib/agents/provider/useSubmitAgentMessage.ts +++ b/ui/src/lib/agents/provider/useSubmitAgentMessage.ts @@ -1,9 +1,13 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useStreamContext as useAgentThreadStream } from "@langchain/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useStreamContext as useAgentThreadStream } from "@langchain/react" -import type { SendAgentMessageVariables } from "@/lib/agents/queries"; -import { AgentsApiError, agentsApi } from "@/lib/agents/api"; -import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/queries"; +import type { SendAgentMessageVariables } from "@/lib/agents/queries" +import type { AgentThread } from "@/lib/agents/types" +import { AgentsApiError, agentsApi } from "@/lib/agents/api" +import { + agentThreadKeys, + invalidateAgentThreadLists, +} from "@/lib/agents/queries" /** * Construct the message content for the LangGraph run. @@ -12,14 +16,44 @@ import { agentThreadKeys, invalidateAgentThreadLists } from "@/lib/agents/querie * @returns The message content. */ function messageContent(vars: SendAgentMessageVariables) { - const text = vars.content.trim(); - const imageBlocks = vars.images?.map((image) => ({ - type: "image", - base64: image.base64, - mime_type: image.mimeType, - ...(image.fileName ? { file_name: image.fileName } : {}), - })) ?? []; - return [...imageBlocks, ...(text ? [{ type: "text", text }] : [])]; + const text = vars.content.trim() + const imageBlocks = + vars.images?.map((image) => ({ + type: "image", + base64: image.base64, + mime_type: image.mimeType, + ...(image.fileName ? { file_name: image.fileName } : {}), + })) ?? [] + return [...imageBlocks, ...(text ? [{ type: "text", text }] : [])] +} + +function appendQueuedMessage( + thread: AgentThread, + vars: SendAgentMessageVariables, + id: string, + createdAt: number +): AgentThread { + return { + ...thread, + queuedMessages: [ + ...(thread.queuedMessages ?? []), + { + id, + content: vars.content.trim(), + images: vars.images, + createdAt, + }, + ], + } +} + +function removeQueuedMessage(thread: AgentThread, id: string): AgentThread { + return { + ...thread, + queuedMessages: thread.queuedMessages?.filter( + (message) => message.id !== id + ), + } } /** @@ -32,49 +66,65 @@ function messageContent(vars: SendAgentMessageVariables) { * That endpoint writes to the thread store; `check_message_queue_before_model` * injects the message into the *current* run before the next model call — the * same mid-run follow-up path used by Slack, Linear, and GitHub webhooks. - * + * * @param threadId - The ID of the thread to submit the message to. * @returns The mutation object. */ export function useSubmitAgentMessage(threadId: string) { - const queryClient = useQueryClient(); - const stream = useAgentThreadStream(); + const queryClient = useQueryClient() + const stream = useAgentThreadStream() return useMutation({ mutationFn: async (vars: SendAgentMessageVariables) => { - const queue = () => - agentsApi.queueMessage(threadId, { - content: vars.content, - images: vars.images, - model_id: vars.model_id, - effort: vars.effort, - plan_mode: vars.plan_mode, - }); - - if (stream.isLoading) { - await queue(); - return; - } - - try { - await queue(); - return; - } catch (error) { - if (!(error instanceof AgentsApiError) || error.status !== 409) { - throw error; + const queue = async () => { + const queuedAt = Date.now() + const queuedId = `queued-${queuedAt}-${Math.random().toString(36).slice(2)}` + queryClient.setQueryData( + agentThreadKeys.detail(threadId), + (prev) => + prev ? appendQueuedMessage(prev, vars, queuedId, queuedAt) : prev + ) + try { + await agentsApi.queueMessage(threadId, { + content: vars.content, + images: vars.images, + model_id: vars.model_id, + effort: vars.effort, + plan_mode: vars.plan_mode, + }) + } catch (error) { + queryClient.setQueryData( + agentThreadKeys.detail(threadId), + (prev) => (prev ? removeQueuedMessage(prev, queuedId) : prev) + ) + throw error } } - const configurable: Record = {}; + if (stream.isLoading) { + await queue() + return + } + + try { + await queue() + return + } catch (error) { + if (!(error instanceof AgentsApiError) || error.status !== 409) { + throw error + } + } + + const configurable: Record = {} if (vars.model_id && vars.effort) { - configurable.agent_model_id = vars.model_id; - configurable.agent_effort = vars.effort; + configurable.agent_model_id = vars.model_id + configurable.agent_effort = vars.effort } if (vars.plan_mode) { - configurable.plan_mode = true; + configurable.plan_mode = true } const config = - Object.keys(configurable).length > 0 ? { configurable } : undefined; + Object.keys(configurable).length > 0 ? { configurable } : undefined // Don't await: `stream.submit` resolves only when the run *finishes*, so // awaiting would keep the mutation `isPending` (and the prompt bar @@ -83,7 +133,7 @@ export function useSubmitAgentMessage(threadId: string) { void stream .submit( { messages: [{ type: "human", content: messageContent(vars) }] }, - { config }, + { config } ) .catch(() => { // The run failed to start (e.g. expired OAuth token → 401, or a @@ -91,16 +141,16 @@ export function useSubmitAgentMessage(threadId: string) { // `status: "running"`. Surface the failure and clear the busy state // instead of leaving the thread falsely running. queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) => - prev ? { ...prev, status: "error" as const } : prev, - ); - invalidateAgentThreadLists(queryClient); - }); + prev ? { ...prev, status: "error" as const } : prev + ) + invalidateAgentThreadLists(queryClient) + }) }, onSuccess: () => { queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev) => - prev ? { ...prev, status: "running" as const } : prev, - ); - invalidateAgentThreadLists(queryClient); + prev ? { ...prev, status: "running" as const } : prev + ) + invalidateAgentThreadLists(queryClient) }, - }); + }) } diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index 2e88365f..8c664b47 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -175,6 +175,13 @@ export interface AgentSchedule { updatedAt?: string | null } +export interface QueuedThreadMessage { + id: string + content: string + images?: Array + createdAt: number +} + export interface AgentThread { id: string title: string @@ -196,6 +203,7 @@ export interface AgentThread { updatedAt: number traceUrl?: string | null messages: Array + queuedMessages?: Array pr?: { number: number title: string