From fcffacba9017cb25ab5ec91599732bf3b6f2fd4b Mon Sep 17 00:00:00 2001 From: Hako Date: Sun, 6 Sep 2026 23:25:07 +0900 Subject: [PATCH] fix(fleet): keep local project APIs off peer sessions --- docs/evidence/issue131/README.md | 43 ++++++++++ docs/evidence/issue131/local-palette.jpg | Bin 0 -> 25592 bytes docs/evidence/issue131/peer-palette.jpg | Bin 0 -> 25645 bytes scripts/cua/fleet-local-api-fixture.html | 43 ++++++++++ .../chat/hooks/useChatSessionState.ts | 25 +----- src/components/chat/hooks/useFileMentions.tsx | 12 ++- .../chat/hooks/useLocalTokenUsage.test.tsx | 58 +++++++++++++ .../chat/hooks/useLocalTokenUsage.ts | 29 +++++++ .../command-palette/CommandPalette.tsx | 19 +++-- .../sources/useGitActions.test.tsx | 30 +++++++ .../command-palette/sources/useGitActions.ts | 37 +++++--- .../main-content/view/MainContent.tsx | 15 ++-- src/fleet/hostApi/urls.test.ts | 11 +++ src/fleet/hostApi/urls.ts | 11 +++ src/hooks/localProjectAccess.mounted.test.tsx | 79 ++++++++++++++++++ src/hooks/useFileOpenResolver.ts | 28 +++++-- 16 files changed, 378 insertions(+), 62 deletions(-) create mode 100644 docs/evidence/issue131/README.md create mode 100644 docs/evidence/issue131/local-palette.jpg create mode 100644 docs/evidence/issue131/peer-palette.jpg create mode 100644 scripts/cua/fleet-local-api-fixture.html create mode 100644 src/components/chat/hooks/useLocalTokenUsage.test.tsx create mode 100644 src/components/chat/hooks/useLocalTokenUsage.ts create mode 100644 src/components/command-palette/sources/useGitActions.test.tsx create mode 100644 src/hooks/localProjectAccess.mounted.test.tsx diff --git a/docs/evidence/issue131/README.md b/docs/evidence/issue131/README.md new file mode 100644 index 00000000..70595a7a --- /dev/null +++ b/docs/evidence/issue131/README.md @@ -0,0 +1,43 @@ +# Preserved Fleet changes: local API verification + +Reviewed against `1c933a39e452b3936afc605173f8c384a565c2c0` on 2026-09-06. + +The preserved `103e70b` proposal identified local-only API consumers. Current +`main` already blocks remote relay file catalogs, but ordinary file mentions, +file-link resolution, token usage, and command-palette file/Git operations still +used the hub endpoints. Two mounted regressions reproduced requests to the hub's +same-ID project and an editor opening after selection moved to a peer. + +The correction requires both the current route and selected project to be local, +invalidates stale file/Git callbacks, ignores late token/file responses, and hides +the local editor when its owning project is no longer selected. Remote token +usage continues to come from host-qualified transcript history. No new Fleet +capability or peer file/Git operation is introduced. + +## Reproduce the browser check + +```sh +npm run client -- --host 127.0.0.1 --port 4342 --strictPort +``` + +Open `/scripts/cua/fleet-local-api-fixture.html` on that local Vite origin. This +mounts the production command palette with synthetic same-ID projects and +simulated responses; it never calls a real backend or changes Git repositories. + +1. Select **Peer project** and open the palette: observed requests stay `[]`; + no local files, commits, branches, or Git actions appear. +2. Close it, select **Local project**, and reopen: the existing local session, + file, commit, and branch endpoints are requested and their rows are visible. +3. Return to the peer and reopen: local rows disappear and requests again stay + `[]`. Local new-chat creation is unavailable on the peer. + +These three interactions were verified in Chrome. This is browser component +evidence with simulated API responses, not release-grade CUA or a real peer test. + +![Peer palette](peer-palette.jpg) +![Local palette](local-palette.jpg) + +Mounted tests cover same-ID peers, absent locality evidence, delayed response +bodies, host transitions, stale action callbacks, local success/failure, and +retained local basename/diff resolution. The existing follow-tail tests also pass; +the preserved `2084ad0` design was already adopted and extended by PR #99. diff --git a/docs/evidence/issue131/local-palette.jpg b/docs/evidence/issue131/local-palette.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d4ef0abbccec258f2405167ad14daee277f36aff GIT binary patch literal 25592 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 Vd{ksK1V%$(Gz3OW2(bUZ2>{mzV@?17 literal 0 HcmV?d00001 diff --git a/docs/evidence/issue131/peer-palette.jpg b/docs/evidence/issue131/peer-palette.jpg new file mode 100644 index 0000000000000000000000000000000000000000..11efde73bc275f1ad067527b1c3ceccb52826ecf GIT binary patch literal 25645 zcmeIa2V9fa`Z$ie#Z?-HvTVaG2nZNi%B(GcwkA*vAu_E&8Nnp70y1y4DkO|jL?8jF zpaI@MC_)HH7%Bu50u)0SfdnD42^(bdPuqKMy`#Tx@9+0r_w&DR_&mvb&hwt}yw5q$ zJZ$%Dzmj|J3;WOQ<#z0lliMNt$Zhw@eJZzi_ny6bcJJN0XYam!d*6|NPhS4rcjXli z9X{}$isF$YDvHX=su~|1Q&rPaS62Su#0Og1I=XtgM~@ktG&p|pqvN{Ae`;jMzJ2@T z-;oE&%Y%-qDytsI-`^x{g9%zY_{OsiSu;m={CxeT2icDuJ9qDr)f`ih)yVCV+x64e zFPFlO_dkT}+;#R6=+w4Q?tpA)I~8^*$l1#MQP%tamTSkY=Bi6HZInxRn_9PRK!2UAPUC3wYQ)<5N`B*JAPt-V2ckCDjP&-X}MV_|g!Syco2$I>L1t`ad@9y|s=TrwTbr zL0`-dTYcG`9g+K`u0Fu_>w>N=FO|^Ic5A6`jxX!!7u9&SmoT7VaD({z_$BQEO|aDt zWbkm1p>BpDKDNLkO;7l}{}J~{(EA4?X|%74kjR-yuW$^j+)3NU2BU$^4$?#LuYFJIY8dtq{%?CE)ZWJ-MBK{2wAAgkF-$eIAr* z`_TjBi-bMjh^Xoz0WMxO2zk6Kcm&5kbf&Y2ez7Q>l#bLh)SiM+7VAG@1Mz2(VX*Hj zN9}o9;2fbd_lbZG41MplFy6=IU8KffbU7YmBb2<+_0FbZ(qenb=jM~S0Z`#e%~y5Q z!XlpZpq2Xw+T1C)Xz=Xae(I5h0N2YRfK1*T`rfm3;?gZQlco}9^T5od2LOA-&i#e0-U`sWN%DS~#*J?5N_*&^=NrqLeEf@>t2xjy@Z} zwwQ*Vh$y&qkwWl^N%aW3+~7pg!#6Y$Z&Ex3lUNUcz6YA)2GOht$f9yXV+K43(a^pyqE zjy*Kbliva6x4+V#kpwy2^gg+^VlV>sy!OOI>wU9XpZ@%~J@pU=YAU;XsUh;7$JvTq-rW0pd2({{|7{z`-jP@T;P2HTy_^TlmbG%vXquH*F47 zIW@B_2P>M_2gnbV=OWGKv^Q-8D%XJ{ZE#F-y-WaJ7N=ltBmytPiPRloZ3V&+JVN@V zBiz~0NAG)Y?pW5`mDUuDZ0o1GiJp@$?Wwb{F0il$j=un|w7Qwv?VQQc?6&GzW^W7& zI2OS`x=MA=av88_rw86V|6L&9f0lCw0vc+|j?Z;Z9!AccCM~_(zZH6h^uzZvNbU02 zqHn!IK1{JtVj98Dcha;=I#AVP%T=RIjL*e-y&Bh%4#gTbj2tRPKP~M@ccCT{A32A^ zd+w&5&sQBrSi6SCeQ{u*~vQKfE=tH}nV2tHQ2= zweJMMy5B&CZi0)Hm@4C}D6mVWe)!0C4%nOtLyWls2?D z`+yZ&cs7vtsL3qdz|9mgqtr}l!`kpEU!+vKj-1k8xv`M-d27L|{A}Ps!xiKQ)s%`V zQiV)XoH6~VJhEs{)2Z)pFAF$nCvO+Czx8UJh@oHaT=$9$21Iu+cRwag$Jlhah^StK zVEuuv++*5B-|eTRf!>kEowv{<7fyWFy|fFHyU$Q}HkkNdx;~x_Th`kzn=7uI@Wjd$ zWFNbEUoz`4`?P+iu4Lb#Q7ZD$6)ezJ&5pQH#{P;Z5|cuRzC@Tylln0+FfwSH}ctpe*(B)z9`$~{=vxEg19{! z9S3|nqjr3b-}j1qa(T%hum98g?y&MmTA1eTq+1@}bMS99T_1Ob1QjLp~V!SReCD9Iej3 z#Vqn^+NXZJG~4K=MNq^gPSc^&gL|+%H^4L8VDp@zp6A>Xeakyf?|!wRY;`T@8FBpS z$|=U3dYgk=r%%69;HsOj7@N@MH+PgHI}fs>)vF4gHXr{leciv+cw?3LsGI=kMyVHJ zr><%tGIR_*qSSW%r+ufN`c71nHnrowc()%a>QzGJ=`_??o(F1z!@qThuEi7&O*xfx z1J8kvJ~|p^r)R|aHsnKx0YBl0vEH{{3m=SpLOuofxL*FALrIkesrU#V&V)IYd{j)N zx_AaX<=sN^65>BSllK0NnXE$*gU|sTVVkiIUP;C3`I&DYX7~2bgfNNw>6fG2ph0?? zZ(OC>SF->Yk3<6`_dv(TzeR32jr_-6q|PV@^*#@uAvW5AQU%##_p(nuG_8PvE-Y| zAw31>{(_#i#4{=9B{$S(KOq*?k0lrDdR?nee&_7D{$bN%w_63fqu!w^qC6YLs)9>x zleI^JOg@p9|6#OeB>RhuG>lH?h(SZ--ShT}Gq0MjjpFxo?^b_L-f>j7*avQEaQZ`@ z^Lx%o0O6ZI{-$U(GueoJlbe)O+do;+H_%(%5I5hAUyh91JWGw$>t26ilgYm(4Omfz z1dS05gn7F0x1aL`OX@NDPMoX^)8InkY5!zB8sO(o&0ON~8tkGG9aR=wQMh|U*%{W_ zZjUF_({yNdAuCp7!=@%5N$BfVaA|)-fJJr9pYW(bSJ4H{XtrL8d{L(Oqwo*il9{E; z#OYLFjPc$e`o)G9Pg`EF%&@KDx_}^J&Z*SDAW$&FZ@N=YUsg3_`ncv1r`%pJpe;d! z{(=0PS+KS7$u5hg(cPQMH{=%}g+r3(-n!CP2fxh{ zn&<8&%m`T+oCgO-3#wg0xWMei)@MIRCN0z5Mmh+@5^SA1667=@sqPs@gy-NhP+tTN zB^9iH_K|`;Gz$do=X@N(w5^wIsZ;+OHT z8&7-)!^$PC;ek}5?xZQw(k`w=e*T0r)D#sdG7+dXv^pz5q`UJhjNDl!Tuuej6w#yr zcedn9x0*MkagGf$CXR4UmhuTys+m8aT|`ZK(QhRD{VekX>FBlohU;?EK>d^|B>x-#{?0=fwV?hna zhE5NLSGhOyI#27|*_()2t@emO!kS*hFY&e}4>ff74qUFFM64=a?qeoF)tjTW(Pp{Q z?g(MDihoeqRW$c`V>zzVla50@g*hl8BrY3QaCTwd9eiX=&C8-ZZ2%p`EwIBSuQ3WBaTbD_&n;*qm< z%@YtI@tUHkj`elV(eWPEl0=Y!!-Yokw#8h?b)=!5U2$>-}i|Mdd zAwblQ7YAf9O&soQ86S3_9pbv*I$Dn3mILwxt^uXj$L)BqMzN+z)9W7Z&ov0*98bcb z;&-T<6YEK?V|kkp72CXsfJ|{@(`R!_$01Vmg}OUY$d|!Jui7$jTuR8-UePLOfK}0| zZpk;rJGsRt^l`_)!51c$uNg&JFm3`J>0Z;ra874WJbfxOITf^t(5#xQNY}O z@x3s@jl{ZmFcs8Vg;(Q{Ug-E7304xzIfpuSu7;4EXjyf}RMK7IbJ-4g$83z=*nHg+bL1{;At;lmc~djR>0^o6=(b7h4Fa+CMzLx zTP|nqX`nrjsDE8?e5vv3@(mh4B+aS~6-gV$S8iB=YPZxJr#IB&CvzSXC$~H>L3tzA zC@Ri7+x_u%V@0O}^O8niKkSIM*80?>yK*{V+BYMKQZSGm9lg<#?i{5Wz*I!S1a?y9 zA--{K$4ec_;m;1N!oImKm~^w)d(fS3_)$=}igQbuz3t(Brv#H}icrYZNbrb_+Ek0t zCpl)Ep5-~i0eXr2w6(dk2dK5&mea$C3zO54)hp84b%!$Xsl_SJ>T1tKN=WdCDZ;Ce zJTXWueNw8kPV+*lvzlptSZ(Ic+9ZzWT<(v_3g(2c{7z{jE+@9; zlaU9w;cWVhl@*H;0 zKNS^k9u_-F{l;SFpJ%>(_wF~=!uB6GUr%?%-kJH}-&Oxw)X4gY)!09?8k11IXnXxU zLCj@a9xE2WEn(>XrS=ZbW9H{yB%S!<-*L){0u);@|!_QIu^tvX@#O*O}Z0e`Utb-1{Z-Udcbho`t0mn*h&z8 zl^MW+qG$}Yxef)FjjXd;_ZAGMHNGE>{{JTVZk_le+H=MUuoJ^Ms;bV42&LqdT|@}hMLXhTA|OK7Re z6rUj8P_m!n!uYm#mwe?u%-3$cjtVNIYi|Mpbhgo%TvBC)Ad2R zpQa&kB@ z|1WEH{j8RS{-Txzm`B+z-1%8wyY+NQwUpsXzc|%cifL8y6#e2B}ooP^7~cyP`7y}<7roSO^ujaR~o+!N0&zDA)pm8H>aT4{+hjIXp& zm@p23t=Kh~;4g{mvIyc_JR<)bE9!mXcqs*nKq<9_F0DGNTd5->U~6wg1G`c+l&6)} z7U&YL#jP{jay+mu3)W8xN_eRaE*4URBbFK0L_LP2<< z%(I>7*sF$|6ergnJ}VP~()N+N$RZ)XkXMuLm7PEm2x~B$5N@^IoYS6=32H#5P^$p` z(*5c48sC7X8j80@e>EnMiC5jSLcrjjFOn@rN~qf4qHzst4rex4>zuLx@aHi|@JVFQ zEU#bEN4k-jP(a$2)2Q6KPia_t6DDqRuR(6^?L-fhxl(R=+qm>Ikc!+8Wyy`KOkvGP zR?fKCEPlD3nebWB%j5JB8g0ZY4~U?5qy!Sp#3FWd98)7)>B8!T<#jgz_SML|A=-mc z9q=ZPl-}XBMz`NkH?C6*8^BQMX@*^j$z{EPi=%$1thi(PYC5UPQVHq~p|v{7*|vj= zR8T_J_DQFN7Ir*=J1K*aolrIawCAJZsa2FTTz15uq#$lAUWggD4$oY%9MV6sTsAV* z%Tx>rq@+u?<8{W2w)uPG=V zywVuPd!BEGZ!CkGv@1o~TQ%58HPtoz23IY=4c2N6nhPT@ZV(IWvQ!_UMFrYK2)(1p zlNaF$)E=cZ-S$s8eDFa`@5I%iz{rvfPtUcJ^u7ir2It1rV(K=UEnJ)m&jl30s8v^posj2rNo1KED4Vf zY?;r6QF2?4(-4dT=d36_&KZyjjS(pzjFKV}hVILc3SfLlg@Yrd=AAPNKL#|`sK$B` z9+zt=799XqE6F88r z`>7CPCwI0TZ2gM(W~v6b&E~_SI^xK&i2h|IoNBbVmN zE^V(G{j?09^Q3{6fP5!vtGQN2YRwB;tma+8wj931!+zkP_&fFZF*gwX(#0AAU_F(!sREr6SiQ!xNg4c2PBGNK`-A>&R?vp_x9>)NKHRw7sX zYnZR`-8bX5mF zCll>gZo>XVV5AOqdX8Z0mhAO>KzY=ypkR@Hm{N54pR$L3C%gTsGu%#lTbYpgcy6Lx zTA2j_Z{}y9NCYNlC148}eWsg|*w%6E=w+;Yi~Godv1L%QN7=o6cUmJ@SMVcLMmoJN-(<_4WIf_#~vet&n8Tkqbz7gR>Im$ zPp^bOHgc~~;&l^n2tp>YNG82QBHI>f2Q;%TFNf&SPKR=jFwu>BiUaa+-T25cI+00b z`$Q53#d>oIefgF948Y~(J-t;`rX7VM3Cmm$h#!H!$V=$M__E^31x>|Gr8o|*eD{Co z5BO`g{a>R^AJJbblrWMoMfdk-DIz@Wq}5SJm3PtSA$8f|w!_`mqt)i-q6tG=rQ_Rj zNnP$IuDQQpf?PWAV77lk&T&?!@F~?H@G$=Vqu8??M@qF5z!9D(7e36cL0YGX?e5d~(o6na>+}`2UO_TBPnTt^D5!=tY!vq41NBumDg8lE@=E?Gh;BmWYD^s6Mh%MCmYe&4 zrFv%V8_Db5#p-%| z^dNlo%0^~V?QV_4^P_>wb3aIj`ALKkQ&%l;pd>FP4N}_yf^c2Mg`XEpgg;)&uJ$j3 zp@j?_+i))I{_(8IV{_}#pBen|uUYAD?sg#4b0WLoLVZ@CW+njc7cpHrJh7UlGZkTC zvu(K#7)pHFg~T=or}gZT6-lhowj6Fv3b z=55-(W&&;LLmmU`#HF|i%`b%@85s6h)=+73VLTo1Pb%t*E#H(3o%Pq3PJ|ASbyT^1 zoie6z;WJ+lt1a+dagSkEoQ&CNG0=X{1r?lG5HVTr(zGr2?%r3A>IH`OrtDIF@bJVK z!%9Ks&%3DH7wcVjhs$t$q~s}!cpyxL2}1?RIYVgk*@2i^efb}Lm0y83Ba0I)Rp`!7 zk_9Obgta*+2$MRf zE%B!NkE%#JM5m%^CiI39$fw6?4;uycbJbx>urFv@XKN_p>5*ag8FMKDLoV(m2juC} zh~^1^Z_$8ce_L4HbqDgHpW#kP>4XM_Lm3zvL1xa}wL@JD7qj(<;M_E0)EAL>W@F@% zh!Fy%^pLrfRRvRw^E{9z?y#FL%72KLd72%!Y-(!tac?zvs3pnz)14!+sUe4!*1*Hf zXgwCRCja5grsalpiPyzX3(nW226Ot=Tg;?4F#nJwGlrV%P*8^{CO~^fl+Nf&y@pxnV)90iG2s!!wumitI@R9|cR@m|(b^Ube2_$X$>(!= zBIyl%SP(hf{{;j90I;^N%)iZ!irBE(Z}`>(cS`7jULv*CHA(;!mcB1GKz92O*=VwF z&REWt0{ z2QPLWx%o5v#?bi4+e245n)u5*{{`+(Z2X0Bet~-$eSUe3zrdXm$Q_G;>ipJyKf@h= z-Si);c$ePl`D0JtlcOGQ?q2`?7x=HzG1e+uGun$q(NWkIMgQ zc4zU23Kt@qAGeR3_4kcV^diSkOE8qsc63~lP$G<>96>%d#b&$ttGRw-IhTP2O64a0kbIx zpSN1L`FQ9A>_Q{31Hf`l zt&<|JO=Mkeqx)CPmUJ%Ci+m;u8S)xhm^5O$EDKN1O^l4>!uyFh5r@DI$O6bZ*gRit zE5ZP-oKNU}i~06lX*^x&b4*i|DNAT4MxZf$F*V)_^2ws;)~>kyAsdF*D}UP1&i~n% zebs(0R~C7Z>A#=+pT+0jNYmeQ``b3`OX1zNE{QhxCoqB7E~*w`htD6I=i_|%WoD0?TSIyX4F0K%u+w2(-hOVGou2+n}v7U zr{`$%B@H2GqOFP;wHr0HwgN$)^HJ@O5}pxFMel2hcok2`w%(e=ptq>}N>-qnhU zBayd|y~K%fIOiUn4@EXDE|Pt90kgh{9Cow)8Ox6++?}unzPXMlNS{8my!NW)MnCGRpi+qgEGf9tnYXXnis=O}>iPR!es%y*VGD{1} zBL-p~Ce)p1iqf!8=C>xc^mL+que=s@2G-|wb~`)=vLkUkgtgX9?1ryj)8M-$%a4tm zecNm(sUhcCso|pHgS@?f~tiarV?l>hw3x`ogogN@I>+VaHk9> zkihnhdY6V@^2iP2ezdo|IjZot+&tG} z42t4pzS#C{xkGirgL!Lqr_)A7Pz7vVn3T8~{$+Vq`1|_PT|s3dt!zEu{!uy_17K*keXR&d zqXi|sEOTp7tlOENp}v)Eiq%wsuW>NPwwG;MoK$vl&OFhgY(&&U=OA!sHnnsE&Cb)h zty7F2>}okTm9qh}84uUrOEa6JH8Q=5<^4G>a4QHhEXZ*|p>A<@rf$PBMSGM}+M@Fd z{6jlE)?&->>*p_i#J#z1G#tT@U|aeqT(|;P!e6tpSa~3I@S6!tLxQ6A7Gp|gq1DC5 zYs@f{+j8ec&PZdWuoR6AnKI8=#=+aeFWz(f8Gcc1t3^fitlujSHXIxtYCY=WK@toP zHbQ&oL~yc&UY0D?gKD0TkhHdb$?QR|2kWRsQp?e>W%X&keO7h%V|I$9MP6xnhK- z(qpIE3KHVLw9PkQSuCPj{&7Gaslt8{i6aiNCZU!{r_x5V@V2)bkIS9=(d1D31Zv$B zdNkc|M5T2%LTj$Wq*?#j_HMLzi<}3)RB~xf2 zFs`s)V92a6wYCn0{oZdi4Hx=7J9%^-7scLq9Q-p(f`4WFGgDsv1#18BSJj{k2Ug$W zhIfniMpN!j*PY1z8Gh5X%jT`tKMCBC`_`NN(DaY(Z}I83Exu>f+F1tjMTe&&bQ0%;Nm|HE31X+)!}P`4DHZ=N0vsXAxhyNeCWO^$UQ^n{E)xeY=#V zVXub#yh`ctP!U;A*wyFIlC(S!L$$FaNI|C>9c2kHS>9K80k6dzFLhI-XT*$}U96W% zEW{YoaDJICezfsex~YFYQFTt*9b7Uu&fy7jhAdVhEHZpK@M6THG1J$Aa4$hvjz!WZ ziRY9{eE2DL2)k~VZsS2Nfrz&9TgZe)o*utvc^z+a5<4ce#}Ry?v~IctN{|YY;IwR7 zs;9t}k1~!#gZ*Nt;eCLH<;qP*T2cY(p+BoP<8c|LmE}tw%cGg0Uw444WEQCl-x8tu zVgT(wqs5zziziGEcb8nGEHAoOsHxsMQl{7%-8|E6XBQC-R8sGkD{SWEmwOczpgQM6 z`-{~wRd10@E(ZfOxFu5|;0rDU5;DcwSqTZV;}Q_dgm89P^n;YjAjMT&=;`qiw`EYM zeauu}b|8yjblHtqICwu-k2n!O+t*jUSX0y2cY(09Kk|Nh2B~n!vx(uTb4rAWq%A-& zx}~Js7JSzQV+D0%%NHi}F$>d+4f?KAp492|^o`~&Y@*(*RK)abMdkU5T)LI6wHjA6 zxg}9`Vk&*`E4N&bk->`xCY8&Kr2#CauM{Y1sv!Wn`B*?)wJk@(`JyeL-Zj(@P3X%O z@PVO2AE~B?hN~@5J=&7Ys<*IoRr~^YV6)cH<{g$5$W?4j=%aD|@cTGWhV`qT;(s`5 z;~%11=WQSiGIS0E2V~~4$A_p{OR&bdx@FPH=i>{#R&HI)?7(tJV0ELX=)UI_JoA`N z>>;C#YBQ1Wn(m8y`PMVt2BKyn*bs%guZp2{Lo^=_O?So{&#jC1I-0BOvku5fCPtC}8h)NlzdY31;-*}MoiVmU!T46LMSvL)6Vx3e>eQ<) zH7{TFN~)t?3ApZltDn`_%*aR!{G4V+SrXN#P_KuglsteyfJOBq#wQIG&5BFUy)?bw z>@Pe7S19V~1YYVlG*jEMEWVj(rQCI{VM}gI!JBK5&So3ovr!*ce`i(x$(E{TUC)M< z^+mm_m%ATG0=f;L3c*)J%wQZh+C%CpfY~+nIUP+R%sFe4@V*U?l)qv|&E#6gps> zA6r{B{U9{7FDAK;&}bgj@`M@LmG5JDXP*;Ed6}^-$5h~&IxZMnAzY+&pWSw=AtG3HUgpcl%1O?a$BuYFTx9Z)s|XiVl3sE6$>L zl{!L!^T-%_t%a1^OT!%n{7U!Efgqqki1@6*zaD>ob?SGI`By}Fn?Kv7M!!1#Jr|?- z=d?{8|BCjX^y{|;Qc`!64_B+zi z6DC+VBEUdtfL!cam9UWND4l@JExs=0U?|V=N3Xh!!hR-_5FD6&HKGZ)f4Q%w<~d!q zV$4^KyL`1CI$a1#5mw7go6-s8ak|L#vcwJwQ4+)sxa+Q(!th9lOK7fYthJ@9V_Ly5 zbmjTeMpipOrq8)kuctXJwt89cBAFrf=>;7yg#F%+Ot_&U;R(J%lhYTN*}0_rfF*+l z^5hZWy+<3u5(^BsJU#pr1y#xqi~s@}n8777L-u~^6xDY4U>hH!V&WJ>xrb?C1cEo8 zGkmp^RSgADQ<%Wh4q;_n%QtnRJ?&*+H5(uJ1`INAfb5w12u;zGln%9mG%4B5@NqRd z55xNMtPI;^=4MYN9W8uOsz$mYT@MwwF3AkFl~A!?Ea@6~vW$)iMdlDhi16I>By?+4 zo=%#|i4IRG*TrsyXTPA~x22I$-g}gFBqJ`%gJ|SYgRIIK69Yb2Dw+($-{-*a69d%o zy0m~)49B_P5|=$j3PUId!~>bL)W`@2z#5(n3p^6B*wavsm zeEiQa&YokqTHi_ATdXY=;$PU(Fw!RAQJSjTKhx0f!~Jb5(Egwo%DMjKHN%mK>tGSL*dO>xHH$Jp#ko!X=x{b!8Uw^-|ldu2d??vf+q-q$I6gvRFc^; zdD<&8X+$?KlMD#02+2t>T(|#qSBMeXukSfCE#rtu@Q`mnjY+Ggl0%Yl1#GELApM|r zclhHIz4TTUdLPqkVwF$?6eh4x2L$y_l@S2|A;kr@_Vow9k0QTd?R+aInEUH1u|8(< z^1nAxGqiN*GMu<>T=d$5KAldTxchn(st6B(s?#bE!2!wTaHVc2VyZ6=!A^X&K(xHL zF;IEIHR?>p_ko#$zB&faLR;Ezf$ro5iHKBk1dSNb=RXj!eB4qYhZOSV6x7}EQEGbW z6=%=o8?D!6=6EJasr5w-4wK1*#2%+w*ozf9rYfA_3JgUqq;}m7+esw`$a0os8GEG@ znaSmwUHLpqyl-OMkoB3W4yZ--a+J|(Xt#I2Or#XtCZqi@fY7hNI|rZB z8>ZgO*>sf(_gA0;+*DzgBqQQ+-`24jB(eh;@-l&*#e^7FIPGpKLI@K#)fgdp{V$KR zPG@k=reLOHh}T_)rRb3bXtS>#0Jd>bj7IC^3-qYuadKchkd;K$HctpqkX>qaiV=-C zY>n9nkLp?{i%Z}$>7owx4*L}cQcBGIa z=yp2P2}8PYTdQ=i%X30=Z56LZacM#S^E~d+dl-&)zX{jdvPse5jVv~kqQPe7*upzZ zVOO)&?xDL(A;Wtm2eq0)?DeC|A9C?P?p)m!mqcmK&9zt?H{%d7vDoBoIG zs()?0(@h9J9~uK5@lqb=geQw=go)DIdUWirU^2(31e5uW4Lq_#P zE#bw;$ebZ^5uIF3$VwX0mPImP)TmxMum7|gHzHk}^loID!6HLUzJ)|)B zG3^*n$f4fen)6{vV6hiroDrTpOuIK;O4OwjM-qgDWD37JbT-pD&ak`o#gDe@_up*G ziDl6j&SIT(rG%rEANPlsU~V%+qBq=U}Z+X#g-{*p`6UjfKC)Z-l zWr1%l8#~+)$zA~qLPM#isQ@@Dv!F42Tp(OOdo!1k;v1Ud7&^r;Dk~K9`kF9l1QuJ)|L<_8;Za^ffznS z_62Bpxh@}}^vom>SatP9*(4W@But~a@7TW&&cq}o77pCvHY&DYAolmL0Yr*R0S zAdjrRhp@PqoSSN=N{5vf!t_C3ySR~$0}7EDUyHLIlk7v@%-&7;)h!i zZ&IiHzTWtRnHp!GU2@EP|1K0WcuU)BVvLL_hXV9D0o>Ra)(QdD%W@#TX>f{Lxt=HN z44o3GsHmN*N%9I6z3v@Pl^xj5;~=`s62w^vOK?cXOOEA(3AQY0KStktHK|n#+VH%I zbJYFv;A{sns~oE2NuHoCwW;P0d)(K^^J|Rst|0Uu9x}9nxzo3@(n)^$?@#+Gg^3t0 z161Pa5pof=h!O_3)dRLQeO0B)QX~`BQ-P_kf^9Mq=m16==~+>MJ=cN zFF7ebV1X^T2ml+*3Dm=!4zwSLuxniCGM{mskTBt%rU1})wrgO za}`Z=ZEbYy`&r}k9G~*j=Ajge^a~e`UbwqZ+8$Kq#mlCJ99yJP7%-HIbx3z{dr`0D z(Tvz>Gn^*8uY2-oopL};r0oO})|bad*U8SN6Ic8sngRm6FenGs$+ou{Tj2(-Yz!Zs;9WUqccr6|^gf?^m zlYB4#QGl{h0Abom6RtducW?o_k**&OCTK;ISZEaCcT3rAYfWx`FB zi~Kp3i?gx;A8$w!bU#(5vvzQ6^4}#8M+!~|Ji|(r!ir|NAP&C74u)^8m^L%$HcrW_h`4m7O4A+E7GcT4+c1T;EqCTelM@$5jHvan*9qCPvJI4hA&1vRIqm}dQm z+ZDVptJl&&M`w+UMJHAGLMnQ({Sk z@?dPw;Als6NC5S|QEC$m>69qtPQq`b{oqyDxn$MX71lzKzUjXps1OF^-!9|=tbzah6ScgK2ub$G(9eOICsa*CGy9xtzdPN`K^PC2r8Dcp?_OPrQ)fgvfvWyYif z>C$kGZyh-U>1x^c!}U=AUJIP}qpzXtS;JQzbzf8YDRIMmMXqpi2=J{+T0_)>jE!vy z?|e$GOE|KO#cVE|d*1Z?3Ev1)og70+M`619UE22`o_a69ur>oq_fUbS$`*Mr_TmY$ zpAL~7z)ABJm^iClYYh{)*dbktnIm|y(@I{lSXgtm7cImtw$qbhRA5&tPzg{08HM3 z*)LRMnT|PL8jQK97{>zd4EsCzBYvKpCM%s-K97mda8N`*JO!3^4Lpa>b4CZNo6Ia@ z@Xp+4oCp4^Yw0a3iWt<1K0u_@%kUH^HgJ*lOS}$GD5P$&{la*iHE9YOLur1(@qXm! zb;La;GK}qWEW_ANL`p>>nk;$Pip8Xj$Ox0rwx<|(r9`9Gs?x-0z+}RNM`6xVUwh^e zCjru3go@T}w(oGPvFvjG_Mg&~!Y zND~=Q&@dO8-%p;wv2h-H{^Bc27D$}9c^$1L0&4l3AtCD=pTKxdb51tBi*ws@E7H5$aw-gBKs;^V`$bFb7y57ZH{D+QO0e}HVfDrMP1M?LozXvNzP2*w z?=4+A;MC>>Q?2WS*q-^|&4+`vnWk}t=-A-MKcVkq9X`1Dl(zXPXd&%S=$~0XkHAw8 zUYt0Rn|=RJ=x3)tt&cg0*_QjVVdM3m@V`48tf}^=?sWcy|83N_vjczX&ic>zH~a7W zdwBo9r=E_(mOykrMKgNIGwZ4w+tr3v8`r7Wd=ywRjjFn`(v6!(IRJ9w4WF?%N} +ChatMux Fleet local API review +

Fleet local API review

Local UI fixture · synthetic projects · all API requests simulated

Observed requests

[]
+ diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index b6c53d13..f1a55c3c 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; -import { authenticatedFetch } from '../../../utils/api'; import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -9,6 +8,7 @@ import type { ChatMessage } from '../types/types'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; +import { useLocalTokenUsage } from './useLocalTokenUsage'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -926,28 +926,7 @@ export function useChatSessionState({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); - // Initial token usage fetch for providers with file-backed usage data. - useEffect(() => { - if (!selectedProject || !selectedSession?.id) { - setTokenBudget(null); - return; - } - const fetchInitialTokenUsage = async () => { - try { - // The backend resolves the provider from the indexed session row. - const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`; - const response = await authenticatedFetch(url); - if (response.ok) { - setTokenBudget(await response.json()); - } else { - setTokenBudget(null); - } - } catch (error) { - console.error('Failed to fetch initial token usage:', error); - } - }; - fetchInitialTokenUsage(); - }, [selectedProject, selectedSession?.id]); + useLocalTokenUsage(selectedProject, selectedSession?.id, setTokenBudget); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) return chatMessages; diff --git a/src/components/chat/hooks/useFileMentions.tsx b/src/components/chat/hooks/useFileMentions.tsx index 1bad7f08..46407cf2 100644 --- a/src/components/chat/hooks/useFileMentions.tsx +++ b/src/components/chat/hooks/useFileMentions.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react'; import { api } from '../../../utils/api'; +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; import { escapeRegExp } from '../utils/chatFormatting'; import type { Project } from '../../../types/app'; @@ -48,6 +50,8 @@ const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFi }; export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) { + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, selectedProject); const [fileList, setFileList] = useState([]); const [fileMentions, setFileMentions] = useState([]); const [filteredFiles, setFilteredFiles] = useState([]); @@ -62,7 +66,6 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef const fetchProjectFiles = async () => { // File list is keyed by DB projectId now; the backend resolves it to // the project's path before reading. - const projectId = selectedProject?.projectId; setFileList([]); setFilteredFiles([]); if (!projectId) { @@ -77,6 +80,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef } const files = (await response.json()) as ProjectFileNode[]; + if (abortController.signal.aborted) return; setFileList(flattenFileTree(files)); } catch (error) { // Ignore aborts from rapid project switches; we only care about the latest request. @@ -91,7 +95,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef return () => { abortController.abort(); }; - }, [selectedProject?.projectId]); + }, [projectId]); useEffect(() => { const textBeforeCursor = input.slice(0, cursorPosition); @@ -260,8 +264,8 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef ); return { - showFileDropdown, - filteredFiles, + showFileDropdown: Boolean(projectId) && showFileDropdown, + filteredFiles: projectId ? filteredFiles : [], selectedFileIndex, renderInputWithMentions, selectFile, diff --git a/src/components/chat/hooks/useLocalTokenUsage.test.tsx b/src/components/chat/hooks/useLocalTokenUsage.test.tsx new file mode 100644 index 00000000..70df7441 --- /dev/null +++ b/src/components/chat/hooks/useLocalTokenUsage.test.tsx @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import type { Project } from '../../../types/app'; + +import { useLocalTokenUsage } from './useLocalTokenUsage'; + +const local: Project = { projectId: 'same/project', displayName: 'Project', fullPath: '/same/path' }; +const peer = { ...local, hostId: '22222222-2222-4222-8222-222222222222' }; + +test('peer history usage survives a delayed hub token response and peer selection makes no local request', async (t) => { + let resolve!: (usage: Record) => void; + const pending = new Promise>((done) => { resolve = done; }); + const requests: string[] = []; + let signal: AbortSignal | undefined; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL, options?: RequestInit) => { + requests.push(String(input)); signal = options?.signal ?? undefined; + return { ok: true, json: () => pending } as Response; + }); + const updates: unknown[] = []; + const setUsage = (value: unknown) => updates.push(value); + function Surface({ project }: { project: Project }) { + useLocalTokenUsage(project, 'same/session', setUsage); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { project: local })); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { renderer.update(createElement(Surface, { project: peer })); }); + await act(async () => { resolve({ source: 'hub' }); }); + assert.equal(signal?.aborted, true); + assert.deepEqual(requests, ['/api/projects/same%2Fproject/sessions/same%2Fsession/token-usage']); + assert.deepEqual(updates, []); +}); + +test('local token usage handles success, HTTP failure, and network failure', async (t) => { + let mode = 'success'; + t.mock.method(globalThis, 'fetch', async () => { + if (mode === 'network') throw new Error('fixture failure'); + return mode === 'success' ? Response.json({ tokens: 12 }) : new Response(null, { status: 503 }); + }); + const updates: unknown[] = []; + const setUsage = (value: unknown) => updates.push(value); + function Surface({ session }: { session: string }) { + useLocalTokenUsage(local, session, setUsage); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { session: mode })); }); + t.after(() => act(() => renderer.unmount())); + for (mode of ['http', 'network']) { + await act(async () => { renderer.update(createElement(Surface, { session: mode })); }); + } + assert.deepEqual(updates, [{ tokens: 12 }, null, null]); +}); diff --git a/src/components/chat/hooks/useLocalTokenUsage.ts b/src/components/chat/hooks/useLocalTokenUsage.ts new file mode 100644 index 00000000..0a46de9c --- /dev/null +++ b/src/components/chat/hooks/useLocalTokenUsage.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; +import type { Project } from '../../../types/app'; +import { authenticatedFetch } from '../../../utils/api'; + +/** Peer token usage comes from host-qualified history, never the hub's compatibility endpoint. */ +export function useLocalTokenUsage( + project: Project | null, + sessionId: string | undefined, + setTokenBudget: Dispatch | null>>, +): void { + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, project); + useEffect(() => { + if (!projectId || !sessionId) return; + const controller = new AbortController(); + const url = `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/token-usage`; + void authenticatedFetch(url, { signal: controller.signal }) + .then(async (response) => { + const usage = response.ok ? await response.json() as Record : null; + if (!controller.signal.aborted) setTokenBudget(usage); + }) + .catch(() => { if (!controller.signal.aborted) setTokenBudget(null); }); + return () => controller.abort(); + }, [projectId, sessionId, setTokenBudget]); +} diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 97c5c32d..ac1808ba 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -15,6 +15,8 @@ import { import { useTheme } from '../../contexts/ThemeContext'; import { usePaletteOps, usePaletteOpsRegister } from '../../contexts/PaletteOpsContext'; import { useFleetHostCatalog } from '../../fleet/discovery/FleetHostCatalogContext'; +import { useFleetHost } from '../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../fleet/hostApi/urls'; import { EMPTY_HOST_ROW_SET } from '../../fleet/discovery/hostRows'; import type { AppTab, Project } from '../../types/app'; import type { SessionTarget } from '../../fleet/references'; @@ -72,6 +74,7 @@ export default function CommandPalette({ const navigate = useNavigate(); const ops = usePaletteOps(); const { catalog } = useFleetHostCatalog(); + const { storeScope } = useFleetHost(); const { t } = useTranslation('common'); const inventory = { catalog, projects }; const { pins, togglePin, openPin, unpin, storageUnavailable } = usePinnedSessionNavigation(inventory, (target) => { @@ -104,7 +107,7 @@ export default function CommandPalette({ const projectId = selectedProject?.projectId; const hostId = selectedProject?.hostId ?? null; - const isRemoteProject = hostId !== null && hostId !== catalog.localHostId; + const localProjectId = localProjectIdForScope({ ...storeScope, localHostId: catalog.localHostId }, selectedProject); const showActions = !page || page === 'actions'; const showSessions = !page || page === 'sessions'; @@ -114,12 +117,12 @@ export default function CommandPalette({ // A peer's roster arrives on the discovery stream; the hub's project route // would answer with its own sessions under the same project id. - const localSessions = useSessionsSource(projectId, open && showSessions && !isRemoteProject); + const localSessions = useSessionsSource(localProjectId, open && showSessions); const messageMatches = useSessionMessageSearch({ project: selectedProject ?? undefined, query: search, enabled: open && showSessions }); - const files = useFilesSource(projectId, open && showFiles); - const commits = useCommitsSource(projectId, open && showCommits); - const branches = useBranchesSource(projectId, open && showBranches); - const git = useGitActions(projectId); + const files = useFilesSource(localProjectId, open && showFiles); + const commits = useCommitsSource(localProjectId, open && showCommits); + const branches = useBranchesSource(localProjectId, open && showBranches); + const git = useGitActions(localProjectId); const peerRows = (hostId === null ? undefined : catalog.hosts.get(hostId))?.rows ?? EMPTY_HOST_ROW_SET; const sessionRows = React.useMemo(() => ( @@ -231,8 +234,8 @@ export default function CommandPalette({ {showActions && ( { + const requests: unknown[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL, options?: RequestInit) => { + requests.push([String(input), JSON.parse(String(options?.body))]); + return Response.json({ success: true }); + }); + let actions!: ReturnType; + function Surface({ id }: { id?: string }) { actions = useGitActions(id); return null; } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { id: 'same-project' })); }); + const old = actions; + await act(async () => { await actions.fetch(); }); + assert.deepEqual(requests, [['/api/git/fetch', { project: 'same-project' }]]); + requests.length = 0; + await act(async () => { renderer.update(createElement(Surface)); }); + await act(async () => { await old.pull(); await actions.push(); }); + await act(async () => { renderer.update(createElement(Surface, { id: 'same-project' })); }); + await act(async () => { await old.checkout('main'); }); + await act(async () => { renderer.unmount(); }); + await actions.push(); + assert.deepEqual(requests, []); +}); diff --git a/src/components/command-palette/sources/useGitActions.ts b/src/components/command-palette/sources/useGitActions.ts index cf765f34..f33c14d3 100644 --- a/src/components/command-palette/sources/useGitActions.ts +++ b/src/components/command-palette/sources/useGitActions.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { authenticatedFetch } from '../../../utils/api'; +import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { isLocalHostScope } from '../../../fleet/hostApi/urls'; async function postGit(path: string, body: Record) { const res = await authenticatedFetch(path, { @@ -11,27 +13,36 @@ async function postGit(path: string, body: Record) { } export function useGitActions(projectId: string | undefined) { + const { storeScope } = useFleetHost(); + const localId = isLocalHostScope(storeScope) ? projectId : undefined; + const selection = useMemo(() => ({ localId }), [localId]); + const current = useRef(selection); + current.current = selection; + useLayoutEffect(() => { + current.current = selection; + return () => { current.current = null; }; + }, [selection]); + const run = useCallback((path: string, extra: Record = {}) => { + if (!localId || current.current !== selection) return Promise.resolve(); + return postGit(path, { project: localId, ...extra }); + }, [localId, selection]); const fetch = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/fetch', { project: projectId }); - }, [projectId]); + return run('/api/git/fetch'); + }, [run]); const pull = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/pull', { project: projectId }); - }, [projectId]); + return run('/api/git/pull'); + }, [run]); const push = useCallback(() => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/push', { project: projectId }); - }, [projectId]); + return run('/api/git/push'); + }, [run]); const checkout = useCallback( (branch: string) => { - if (!projectId) return Promise.resolve(); - return postGit('/api/git/checkout', { project: projectId, branch }); + return run('/api/git/checkout', { branch }); }, - [projectId], + [run], ); return { fetch, pull, push, checkout }; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 44bd8182..eeebc7e0 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense } from 'react'; +import React, { lazy, Suspense, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { SquareTerminal } from 'lucide-react'; @@ -8,6 +8,7 @@ import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import { useFleetHost } from '../../../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../../../fleet/hostApi/urls'; import { useExternalPaneOutput } from '../hooks/useExternalPaneOutput'; import { useTranscriptCliTarget } from '../hooks/useTranscriptCliTarget'; @@ -66,7 +67,8 @@ function MainContent({ }: MainContentProps) { const { preferences } = useUiPreferences(); const { t } = useTranslation('chat'); - const { activeSessionKey } = useFleetHost(); + const { activeSessionKey, storeScope } = useFleetHost(); + const localProjectId = localProjectIdForScope(storeScope, selectedProject); const { showRawParameters, showThinking, showImagePreviews, sendByCtrlEnter } = preferences; const { @@ -97,17 +99,18 @@ function MainContent({ handleToggleEditorExpand, handleResizeStart, } = useEditorSidebar({ - selectedProject, + selectedProject: localProjectId ? selectedProject : null, isMobile, }); // Resolves bare/partial file references (e.g. links inside chat messages) to // real project files before opening them in the in-app editor. const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen); + useEffect(() => { handleCloseEditor(); }, [localProjectId, handleCloseEditor]); usePaletteOpsRegister({ openFile: (filePath: string) => { - handleFileOpen(filePath); + resolvedFileOpen(filePath); }, // Opens the editor side panel in place, keeping the current tab (e.g. chat). openFileInEditor: (filePath: string) => { @@ -211,7 +214,7 @@ function MainContent({ liveSessionProcessing={liveSessionProcessing} ws={ws} sendMessage={sendMessage} - onFileOpen={handleFileOpen} + onFileOpen={resolvedFileOpen} onInputFocusChange={onInputFocusChange} onSessionProcessing={onSessionProcessing} onSessionIdle={onSessionIdle} @@ -244,7 +247,7 @@ function MainContent({ - {editingFile && ( + {editingFile && localProjectId && editingFile.projectId === localProjectId && ( { + assert.equal(localProjectIdForScope(unknown, { projectId: PROJECT }), PROJECT); + assert.equal(localProjectIdForScope(local, { projectId: PROJECT, hostId: LOCAL }), PROJECT); + assert.equal(localProjectIdForScope(local, { projectId: PROJECT, hostId: PEER_A }), undefined); + assert.equal(localProjectIdForScope(peerA, { projectId: PROJECT }), undefined); + assert.equal(localProjectIdForScope(peerA, { projectId: PROJECT, hostId: LOCAL }), undefined); + assert.equal(localProjectIdForScope(unknown, { projectId: PROJECT, hostId: PEER_A }), undefined); + assert.equal(localProjectIdForScope(local, null), undefined); +}); + test('Given a scope, when locality is asked, then only the local host and an unknown identity are local', () => { // Given / When / Then assert.equal(isLocalHostScope(local), true); diff --git a/src/fleet/hostApi/urls.ts b/src/fleet/hostApi/urls.ts index 0df2f7c6..bd02c1d3 100644 --- a/src/fleet/hostApi/urls.ts +++ b/src/fleet/hostApi/urls.ts @@ -28,6 +28,17 @@ export function isLocalHostScope(scope: HostScope): boolean { return scope.hostId === null || scope.hostId === scope.localHostId; } +/** Local-only APIs require both the current route and the selected project to be local. */ +export function localProjectIdForScope( + scope: HostScope, + project: { readonly projectId?: string; readonly hostId?: string } | null | undefined, +): string | undefined { + return isLocalHostScope(scope) + && isLocalHostScope({ hostId: project?.hostId ?? scope.hostId, localHostId: scope.localHostId }) + ? project?.projectId + : undefined; +} + function hostPrefix(scope: HostScope): string { return `/api/hosts/${encodeURIComponent(scope.hostId ?? '')}`; } diff --git a/src/hooks/localProjectAccess.mounted.test.tsx b/src/hooks/localProjectAccess.mounted.test.tsx new file mode 100644 index 00000000..c11b85e1 --- /dev/null +++ b/src/hooks/localProjectAccess.mounted.test.tsx @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createElement, createRef } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import { useFileMentions } from '../components/chat/hooks/useFileMentions'; +import type { Project } from '../types/app'; + +import { useFileOpenResolver } from './useFileOpenResolver'; + +const PEER = '22222222-2222-4222-8222-222222222222'; +const project = (hostId?: string): Project => ({ projectId: 'collision', displayName: 'Project', fullPath: '/same/path', ...(hostId ? { hostId } : {}) }); + +test('peer file mentions and file links never read or open the hub project with the same id', async (t) => { + const requests: string[] = []; + const opened: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + requests.push(String(input)); + return Response.json([{ type: 'file', name: 'hub.txt', path: 'hub.txt' }]); + }); + let mentions!: ReturnType; + let open!: ReturnType; + function Surface() { + mentions = useFileMentions({ selectedProject: project(PEER), input: '', setInput: () => {}, textareaRef: createRef() }); + open = useFileOpenResolver(project(PEER), (file) => opened.push(file)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface)); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { open('hub.txt'); }); + assert.deepEqual(requests, []); + assert.deepEqual(opened, []); + assert.deepEqual(mentions.filteredFiles, []); +}); + +test('a late local file lookup cannot open the editor after switching to a peer', async (t) => { + let resolve!: (response: Response) => void; + const pending = { promise: new Promise((done) => { resolve = done; }) }; + t.mock.method(globalThis, 'fetch', () => pending.promise); + const opened: string[] = []; + let open!: ReturnType; + function Surface({ value }: { value: Project }) { + open = useFileOpenResolver(value, (file) => opened.push(file)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface, { value: project() })); }); + t.after(() => act(() => renderer.unmount())); + const oldOpen = open; + await act(async () => { open('hub.txt'); }); + await act(async () => { renderer.update(createElement(Surface, { value: project(PEER) })); }); + await act(async () => { + resolve(Response.json([{ type: 'file', name: 'hub.txt', path: 'hub/hub.txt' }])); + }); + await act(async () => { oldOpen('hub.txt'); }); + assert.deepEqual(opened, []); +}); + +test('local file links still resolve basenames and preserve diff details', async (t) => { + const requests: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + requests.push(String(input)); + return Response.json([{ type: 'file', name: 'file.ts', path: 'src/file.ts' }]); + }); + const opened: unknown[][] = []; + let open!: ReturnType; + function Surface() { + open = useFileOpenResolver(project(), (...args) => opened.push(args)); + return null; + } + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { renderer = TestRenderer.create(createElement(Surface)); }); + t.after(() => act(() => renderer.unmount())); + await act(async () => { open('file.ts', { before: 'old' }); }); + assert.deepEqual(requests, ['/api/projects/collision/files']); + assert.deepEqual(opened, [['src/file.ts', { before: 'old' }]]); +}); diff --git a/src/hooks/useFileOpenResolver.ts b/src/hooks/useFileOpenResolver.ts index ed5fd1fa..0dffe390 100644 --- a/src/hooks/useFileOpenResolver.ts +++ b/src/hooks/useFileOpenResolver.ts @@ -1,6 +1,8 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { api } from '../utils/api'; +import { useFleetHost } from '../fleet/FleetSessionRoute'; +import { localProjectIdForScope } from '../fleet/hostApi/urls'; import type { Project } from '../types/app'; type FileNode = { @@ -61,9 +63,17 @@ export function useFileOpenResolver( selectedProject: Project | null | undefined, onFileOpen: OnFileOpen, ): OnFileOpen { - const projectId = selectedProject?.projectId; - const cacheRef = useRef<{ projectId?: string; files: Promise | null }>({ - projectId: undefined, + const { storeScope } = useFleetHost(); + const projectId = localProjectIdForScope(storeScope, selectedProject); + const selection = useMemo(() => ({ projectId }), [projectId]); + const currentSelection = useRef(selection); + currentSelection.current = selection; + useLayoutEffect(() => { + currentSelection.current = selection; + return () => { currentSelection.current = null; }; + }, [selection]); + const cacheRef = useRef<{ selection?: typeof selection; files: Promise | null }>({ + selection: undefined, files: null, }); @@ -71,7 +81,7 @@ export function useFileOpenResolver( if (!projectId) { return Promise.resolve([]); } - if (cacheRef.current.projectId === projectId && cacheRef.current.files) { + if (cacheRef.current.selection === selection && cacheRef.current.files) { return cacheRef.current.files; } @@ -91,18 +101,20 @@ export function useFileOpenResolver( } })(); - cacheRef.current = { projectId, files: filesPromise }; + cacheRef.current = { selection, files: filesPromise }; return filesPromise; - }, [projectId]); + }, [projectId, selection]); return useCallback( (filePath: string, diffInfo?: any) => { + if (!projectId || currentSelection.current !== selection) return; const ref = normalize(filePath).trim(); void loadFiles().then((files) => { + if (currentSelection.current !== selection) return; const match = findBestMatch(files, ref); onFileOpen(match ?? filePath, diffInfo); }); }, - [loadFiles, onFileOpen], + [loadFiles, onFileOpen, projectId, selection], ); }