From 4180ccb37828fe9de4fb77082805a1e8a33b08db Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Tue, 15 Sep 2026 03:52:02 +0800 Subject: [PATCH 1/4] fix(desktop): stop Daily Review transition flash Generated-by: Codex --- .../src/renderer/styles/daily-review.css | 18 ----- apps/desktop/stories/module-hubs.stories.tsx | 65 ++++++++++++++++-- .../pr/daily-review-transition/after.png | Bin 0 -> 46790 bytes .../pr/daily-review-transition/before.png | Bin 0 -> 9426 bytes packages/ui/src/module-pages.tsx | 6 +- 5 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 docs/images/pr/daily-review-transition/after.png create mode 100644 docs/images/pr/daily-review-transition/before.png diff --git a/apps/desktop/src/renderer/styles/daily-review.css b/apps/desktop/src/renderer/styles/daily-review.css index 614f5a7750..377894d584 100644 --- a/apps/desktop/src/renderer/styles/daily-review.css +++ b/apps/desktop/src/renderer/styles/daily-review.css @@ -24,7 +24,6 @@ .maka-daily-review-report, .maka-daily-review-content { min-width: 0; - animation: maka-daily-review-enter var(--duration-base) var(--ease-out-strong) both; } .maka-daily-review-content { @@ -39,23 +38,6 @@ gap: var(--space-5); } -@keyframes maka-daily-review-enter { - from { - opacity: 0.9; - } - - to { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .maka-daily-review-report, - .maka-daily-review-content { - animation: none; - } -} - .maka-daily-review-range-label { min-width: 9rem; text-align: center; diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 543c418aa9..0403868ce8 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -18,6 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; import type { DailyReviewArchive, DailyReviewSummary } from '@maka/core/daily-review'; import type { ScheduledTask, ScheduledTaskRun } from '@maka/core/scheduled-task'; import type { McpConfigFile, McpServerStatus } from '@maka/core/mcp'; @@ -27,6 +28,7 @@ import { DailyReviewPage, getSharedUiCopy, ModuleHubSelector, + type NavSelection, SkillsPage, type ManagedSkillUpdatePreview, type SkillEntry, @@ -789,9 +791,15 @@ function ModuleHubHostSurface(props: { | { section: 'extensions'; module: 'skills' | 'mcp' } | { section: 'automations'; module: 'scheduled-tasks' | 'daily-review' }; }) { - const base = createFakeModuleHubHostModel(props.selection); + const [selection, setSelection] = useState(props.selection); + const base = createFakeModuleHubHostModel(selection); const model = { ...base, + selectModule: (next: NavSelection) => { + if (next.section === 'extensions' || next.section === 'automations') { + setSelection(next); + } + }, skills: { ...base.skills, skills: INSTALLED_SKILLS, @@ -808,9 +816,9 @@ function ModuleHubHostSurface(props: { }, }, }; - const agentsView = props.selection.section === 'extensions' - ? props.selection.module - : props.selection.module === 'daily-review' + const agentsView = selection.section === 'extensions' + ? selection.module + : selection.module === 'daily-review' ? 'daily-review' : 'cron'; return ( @@ -914,9 +922,53 @@ export const HostAutomationsScheduledTasks: Story = { export const HostAutomationsDailyReview: Story = { render: () => ( ), + play: async ({ canvasElement }) => { + const dailyReview = await waitForStoryButton( + canvasElement, + (candidate) => candidate.textContent?.includes('每日回顾') === true, + ); + let lazyFallbacks = 0; + let dailyReviewEntryAnimations = 0; + const mutationObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof Element + && (node.matches('.maka-lazy-fallback') || node.querySelector('.maka-lazy-fallback')) + ) { + lazyFallbacks += 1; + } + } + } + }); + const onAnimationStart = (event: Event) => { + if ( + event instanceof AnimationEvent + && event.animationName === 'maka-daily-review-enter' + ) { + dailyReviewEntryAnimations += 1; + } + }; + mutationObserver.observe(canvasElement, { childList: true, subtree: true }); + canvasElement.addEventListener('animationstart', onAnimationStart, true); + try { + dailyReview.click(); + const content = await waitForStorySelector( + canvasElement, + '.maka-daily-review-content', + ); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(lazyFallbacks).toBe(0); + expect(dailyReviewEntryAnimations).toBe(0); + expect(getComputedStyle(content).animationName).toBe('none'); + } finally { + mutationObserver.disconnect(); + canvasElement.removeEventListener('animationstart', onAnimationStart, true); + } + }, }; // Real path: sidebar → 扩展 → 技能, with several installed Skills. @@ -1379,7 +1431,8 @@ export const ScheduledDailyReviewGenerationFailed: Story = { play: async ({ canvasElement }) => { const generate = await waitForStoryButton( canvasElement, - (candidate) => candidate.textContent?.includes('生成分析') === true, + (candidate) => + candidate.textContent?.includes('生成分析') === true && !candidate.disabled, ); generate.click(); await waitForStoryText(canvasElement, '生成失败'); diff --git a/docs/images/pr/daily-review-transition/after.png b/docs/images/pr/daily-review-transition/after.png new file mode 100644 index 0000000000000000000000000000000000000000..d497e95380b826cead16c31b52caf4fdd33cfd3b GIT binary patch literal 46790 zcmd?RRan$r{4Y9)q7ouf(jp+8f`F8Qbayk-CEX1oAT8Y>-Q6{G4&5En-CcY6y8q8U zm*?W#oOAHP^`O6*H7maHiSd__5=KMDM}|NkXrdxt>?b1o9Fh z`sK5NW8&`olTTx=Bu@{6%>~6ieL=u3{j|G}Q(s?CP+yN(udYzS)r|A<`Dcnu@{EM~ zi)yq1%+GGglg%5jl#2xy``L%8d8>uJ`o;4Uel<0GMDX(9=MYNS^=|~}hCKfIZ}{u^ zv*v%JH<&Ly5&j!~diMtM?-@^tPY|Sk{~YHDLH##kK|px@Z>0C=5!1g>_v8O}z980X z&$nOuw=0Vq8}bwiS!_CO*B@{&@SK)!pZID&MZ^D{l@)DaqT+bD6_I^|zL}k!tvp5e z<*lrckZ)ez_GCo6&e82vU|CuD_GGc?WGUIdW%GFc_$$?`aCtfNg$BXuQe7caBO|Pd z-K!Ja6y6TmvP`Vr5#4Au2F1K^#SYj0x23K zv{_N{@$s$V5)#f2yv^{PO1~~zeMXRekt38$r~B=;%(!tx&!F9 zH&^5Yd_uzGvu<>fR%^dYk%IhuQxg;LD?&`nwt(rOUqhEa92ul+d{p)yyb!$jZ9F_4 zHaBC-^x;M(Cdj&kM1%6znO>zULQqTFnYCYh5asI1Lb*7V@nYy;U!R2YtML>+RJ=~t z(C`4{latle)owW4qgvI~)%EC42i||vp(1<)W@D{Wv{R{8X(E-(E&5tuI;t4)mA&9Nb+R4cBjjYT6B71 zLx{NORG69^k91fGadE3Hm%~jbi-s-zUhEkT#`oNojE#-;_4T!u?R13>pWEyz7ife< zMTyd;gooo3``Byw0;4I}k@ym02$4u`>D z3T?MJkLfd@XlQ7(`2msD)bxDr$F}3W`kb6;#-j$$6xX&!vn7*0s!HwqeN>hf6;-XZ zw)^@C0xv_N5TBVHoERSNnOyISdmlM2O~{Aw^5yS>0xV);*=jA#JaOf+vhWJ0``xSM zHa~bY8<+d-<2L0WL_|b*Y&?T{Y0$qwME$J!@WzVzJw02Y^_q#ocppIzPAqTX<%^<> zch5ATZp_v14W$CUyxo#bF6QR4+ZRKWPOia!{hErFcJ%OUhyDC8NtyXJuJbCUU2NW? zqdjBnd`~@ukh98eCt5Bu2%VUl!*HM>c5`#NO;~hhcY5WMzV^D`-c+r)R>(ZeEg>MU zH!mvx;(&)qcLJB^tKVZkZl`10o#_6KN}HXwyBoCE?B)fw35Yl7b#--0c?z6#bl=*$ zmjjT+>h1S)pD!vyPKRL^Crp0RaIcWVw)f zmy6`5=p-y)XZT@gC@Ab7KD@%GqF$@Y7H!Q8GTN@O!i1H+xt}r-+KkQrH1EioGMYWW z?S8ttgG4Evzu;w!4Mo~{;3{GsZgu% z4I`sy)9v+IinO=ynak;}6x&7;mwl0DdGsr8dbiD=+N~~s6A}b6^7rh}4#0bQtn6X2 zxE#}~s~O;wj}G-J!fBQB$Z^W)7A09JdR(_+=3s7q=B-SX;*ye@6-GT^J&QE!^~}uL zR<&X_QKY1#T4j5`|M<}!kuCM!Ro-_^iv9fLEK}J1-aR zfgnO%`NHe=9_Z@HX!c-EUXE-MH#0LcH8u4Cxv|)B?VT4ktr8t6>2im7wemhO3Ga`* zoILxAa(Ow02J5r!pUY&y!NK{e%BE8#pSMOb|LximDWolhl#%gaJ1V=ZOm5!Uj=ry- zqNPkMlZMS?2IriPlqn%Tx=5)oJ|@QD(llky=&J@3IT!i&R~LxTuoX! z?zg*bew~u|#TvD@>;3A61Bvx6?CcQXFJBUQZ0wKHp7=DpSO+KSZRzDvdUjwm!$4p6 zREhga3*uAZAVN~(Pd;-v{Ry+PgN-&D`yD8_waG1)%I_`qUGLqNiXIfQB=GoB*egvZ z@mXvwYpvI^B$Jr+do!exxSU*W8jjA+PA;koWQhNz`cID#Uf%~@US2w#)X-Hs!I6-g z!;eT>d9fxLqHjF)6(beJqFACOu!~UVsx4APBiaK40`|4G7SCgiJdf3eNE$^oJ~l4Fib9*zvMeYd zfMz@&gg)X|gx^d})f=7ddb}pUD_h#RWu)hi=0<2v7HMuqCneqSzG*%PPMI`iB-33f z=Bf<;eN8`)PQoVPxYy`%_R;3Nz!sAAWLW)+=}@f=W!-bKn==B-r6u>RtBeeCVL_qc zqQ`^E$)%;t3iy*lLs%b2h(GH6lwHXCcNM%Ls2;tIE*Cyt&UQ^4n2x8Lj31rSGOqWN zps)=whwl`G0`s-yN#5RwPnM$cmF%`=sG;ntxAIfw^L1iHl;6JT?QUPo)Oj`DHQe2x zPfgoRHO_in^$wAG3x^P5zruH3Zta-s2*49-VzoWry}CF+RLP|nsW2YR0%7H_W$;nz zs4;~g#d2y|S@+S1(<^RPC0YO1QSwTijS~@$*$@1W{5Axdo zuIDoTp5o+(<#P;dyIZpjmw)p+(r0!x98xzy!C`}$b)5P=iNO@kg zw17~4sHg2p>CzMfW7!tYZawrHp^shmNsW#mTvs@BFqV^thi7GOx?C||Q6E`l(A6}q z>GNoo&P`#FMvI2XI)KuqE$9}^XAP`KLMXO z964NcV=yIOwOlB5a1}GPD_?nI;3MqZxj(cA%?AztgVB7RqEA4yX1)E<`MCkiUmqp; zjGck1!LS%nS)<>S#ih#4+L{GBN4Zd~rOTMX-!RbnhkRgElw#}w9;eA_z#6S^$ZJ7S zSdej?cAFn|N%F0l&*P_t`uZr^mmn!uPzgFYjk4%(YOATu+_|7jC4C&v6{i%vmXwqX z4!F+A&xijP4Lz|Qf4xMEW`r`}?T=P3j>8A=L1iW&C@A>Er)8v! zIZ8^(E>}eL<{|X$Dbfr4rng~6YrW(A#hUWjl3NRn2^nZlT(m#EKUj11PEKCvlq5(? zPmhX<1i>16N{FM`<>4ODUsNGE{W*hJXoG1ymqIh zaSS1NAA&@qkW*r^cKqYVj}~t~7sncl`7%YL606ZHF(qtjoV>ie{M@{suVuxok+fUho&Fu%5P*G7aDk=&T71Tj=bWh7%Rrcnyn^wCw zazaV)_P;<485u3yQ717@*ZT>4R!ZgNuI?DA#r9wv(rrKXe#L9kKb&_*YaE+qbD_TiLPk z(F}AykzwPkEef{Ux1ucX-%6+m4lo%FF*o|0=h)w|7H&0~x6 zbay!Ezt!|idkR)F-xrNQBH@FgrY5L}=JFr+z~ShG9G>PF7#LWg0-Pi|y1JvIqwiT* zHhHrl5U1)aNj_|`bkwUe~3`Xa%EJ;&b+?c5; z4qvW!wHgT@=(|FQ{&%TA+?JJ@$qZ=;bG+9TlgZ%%p75%?-->@<4qyNDI{?F?C-|fVoAVKbFI6;-p9kd$; zCu3wB{BKonF+r#AfmW)L7#ByKP^-)zeoKu=OHEx$_bJM@L9^mFs03}e3=v7s3csxFM_4ohWJ_FCbMXTL^x*YuXFnAvDLww&# z(&2PcrF2Q>)RL`5^_ocV}qA7mLXjfg6vKyS}*cv(r<9 zzS#3U16f(w;bCSS-+O$zHj9;G4}9i3VR?aci8z)%J}ev@se?SGKI_%UNGxy}jn-^7 z`bw^^ok5UWOh$ji)GU2eUS_I&(o$KE09hIYA3AL_Goik1K$Bjrf=a2uv5G3;`%8ES zJ;O3+UO~^RXJAmIT4}KRqjWq^34Srx-QC?6G^qL3$4`(WnP;+6oxSHZwxS}k0;}cj zW@%8Lgu8B#*=&_$PdEjI*+Qe6!_N7hD*|XYG&?&xsdIB9^W{h7!y`OK{-sG|MbcUC`}?R3Q}cM0 zM6y@dj75$5JMWxr@2>4kO$jtLlhoNQrj@c0G0^S02y)mZKWlx^K!2*dUi|;g;?|qzU7#*EFIX?d*pE5Ohfo3LV5=wj} zjd&R9a1Ls9>eZ_F{K#`5;-eVii%70k?5+uJ5dTn+>CIa#d?7aFALW!cgx z{y{;#X1y$+-oj@yA)geZQ&LbEV^n3iAI(mxCvX@hBI-OjN>V|mlvZtUsRs`|RSIczs&=CAFjcZwE&sXq{M znY->p)9*|aQ}R531w5E9uNI4<>FJ^F5%u>*LQgC7ySq83rOlT};O!V03BS_MQ^;-5 z>0sgW@bF!9#gKwzABF)ywuHeuoSk0!NB6a)Y$?kjg z+fLSBY#`S9^p9sr#^d8hMR1##c&l~Mb^!A$cMv7wP8M~;lBr6F?OAn@;d?=>Y zH8Bxeb8zl5TBhmkC2eUr#eMEb@(Gg>3QJ2%8!1qgM}oK`i1z-i!o|hYTZ_ZP!{g)e z-K>Tea#FBeey|EcpJ0_xD6>7dF<%yQe$1dMqPiR3oczJU7hjD{FnmEP_(PME05zMQufJIDFV8m9cVNE=#7&B(S8s(h55BuH6uG(FOd&us zGBzH*z&?o1ahl?w#2624)&#AyUXshTcu|FC^%_%@sC35FnQ~q!0nw$Rzqd zFADXqybWy76ZEI%6ezw?ttIE5pV{PY#)pTe6{OLv8?5YSsAmtsu9i zWwqZLo<(o6y?r_=_1@0LX0ThNSe?DvmTvk@z1z(|xgp~v$vq=WLM)WeSYb^>MB@19 zh{t}HqeEoIC`YL>J}tZzj?35;n($SwHzQt>jbO(FtIA_uRZ*cPnbXD|)RRIX_<{-a zv(*QQY&P~M29hUd!xXZquC>CU8lW+tf(#~^lHd}QTMoa94kLJPuLmkj96A--)$U=& zD#Tta3Ty zxC55xz3KFX_`1$DT4iG$W_tST{Jp7C)62uuK09zoKZ;&w<}tcG?S;c*sAT`JD_l|O zj!q7Pc=E$W;+NM0&glw&lLiM4KI9(prDrPD$S^}udLg~r_QB*U%vUHk6j^TtL@bTk ze9^?>B9W1iBV&|vpiTFa{YDY+y1L5J)Zx0Lo}tFCdc4l?}Z<>bPzu(}O0vJd8JZ5G{O3Hr>V)lew5;@aMn@D@VojXA2cf5^h(Vz~%g*I+2_bV6 z03efE3#|9;{t@dq>y;)GtI5qI@$NShNslH3oKK<{$4@MRrkb07i(wb-d?O+vdUuc% zV>=WTmDF!`iQK<-DKNB^Wau+bq`=Yofw`IS%LC39$`2NRe{@=Kbw-_2%5gH2l6ox% zw*7>8Jh{#p3Jp9RdaMVQ0KffgxuHcP(ph0a!6iSFsfk)Vi{W&cK0ttdC=K-WYwK&zcE<(e z<;y{bQg^h2lU`WZf2&Y&7eC|w`NnHsI3B%tYdo9_{3&sq7d}$C~cI| z$h;9l1Ze z%Tg)*6U1@Z=Euaz>9pK!Ju}iPBE6EgKgEJ{`GkC4Ll!R@mP>UlryYG2Y-GiM7@df8 z3#EGWz}9GXC(e()e0+S23Ta+M{hOakMf%pU9U2=zIvzDM%je?Yc-(-#J6bKh|D&}! zaxS?^Mn;Z_+N`x&2NBU5mtKRM&?dh)yIOnHcP`WN=_ayG>cdZJ^RxZgouw85tdgx% zjRpMGiMU$b;%SgtMWU@Y55oemX~uFj#hr(%%@^tb=y&3Tu$wr2u9F+Z{LyInBQ3+_R*^LE=%6$|KR;k-a&?T&G|f8uww}=WVE74tp`xN0diT4e z0`%xePoLP#+SAbvG|`h89N(E^D|qeC6q=sk^!qIDsXNHf5%Re=l&E6izpAs_se<0k z3Vn9yk5@cDw*hCq>oDnSp=y0y38tq%0wWtwu&-JEq>Pf>b5zuij!VBmCjqdywCe9^ z#Xde)8}!6hqy)KFi-B_yu3e!75s`^~>&fVSdCw@nme+bLez|`wLX`BX;%qj{ETL4l zGm_4@M5lv>MvTYpxIi*tf)3DK&C>6#?6GN@v9LVIhu*z=hepDD+<8WD%Hy=uyx`h+ z&Et4!IsBa>=&<9eHXSL;CDgp)eNi*sNKERGWFtUiUVD@9xm&LtV+&b76J2=G0g&P6 z(5lH|sLR!e?nuUStQ9D?$IZ^RNO z_Z~wT3@a)F9rhN2ulVTH>$u;Oc^go7vAl%^KGr5h!D09vl@Wd!P9a`rzo%AjZzk4| zEtM4DACl_!_~^O6PFRlefmU#ai)CL#SlDMi?b;ihOmYfJYHFHc_rBs3>-|JsR}Dfk z+k%#WW9Qh_ci4?*vHtRO>-|i=MqXs8tv9uN7D#^k z1UA1t7-y@@wINHm0-z7_kFsSFOn9Ukr)oHuSen9KKNWK$k3xs;+<5MMao%V}-DYPx zF_>gAhy{~Rc0VSv=M)J|l`KuIjTyw=a3OXeon>V2oBAFww9P$}H{0~^Nyc($?&>5b zh*~1;IIESBw`2eE2=id^X$M3ZL0nd|Xr}Zl%$1IPx0YjUm|sBo_zNNgq4? zp4T&*6(u=uP)D1iXk%!VR*8`r<9b{1@V0902;6BI=;zk zjEqdNmRoL}-Tqvh@u(rI#lrUK7UueTO0fwsEx|iH%h$*5WC(Bz!^Q3Hm5J0s>mr9L?+j?;8eq zG(&lc>A}^^`)yr?q&hp)(=#!AyzQN>4N%dkm3}-G$Qhe$rqe1D*<}9wJ1do;w`V=3 zo!jL+@%-0}ihB8N=JFUDTkM1MmoHyB0b@s4nMP(_*iG^QXr-PQRmz%V^cSJ;aNQl%O zV=?Y!%Ie@!px zX7hEG`tbF}n^Ubi+vy)in7IeLaWJX=CH^ZGlhbGU0V#~P zJIl+Y&)b?>cP6z!-iB71P_$7xVMfSFEF8^N^ss@gFqxQ8Qo=@YEz+c{$Dxnk(0mg2 zLA2xe*!omJ%D+OR)|zN+&i2IWvRh@ zmIl|Ytjvx*xTTFvwu`<#c5kC?`rFQ*7y?^Q!$%k!Q+j$tyF!x)KYn}mq7Ly=^_a*j zj_l7s>s@DQrNZTWmDx=^n<*`Tb&W@nX!y07>@$@Id8?`_L7e`m-Rii^A-%j~efaD1 zNLK!XMb^gZtHkV)F$8$om6uY0dc@22KnIuYPvp zN3!MsR&5B{0`i}+TSmR+=46wFnlnw)n<76C3&sQ2%F!w_HF4iXn2VuOl>{jL;!*_r zhG4Cyr>y(MpE51NsMzQ=uNguf*ZWbUi=oco1OVYmrcgZfOS0RU$aEu&VdT2l9?v$L zeF4aZq>pw1ad_1hX`1yPY$LeC7EAz&Xm&EU(<(2*KymrZ5}f*y)hbT z? zSI1HOA?bzD`7y2myQal|CI^Us582LT1y>=5vYk^W{H9?}w`&C=Ga`%&gLN$H+Zt5+GR9LVdV_-r05~<~xb9LhX991K**t%57| zrMe+`XIGbUkp?{|AIG{eRysq$89*3+8^fTw>bmtOZ`^dUcyVc|WVUT3v#7|trIF=l z+(9xf?O0mV@b4c*MWtb~whECP?2R__?>D!0@K!aSF04cuI%^%Yzm*LER3oEw@m&5U z^EYG6h-n)G(e-zyXf20}{P*teXapaA?Y_nqf}uatJJymo?J`1ibAysV_tirl_L$z& zsxN)+;Om}i_@<;J^+f^8qpy^(@rG4;W}5PcQ=YL6(eda#^x9`7ly#wGIq+b-NB&(5 z?ZuV#Dd^$ZOvX#7y2h7j6|1c6+}aPZ)fgFz&xvMi=;5l|?zflAh`3vjzNStHY{PB; z&fh=e1JlsSkyG2M{n6nAOz1i3U6EFKo^IERJ`dczwK(2jE{R-0I1X(zBf)!vxg1$t zz`1Z(uO0$?Wgvz9VOLeSu27>=XQ&zBG&^kfb9Y~NhUQ6yH{fKHZ{10Rs89h|b;ak3 zUy`Z+Qn!o8kiV`cUH z#dBGC#q<&8I}K6k<1;q_C!k}nA1Mx-Yp@bm6%@60%KkO7@_>QdwyjlRHx12 zH?=2wyC0Do?*KgQeq9mga4=u*erK{bQ<==?VRe7oflV7dl7WJogoKW-quL^;^*OnJ zhp}aL*6{X{86Hhf`)%D~5vC|3qagDuW@YVMjZqO{0cK|C>YB*mkWB`6_!X;8odrd( zlVt1t0hAGy97?(gqcD5YfNV3p$M!kKDt6dLfwWAq%}lG8^Fpdw-`6~R!*2%wp*_P z0kpF_RI2;sOM6)+yepVQS6|T|U!!5uc4xh?aJEXMsdG(S`EY}HBYCcmK zewftoRhuyC&7@}dhc`YFXOu zb(IYb{N@!p(@RUgEN~oruwVLj)Czxo56yzgQkno_57UVv`XqO} z`+VcGZN*l6Znx_{09vW|2rBfFJO$C8o+kY4?oS$CtG#Fj*O&1U(RIHO>c-7TaCwqd5n?_A0?8(4~vI;#YF0! zG<14g%3CkD3W_yFK7Tmbic!eZJG;G#n2Md6Ys*qGxc~m-Y83X?~)zt?% zl$a;uuDpWGeJ@^dE~=>pE>W^Ez{%@!pO`#c$@Hax;lARr8w&;XxblWDgS#*J_K^7EMhLTwAYK_xHmR6TfMjGdm8w z*@`CkP!z-abn2#Z@Pg6&w111ube!AczF{^n#+(r+TL>_a)!#YO^>@Z&>UngDUJ@*uyy@@?_ zs#CcRyU|X`9rgm+RL$=O43X`kx7m)p3M0bYN5#w+6&o>(T7ZWxH2u9{J6vnfAJ4a= z9(liskB?tL`y&i3H#e8fWa8fWHm=<82LgNbM&zlH@U5p8;t%Fiys1(f%0}^u(6;%0 z-Ps9xpMCYK&@}X(P{OGvhk-ycjAh(y$M?(vtGVec>6638K%uK{kyiaew!`AEu8@1O zpqKH<4!j3@=*QiD?|6}XV6RcN`J95Btnp}3e>vqKKsYLOXJ6#n$klYz)Np59YR2=y zm2VZ9Ro`-P-SlRPE7B;BuSjV!Op#3rfByX0+YdGEIPxyBR>3toQRv13yD{ zA!Xs{=x9J{z~0`Tmlt+-ebMN)6H;3nXItLaR}jNESGS=VBGQNRvTh5F&u&}6j$rl} zyVuy%l-+pFF|^6U$SB9(|9K@EDsrAe9M|KIOLs0jh&<~A#a5eN;mFwQGlTUu8&ZKd z^sy)4NW$Yk&5d<1?20OAVw9>`2Q-v*!KmCJ1ee)ig==Vu>= zJpAN9KSAdKrPzo?T$w)7dQ^EV|45d~?fft3DW2Na7eGr=lH%n%8b*T>7I%xpT+R)3 zW_C0IEEC^{FPuoAYoM`E<`)tY za^-iO%a-m4#Lkzetj_7iQGjPD<6^zuJs)w~93rL3%L7nXb{OCuPbF0v>-q~5*x{-w zUxbC9K7M?@IiJ2X>A33%F0|=UajDtt#0C)~6>UE2w<*snJdh^aS#}FlZH(rhq7k>> zX3HMgpDNF@j-*t2cuFkDq@ML}SG0Od$|M+TERiIx=@@l<{8Z) z2V=RTSTay7tg2u_0LOvUN%mfB4C2eVl{y^PWP^NW)mzTXc`T;}iJzP-IF->AT+7<+-Q{6APg zMP<6KF4e)obbYnhTCZwcob|DF%jcGvi5Va^TJi12GsYV$lbZhsdgAd3h87lU5d8;w zd+%Rfds#=m@K7rG3Y4mVa+m_DW(qfK>~VigN@PvFv#nW|72oduglZYvB6D4Vj%C;4 z;jOgMe)Vc2i$_s4CtAAd&goKwd{+~T574Gg*PL_OSN?|mRg(;|tpi15Wa67AoE8hM zHx~!9Q&YA(6N=8Sl+O>k_BcN}S~gi$wsr*KF4S0+8%G$pobPT=9r4`@H9DQFpBJhF z^83@Z4C<@~1A}w2ho4pE z^PoR@)T?P@FxWdOyVnsY<&A_ky;masS@tuBkVv5L5?%YAcZ|#@33!zJ?eaOPDD}tPLhD@qn3=kVp7xBg{sn~X-V9K9kv=%VD_kOx;c9AXSDlpYU9`DW`FX1cvRf^PG4hmB6$eAG ze&yof`Tis=-HZKse0DR9DU>i?$!lQ_PR)_w;Ueh618wpH&*q@j;ZT^J=_F9^Pv&ag zG`F~gucT}tHraFytkEhBGr7HUx!9&~ngCrA5;`#s1_txDsb5zo8=@&945**aqo;g`w>7Q^9L&sNAEtwYf>>EuVK*Aiy?*H0nI{aHwDi2Z zyyP4e)=^?3aWqs^BC4v>LqjFawX*!rLZ4-OGzB0Bgu~ygp)`*e4Qt3K{hAzIkuu4Q zk`@v9O(?Y^%_w|LB`-_ti~fxd<{h+ta->_~<>l@rpIacTy?!(A?1nCA8*l_VV2rh& ztd%uPctn_m+3-fH84^-ASk560xp3Yn%GCoG*AjH4tF=h-B`z*JDhb6SxS~yVqR#YM zn46n>ree8BgGu-L)M}+mM0gr}d^9|kzPUNqn{#?7^d2ya2B(`=;I6-T!Np})rVCk8 z<_Cfl)1O!J-S?W=Lk~O^q#|1v4~ws#{G!&OjwF?L#HzDJIl$P)O{I75bqT=QUS0DU zcmX2<)#q;>{Di0U!54T>PbF#LlG9f!wLUec$lR^DWo^vQ}C8%Va zAxsW0b~m)0>!>X5!)u?F>({QW_D83X4`_0b75H#iBwN!hhI>SFSR_q1bZpqCk`-B` z+E~#|%A=U!$?r!I()U1``63x1@>h`o&8HT5-=@o&P8PA;++^Kil|FIY;~`vHd|sC1 z7<~;{I`F~VfZVh2hjxv0-SyHQ#>vsy`G}Dsl{?|d{fw+_QKY%EhP_DnwqG#Sprt`k z-cK~hr%WE_&+$MmdcLkq!M-UrC^n)(r}}pOi>u>$3CwKS}V0EKxd2s}=2Y{-ox z-sS*8K6B`I^Y6{gO{nv@RXivCTP7x%5PrrJN`A)Hvl#c=yWdHG`+yuSdLje!lP78z zFPh4xjOS42CowT(9vLNBYORKvp~>OFy5`qWF}d3b05i`tz+ZAPP+A>ai-9{JkVkrW zv=4G5UZv25=mRmM)!}={t>J&3tL~RQ`gY{PpHY}&b!haz2ITTHt!J%Qt=C~MxlF7fuTpDzW@?wJ z$O02c4&r;%lZQlXx(s!tpD?G3kuBwG)~|YBA&^V7|0HgDQn1%_)SV-cH7#DG4WzFv ze8DXv#!AlX;VxV4ftx50NE6y$Tbbv#Fy6;%@xNSra=&P4-9H>)sns>Bzed6XZ|jcx zpWO_PLeo2HpC~?z=ITDh?~^#XE0J@U3(!=t3;Y$!{o=fO;RR}0M&#xoh5ZiFA>e@n zN&uiC>wde1N=JA_hxd!{jpkC=$b&8Q2qM%^D+3Cqne=a(3b068JgNfTTMq`RMW!@H zpjUGTHxdG2*)zm;X{!I(N(@A6w;%rd-61EEINz%K!KL8)a>TvxnLj;FEkB z_WI8|x9If=e7XlT>;G+}^F#%*Ht>Sr=l@&ox@Rpsm+jEdlEjO!MOK^z{aAg8noSjuXo-#!?RjSrhm=jnfwC@^3yEKH~>QY#NP z_~Urb|4pK=L|XtbZ^wr%0@3>II!aO@%js-f2D?V|^z@932k?QI+Ag{_xdnq7m*g7I zdurC%8bI#-e=WTXQ~B$wKw6pvwN2axt@JevAaokFa%+U8-IKHR?%vlyvBpz1+CIqG zIcOL34qIg<;$hlX4{Ov=m}^YYWi+BE9}rF7d%iau{E!2EXg|V4KsW$WcB;FHd}uKU z!@&7RZkEjBVZVBaF_QrhUXHfOKi|JUl9YrqKuaSLF4>G|_pU1mfFM`_!Jd2LH6C76 zbo9F8zAIdbK2N!Lp-exJXx5?9d_G{C7yw0J?nf6MH@DN}Me}u8hO0OMvsK2x*)|ir z!;XiK4<7$uI&|t^K$y{m^GeB3} zv$?Xi<^w_f4m<+TT5BI(@<>)|hwz_q!3pd``?0R{7Qhji(rmTT$pUEtZVM|D>wsA8 z6do7tZ#s0n1J4~F7Fy?S7w-HJ4iJ4H5{uTx##?ST=UHw()#i($q7vI z%?(STKsvb?9DprN!UtpuHdcFoT4Mq29z?_?RM+o?_*7FvgH|yQ3+G6?{SZS8S&WO& zek@nfuzeE)wRsQ+3#$T<#2FcSy*ZJ5pdlIR25y@skB4OB)W;Rf;e+xq=Ae%HyxeAL}2 zi`J#grCP;D^iLrkuYt`>>GXZA^*D`_lauJR{_FJ}v}z=@?TgJ}O(`k!jTe5ErW3=j zk=q1>Q5BVx3?S}R06mvT`C!)>qze2Sl44?WRl3UMS(2b90tc_W{0~E-L;MF_8j9te z$s&2xw!KVsYcVlvx#M8dNh8c(nY%5NFXIOR7_3QL$3kan!KY4cFs2as6dk|8_+%Z2 zUJY2>H0oR_9GvJ@miKw}4rPQK(_(8q9uPdfvdJeiCPQ9g6Qfa4Q32DI5%%b-P7*HX z)B8KW3dSshLNziW)bs<vsRDGz%Nq%_s_!8WG9n_++=?_v>Q;ZY7jyI27zuy1KOd!(HXhNP z>;j;J_N6z@91l0Bc=-ixF^2x!To9L7pStxZP=#7>LN43>YX3h1-)vO($mj?rCT4EN zyCAHSS+iElQk@PeW5A-VFYh0b?s^B51!#7KAO|2T7i$u5>O_X6%2sYqpb?BQ0^u>@ z;Ry#c&6Z}NRM%zE{SI?wg=DA-pEm0ys1HzSk20QN_GW zLqcNr`xOe8?e3>7b|xmvt`~j?h}UuP(F>(+U0!!jg+uVe5+g5nx5`Ubx@c%<_GW6~ zBO_(TZ&$lL(#B0~PI-vQ{#p&kqN-oP$R~5z$uE7^$V`J$Q zZdnBxB}Mrr*DY)66*;#XAQ;STI|#P0w9Fa$Hn`u2Wfc_}dFihC<;!f^hC%b4YTD{AV}`n)f=+F&*ywLaV2o_q zLuA2!`7-0mhyofWd_zN~ZQ%7bn$ z=rWKj=_JXsZGe8HhcPD4*45UMLE88zUirTZN)frq%~!}RD3t$m?|pMrdW5eQ586R@ z;14^-hD(3$()b@N-~?ZcB$Cm9*JFkG3DMaT$e}v0d|Bx0>XKY7m>qqCQVnPlU~QF~ zsj7qk zV_Dl*s21wk*(vlq^%Y_BMn&?n9i2s3?V@!k37-MvKQ+W98MsW_`768$tY-Wd!1fSGLccYgZwOREKaPlr ziA_vQP@AR43x(N>s!8`P^*+3h*qOST1~QiWiGBd-R7dKnhW)$=f}LGloVk&;hoPqe zwZ(KOG2fpT1~`GVDr!pIJ54lPl!ixX6un{r;9{rsw>P?+BOyJRC{k4~s{uG#y%dq1 zt*w!Yy!?AwTJ_fxLt`&jmOfF*ENrByvF(6pqL|-H6yR}tc?4n*3TD1S{!Fbn?%-sx za-mka!C5ch*b6ip@1weC;Q`UO1JJ{xBh3aq47mHzk_*2T7uD^^@h z#M1I<{R^kXZ}D-QU@hX+;yFeUA@>}>{-^uh+q=B1tcEn&>0+O#&B0y`lzYB>lqdIh zH`!8&`*W4$lNp_7i(6C4LeIb7(OCORflClj@0nJ9|%1Hm7oZ)nqau!;}B0U|Q zx2KPY@Ws@9KSxJL*GjKQhoo;97#KJ^!=ht^-+A4(qmoU(AIX<4F6sV>H)<&NnA%XA z*|nX?ot2idOFtYauOyPV_l8mhfIfF;q!506wqv}Aw#S-LszJs~JHV&w>5=Ymuuy@r z4wR0iR#sjIj|c}jKYUYZ5+2;=Pgx};C9Rm0-c}FU$8jrrlwSuEvY{P7+uXIUWu7r z`+%jn_$3C0@z>wrs~*A`naIh>K_T-nn)jq%*g8z{8sc$%DypuoUh|9X4)YV*W5~Un zwl*-YszDD_Qj=If!z1KMY4*35NCV2(fBmK2qiy4VqyGoJb%$MghK5Z}DGcQZ!^AlA@x)^L2D?c+`}XJld+L}wxPDBl;*O8XZ_7J zD3Vr*j13F~kCH_q{qY$%85o2a2Rl1En^WEoY4|S$qcS)`7wo6TVe@bWC``;N-iEuFJA_7s- z(do>15!HrdPPwd$H44#eMvq<|RErQAD0}bB);ga(Kt|dL&U4?~y>%?8f0iyhue*(W zQ@>0Mryq_&+#ZNa9JGl;F6qW`BCE+nk;8$qrX7Tp;PpxP0>F?knz7@Pv);U7zhckI z+h{9mYvtuUV5QeWazPtoV%P6^PnmcZ9VW$~Q$0oV#o^kjn!kmcCX;HTrS&TyfDVmL zwd_Mjgw)B>4x9{OG~@K=wpoGsfwhEency}f zrYy0sba(w$F+}54KswNSb?d21DX6xnt4AHHGLmuJ4tpb^CDr<<8vstYUq>hyeawGa zT#)gHN7u5&(cTetb$D>`cQJUK)XN*${0|TJpbtXJ=$GdHi7--5DMHv$Ufo-@9n8SO zre&Whw=}Bz?1JcNZ9Q3!R4sS%+gcyYJUT6I`F3Utld!kwrjr%ovtcOU`4*V2#+2aZ znKYd!EBUzk5?44-fL;1@Z;^`s)Y_(IWp>x%+|8W1%6a?&eg@@yj%tm_YPd_;+2N)r zI=F3wk7QCLFnDEQLdburR%Ditn%WZBaN8i_UQVdA)hPLp&{2=8>F`j_t3J9T*Qjf+hW9!&@}{pJJnoWD@w3CDx)87#Jus0b><9 zT$3;?hj0{noqe>bx|2?pLM}vumY6@tOr|)U>@n{&cgKT{P*A`~5i1cve+YDxzUz0( z{Y#0lrDSbV6wM?&rHAX0A9Z!WtojKe6ySeE4KS0g?|T447DGI+4zCp}GTuxXEaN{rmSn z5Oz>q0|E)gh7bgInrqwz%h0<30==^5z3c0dU@w+Doh`TYzwf&B{kt#)?ntrG?MY%8 zIP}Pr{+T>GHJcvnjAFxhYiMA=s;_tjvDHh{X*Q!F-y2FK#C_MofT2ObeRtKvt?F^D zd`0$pCGxcr!$JPNXqYYfz;q4ucCQc~vl-GPG5V?_3$@s>I*q^)ldc#rgtDuuoTd`k zsjttG=g+N3a>6#00=cnTFs8)O${d{@R9zsDR=6n`*X7>~iPjn0~Y=M_Qm#4ahLGsek z`pH$bAw2f?T3B`(rH_!1_-;J-;xSG6;>GKyKM|EClWU%|NAkJq!sy*x>SboLFOhdV z!Fcor&k~*sA(KglC!gE?#pH?C%axgFl;WC?zcxf^(wT6#dBGrOU z(^h4V`gF^#w|ulJ2H!-KmV`y{&;7^k;kikDw;P^`IE?*@xrMwl-WwN%!&Ig`+7&wv z#;FJQ9QdrqLcF}Z2zVT@ZNz6dt&JS*s~wKIOxH^~;o55P#ld_0m@*QuW1Mz7@6pkZ z?qN{^Uh2(?U%Yo+{i91MsrY3aoYrRvBAriv7>80S8BbO9?&1%R4z5TMtz7oZHt^pj z#5y7Php2&w*v~!Sy&zS4{Ss(5d(~%$y>%QdOrY+zv#}f@=`U#fdt#OT3X1BfHA(b9 zBOwySu~?37YHngca;>jC+IAPMVm2E3B}*Ywp;q6 zEO?`C9Mg=@8ENqA@w2kBf~qP1{{2ts@sQT?+kAYkyV4aTn{DP6DHUTRvksn!-p$@> z$Knh*1Ni8L^#y`G?h|aSou=`h>_LP%WFzroSxry&%B9~n-t3FeRrTt*2i~*+YJ3I8 z`^}y0VYy!Z{_^tjv>FNZznXkDIBk-4Z`}AIHq|m;e6&ok=j>G8ZlxqwL}_LMFUfR? zF_|+vorcX5-k6gG0;KDByvq~lB_?6E`|b!TMyv;3B0@qyLn1;LgG_{X?FQReh~D{fIxM8od)#ZPms{}_HIMzE^YNoEKF28t3z&Rq$0@nF zbQcKD;xZYA(od6br~8=A=9|T5y&L8BE~P$y+H*ZJ86Vn2#isW6zl?|tUEJKDvtAYDGsQF-B!9C~Qjmxs%0v_s> z4$l7;IGV@nDTmwf}FIzO*gF1KJ&9_l6XR4)4T8`QD;X-Ey*De39DuYVc8P)K#Ka*1Wt<@{>AOE(d}A}768b*e*|~Xo zfXAE;N_|mjU>_ZkwXi1X_BWb`hrU#llv@+!*-C{e2x^hqq7ZQL`p-!o|Dz;dEV5W> zYufpzJq;NJF`chHeu?bpjt~Wx*=VT-;c4AOnRzfTH&9Z>^E6|3R(b#ta?EBqOS0z1~d-&RqQYYbg`HP#+(0@qaHB*QI< z&bBrP=oaVEV^_!pdqKggUd0YK-oD4i;+V8*kJ?(<}PNkDq@j zK<9FKY}iOhGkh<(sZ{~h1$rhl7%6FKUI&KAuN&dQS|q5C*GBpmsx_2+54V89Z~(CHk%Hhga!c}Xiz|0j)ozy z-i+VMst6h}&8qNWXipS`qn`0Nd}{KRv$1&w!^ps>u&^L84$c#8tB~;U+|*P(F{=)! z%1#lhB-JK|hx=V|JlA>ON8jI^R}@&$wG?QX7cO;zMdCmF!su&7xv5~|@Yy)T1FDSI zOY|h;!zgt&R_tC^yas{xLo4Gop_=y3bE-eDF8a|$@WR7yTc zD=6swedA|LJ+3*Yw;vE>eUvlUopT30V|KRuHn+?|Cj=+H{QUV`xcxDSm0KPz2FBUm zVmlBI?}$^-FrAk-S^-M+iu(}c4$&uc$zL83dGtGn)5f~*S}q$(A$5hjd=Cz0O4d+Q z#pISCX*;ZvuJojN1Nl(I*RvlHpj>B5|P-4i?mD#9Qx>s6ffSzAeDB+Wu>P- zwwTgNd46$rai>F+<^ zEb*aiZtc%Z4?49%KbFtarMAV)`>s_Cwi2{#LEzH-0MaAWyHOz_PA6@lpO0?9QOevi zJe_BbQH&`~+YpaJeP6YF&S`t<;uh+YyXY_=maZuJ{;`~um4)bKJrPkul7h$0!hnEn zQMS^S0D|dqOF9k?is>;R*9>K=r0RC!mJAwaQ-ator}OtOH?!>q?76yz+qBxv{#=fS zMG#hL{li(je(M&t*@L`*0Cd3#P#e|C%zC@K**H(NzoGb{#$@}ffAd5q=!#?tmo^d; z>j+Br!S2vdJXWSxDKU{(kild9++q^Y*c!hQkgb1Fcv^T0ZwRK6x5mCi|wQmQS>hL?0`klFpXD; z%N6+gZM@j%Ul3HC1}faX$T0skHa0q3A1-yhsxcn7A#ihc}`8z|2YLfZGr z-C}X9$biV;$2R^4e?|DoN{q*^iOi5aH5xW?U^hgN*Rr`lNHg`H@obgh?oFko7Hj?K zt<7<^`kg5rM}=G^_%cHwrgKL-ZQzrRoS>kR-~au82r9eZ_wV^YZGjKFbJCCSsOgrs zTS12jsc+ed$@gZvT^+6!;$;uq(%P=kb7mTkOdB^l*`2ClWD| zKMzk-cP#Z2vc2J#b6nM0plA11!w*Tn+cs$AL6GbAz5-Im6~n!~kzB(rFnqi?MK818 zj{-Kv^bf2{Nl73dR_v(GDnThcFR`vO0-?i$?e1)W4ru7f`pAgR7l0 zj*qVTroTV7+YM_pmV0#|F#{3=tNA_wvwp{j6lk){RS;p(OYJ5@DK#FC`tSGS0I~wj zix<0qqV0?_&kxSX1p&ciAT1DqQx%u1zB%riC(!bMMuj=R$7lb*S=&fO4N#B~K(V%1 zLX565rHFJhF)*w`dI^I};$auzRDQ$nh6d-8y<`x}Fv~5!v4{t8<|vDc7r*)bnAvP^ zA>FO9F^;Fg$jC@ErNXg{&uJzoC`RjpeovaF04gf#e_je0LH_;FfRVTGA8j8A=YLl; z`>%ze>~S#Rz!!!|0BT?k@IYu(Sn0B=g^1e#g2pDTt{$gb|K};6>lK&nmLCq!-nuZ1 z{imHPpm+HC^=s~S^uof&R-rJRrwnYbY|Fn`3mfw`8nQ5zzbBhoZrmJ33$W7fwq71{HmdM zw+aZB$vW=!xNaUUX110XsOXKpU>)aZ+4mqpBQGg9C%3lt1G&9l#ZXgfw$djWTsm1w z{a~LuS@I*~3D!DG-XJt0RqtizG_ksGk@*0qPUmnjxr^eUh3|duDSeX2HNw&51fvOq-RJKsR zxEw#MFdpxks&<6*tz4y>4N!Oko^d6psaC3;v1S+=0xd*Mm2jfM-m$4o4pg)%yWZ6e zrIuXm-M!@T#=F4)nY)8d=1a(>y>vg}y%;JmVdNtjbt10DSDEzsrKMd&kks=&^!nn+ z)IR#$(9jS|tERh8;Nfz|=ll0^r;~f(9!=Dn1oS<_iAY&FIa{l%v=@ysWRhGO6;_B9 z&DB5{LR;~f&$)q3O-Dy3&tea;Bl8KrJ=7&KVuW1>%OQ6Jj?cTRM~GG#3^K`3?#b3x zMaEACO5eAvt*xu8)EWN1Qi3Qq=`pc@kg(lfS_#VGZ(PLlxjw?cb4092X=q#l){N*~K()i-?m-7s`(vfs z?D{*O3zsGO@zWGJtyb=^p!rb*lYM{lGULVeSn+)7KOblv8TtGk@}deP#2Qw8`Ldm^ zDw;fsO*g^o<}o&E9fDsZcJ`<&GVhaw|4L9H6PkHzgk}t#24#wz?;)m_yPkIwT<@`* zqjw9d;ZphKj#^Y)46`gUaT6RWRgD{e$d2f)bWZP+FPC7kE4t@jV?S>sWt#>V`P^3;58y)o|d|Ukv z)@(?~VlCA|F`_Ws0%Hv<`A-cVerHF7Nk2x*MVAfwNl}dm9zDtjmNxu7!^ow60q7kx zaqPA3^J8l^lZn1wir$RDEUfHT=-hqIVFc1Sp8Q?u&gA>6CXzq_V+MYT9V4KM#ClsXszKr2@ zuE@x+C^b>Tc>H*4x|R?Fg9qjnuc=#%c2Q{ywvJd z&&kQ%g0c&|`x@`g1B(}Yfk7cLjINieZQd}l^YghVa%oUD0hBIK{Pvedq4EOL1}p6r z290vT5|m`~E#ffVEKZysby{U(MtoBmWh}D&Ot-*jYcjd-xbK0Q>GWz5*Xa&R4_$h4 zvasVnMMXuYhpUMR2{;4p0TClr+M^+bA@P7Wlm<$o-JL>~F3OoXkud9q=-xA(p3 zD+|?w2wftVi?K}mecccjOA8C#?x@#nvr5oMs#Ka>OmeX$55F%U{r8x-0msDM>$wl> zr-f7N^-mcJGV;>G5()RY-S!BY)b6E~3}(udWvG^qHFzF@>K4UjD)CzO-Mdo3_9f^* zLn5Kbdd~qk}a0YH7-&Ruu2hJ#vA?lT6@*Ah%{TJbgr^V z%YCrYe*Z~zFtgaAmb3G>2^)I`0Kb#Kz|3-_nu>xUQkt`RWM<}h!fkyAWJX5D>lGJ+ zBL?yOx2RSxf8AWl1e_WQYbNT?UPuqnPLZim2Fpr*))9y~F`!jr-zCYn6CWL&=h>cOTjz6nw zA;%7nz+L2YJ`x(YGB;OXdi5KwcN(<{yGF0sdRgIwzV?0V1{5^%i+kvJtcG@o5%V_h zkQ*R@fP_izS^BAW4&GwS&!$rqI)b+ry~FOi_E^Mn7Q?^AW%*QQyzURKshY#RlvLE! za1iptw_wJ;e>mF`z388-KxfO{>4vhgx!ilvgu3kKhsqv**cGc%UkBNI82wh7H*iu8 zzJ2%3bgT+o3Z{V%lTIGzo1qbHIV%FZ#c%&;|GGJD5SpX$oYI65@a+fqN!Dd9nR~?uT#{TEuW;C+$=4+ltq zXBflP2`WxUC~f&pbBRI!+dg&n=i#$2!%E(lL!Sc&mt0j5g* zHaY>YNWc4b+!tKz9)9?wDNAkBHuJ+n;~jsydPVv-xpYN|EYCqx9o2?L{{k%TT$HecgSI--nyy zdN5H0Q=9Yoh@gZ-9OW`XVWz$1lX|>rx#K%Qoz9 z-A^t8IK#B33|eolE-%Q$qYAWJSa02O&)05=4x?Z)d&Q?o*~AB2zx1UnAD{IDywWIE zgDNRS0g}JkgUasZ*teNkS*nxGOW=bGLIG%0{j0%@(Cukg^2guZ z;xgf)>FDen92~4wpM}$tl#DE%!*Xc6B^894pj}{wiiwM3Ji7c5cJx+do z1{1cTLoW223Qz)p@af4i!kvQ9JXoCYxTOPES@~^i)M|Z zcj>DaL^YV)SRVOTh7$C}M}GhDeZpXCYh*py(JShyGR;IkakN=hmviC-w8g2e*h(nv zC0?tW_tag2-5b=()_pxm*tDZSl_^UFC{Ms`i=ej2fG+#5Dz7_!f8?)U2eaONn;+Xb}R+=+jaDu za*lVcs2ITjjVs1<0}}W@Ez-A9k9K zE00pd<0*Wxb>*@mz_l7X1itelkc`)6_7>- zsfOV%Ujyn1IDSQRsU8jKNZ%lnQYeF#)3MvF$fvw8WtN? z+L&NBL(qnqgd`MiIu%e?a_+k~?d9XPRu4dTvV}}HcN0DDZx>l6_ZVz;=);;$$`+0i0 zpH%NkN=hCb9ih(*@1sqdvt6I3(?I#&+T3hMsx$rlmv-+8w|x$e1LEAIaDTab5cWjv z(ev{D?(55_3xnTCG&?>xPD@L3Yjk5w(bgGBV~xYxTX^4Hk%PnCXxM_PL-yxXiSu~OXdyZQC;sEdj~_lv z%gxnOu0RN2D?FQg{A6hfP@T!QlvKuDY$uxo2-)oS-oUdpQ_D~M*xl`)^>w^ETt9CW zC2}w$kT;FPr#?Xkeg_?$je+4&PTcd|S%C=fw}D_D#85;d1<lqwqDUBBtF=}R#Q-bwETRZkzjXd(R86Bx^d~kczE}LcV9K$e(1)=Ck}#X zw6Z9WXN9*(~1fJMsCvw!BQ` zwyadRG?1&2Yd$~GP6&9Up>&z6NiM$G7o9d85qE`!zvtwjyD@S#CLoAg zp3{*3R}1(jc?kLy;qLgymvKC2pe2{-1qeX;96*_~Yo|ht*7a`Ymdj(JY*Uqb(_h_v zVC&n8ev2Bq1$0=@{Qg2?_5KZgS+-mzDf0V97;ut5u34L3qkFGfc^oxJ#bvwNaV{0q zA5i;tSMDj0eW|H07@b_Z`ofihxG`N?lX`26E3W%&#?~np;J55M9B#~R%}$k|xi{p< z-j3roXah&`J3m}=#L6y%HW{~_85;|Waa)K%0Ac0(8LN?M2+J*E)3HlGv)&lF3o(tS zOuEdsX5jX)Ui%2bAH)u(;p!|h?T+;nyy4iNLUa4UMzDHXl14Wi%&=`lTvSxXj(kZp zw+KVkPUwrw9%htesR>V|&HCD2kDP-%F78_W!J}IlhLn*wqbKU~&>L!N&u3*(-gBP@ zjrYzx#X74&9~{og%-q_|Vm=m6VD63I&Emi4&pS^wed%IKalz|k7tVMN`9wXjcv}ZSDjX0^1vlB{7cYB zfL}mFKb+li>7Qf`Tmz+qiP>+x%nxy}R+pD;=Nj{2kB9V@Gh*e1UzP3E4|v2v-=bdKxQmlIOyb9pH#keE2ohO^`{iCx_+ z;qney-Vd&l@;2QBkU2l;0`5Q%V-XFTVW)HXK|R@pVVVpGNtgD%NTW0I&M~ z{rlj(Gtt#0jfp3LNQb88Bg}^%hbFQ#8{RUL^6)r=6aeP;q}jU9&&9<%L1_o!>EyJ= zU?lJNP<>qp>QniN#y`TmI(dF^(JlOsAtDF6uOv7IWDso;* zr`sQ7u=s>iW9p6+zB{UFesk->%~pa3$kvO<4I&Lbu+?C0eLU*<;`dBGqNMC22wK0yrG>;4eU zVkB}fr9eOc_8|cj^tRGBw5~wRv0BwyeMP19W^0Q8u*HfWZREp_+aKp1w@2khJnD?r zqg&nkhRCR4mKehUOtk@~pm|IX0fIwFM1<8$ zo%)L#Q6a1eXW&&~nqYb6Gd?!meroV*&zNIyxvWEM2c`AuAA+`4ld(yVVkX z{4n#5Vl{qQP5R=uW+YDe@`W^qLI_P;ZH^Y{G#0+xpezeBGcmfHtSn<6Q{;BSaIxVc zT-f2Fb4R_2E%(vGz4LyQjFrR;EgDbZ;ac1*cj=>LlAoTB{m&jif$!OQvO%WE=!OEm^hPr*2L7j90{Fv>dUhZl0cyf6Xq z?yZ@utSsQEWdCT3zsYyS24R?BD?`Cf|9E!V$J%t>!nCq& z?i#NiZ?3PCO)fvF)tX=u6Bp&;((w2DKG-{Gf4%@*+Xkhe8{}HoIQ3sV*IxKOg^^$( zVPPrw+q#GW2_0N5n;{ntkDZsvh>3-c+>h6jQd31W9q$AX7LA@Otvy_S3k_=m}$kr+Qn>_H^y`XyR>|=d4G0x+2@)wpS{3$+kLI2Wv03 z2st8HL5uFfi@TgYWF^@nv|Iy<^_dpxP*`?`)K@MvyhL9CLWOd`r`{uGU`wiJWZ z|Exm%$_4q5aO67oNQI7mnXx?)i{Aj5(Sr2>3i!k**NB|op=z9R*=}U%%LN`Pi9ER3 zH$0qZhlBYvDJOwfujFFN+3_YavSd6b)yCsR2e%JziW-U)&5iZ+$ViXg5V)9}V|^c; zXi>#}6r!E0R{MMV2(z%<*bT$vsl5MLq`_oLF}Ui5h;dmgIzF8#E!l^!LHO`=#XOOw zgNB&S+eM8SIha%eUVMG)@wK4zAkh)16<_sY;yjY->)EO7)W34Evvd3MrKZPJo|k|i zm(_U6X{YY!%s-0D)+O;Rno6k&33m*q^(h#{fSW5AS(ukftx+Ql&JtD&H)G;4-SU-< zq|8KEqj6^`^_L&cSTvL@ok*C%pqI$Lw|U0opt{&554JG%-0EN2zoK`4hwkYZ$jHE! zwGiS}J?)*JFDj4N!4Zp(iTS=({Q@R-rH1_?O0QSf4ve6TJaW1?ojXCr6cWr*^bbu; zH9JyeUSy<%dQ&EuG8D=MC<+(AQm_(6-uqLk0W5PqO&s70jDro6SiS}U8DFdT0bdk> z-igSXZ;`;o!(%b&6AE%e6UFW8NaTGhJT$$VrM>%jtTT#5$0FdCmI!W0M^~%M=E-T- zq$9`ZC^Y+b^e%yjYP?aF4<%_mMxwS>ig;ug(c@lApb8K>ek#A}>04NY=)=#nHv)9dx$F#y8H+7u(lcLw?@r6tON7j7FQD(v}!M_l6Cd#*9qm7@tvn=a8;6)2Lrd znJ*y>zsF@}tbRFh>L+`Pi7?vYd#jMHx(Y@*J&Aa%n`SlbFcVoL;I;5_ z*_Wr$P#UUts{4G|D%=XnEfcK{qth`xFD!ULs&kFMxBX6eiK}}3orAJ`XQfEqKfmby zkX~;q2H?{6v@H&wrm7!q3E~IWKc{{gM^%{}cRv}8^Z~8x26$O5xR=QvKbQ$baFb(E z^1T&YUkO_oEe!wI5huLBUimTEaC^uhEs})5IUma;w-2KEi zvQS^gZ5j)Ax>SR&A&BI92~Th^T}m<*boG$;5=N>q-{eeI9a}zy&J}9q_Xull*BY>M z6hY1`G*di^89XT~Q-xfeX7Hsz0Tz@c3C1UT^;?Rs`0OmPJ5;JI{4Qe3!5y{9RkYrh z{O|fIBtE#ZG4Gn2??Q_sAtqL4x%@y!Nke1WYv&IP4wodJFoj{!`1!iY7&U@pNS4rL z{ixk|s@G)q-la8u!Scgmthy*t)vf zx$ITD_aMsvMKbs*?d__9n7IJn|E1=}rk*u;kO~VE8Gw&2DK2arX3OE&)&NBwA_QQi zG-#bMG5F!0IXJ60z|gMXtbk^t51Z!tIz;;H3c)FFH%W7Ib@eGs^1?#Ki;HR4B$=+b zt95p?$;QSeBQ@(k(%V1G#7w=qzVCrmm?@tFYJ#SSvWTLcqF+=Ag+cnjPbkjnv$|p} z@1N~_X)BR^xpbN`$5}GRnd$(mdp*&wcN)Vj?r^>$ti)8PK(OV9%U0#O{bD%pc~DZj zNY3qUMh#V|SB)B4uc#lOqN-HtvaopjoDpBnrFR4pwQNn5x3}fPa}~?$97P`vekX|j zZvcHA0w2In{hfg9DH2L6*V|^;`^)`p^IOc%>Br{_yE23t@qfJ19z27=*jnS?pSSMn z@6UTLY;kkBE8$CB9RS@M2!5&nD5A9){&?BZpku+W7 zToX6Auh*SMY4q_U@ugESmkB*IhfVv-n4cjt1*@jVyNiKdk}4USuCF7E!%9TNL&Zcj z-AZp^e_Ot#xmh`+@Zl1%bEG{(y9R#%fRNj06xjossg(+RXHmQAF>=+a4`+Tmga5{0 z6{m)mS2lNP`2{O0MX_OLMw3q%EE>xO2N|4($UWq$RHl41+lY3N;cC(MeAMcC|9(H!f=<|6cmf zu=6i34KE?$y~9I;-e0#G2r+4nngkczsa6?a(ON89_)U5|eEbd#HwxQL1mXZbNZ5b! zOi=KI)mZt~KmR~e{B4n{jYK3wvDo1jn?Ox;W>FFIrN!@ZXJs$cOp(7@bX_eVLIj1R zrTg6Xu~F{hKj#i1R&BBLPZslqR)GqqUBmIn$IWiOxziVca8W&yozQH38CYges4Kgj zEX>>;vw@!z=;fGWEIwYaq&x6dP$}7=b4}K0vo}%2?EMdSclW|z%K(!}66+b@0zeB8 z)7Au2g!nnW$twFySojT1V;Hq8wQOXQ?-$M1OI0$(Ip;ID<7IjNcfwza(Rak^7AGwogh|&qoeV7!XN!gRm@WRRW^+` zoeZ;0wTh1RaTQ4oIvN)7Gzytj7W(HO_L3+nP#gq2@6YB$r>2>$4{gGN((;Njhm+Gg z!;k-Tbqx&=|`rkqu z>D)36#t1X3Ck~Ti*3PE~2K^Pq3mIEbuZ$HKFxmcAmy?6_TSwc|xk}X%!b+C1M>8$``1gSn|jWCbSf<3oS_&_k)1PVl))uF*z{-AsHsE1uy83 z`WiqztlD*E(q2MP%2CoO7Jay^x&?-Ju#PrGFw8SE4@5H?Xz|%T7<6k7p;5_{r-^D^ zmC?&luc^=;w()Mnl+ti?ctWRMUsROd-r$Mp07}5c3xoi_(^o489UPB~xffKE!SYsC z2=B$rDA@Q8x6lV62G)LR2M&$Rx|2F~$5DJ$bb?39><#K6#S36Ek< zN;s_VdFaRVQ)T|t#?stJgal76t)n9%o>mMG4q_32Oe5v}!~@O|@(-wFbNA4}^Fc8o zEm)d|90bF7cW)~olXS`eB}U^L)GshTl79MxK>CYvM?50B(gPg)R;!{T{d^5h;i=f_ zkiqQ5rlaBh%AcQ*@Z#@#gmeYIwZBgx(w~1~kpKOLkQiYf+rPj0@#g>h>wg`z0L8ld zKMtb*SN7~%C^Kx{zP{%U#O?H2vs>%E)1P+5!>=OT@g|J;bWUFEFY%4)H4#;e}s*|+!CJ4)!R>W4R+eRr_2;qi!CZidAx{SmjC#^CtXF`LOnv(K_J$N`y9U#Rb1aIHGp;oJ)CLwYE`=lL5Q_v$Z23UO1 z($azzj*+e=b)R2Dq9S$t+u2dIdTj~=&~JforCOn3SjD!=`AYpd zhJ5`Q9fLnyKuW40DgR>xqx!l$jb3-88Zf$#kN<(VcPlXy(>@4i@M?<-3NEfk#<1J3 z!45{44qsccdw=P?X}le#Lm~sO00S&O5)dG=JzB1hKu;h9RsrCWQPCgE8wblTpWd>=+XJeCyvjN;2FmHX%CAn?fE^*^t2dW`xwW@+@1Lr+LN2hsgk>JqIH(u z<>)u>%tNNZ?@ zY|5?oGpR`2`a7=xt&%}$1g8bfVm}B5>0gSAJI7eo9jvzF0wZfouPy_L-oA3*1~3ghN?!!Th=OZ;2lRWF}R5QG`x|H}xO8 ztqCPcM`a}?xh#F*$RB;%aXgij3OUrw^3s&gO{XiIMFj-rVbl=v2}VN@4=dH{R)0&K zA~s&EGW_{75(tZ2mHx~P_Lj@iXKs!EP(A=dScdqxjk)|SF9G96eHbC>muD-N;r7P3Y{?mRl!Hx82jS~p^$ql4PvG_aus2*fK*;LR3|Fecx86v1e`*tS^h zV4I3v@ae=G6b%^Zh*9eTPd_>|77C8*&(@U=dL2r*INFsWqNAev^4#5THI;yG&&}N( z)%|_OUDRvB>GtNLGh$@d>BjL&;QSHcl&#I&O(rZs3j4k~ufRmOr?ozOcr|KDuIB|n z94?zJ%($SiFjk0FLZU)yGn@bh>AGDp_CT%+Q2@=)Ky1)(P)s!ZvY9iS!kAP0v?vCD zX*IY7@gpuGxFK2NZHHnA3%2F*zD4SW^u27Q%(oH}T>!vO=lx`ADk(Qw<9y1Y#%(Is zno-9AO#-!Y5iuE8s={_$i)2iiT&jOYRE8Dutn3U}9UZ7xO{W0qtAb`-4A!h(@u!mR zTddjRvF!F6la)uvDEB$d=M>>Vu-%x@(KTr{yTKK_)gH!@%+J{gTZfpySd_@-efac! z61aV5Z?e{&y%Nog$jKp~$+P=DhgtYH4f44bltj#DSwd^CSv7varh@L+W|yt=)yXp? z%KjzC0(pkcdy8!{YtQ&hs0RfpkfNg_0apRW*8TzbI}nqlf-if1Ba$h|B#B%Z6`~QH z9E_BPpuWPHlmqhx@I?D1oOvD*`g2>zN=VemWiA0k0It!t(w>ygs2)EQRW&tOOu%P2 zAe8bj#U0Vir z!F6oS-{$UCHl|PdRMm&yPGMLTzcKbri*MGxRJ993e8$4S*zZ}Ktt{Qv7y0nstK44c z?{Ib|_qyKoRQzKjdj^if$r?)q_dTpMQXIngIx`-!N~kDgUL1kf6|iOS%uCgf!LDs9 zOG_@ttqn+YfZr;dTFqwTSTli$oRTP4G2feype(|x-9h7{5TsjYCzJ(<8utD5+zK_d z6X#c4T5{B6nJ=H>lsj}*lx|OLkK||99Xz;yzX~imXmWm7?DeR-NVT(oGexgh|Z~Xef@ma~!P$uw)dhxoPae4YCyw@kv8irq_s+?}C1;(;h`eLc-&C z==;{tDE#Mwuz_WhcPNyY;1@^rJKqyikyz~`1&+eK2X}camp-*WF*cbl7t|gY7zA_y zr{j=Vo8Lf6Rr2DoImQ#}2!#)ly%6rlKrxx9N=Z%^EZ;laoZyn_^2evhi24R@qfOPAVxR|aCL%&+Jpe;>bNs=rQ7eSJ7E z%Uv#%UCM}cXl8$^{@$H`2O|hv+|vZz&M~D(9R923$8ZG$CMTR;H76#S{(S;}6I3hA zCKD?Y94%jv&!8ax`gpQ`nGV!w3bPXZDG=MdqNJ#cO7as_po7VfFE+R0z7`1qbloG) zctz@m*INxX>|A6sEv-J7@WJrN3_|wEXXrU9r5=xu0ow;o&fJs~(xTPr+N(c-4eg;+ zEqte4?oLE6U(!&Qg+Z|c?_ObXG3X%#Fc;h$o5&yK7*(=IvrKe?sR}l=Vv*f$J;b>B zx!z`8%r&(a70H6LNcbk%;o&|ma~hw?gd*jIdZo=gjM?imh@HH{)hi4P`jKxRN_~mK zvOI~HGat7xc5jsMLqj0`*3SPPaaaxx6@JmohMk1QDoaNu zo$KYfFbuAMNH__Z1oQcpa7rCU$b~&7DFX9XB0j{npH)cdCm}?27X0v7m6VjF2k^OU zCfrf|`QIm1DUS^hGctO6bjMl3$uv!}?8AQt9SsdDEY8%qQEL~@<20>_#CJz9c(E>8 zXSCUHo0)kCB^MM8LQyc@N}d^RctTIh&OVZ4mAdTutO?w>^B`U!h&4MD)mkbmXFJ8p;(er?(6~U)>-xf=T<>V=8JF!{ZAkUO_?1*5Moda6Gh)cTWuJGdW`C(GQJBSO#GjB&b85mJ zqFkq#R|b1Y=}Wpx^>?fL;PsMN9EEwO)N^W?l#UUG#BgdQk$I>LcdP@HZdH9`*oS8x z#*ZHB-$bOIuELEuoIhW8LttQV5U^ZAx7J|e3_C*5SW$Wh03V-A_x!iO?bQURIWI3NlXEG>Ah%ul>7;F^lP~bUTEHSznDpim1bx$G*U`7=g4&H0~wYz&4%*t@1X8%ls zG1J2OJa}|p&l@C#$_XmRf4lvlGnVUJ0ZMxqU4D)(4L5WKkm>nKKT&FGS|OZkyst~6 z+Zi_~k`ZxT$?^GszuKXqqA!*DMqO_6UkgrwA0t?I3uwI z&y#+z)TTVZW1RyS4`{%#%m)1K_ZD$35tG$1$g+jj*7ncuRtpOY%l2YD$SlcAGwWS- zN~ZgE3L$uBrz!|as?0dRs(~u)mM$m?YV3L$h>5TLa)E)G%$GnJf^<364h4unpmPEb zXtUDRCB)l64ER(~$Eqiy*eY*#L|Uq+_qG^!9k;97~`9JAJKtk{j`TF=U(bNAR5SF}u@laQ~?RfnVNf4~hb+vD5 zKk{1-x3^b{MN?8M4ptus?pWGeP1sxa+Q*v_Dv7ZcaZFb z1#1!sldUJx+pw#T@F5l!$%*-|EcIM; zoMFW0mnI{5<}LneQBlf3W`|yrRwHhGb8lkQa=uw|p}kg>kc8Qz@(EEpB^AZBMF%)t z;B^hm0-L5Bv16uz#vyE~bVp?ig1y&$+XwJ+;kZm+fO+N#t!mM~=9#B8rCnf4yvOrh zXdq20`p|dT(Qg2ZKeu3i>fw%^Qo%=ISPl<{Igu0sF!ck2rf(DY3J{-upvm%pU__y_ z0vGMLW)}~xH4%%bU%%>qBRgg)ses1-Y?DY=-U}m3fHZWDb-7D2Sl>stsz1!2af-Ec z;)f6gF@fTO@!X)BFT&+JB4-qguXk6&M-)|nB9xSeXwUxG@&GvXNYCfVaiCQ_Sn4za zkC0wRM2!{B@bvA?ycC4v65=i&AEBjXAvon2&UGjt@&l=r!@Wf&n4-h9FEtR9Jh+vA zX09VcM-C5{Vyz32pA%dW^@5@Vd^YB`uCM38?858VC1x_R$&svKNN>NF^yd%#EUVUW z9*B4%L!OI{>A)p>Xd>%@b$aj|{QOmx%Q^7UuO28!%Tr$_=V}mPd8-r~((q7ll9s3p z(*{{T?4#Sf^OVDax#0kBuF$so63&`FPSj^1j82=jdyTCP9uE%o_F#3++K4%ZegG0! zl4kHfz~h4Sng9D64ooX|o`JBiMf7K!V#u(5E(>54;?_Bs+`1hrU^ay@Re@}}bR~WX zYGnq*xtp7ue|>>Sr&u&A2@TEW$v!sB%b(-~9YZYv9g&^AJq$YHH6zI+#CJTu+xgK0g!cxK`zSu41w=LzcNQ;ffE7Z_xW&+e3~_j#acWw|L3`%`@XK<<;4xa z3Ko_Y=;q+#^vZwtUx;Ph`U6Ag_z&Zipdm#s@bXP8NGEZr7<5rQ_AnKQ3=d})dCSSd z;tJwu=(c8<02Iq#Qj$0ZT4`Gw8xn4dFAMFrgD4S~*jH9AR5*;4$*fPCN_-U<#n%2+ z|Hkyu_o|SXxZ<1$v-YsF;uaC@VJScblA^^}w{I^g1|^gGrzko(Db#uS;Kxgv#2qI> zw9Tpzl>X}b)XL(V*KY4@<(5O@P}a1QrR6?$bJXIhJxmrurpR30{|H?{jv9OZK}aBS zYnh71+`)mz;H)%^u0CZLKvXPTHiiD#w48vPr5kW%fzkjPYkY@7r&*K5h!AQUd^c}a zxv$pmxXkNrS~UWa!Q1=bW#AbOV3m9?cLyf|h+SC9SH73ODlJv>vdn(|{18&MH@F3F zvFt6!y9}U>16REk;j#km%umwB6T%sXvg5p7sTq?A#a*009Gdfn8+?MOqe|-dD4OZo zy(B5S`iD)2KK8al1x4vuDnhpM?VI5Au|VrCblI0IhYh9R`w9P(Ti}@f8u@T4S|Tc`N04O%)-)AQZNh$4ty`G4!@a$iBX0O5#4C5=Mn&1vbq{TKM!HLz!5h+ zMukSzNI#dZC#jX<$8NH-TCh$joj*~4j>LyHP7-1Jud|~f@^l^>Te+{0^FRBqGGBe) zl98lWTjRC}5lrS_Xv@x0Caficv**6HcJ?-8q7;G!hvW`9B$VPFxHWEgay<4L(F3e( z1OxyCUPkx$%3->0CJ-* zfo&x)^I6MFnZjDe_<^5iQ@B%G zZMf;P_XTLH1Ga-sps194OM4fHeQsIw=PDk3hxy_K_$1SNZn~Y@K7FuDbo9{vdzr~v z_!PbHBS;kj*ME8mH}g};vm^L{%(Gj$gTO&JBHZ%c*i#&8XUCYXj9w7RH_VWMD;m4L z1FAXz{lu)SLYiMxlv5zsvDtS47+xa|qt$BQtIO5M!D2|iug|o@jknmS(rYv3Ud4%9 z_s>!&<~cR0NV4Q5B_%QL&sK>|M|zVhmxfA9OUb`Jv(#-Wt6lT0bL*-qv$VMFI>`g* z+T$1A?ien;+Mi3qmms#G7P}epmc+)Rb%XjGy-*GG9A`Nw8|u3op%DwW)PX!VyM=cW z663upu>lUeFGo1%`o_{tL`1Y%D4-2fk!Q|ob8_qpzilVW-2XOy5O+U?kF)Wz6g~Ch z$B(gIAFki`@sYB$lr!N5-_c-|QazqQ8q|b9Sc_}$;&ayn4edP%ze50Eb$?1x)Z=m|5UcN#;~Jn`&naH{?w4EURT5)mNOF z?@dhRd$%P^E~3uRPZZ7NDe&h?~rrT%E9>O(%_MVw4Fy?Y^lO*Ouyv!hV2 zGTSkAyhmRUM_idyO%tD!jPScy#JONP5r@jx;Z1+OZwOF0#hQkUZrJ1vr|v9$LO~B+ z%yT+nm0=RWVxTBFEa474=+f^M<%$yCpY`jWFFe-d_kWVv~t(xfY$Xv_`J_NE5nZdF8iMa0O+Z)}vE`YbP+)BkNy{B4V}q7=-xezVwOgL>=r3~*S0 zn=U>s4qlj>n?s~TPlEUjR#uC{Y9a`xcNoqa2D5fRB`v=X%Td}FRnp-kN27+jW*N*G zp_jy!nnW=gqYII2#n#rHr6h%N3j72aZ61}d%vQfBU} zGY|{Biy6;S;G!r8BK3J@)?p7(_m!j(CApLv$^X?0;KDj`P|hf6M}OerTISDMEOUDN zET&NJ{@C7nc@9a_sqWt&GWz%Nuss;^D#3dyBAd|QPwf>ms4Gq^J)dfe(UPFoT zsv7$hy~>#x%F)p=UH*e<^q-z0o=b~2D`@Od75(W~dT?hblpFTAl>&_d)#o{4F=31pb1UxKkEZ594 zvnPN>mkoq@4MnwF#ldWFZ*+Ec0+JQ!zFW^npLYrHM9z(A_(0Y!x9Yig(efm|E?qYMsBRK2hK2HHGbOu0#~`di(zXZW(yTT!l495^X{-Kzsmpq?^J zEiX)wQ&8RZ>i)S{HEc6mQ@NBP=84KwoN0PNC}e&AqsohkQ8co>83_^$AD4QgkPrz=KwQ zWd-L0`vUy>g^3?W3iOW<$#IZRX@{!l0MRZLndpawJldb$AucH-O1C`RC~dV&b$^S2h=vqe%zH?LfIxKgtXMV!w8CV5FZ7S0?0 zqJ4q48P7pzzBH&lIM?WrnX~QR#ROdxDP?El z;TiAh61{RIe05BZU8o#f&er3#7sb(V&`TT>{C4{`B&=96FCC<^*)Gd)lwJB zo&{80KGUmoe!-@R5nfJCyG~7v?oXF_hT$PizyQfNLr^Y)QL>~PW(`Q!#o6U$gWL!R zTGECdxJs%7cTl7A#)hV^mbyAcFGKeAt=W!9Qbw`A@Z-LQ2!M5uDr+J^=#gU<7Cu=XRl#~L#n?k2M6Q0ZgAl#l zRWq}9ao?*=q(OTQInZro(?C3sm6IW*{haJpkdx|-f2U6;#Xww7h2a8ab7yI>v3LIu zS#r8x(vV4coEi8FB9alU&V#p!jii`^N{E7z!rnfg*MQF-8%!<5b`T{4SqZchbhn*{ zUPrR25EJ~E>q!`^_C&%sXy8V7cW(`Zr2q^Qt&VwZA+j@`d|;|QT4jhd68Bzf)v}y^ zxehT88NqqTS8U=t>+2TPHFgfCXo8%_8~kHq5_;!kO@U*^sZ;!LuK=vFKA<)Ns?XNt zZ>#8UrGguCA&Epp>soo*{Hj7=mePIUwaxK;Zgv*u0A8P#Rq5>=YAZ8sHEj)6bMif2 z_AICAQAxU`mgB&9+6egYJ)5nY7c?h@1O;5(H+AbQ1DGa$l(zs!A>i42`B1ef9MuXt zF4s95m2VT!u1d$tAaw5U4Tb_|J)hOKKuG(w8tfY)Zr{A=fbFyZvKZ98dbj-hgSEO4 zXSi;_xz-u3i52rZY^ggSs}(aAwjD|{Z6yYy3AbKt_%;4fp$WdJ zQll|rHGW1Zaf+|QCXnvNGBPqS6Dlm=%KgLlV2+!B+M>C3RDodDP;~b`Nesp~xU&$3 zAFis@lc9{Fv&l)_aIa+)adw@~B+^7>jR-goeGW;idW5QAy25uyL!++3#zrJ2iwzvH z14@?W=8p^-OlIcp2|J9Zy@ox)EkX8snMEv*K19Nw>s+B4u}OzHGe~N=jnf(Gbl)k+ z5SVn>g4K8Kb^PlRrw;NTa%~2h^*&f7hz|R3v~4#+NJ{tl?=^Y~A;s)Ux*~GjH8s`h z&kd`5IKZnU?Ae(KjU+Whr6ICPvx-`&i7Mj8z(6Dg#s1EcAyD*#laUNhj2pZP<%?4& z+5QSvRRHE|(|l1mgsd&j8U;!cej8a02`ViO4Xv8D>x0X$##Z(^)3IM5H=;R+VqNosL5c;7685 zF5mLt3(3_&el21oRlOumy*a=0^@Bp7( zO!U7+sj=Tc`gkVII&DtC<`ZK}9`E&IPoX7~FK!YA~fR`mV-CHRj%6aV{&wx=hh#)P;BpX3Eb)i*p^ z7L;Yrap9-oR~q46ma{nJDA6e>B!9pCKi347hWHN<0zCZ#{|tQl?&6y43g;iff>Hc_ z`}dmsU;RQb>>CW=;SuaA%G}lb{aye4{Qutke((IhkG=mdXFe$)+)r5I@R?!jKZ830 aPUFQ(WBd6GR2<049hy2h7Mi;6&Pw<998R;?nSGAUD9sZynY1A`cosEEiA!W@Q#Kvi(dknSyz zLKvh9$RH7c1PBBWHz;8e48aV9L8c4@0wiSKi?h%92hOK+_3-6gD=WFy^*q1#eeUOe zv)11)xx46Z-MbY40R0QU{CoufHi4%)4>oTE58-G%PXPD|xbX8&p0W8tZd@}XI$66I zX^~d-XULz&k3A^g{Q7iCR&nXQZw6C*<(0>Goap^{_S``zEb)5K^z%~dKLlRI)QZp>jc&btP@x#uukCr8v!#@pe>Vfo59Ks9v)rp z6%fqfoPbuf0YtShnd#WH5fJ_iB8jQ;u?@YxI`O(*UqHwB`Az0ADvK2KZ#*33pVLzV z@|ESaGGTMTCSc?!fA8MC$AC72CCUt;D)1Vw2FtEahzY5MR$2!S_Yf&#h2^pyxd5S* z+K;&+@@{MZVz$Zvoi>9y%v4^f0?7;zn~PX=)uc+nNO8y0WL=yla+eG;`!t{^7pqkp zdICuWXaDQ>5z#A8ytz{0f`ZaXleI)=cbn{2`mM#l(b)#rU+Pf!bVpH!U&6KC;?b7w zsP@PuFj@BlNgHnJR+wtqp++_#_o_a6@xgHW#t#kvuXO4IxfL~6e6`&v%vS4}xiZ1A z46Kw(0V(`#jl$Euf(o?O+?N(XovQS%ZW58%MgsnMyk=%A@apR3o4`o29T_!L3?njv zht00l)!pKn8rdRQ4dpdG7+C`4WxRZ%FaI-AT@B|>d<|yMgFZ|2Dl35L{ zkAcPcGOuY!LT)5a#}FRf8E8TR0DD)Mv$HdpSUVhEFP-|jT>w9-W|Q3n?#8TW{z&zI zPZH?o=Qn%_0N?%w{O`xJ^sRs}Z@9{LsnfI$$tKehi8wxNmGdUDAEI?WQl!$NcyRnaWgV+Qr+ zdX$i<7uhyQqrH2{@G`H;VaJ5|s~gs~FiEgMKBJPiTx#38Xm_<#b@TJPXJq)PXqHES z!}7v|@X67el?at;SBVktbP}@~me9WGi4Bsoy4sT)In##>2?^m=c(rQO7Y)(EfpM3s z^M5!hA+3^Z3x;>%3d6^u@s1?_Ok_+jL*Zkns$kWX1XTJ$eGnpcryo9{>-PiB$J|`; zCM$ior~#^2{(gH$Dj|1n2p6r97nSdfn6xpq3WyBvwMN*_H~5KLwS<5oY~Qe8Uu4e* ztwwAZ+VUL1qZvXZmo@<#!Nn7E0m-Us9DCDm6B@MjeWyaA_40wW@Mu_fJGoAFXH#dV zTlC6GOM>iZuzOzA`xcp|j?5S=aivGS$qmTvr5JS5u(Bc>Q6bANgSZq`TcrA29XM2) zjrDf9Y?^I|TXpU-fmHPL5N%0S4G~k_ryw|u%G(So6Edhhcn8IcmiXm{`6d)**4bp= ziLtTQpV>TG$wyRf9q-@` z*7$W@!O7Iiw6EKa6rPUPs-^4&e#+PVFQMY$zh!5@nxc-jx}UzaJc-vflD+Wz9qCc3 z)o>A;(yG>w_KchH{F3R60yk8*L8|p zlYv*u^mm^aYnU8e{CL$P8=%SA6wG28!kiA#%HNAzgF-_`BJ5TUA#J^mrS6M(_o~>L z>@-fpDl_&(P7J$tvkoN@Yn#1AWr@OPYQx!9j9gNBel(-g3*3?a=f8mFyJhYLg>f;V zTgJ>xeekxc1J1VDxTE*F=iI5{M7Asx(G+#TSM&CXhdH9=ws1t|1$=uS`10m1@00*2 zB@~uNR{Z%r!f#T*jFVBjvWmdC`kA;%Mf?3!i~wqz6-!$lv>1&V>Rp<493msx)7GTM zX+EgP`O(9lK*&w=O~L2!`)d=`3aK&6wq>RM)a><*4Le_@!EacB3S1k|f3zK=dT}{z zmxUP}5jT}B@!lguvP+SKJ{Tf)TrQDVcsQK7v+#Q#OsX0r&@i*b_wV-Q*ULtgQG(%; zs@9;DyvE2Gc*Og2Z0_!6^$}t)dLt^=JMVmTu%(P+DdI#@wrEH=HUG-T?CxpnAZSmX z{H;F$aXFKE?}MHXJY1f26pc~jM`p%#o#axZA(QEg&MDiRB<~KI0$=?I=)BUSj;G+} zMzN&p0Kw_T0w+>7nt*z#QGkn6`9(xc?Baw6W+MI6QZEV)KEhjf3V=uh7_uzNLF z_Y5l&MULF+=nhHaTh{T(XYOEEre;4pqbF;E7bi~fN}z;M$AoN{KIHZ)49&&%oOF@R zac6ePc-n^+@av;3s>k$;yK#J>2LU@8*$>mvH89_ym`CIH+s>ye6iw~-4TBj?y)nD2 zu?n3chb3WWsr|!X7o0}^uy=-oVKEH-Z3|ekR@~ZT63no39c8N4X}eLy8yhc|JX17J zJqa%P>j~(wr_SyWET^6c110eLH3?+r-1-n?=Cnd6>pSgtRy5T(QvlT)^QY$&SV=#h zOWT=IbRu+~6Nl!62W)xgVxoq{2`Vb9Lm985Vc4mxtL}3{WoTrF97oi8bfh3hmppmsQYTL_>{g%ahqov1Y&+ycj}l|! zhtK;$$@eaQ7eSn~Hb(aRaZ+y=ZDGm+HJ7K_Dh;em34j~Kuw zE@w$z7Z=}}c&kUGw2b|hmN(t-Ijj6yH5XfRR^z!bxk3GK|pE z!7~F^TVd+AvY2N>^)vC-!#lk_5*XcH*`-GJ=~11|cq=o~;l-2;k~gW^E3I_J=6@=B zIO`c{Q;@8s35B)~W|NWB7*PyZcrvYo5S&0eaYzHYpWNKi2z z@30^Q&X^a_mmiyIak;cPH#I$Kx~HoWR~^bFk~RZhul2xN8PNHEs$H(v-3uRb(_Xo2 z;1KqsZ+QRVsVNXQyi_Ty?Tjw3fFFoOjjeFZf zZ1f9Hlie{cV2in!L6E!bPptbjK|1eMy0Stf&DL~j252zqeWH`C6Kb^iVSnqw@OGE1 zZ=f`c+(f%tw*6q{!ov%IkpIOAfJ&a$^|i)73Jt(JO0U%idHJ(l;A(C(Xnhh_J8(u0 zvF~U~sc+5gbH0PsFm_4`e;40LDdbF<=J=2?Uo@pxW$ugn{M1>u7c2dgY7$rHoSNxB z{?8KEQnwb$l?;*2{=$=!6Rw9Li*G0WH!Dy$BBI=&4ephghqsOq>IPYY0R=vD!PBO4 zhDZ|b17jswXNMygOdEv+A3%*)CLSpz;zdcJ;zv9M{`R=s%IQmYrZWfFhRtnKZxhU}p6$`0A&KD^snC&ZZ2aJ$bPULaVJ?3@pP=n4!*K z1g@q;LQ!jEFVra=#%@1UMC8=F&U7N3{)DNkc{@zu6)it-l?w;)`7x|6oYhpOM3V1O zWE=7MU9@1B2f->pOV^cQua=lrABUzJ+mjU(zNhhS@j$Wjo@p~37MF^)z&-ABL*O}; z0V|>WQ}H)8B*F4QGeB4t@Kkp}t*@6;#f8@cA5*|PGNw)4be?jX&^)^&CJCx7Fu|Y& z1)O*RI>=IYUszi?Xi7k--gheY-DQ^yhlxiNM$Brz*kODaT1mt#5t%Pi*<9dXg8s3LzJnOqX3B093 znR?Cx_XAhcV_6HWyLUNF%2Q3ZGb)+F8kdzhU!NP{p8_BWnql-&oi_7|>f++!jexc+ zyJmohqD{T(NYOXZg);ufN@JmJWd8fGZ{?T z{7~@h@5S}+LTvoIjg{b*U_4bjG^{t%k60M-!yCtaW=mPYB-T%a<8V%`Prs import('./skills-panel.js').then((module) => ({ default: module.SkillsModuleMain }))); -const DailyReviewPanel = lazy(() => import('./daily-review-panel.js').then((module) => ({ default: module.DailyReviewPanel }))); const ScheduledTaskPanel = lazy(() => import('./scheduled-task-panel.js').then((module) => ({ default: module.ScheduledTaskPanel }))); /** Skills renders its own labelled region inside the lazy chunk, so its fallback must too. */ @@ -137,9 +137,7 @@ export function DailyReviewPage(props: { // The page header lives INSIDE the panel: its primary action (生成分析 / // 查看分析) rides the panel's run state, exactly like 定时任务's 新建. // The bridge-less fallback keeps its own static header below. - (}> - - ) + () ) : ( // The disconnected state keeps the module switch: it is the only // in-page way back to 定时任务, and a page you cannot leave is a worse From e2f212d64f03d4a6082397bae8c9dfa7b84e7dbe Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 17 Sep 2026 04:34:21 +0800 Subject: [PATCH 2/4] fix(desktop): retain Settings snapshots during refresh Generated-by: Codex --- .../__tests__/settings-resource-state.test.ts | 33 ++++++++- .../renderer/settings/health-center-page.tsx | 27 +++++-- .../settings/permission-center-page.tsx | 45 +++++++++-- .../settings/settings-snapshot-cache.ts | 39 +++++++++- .../renderer/settings/settings-surface.tsx | 30 +++++++- .../settings/settings-pages.stories.tsx | 74 ++++++++++++++++++- 6 files changed, 226 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts index 22f8bb6dec..1b69ea3176 100644 --- a/apps/desktop/src/main/__tests__/settings-resource-state.test.ts +++ b/apps/desktop/src/main/__tests__/settings-resource-state.test.ts @@ -23,6 +23,11 @@ import { createDefaultSettings, type RuntimeHostAppSettings, } from '@maka/core/settings'; +import type { + CapabilitySnapshotCollection, + PermissionSnapshot, +} from '@maka/core/capabilities'; +import type { HealthSnapshot } from '@maka/core/health'; import { beginSettingsResourceLoad, completeSettingsResourceLoad, @@ -202,7 +207,7 @@ describe('Settings snapshot cache', () => { assert.equal(settingsSnapshotCacheFor(secondBridge).readClient(), undefined); }); - it('isolates settings and connections by selected Runtime Host key', () => { + it('isolates every Runtime Host snapshot by selected Host key', () => { const cache = createSettingsSnapshotCache(); const localSettings = runtimeHostSettings(); const remoteSettings = { @@ -213,15 +218,30 @@ describe('Settings snapshot cache', () => { }, }; const localConnections = { connections: [], defaultSlug: 'local-default' }; + const localHealth: HealthSnapshot = { + checkedAt: 1, + signals: [], + summary: { ok: 0, info: 0, warning: 0, error: 0, unknown: 0 }, + }; + const localPermissionCenter = { + permissions: { checkedAt: 1, platform: 'darwin', permissions: {} } as PermissionSnapshot, + capabilities: { checkedAt: 1, capabilities: [] } as CapabilitySnapshotCollection, + }; cache.commitRuntimeHostSettingsRead(LOCAL_KEY, localSettings); cache.commitRuntimeHostSettingsRead(REMOTE_KEY, remoteSettings); cache.commitRuntimeHostConnectionsRead(LOCAL_KEY, localConnections); + cache.commitRuntimeHostHealthRead(LOCAL_KEY, localHealth); + cache.commitRuntimeHostPermissionCenterRead(LOCAL_KEY, localPermissionCenter); assert.equal(cache.readRuntimeHostSettings(LOCAL_KEY), localSettings); assert.equal(cache.readRuntimeHostSettings(REMOTE_KEY), remoteSettings); assert.equal(cache.readRuntimeHostConnections(LOCAL_KEY), localConnections); assert.equal(cache.readRuntimeHostConnections(REMOTE_KEY), undefined); + assert.equal(cache.readRuntimeHostHealth(LOCAL_KEY), localHealth); + assert.equal(cache.readRuntimeHostHealth(REMOTE_KEY), undefined); + assert.equal(cache.readRuntimeHostPermissionCenter(LOCAL_KEY), localPermissionCenter); + assert.equal(cache.readRuntimeHostPermissionCenter(REMOTE_KEY), undefined); }); it('prunes snapshots when a profile reconnects with a new host id', () => { @@ -231,6 +251,15 @@ describe('Settings snapshot cache', () => { connections: [], defaultSlug: null, }); + cache.commitRuntimeHostHealthRead(LOCAL_KEY, { + checkedAt: 1, + signals: [], + summary: { ok: 0, info: 0, warning: 0, error: 0, unknown: 0 }, + }); + cache.commitRuntimeHostPermissionCenterRead(LOCAL_KEY, { + permissions: { checkedAt: 1, platform: 'darwin', permissions: {} } as PermissionSnapshot, + capabilities: { checkedAt: 1, capabilities: [] }, + }); cache.commitRuntimeHostCatalogRead(catalog([ { @@ -244,6 +273,8 @@ describe('Settings snapshot cache', () => { assert.equal(cache.readRuntimeHostSettings(LOCAL_KEY), undefined); assert.equal(cache.readRuntimeHostConnections(LOCAL_KEY), undefined); + assert.equal(cache.readRuntimeHostHealth(LOCAL_KEY), undefined); + assert.equal(cache.readRuntimeHostPermissionCenter(LOCAL_KEY), undefined); }); it('stores settings and connection reads independently', () => { diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index f52158751f..562c074869 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -56,12 +56,18 @@ import { * Read-only boundary: no test buttons, no repair flows. Test/repair entries * will be wired in PR-HC-2 once typed actions are exposed. */ -export function HealthCenterPage() { +export function HealthCenterPage(props: { + snapshotKey?: string; + initialSnapshot?: HealthSnapshot; + onSnapshot(key: string, snapshot: HealthSnapshot): void; +}) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getHealthCenterCopy(locale); - const [snapshot, setSnapshot] = useState(null); - const [loading, setLoading] = useState(true); + const [snapshot, setSnapshot] = useState( + () => props.initialSnapshot ?? null, + ); + const [loading, setLoading] = useState(snapshot === null); const [error, setError] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [signalFilter, setSignalFilter] = useState(null); @@ -74,6 +80,7 @@ export function HealthCenterPage() { .getSnapshot(host) .then((next) => { if (cancelled) return; + if (props.snapshotKey) props.onSnapshot(props.snapshotKey, next); setSnapshot(next); setLoading(false); }) @@ -85,7 +92,7 @@ export function HealthCenterPage() { return () => { cancelled = true; }; - }, [host, locale, refreshTick]); + }, [host, locale, props.onSnapshot, props.snapshotKey, refreshTick]); useEffect(() => { if (!snapshot) return; @@ -95,13 +102,13 @@ export function HealthCenterPage() { }); }, [snapshot]); - if (loading) { + if (loading && !snapshot) { return ( ); } - if (error || !snapshot) { + if (!snapshot) { return ( + {error ? ( + setRefreshTick((tick) => tick + 1)} label={copy.readAgain} />} + /> + ) : null} > = type PermissionStatusFilter = 'granted' | 'pending' | 'denied' | 'other'; -export function PermissionCenterPage() { +export function PermissionCenterPage(props: { + snapshotKey?: string; + initialSnapshot?: { + readonly permissions: PermissionSnapshot; + readonly capabilities: CapabilitySnapshotCollection; + }; + onSnapshot(key: string, snapshot: { + readonly permissions: PermissionSnapshot; + readonly capabilities: CapabilitySnapshotCollection; + }): void; +}) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); const copy = getPermissionCenterCopy(locale); - const [permissions, setPermissions] = useState(null); - const [capabilities, setCapabilities] = useState(null); - const [loading, setLoading] = useState(true); + const [permissions, setPermissions] = useState( + () => props.initialSnapshot?.permissions ?? null, + ); + const [capabilities, setCapabilities] = useState( + () => props.initialSnapshot?.capabilities ?? null, + ); + const [loading, setLoading] = useState(props.initialSnapshot === undefined); const [error, setError] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [pendingPermAction, setPendingPermAction] = useState(null); @@ -125,6 +139,12 @@ export function PermissionCenterPage() { ]) .then(([perm, caps]) => { if (cancelled) return; + if (props.snapshotKey) { + props.onSnapshot(props.snapshotKey, { + permissions: perm, + capabilities: caps, + }); + } setPermissions(perm); setCapabilities(caps); setLoading(false); @@ -137,7 +157,7 @@ export function PermissionCenterPage() { return () => { cancelled = true; }; - }, [host, locale, refreshTick]); + }, [host, locale, props.onSnapshot, props.snapshotKey, refreshTick]); useEffect(() => { const refreshAfterSystemSettings = () => { @@ -203,13 +223,13 @@ export function PermissionCenterPage() { } } - if (loading) { + if (loading && (!permissions || !capabilities)) { return ( ); } - if (error || !permissions || !capabilities) { + if (!permissions || !capabilities) { return ( + {error ? ( + setRefreshTick((tick) => tick + 1)} label={copy.readAgain} /> + )} + /> + ) : null} {/* The page opened with a SectionHeader whose title was the page title VERBATIM (权限与能力 twice, ~40px apart) and whose subtitle restated the 系统权限 section's own help line below it. Worse, the refresh diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index b06a09041e..5f067bcc8f 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -18,9 +18,12 @@ */ import type { AppSettings, RuntimeHostAppSettings } from '@maka/core/settings'; +import type { ProjectedLlmConnection } from '@maka/core/llm-connections'; import type { - ProjectedLlmConnection, -} from '@maka/core/llm-connections'; + CapabilitySnapshotCollection, + PermissionSnapshot, +} from '@maka/core/capabilities'; +import type { HealthSnapshot } from '@maka/core/health'; import type { DesktopRuntimeHostProfileSnapshot, DesktopRuntimeHostRef, @@ -31,6 +34,11 @@ export interface RuntimeHostConnectionsSnapshot { readonly defaultSlug: string | null; } +export interface PermissionCenterSnapshot { + readonly permissions: PermissionSnapshot; + readonly capabilities: CapabilitySnapshotCollection; +} + export interface SettingsSnapshotCache { readClient(): AppSettings | undefined; commitClientRead(snapshot: AppSettings): void; @@ -46,6 +54,15 @@ export interface SettingsSnapshotCache { key: string, snapshot: RuntimeHostConnectionsSnapshot, ): void; + + readRuntimeHostHealth(key: string): HealthSnapshot | undefined; + commitRuntimeHostHealthRead(key: string, snapshot: HealthSnapshot): void; + + readRuntimeHostPermissionCenter(key: string): PermissionCenterSnapshot | undefined; + commitRuntimeHostPermissionCenterRead( + key: string, + snapshot: PermissionCenterSnapshot, + ): void; } export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string { @@ -53,7 +70,7 @@ export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string { } /** - * Renderer-memory cache for masked read snapshots. It deliberately has no + * Renderer-memory cache for successful Settings reads. It deliberately has no * method that accepts a settings mutation response: update responses may * reveal the just-submitted secret, while subsequent GETs are masked again. */ @@ -62,6 +79,8 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { let runtimeHostCatalog: DesktopRuntimeHostProfileSnapshot | undefined; const runtimeHostSettings = new Map(); const runtimeHostConnections = new Map(); + const runtimeHostHealth = new Map(); + const runtimeHostPermissionCenter = new Map(); return { readClient: () => client, @@ -84,6 +103,12 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { for (const key of runtimeHostConnections.keys()) { if (!currentHostKeys.has(key)) runtimeHostConnections.delete(key); } + for (const key of runtimeHostHealth.keys()) { + if (!currentHostKeys.has(key)) runtimeHostHealth.delete(key); + } + for (const key of runtimeHostPermissionCenter.keys()) { + if (!currentHostKeys.has(key)) runtimeHostPermissionCenter.delete(key); + } }, readRuntimeHostSettings: (key) => runtimeHostSettings.get(key), commitRuntimeHostSettingsRead: (key, snapshot) => { @@ -93,6 +118,14 @@ export function createSettingsSnapshotCache(): SettingsSnapshotCache { commitRuntimeHostConnectionsRead: (key, snapshot) => { runtimeHostConnections.set(key, snapshot); }, + readRuntimeHostHealth: (key) => runtimeHostHealth.get(key), + commitRuntimeHostHealthRead: (key, snapshot) => { + runtimeHostHealth.set(key, snapshot); + }, + readRuntimeHostPermissionCenter: (key) => runtimeHostPermissionCenter.get(key), + commitRuntimeHostPermissionCenterRead: (key, snapshot) => { + runtimeHostPermissionCenter.set(key, snapshot); + }, }; } diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index e7b5c704eb..fccd98c203 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1053,6 +1053,15 @@ function SettingsSurfaceContent( archivedTasks={props.archivedTasks} onTaskImported={props.onTaskImported} onRemoteHostAdded={props.onRemoteHostAdded} + healthSnapshot={selectedRuntimeHostKey + ? snapshotCache.readRuntimeHostHealth(selectedRuntimeHostKey) + : undefined} + permissionCenterSnapshot={selectedRuntimeHostKey + ? snapshotCache.readRuntimeHostPermissionCenter(selectedRuntimeHostKey) + : undefined} + snapshotKey={selectedRuntimeHostKey} + onHealthSnapshot={snapshotCache.commitRuntimeHostHealthRead} + onPermissionCenterSnapshot={snapshotCache.commitRuntimeHostPermissionCenterRead} openProviderCatalog={providerCatalogRequested} initialConnectionSlug={props.initialConnectionSlug} initialCreateProviderType={createProviderRequest} @@ -1114,6 +1123,11 @@ function SettingsPageBody(props: { archivedTasks: ArchivedTasksBridge; onTaskImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; + snapshotKey?: string; + healthSnapshot?: Parameters[0]['initialSnapshot']; + permissionCenterSnapshot?: Parameters[0]['initialSnapshot']; + onHealthSnapshot: Parameters[0]['onSnapshot']; + onPermissionCenterSnapshot: Parameters[0]['onSnapshot']; openProviderCatalog?: boolean; initialConnectionSlug?: string; initialCreateProviderType?: ProviderType; @@ -1242,9 +1256,21 @@ function SettingsPageBody(props: { /> ); case 'permissions': - return ; + return ( + + ); case 'health': - return ; + return ( + + ); case 'memory': // PR-SETTINGS-REVIEW-0 (WAWQAQ msg `886f6406`): the merged // memory-review page was too dense; 记忆 is its own page again. diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 289d46bd36..375f126d42 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -772,6 +772,15 @@ function seedGeneralSnapshotCache(cache: SettingsSnapshotCache): void { }); } +function seedSettingsPageTransitionSnapshotCache(cache: SettingsSnapshotCache): void { + seedGeneralSnapshotCache(cache); + cache.commitRuntimeHostHealthRead(STORY_RUNTIME_HOST_KEY, healthSnapshot); + cache.commitRuntimeHostPermissionCenterRead(STORY_RUNTIME_HOST_KEY, { + permissions: permissionSnapshot, + capabilities: capabilitySnapshot, + }); +} + function seedCopilotGenerationSnapshotCache(cache: SettingsSnapshotCache): void { seedGeneralSnapshotCache(cache); cache.commitRuntimeHostConnectionsRead(STORY_RUNTIME_HOST_KEY, { @@ -1003,6 +1012,35 @@ const makaBridge = { const withSettingsBridge = withScopedMakaBridge(makaBridge); +const SETTINGS_PAGE_TRANSITION_DELAY_MS = 80; +const waitForSettingsPageTransitionRead = () => + new Promise((resolve) => { + globalThis.setTimeout(resolve, SETTINGS_PAGE_TRANSITION_DELAY_MS); + }); +const settingsPageTransitionBridge = { + ...makaBridge, + health: { + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return healthSnapshot; + }, + }, + permissions: { + ...makaBridge.permissions, + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return permissionSnapshot; + }, + }, + capabilities: { + getSnapshot: async () => { + await waitForSettingsPageTransitionRead(); + return capabilitySnapshot; + }, + }, +} satisfies Record; +const withSettingsPageTransitionBridge = withScopedMakaBridge(settingsPageTransitionBridge); + let typographyStoryDefaultSlug: string | null = 'zai-live'; let typographyStorySelectedPetId: string | null = 'storybook.typography-pet'; @@ -3345,11 +3383,41 @@ export const PermissionCenterDiagnosticsExpanded: Story = { // Real path: 设置 → 健康 (also reachable from the topbar health action), with probes // reporting. export const HealthCenter: Story = { - decorators: [withSettingsBridge], - render: () => , + decorators: [withSettingsPageTransitionBridge], + render: () => ( + + ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const errorFilter = await canvas.findByRole('button', { name: /^仅显示错误健康信号/ }); + // Both Host-scoped snapshots are already known when navigation starts. + // A delayed revalidation makes the old full-page loading placeholder + // observable instead of depending on IPC timing or a paint screenshot. + await canvas.findByRole('heading', { name: '支持' }); + let insertedSkeletons = 0; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('.settingsSkeletonStack')) insertedSkeletons += 1; + insertedSkeletons += node.querySelectorAll('.settingsSkeletonStack').length; + } + } + }); + observer.observe(canvasElement, { childList: true, subtree: true }); + try { + await userEvent.click(canvas.getByRole('button', { name: /^权限与能力$/ })); + await canvas.findByRole('button', { name: /^仅显示已授权权限/ }); + await userEvent.click(canvas.getByRole('button', { name: /^健康$/ })); + await canvas.findByRole('button', { name: /^仅显示错误健康信号/ }); + } finally { + observer.disconnect(); + } + expect(insertedSkeletons).toBe(0); + + const errorFilter = canvas.getByRole('button', { name: /^仅显示错误健康信号/ }); expect(errorFilter).toHaveAttribute('aria-pressed', 'false'); await userEvent.click(errorFilter); await waitFor(() => { From 0d09ec1d2739728d8ea287da1188681ec8c20f77 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 14:46:27 +0800 Subject: [PATCH 3/4] fix(desktop): address Settings refresh review findings Cover cached refresh failures and successful retries, show refresh progress, and scope the Daily Review fallback regression to its own page. Remove unused loading copy and reuse snapshot types. Generated-by: Codex --- .../renderer/settings/health-center-page.tsx | 1 + .../settings/permission-center-page.tsx | 14 +- .../renderer/settings/settings-surface.tsx | 6 +- apps/desktop/stories/module-hubs.stories.tsx | 25 ++- .../settings/settings-pages.stories.tsx | 177 ++++++++++++++++-- packages/ui/src/shared-ui-copy.ts | 4 - 6 files changed, 194 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index 562c074869..3d8e13fe7e 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -162,6 +162,7 @@ export function HealthCenterPage(props: { variant="secondary" size="sm" onClick={() => setRefreshTick((tick) => tick + 1)} + isLoading={loading} label={copy.refresh} /> diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index bcab046a62..76d4d2ac3f 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -66,6 +66,7 @@ import { } from './runtime-host-settings-target.js'; import { dotForStatus } from '@maka/ui'; import { SettingsSkeletonStack } from './settings-skeleton'; +import type { PermissionCenterSnapshot } from './settings-snapshot-cache.js'; import { useActionGuard } from './use-action-guard'; import { SettingsStatusSummaryFilter, @@ -102,14 +103,8 @@ type PermissionStatusFilter = 'granted' | 'pending' | 'denied' | 'other'; export function PermissionCenterPage(props: { snapshotKey?: string; - initialSnapshot?: { - readonly permissions: PermissionSnapshot; - readonly capabilities: CapabilitySnapshotCollection; - }; - onSnapshot(key: string, snapshot: { - readonly permissions: PermissionSnapshot; - readonly capabilities: CapabilitySnapshotCollection; - }): void; + initialSnapshot?: PermissionCenterSnapshot; + onSnapshot(key: string, snapshot: PermissionCenterSnapshot): void; }) { const host = useRuntimeHostSettingsTarget(); const locale = useUiLocale(); @@ -120,7 +115,7 @@ export function PermissionCenterPage(props: { const [capabilities, setCapabilities] = useState( () => props.initialSnapshot?.capabilities ?? null, ); - const [loading, setLoading] = useState(props.initialSnapshot === undefined); + const [loading, setLoading] = useState(permissions === null || capabilities === null); const [error, setError] = useState(null); const [refreshTick, setRefreshTick] = useState(0); const [pendingPermAction, setPendingPermAction] = useState(null); @@ -291,6 +286,7 @@ export function PermissionCenterPage(props: { variant="secondary" size="sm" onClick={() => setRefreshTick((tick) => tick + 1)} + isLoading={loading} label={copy.detectAgain} /> diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index fccd98c203..2e3fc8215c 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -64,6 +64,7 @@ import type { DesktopSessionSummary, } from '../../preload/bridge-contract.js'; import type { UiLocalePreference } from '@maka/core/ui-locale'; +import type { HealthSnapshot } from '@maka/core/health'; import { createDefaultSettings, DEFAULT_APP_ICON } from '@maka/core/settings'; import { Banner, Selector, useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { ProvidersPanel } from './providers-panel'; @@ -124,6 +125,7 @@ import { import { runtimeHostSettingsKey, settingsSnapshotCacheFor, + type PermissionCenterSnapshot, type RuntimeHostConnectionsSnapshot, type SettingsSnapshotCache, } from './settings-snapshot-cache.js'; @@ -1124,8 +1126,8 @@ function SettingsPageBody(props: { onTaskImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; snapshotKey?: string; - healthSnapshot?: Parameters[0]['initialSnapshot']; - permissionCenterSnapshot?: Parameters[0]['initialSnapshot']; + healthSnapshot?: HealthSnapshot; + permissionCenterSnapshot?: PermissionCenterSnapshot; onHealthSnapshot: Parameters[0]['onSnapshot']; onPermissionCenterSnapshot: Parameters[0]['onSnapshot']; openProviderCatalog?: boolean; diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 0403868ce8..b23dd700f0 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -919,6 +919,12 @@ export const HostAutomationsScheduledTasks: Story = { ), }; +/** + * Starts on Scheduled Tasks to observe the first switch; `play` leaves Daily + * Review visible for the visual catalog. With autoplay disabled, the initial + * Scheduled Tasks page is intentional. + */ +// Real path: sidebar → 定时任务 → 每日回顾. export const HostAutomationsDailyReview: Story = { render: () => ( { for (const record of records) { for (const node of record.addedNodes) { - if ( - node instanceof Element - && (node.matches('.maka-lazy-fallback') || node.querySelector('.maka-lazy-fallback')) - ) { - lazyFallbacks += 1; + if (!(node instanceof Element)) continue; + const fallbacks = [ + ...(node.matches('.maka-lazy-fallback') ? [node] : []), + ...node.querySelectorAll('.maka-lazy-fallback'), + ]; + for (const fallback of fallbacks) { + // Ignore Scheduled Tasks even if its chunk settles after play + // starts. The target also covers an added fallback removed again + // before this observer callback runs. + if ( + fallback.closest('[data-module="daily-review"]') + || (record.target instanceof Element + && record.target.closest('[data-module="daily-review"]')) + ) lazyFallbacks += 1; } } } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 375f126d42..1f6108455c 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -97,6 +97,8 @@ import { withScopedMakaBridge } from '../maka-bridge'; import { getDailyReviewSettingsCopy } from '../../src/renderer/locales/settings-daily-review-copy'; import { getExternalSessionImportCopy } from '../../src/renderer/locales/external-session-import-copy'; import { getUsageSettingsCopy } from '../../src/renderer/locales/settings-usage-copy'; +import { getHealthCenterCopy } from '../../src/renderer/locales/settings-health-copy'; +import { getPermissionCenterCopy } from '../../src/renderer/locales/permission-center-copy'; /** * Read from the copy table, not typed out again. This selector matched a @@ -1041,6 +1043,56 @@ const settingsPageTransitionBridge = { } satisfies Record; const withSettingsPageTransitionBridge = withScopedMakaBridge(settingsPageTransitionBridge); +// Hold each read until play has observed the cached page and its refresh +// control. This covers an indefinitely pending request without timer races. +function controlledSettingsRead() { + let pending: Array<{ resolve(value: T): void; reject(error: Error): void }> = []; + return { + getSnapshot: () => new Promise((resolve, reject) => pending.push({ resolve, reject })), + resolve(snapshot: T) { + const reads = pending.splice(0); + expect(reads.length).toBeGreaterThan(0); + for (const read of reads) read.resolve(snapshot); + }, + reject() { + const reads = pending.splice(0); + expect(reads.length).toBeGreaterThan(0); + for (const read of reads) read.reject(new Error('Snapshot request timeout')); + }, + reset() { pending = []; }, + }; +} + +const healthRefreshRead = controlledSettingsRead(); +const permissionRefreshRead = controlledSettingsRead(); +const capabilityRefreshRead = controlledSettingsRead(); +const withSettingsRefreshFailureBridge = withScopedMakaBridge({ + ...makaBridge, + health: { getSnapshot: healthRefreshRead.getSnapshot }, + permissions: { ...makaBridge.permissions, getSnapshot: permissionRefreshRead.getSnapshot }, + capabilities: { getSnapshot: capabilityRefreshRead.getSnapshot }, +} satisfies Record); + +function observeSettingsSkeletons(canvasElement: HTMLElement) { + let inserted = 0; + const inspect = (records: MutationRecord[]) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('.settingsSkeletonStack')) inserted += 1; + inserted += node.querySelectorAll('.settingsSkeletonStack').length; + } + } + }; + const observer = new MutationObserver(inspect); + observer.observe(canvasElement, { childList: true, subtree: true }); + return () => { + inspect(observer.takeRecords()); + observer.disconnect(); + expect(inserted).toBe(0); + }; +} + let typographyStoryDefaultSlug: string | null = 'zai-live'; let typographyStorySelectedPetId: string | null = 'storybook.typography-pet'; @@ -3396,26 +3448,15 @@ export const HealthCenter: Story = { // A delayed revalidation makes the old full-page loading placeholder // observable instead of depending on IPC timing or a paint screenshot. await canvas.findByRole('heading', { name: '支持' }); - let insertedSkeletons = 0; - const observer = new MutationObserver((records) => { - for (const record of records) { - for (const node of record.addedNodes) { - if (!(node instanceof Element)) continue; - if (node.matches('.settingsSkeletonStack')) insertedSkeletons += 1; - insertedSkeletons += node.querySelectorAll('.settingsSkeletonStack').length; - } - } - }); - observer.observe(canvasElement, { childList: true, subtree: true }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); try { await userEvent.click(canvas.getByRole('button', { name: /^权限与能力$/ })); await canvas.findByRole('button', { name: /^仅显示已授权权限/ }); await userEvent.click(canvas.getByRole('button', { name: /^健康$/ })); await canvas.findByRole('button', { name: /^仅显示错误健康信号/ }); } finally { - observer.disconnect(); + assertNoSkeletons(); } - expect(insertedSkeletons).toBe(0); const errorFilter = canvas.getByRole('button', { name: /^仅显示错误健康信号/ }); expect(errorFilter).toHaveAttribute('aria-pressed', 'false'); @@ -3434,6 +3475,116 @@ export const HealthCenter: Story = { }); }, }; + +// Real path: 设置 → 健康, revisit a cached snapshot while the Host read fails; +// retry successfully, then leave a subsequent failed refresh visible. +export const HealthCenterRefreshFailed: Story = { + decorators: [withSettingsRefreshFailureBridge], + beforeEach: () => { healthRefreshRead.reset(); }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const copy = getHealthCenterCopy('zh-CN'); + await canvas.findByRole('heading', { name: '支持' }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); + try { + await userEvent.click(canvas.getByRole('button', { name: /^健康$/ })); + const refresh = await canvas.findByRole('button', { name: copy.refresh }); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + healthRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(canvas.getByRole('button', { name: copy.readAgain })); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.queryByText(copy.readFailed)).not.toBeInTheDocument(); + expect(canvas.getByText('Z.AI Live')).toBeVisible(); + healthRefreshRead.resolve({ + ...healthSnapshot, + signals: healthSnapshot.signals.map((signal) => ( + signal.label === 'Z.AI Live' ? { ...signal, label: 'Z.AI Updated' } : signal + )), + }); + await canvas.findByText('Z.AI Updated'); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(refresh); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + healthRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + expect(canvas.getByText('Z.AI Updated')).toBeVisible(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + } finally { + assertNoSkeletons(); + } + }, +}; + +// Real path: 设置 → 权限与能力, revisit cached permissions/capabilities; a +// permission read fails, retry succeeds, then the capability read fails. +export const PermissionCenterRefreshFailed: Story = { + decorators: [withSettingsRefreshFailureBridge], + beforeEach: () => { + permissionRefreshRead.reset(); + capabilityRefreshRead.reset(); + }, + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const copy = getPermissionCenterCopy('zh-CN'); + await canvas.findByRole('heading', { name: '支持' }); + const assertNoSkeletons = observeSettingsSkeletons(canvasElement); + try { + await userEvent.click(canvas.getByRole('button', { name: /^权限与能力$/ })); + const refresh = await canvas.findByRole('button', { name: copy.detectAgain }); + const grantedFilter = (count: number) => canvas.getByRole('button', { + name: copy.summaryFilterAria(copy.granted, count, false), + }); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(grantedFilter(2)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + permissionRefreshRead.reject(); + capabilityRefreshRead.resolve(capabilitySnapshot); + await canvas.findByText(copy.readFailed); + expect(grantedFilter(2)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(canvas.getByRole('button', { name: copy.readAgain })); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + expect(canvas.queryByText(copy.readFailed)).not.toBeInTheDocument(); + expect(grantedFilter(2)).toBeVisible(); + permissionRefreshRead.resolve({ + ...permissionSnapshot, + permissions: { + ...permissionSnapshot.permissions, + screen_recording: { ...permissionSnapshot.permissions.screen_recording, status: 'granted' }, + }, + }); + capabilityRefreshRead.resolve(capabilitySnapshot); + await waitFor(() => expect(grantedFilter(3)).toBeVisible()); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + + await userEvent.click(refresh); + await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); + permissionRefreshRead.resolve(permissionSnapshot); + capabilityRefreshRead.reject(); + await canvas.findByText(copy.readFailed); + // A partially successful read must not replace the last complete pair. + expect(grantedFilter(3)).toBeVisible(); + expect(canvas.getByRole('group', { name: copy.capabilityListAria })).toBeInTheDocument(); + expect(refresh).not.toHaveAttribute('aria-busy', 'true'); + } finally { + assertNoSkeletons(); + } + }, +}; // Real path: 设置 → 关于 (also reachable from 反馈 in the topbar). export const About: Story = { decorators: [withSettingsBridge], diff --git a/packages/ui/src/shared-ui-copy.ts b/packages/ui/src/shared-ui-copy.ts index cc6fc00034..dbedea2ca4 100644 --- a/packages/ui/src/shared-ui-copy.ts +++ b/packages/ui/src/shared-ui-copy.ts @@ -87,7 +87,6 @@ export interface SharedUiCopy { automations: string; loadingAutomations: string; dailyReview: string; - loadingDailyReview: string; dailyReviewDescription: string; dailyReviewDisconnectedTitle: string; dailyReviewDisconnectedBody: string; @@ -183,7 +182,6 @@ const SHARED_UI_COPY = { automations: '定时任务', loadingAutomations: '正在加载定时任务…', dailyReview: '每日回顾', - loadingDailyReview: '正在加载每日回顾…', dailyReviewDescription: '自动汇总本机任务,生成摘要、遗漏提醒与深度分析;可在设置中开启定时执行。', dailyReviewDisconnectedTitle: '等待连接每日回顾数据', dailyReviewDisconnectedBody: '桌面端数据桥当前未连接。', @@ -262,7 +260,6 @@ const SHARED_UI_COPY = { automations: '定時任務', loadingAutomations: '正在載入定時任務…', dailyReview: '每日回顧', - loadingDailyReview: '正在載入每日回顧…', dailyReviewDescription: '自動彙總本機任務,生成摘要、遺漏提醒與深度分析;可在設定中開啟定時執行。', dailyReviewDisconnectedTitle: '等待連線每日回顧資料', dailyReviewDisconnectedBody: '桌面端資料橋目前未連線。', @@ -341,7 +338,6 @@ const SHARED_UI_COPY = { automations: 'Scheduled tasks', loadingAutomations: 'Loading scheduled tasks…', dailyReview: 'Daily review', - loadingDailyReview: 'Loading daily review…', dailyReviewDescription: 'Summarize local tasks into highlights, missed items, and deeper analysis. Scheduled runs can be enabled in Settings.', dailyReviewDisconnectedTitle: 'Waiting for daily review data', dailyReviewDisconnectedBody: 'The desktop data bridge is not connected.', From 1913981cd72ac660480aa2ef19ee8f98eca5ecbf Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 21:41:55 +0800 Subject: [PATCH 4/4] fix(desktop): retain Daily Review content across visits Cache the last successful today summary in the Module Hub controller and cover delayed reads across remounts. Derive Settings cache keys from the active Host, make Health errors accessible, and remove PR-only screenshot binaries. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 4 +- ...module-hub-daily-review-controller.test.ts | 125 ++++++++++++++++++ .../runtime-host-settings-target.tsx | 4 + .../renderer/features/module-hub/README.md | 3 + .../controller/use-daily-review-controller.ts | 44 +++++- .../renderer/settings/health-center-page.tsx | 9 +- .../settings/permission-center-page.tsx | 14 +- .../settings/settings-snapshot-cache.ts | 12 +- .../renderer/settings/settings-surface.tsx | 4 - apps/desktop/stories/module-hubs.stories.tsx | 85 ++++++++++-- .../settings/settings-pages.stories.tsx | 7 + .../pr/daily-review-transition/after.png | Bin 46790 -> 0 bytes .../pr/daily-review-transition/before.png | Bin 9426 -> 0 bytes packages/ui/src/daily-review-panel.tsx | 13 +- packages/ui/src/daily-review-view-state.ts | 7 +- packages/ui/src/module-panel-types.ts | 4 +- 16 files changed, 290 insertions(+), 45 deletions(-) delete mode 100644 docs/images/pr/daily-review-transition/after.png delete mode 100644 docs/images/pr/daily-review-transition/before.png diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 030b29268f..565f09cba4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3447,7 +3447,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "../application/contracts/settings-presentation/runtime-host-settings-target.js": 1 + } }, "src/renderer/settings/settings-status-badge.ts": { "bridgePaths": {}, diff --git a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts index 218525a58e..2d57bf065c 100644 --- a/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-daily-review-controller.test.ts @@ -85,6 +85,131 @@ test('stable page bridge retries rather than exposing a stale default-Host read' assert.deepEqual(reads, ['host-a', 'host-b']); }); +test('page bridge keeps the last successful today snapshot through a failed refresh', async () => { + let fail = false; + let reads = 0; + const services = createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => { + reads += 1; + if (fail) throw new Error('offline'); + return { ok: true, data: summary(reads) }; + }), + }); + const bridge = createDailyReviewBridge(services, 'en'); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + const first = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), first); + assert.equal(bridge.readCachedDay?.(-1, 1), undefined); + assert.equal(bridge.readCachedDay?.(0, 7), undefined); + await bridge.fetchDay(-1, 1); + assert.equal(bridge.readCachedDay?.(0, 1), first); + fail = true; + await assert.rejects(bridge.fetchDay(0, 1), /offline/); + assert.equal(bridge.readCachedDay?.(0, 1), first); + fail = false; + const fresh = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + assert.equal(reads, 4); +}); + +test('cancelled and superseded reads cannot overwrite a newer cached summary', async () => { + const reads = Array.from({ length: 3 }, () => deferred<{ ok: true; data: DailyReviewSummary }>()); + let index = 0; + const bridge = createDailyReviewBridge(createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => reads[index++]!.promise), + }), 'en'); + const oldRead = bridge.fetchDay(0, 1); + const newRead = bridge.fetchDay(0, 1); + reads[1]!.resolve({ ok: true, data: summary(8) }); + const fresh = await newRead; + reads[0]!.resolve({ ok: true, data: summary(1) }); + await oldRead; + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + + const request = new AbortController(); + const cancelled = bridge.fetchDay(0, 1, request.signal); + await Promise.resolve(); + request.abort(); + reads[2]!.resolve({ ok: true, data: summary(99) }); + await assert.rejects(cancelled, { name: 'AbortError' }); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); +}); + +test('a new local day cannot reuse yesterday as today or cache an overnight read', async (t) => { + let now = new Date(2026, 8, 18, 23, 59).getTime(); + t.mock.method(Date, 'now', () => now); + const pending = deferred<{ ok: true; data: DailyReviewSummary }>(); + let reads = 0; + const bridge = createDailyReviewBridge(createFakeModuleHubServices({ + dailyReview: dailyReviewService(async () => { + reads += 1; + return reads === 1 ? { ok: true, data: summary() } : pending.promise; + }), + }), 'en'); + await bridge.fetchDay(0, 1); + assert.ok(bridge.readCachedDay?.(0, 1)); + const overnight = bridge.fetchDay(0, 1); + now = new Date(2026, 8, 19, 0, 1).getTime(); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + pending.resolve({ ok: true, data: summary(99) }); + await overnight; + assert.equal(bridge.readCachedDay?.(0, 1), undefined); +}); + +test('the controller invalidates cached and pending reads on Host changes and disposes its subscription', async () => { + const { root } = installReactRenderer(); + const hostA = { profileId: 'a', hostId: 'a' }; + const hostB = { profileId: 'b', hostId: 'b' }; + let currentHost = hostA; + let listener: Parameters[0] | undefined; + const pending = deferred<{ ok: true; data: DailyReviewSummary }>(); + let reads = 0; + const services = createFakeModuleHubServices({ + runtimeHosts: { + getDefault: async () => currentHost, + subscribeChanges: (handler) => { + listener = handler; + return () => { listener = undefined; }; + }, + }, + dailyReview: dailyReviewService(async () => { + reads += 1; + return reads === 2 ? pending.promise : { ok: true, data: summary(reads) }; + }), + }); + let controller: DailyReviewController | undefined; + function Probe() { + controller = useDailyReviewController({ + services, uiLocale: 'en', + toastApi: { success: () => undefined, error: () => undefined }, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + isDailyReviewSurfaceActive: () => false, + }); + return null; + } + await act(async () => root.render(createElement(Probe))); + const bridge = controller!.bridge; + await bridge.fetchDay(0, 1); + await act(async () => root.render(createElement(Probe))); + assert.equal(controller!.bridge, bridge); + assert.ok(bridge.readCachedDay?.(0, 1)); + const loading = bridge.fetchDay(0, 1); + await Promise.resolve(); + currentHost = hostB; + assert.ok(listener); + listener({ ...hostB, readiness: 'ready', isDefault: true }); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + pending.resolve({ ok: true, data: summary(99) }); + await loading; + assert.equal(bridge.readCachedDay?.(0, 1), undefined); + const fresh = await bridge.fetchDay(0, 1); + assert.equal(bridge.readCachedDay?.(0, 1), fresh); + await act(async () => root.unmount()); + assert.equal(listener, undefined); + assert.equal(bridge.readCachedDay?.(0, 1), undefined); +}); + test('today paste captures its composer claim before reading and drops a late result', async () => { const { root } = installReactRenderer(); const pendingDay = deferred<{ ok: true; data: DailyReviewSummary }>(); diff --git a/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx b/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx index 28ca53082b..70aa3d2e40 100644 --- a/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx +++ b/apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx @@ -28,6 +28,10 @@ import { import { useToast } from "@maka/ui"; export interface SettingsHostTarget { readonly profileId: string; readonly hostId: string; } +export function runtimeHostSettingsKey(host: SettingsHostTarget): string { + return `${host.profileId}:${host.hostId}`; +} + interface RuntimeHostSettingsTargetValue { readonly host: SettingsHostTarget; /** diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index 98e5e15896..bb8689d222 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -81,6 +81,9 @@ reach it only through `testing.ts`. - The Daily Review page bridge is stable for one services/locale pair. Page feedback is live-surface fenced, while Command Palette commands remain usable off-page. +- That bridge retains the last successful today snapshot across leaf mounts; + every mount still revalidates. Host lifecycle events and local-day rollover + invalidate it. Cancelled, superseded, and pre-invalidation reads cannot commit. - Daily Review paste captures the active Composer before its first await and validates the Session, navigation owner, and Composer handle before append and feedback. diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts index 64b68812c9..95e4686868 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-daily-review-controller.ts @@ -17,7 +17,8 @@ * under the License. */ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { localDayBoundsAt } from '@maka/core/daily-review'; import type { DailyReviewArchive, DailyReviewArchiveSummary, @@ -67,7 +68,8 @@ export interface ActiveComposerClaim { /** Structural equivalent of the UI bridge; kept here so @maka/ui stays leaf-only. */ export interface DailyReviewBridge { - fetchDay(offsetDays: number, daySpan?: number): Promise; + readCachedDay?(offsetDays: number, daySpan?: number): DailyReviewSummary | undefined; + fetchDay(offsetDays: number, daySpan?: number, signal?: AbortSignal): Promise; runOnce?(input: { range: DailyReviewRange; offsetDays?: number; @@ -147,11 +149,30 @@ async function readCurrentDefaultHost( export function createDailyReviewBridge( services: ModuleHubServices, locale: UiLocale, -): DailyReviewBridge { +): DailyReviewBridge & { invalidateCache(): void } { const copy = getShellRemainingCopy(locale).dailyReview; + // Every leaf mount starts on today. Keep only that snapshot, rather than + // retaining an unbounded history of date/range selections in the renderer. + let cachedToday: { dayStart: number; summary: DailyReviewSummary } | undefined; + let generation = 0; + let latestTodayRead = 0; return { - async fetchDay(offsetDays: number, daySpan?: number) { - return readCurrentDefaultHost(services, async (host) => { + invalidateCache() { + cachedToday = undefined; + generation += 1; + }, + readCachedDay(offsetDays, daySpan = 1) { + if (offsetDays !== 0 || daySpan !== 1) return undefined; + if (cachedToday?.dayStart !== localDayBoundsAt(Date.now(), 0).fromMs) return undefined; + return cachedToday.summary; + }, + async fetchDay(offsetDays: number, daySpan = 1, signal?: AbortSignal) { + const cachesToday = offsetDays === 0 && daySpan === 1; + const read = cachesToday ? ++latestTodayRead : 0; + const readGeneration = generation; + const dayStart = localDayBoundsAt(Date.now(), 0).fromMs; + const summary = await readCurrentDefaultHost(services, async (host) => { + signal?.throwIfAborted(); const result = await services.dailyReview.day( offsetDays, daySpan, @@ -160,6 +181,12 @@ export function createDailyReviewBridge( if (!result.ok) throw new Error(result.error.message); return result.data; }); + signal?.throwIfAborted(); + if ( + cachesToday && read === latestTodayRead && readGeneration === generation + && dayStart === localDayBoundsAt(Date.now(), 0).fromMs + ) cachedToday = { dayStart, summary }; + return summary; }, runOnce(input) { return services.dailyReview.runOnce(input); @@ -186,6 +213,13 @@ export function useDailyReviewController( () => createDailyReviewBridge(input.services, input.uiLocale), [input.services, input.uiLocale], ); + useEffect(() => { + const unsubscribe = input.services.runtimeHosts.subscribeChanges(() => bridge.invalidateCache()); + return () => { + unsubscribe(); + bridge.invalidateCache(); + }; + }, [bridge, input.services.runtimeHosts]); return useMemo(() => { const copy = getShellCopy(input.uiLocale).commandActions; diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index 3d8e13fe7e..53f2784536 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -35,7 +35,7 @@ import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsPage, SettingsRow, SettingsSection } from './settings-section'; import { SettingsSkeletonStack } from './settings-skeleton'; import { dotForStatus } from '@maka/ui'; -import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; +import { runtimeHostSettingsKey, useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; import { SettingsStatusSummaryFilter, type SettingsStatusSummaryOption, @@ -57,7 +57,6 @@ import { * will be wired in PR-HC-2 once typed actions are exposed. */ export function HealthCenterPage(props: { - snapshotKey?: string; initialSnapshot?: HealthSnapshot; onSnapshot(key: string, snapshot: HealthSnapshot): void; }) { @@ -80,7 +79,7 @@ export function HealthCenterPage(props: { .getSnapshot(host) .then((next) => { if (cancelled) return; - if (props.snapshotKey) props.onSnapshot(props.snapshotKey, next); + props.onSnapshot(runtimeHostSettingsKey(host), next); setSnapshot(next); setLoading(false); }) @@ -92,7 +91,7 @@ export function HealthCenterPage(props: { return () => { cancelled = true; }; - }, [host, locale, props.onSnapshot, props.snapshotKey, refreshTick]); + }, [host, locale, props.onSnapshot, refreshTick]); useEffect(() => { if (!snapshot) return; @@ -113,6 +112,7 @@ export function HealthCenterPage(props: { setRefreshTick((tick) => tick + 1)} label={copy.readAgain} />} /> @@ -140,6 +140,7 @@ export function HealthCenterPage(props: { {error ? ( setRefreshTick((tick) => tick + 1)} label={copy.readAgain} />} diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index 76d4d2ac3f..e851a79b71 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -61,6 +61,7 @@ import { getPermissionCenterCopy, type PermissionCenterCopy } from '../locales/p import { botStatusReasonCopy } from '../locales/settings-bot-copy'; import { settingsActionErrorMessage } from './settings-error-copy'; import { + runtimeHostSettingsKey, useRuntimeHostSettingsErrorReporter, useRuntimeHostSettingsTarget, } from './runtime-host-settings-target.js'; @@ -102,7 +103,6 @@ const OS_PERMISSION_ICONS: Record> = type PermissionStatusFilter = 'granted' | 'pending' | 'denied' | 'other'; export function PermissionCenterPage(props: { - snapshotKey?: string; initialSnapshot?: PermissionCenterSnapshot; onSnapshot(key: string, snapshot: PermissionCenterSnapshot): void; }) { @@ -134,12 +134,10 @@ export function PermissionCenterPage(props: { ]) .then(([perm, caps]) => { if (cancelled) return; - if (props.snapshotKey) { - props.onSnapshot(props.snapshotKey, { - permissions: perm, - capabilities: caps, - }); - } + props.onSnapshot(runtimeHostSettingsKey(host), { + permissions: perm, + capabilities: caps, + }); setPermissions(perm); setCapabilities(caps); setLoading(false); @@ -152,7 +150,7 @@ export function PermissionCenterPage(props: { return () => { cancelled = true; }; - }, [host, locale, props.onSnapshot, props.snapshotKey, refreshTick]); + }, [host, locale, props.onSnapshot, refreshTick]); useEffect(() => { const refreshAfterSystemSettings = () => { diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index 5f067bcc8f..2ad790af29 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -24,10 +24,10 @@ import type { PermissionSnapshot, } from '@maka/core/capabilities'; import type { HealthSnapshot } from '@maka/core/health'; -import type { - DesktopRuntimeHostProfileSnapshot, - DesktopRuntimeHostRef, -} from '../../preload/bridge-contract.js'; +import type { DesktopRuntimeHostProfileSnapshot } from '../../preload/bridge-contract.js'; +import { runtimeHostSettingsKey } from '../application/contracts/settings-presentation/runtime-host-settings-target.js'; + +export { runtimeHostSettingsKey }; export interface RuntimeHostConnectionsSnapshot { readonly connections: ProjectedLlmConnection[]; @@ -65,10 +65,6 @@ export interface SettingsSnapshotCache { ): void; } -export function runtimeHostSettingsKey(host: DesktopRuntimeHostRef): string { - return `${host.profileId}:${host.hostId}`; -} - /** * Renderer-memory cache for successful Settings reads. It deliberately has no * method that accepts a settings mutation response: update responses may diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 2e3fc8215c..82df6d9e90 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1061,7 +1061,6 @@ function SettingsSurfaceContent( permissionCenterSnapshot={selectedRuntimeHostKey ? snapshotCache.readRuntimeHostPermissionCenter(selectedRuntimeHostKey) : undefined} - snapshotKey={selectedRuntimeHostKey} onHealthSnapshot={snapshotCache.commitRuntimeHostHealthRead} onPermissionCenterSnapshot={snapshotCache.commitRuntimeHostPermissionCenterRead} openProviderCatalog={providerCatalogRequested} @@ -1125,7 +1124,6 @@ function SettingsPageBody(props: { archivedTasks: ArchivedTasksBridge; onTaskImported(session: DesktopSessionSummary): void; onRemoteHostAdded(profileId: string): void; - snapshotKey?: string; healthSnapshot?: HealthSnapshot; permissionCenterSnapshot?: PermissionCenterSnapshot; onHealthSnapshot: Parameters[0]['onSnapshot']; @@ -1260,7 +1258,6 @@ function SettingsPageBody(props: { case 'permissions': return ( @@ -1268,7 +1265,6 @@ function SettingsPageBody(props: { case 'health': return ( diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index b23dd700f0..7bbb590b88 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -18,7 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect } from 'storybook/test'; +import { expect, waitFor } from 'storybook/test'; import type { DailyReviewArchive, DailyReviewSummary } from '@maka/core/daily-review'; import type { ScheduledTask, ScheduledTaskRun } from '@maka/core/scheduled-task'; import type { McpConfigFile, McpServerStatus } from '@maka/core/mcp'; @@ -46,6 +46,7 @@ import { import { createFakeModuleHubHostModel, createFakeModuleHubServices, + type ModuleHubServices, } from '../src/renderer/features/module-hub/testing'; import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { McpPage } from '../src/renderer/mcp-page'; @@ -828,7 +829,13 @@ function ModuleHubHostSurface(props: { ); } -function ProductionModuleHubHostSurface() { +function ProductionModuleHubHostSurface(props: { + initialSelection?: ComponentProps['selection']; + dailyReviewDay?: ModuleHubServices['dailyReview']['day']; +}) { + const [selection, setSelection] = useState( + props.initialSelection ?? { section: 'extensions', module: 'skills' }, + ); const [commandPort] = useState(createModuleHubCommandPort); const [services] = useState(() => { const defaults = createFakeModuleHubServices(); @@ -838,14 +845,21 @@ function ProductionModuleHubHostSurface() { list: async () => INSTALLED_SKILLS, listBundledCatalog: async () => BUNDLED_SKILLS, }, + scheduledTasks: { ...defaults.scheduledTasks, list: async () => CONFIGURED_TASKS }, + dailyReview: { + ...defaults.dailyReview, + day: props.dailyReviewDay ?? defaults.dailyReview.day, + }, }); }); return ( - + = []; +const delayedDailyReviewDay: ModuleHubServices['dailyReview']['day'] = () => + new Promise((resolve, reject) => pendingDailyReviewReads.push({ resolve, reject })); + +async function finishDailyReviewRead(fail = false) { + await waitFor(() => expect(pendingDailyReviewReads.length).toBeGreaterThan(0)); + for (const read of pendingDailyReviewReads.splice(0)) { + if (fail) read.reject(new Error('Snapshot request timeout')); + else read.resolve({ ok: true, data: DAILY_REVIEW_SUMMARY }); + } +} + /** * Starts on Scheduled Tasks to observe the first switch; `play` leaves Daily * Review visible for the visual catalog. With autoplay disabled, the initial @@ -926,9 +955,11 @@ export const HostAutomationsScheduledTasks: Story = { */ // Real path: sidebar → 定时任务 → 每日回顾. export const HostAutomationsDailyReview: Story = { + beforeEach: () => { pendingDailyReviewReads = []; }, render: () => ( - ), play: async ({ canvasElement }) => { @@ -937,11 +968,14 @@ export const HostAutomationsDailyReview: Story = { (candidate) => candidate.textContent?.includes('每日回顾') === true, ); let lazyFallbacks = 0; + let skeletons = 0; let dailyReviewEntryAnimations = 0; const mutationObserver = new MutationObserver((records) => { for (const record of records) { for (const node of record.addedNodes) { if (!(node instanceof Element)) continue; + if (node.matches('[data-daily-review-loading]')) skeletons += 1; + skeletons += node.querySelectorAll('[data-daily-review-loading]').length; const fallbacks = [ ...(node.matches('.maka-lazy-fallback') ? [node] : []), ...node.querySelectorAll('.maka-lazy-fallback'), @@ -971,14 +1005,49 @@ export const HostAutomationsDailyReview: Story = { canvasElement.addEventListener('animationstart', onAnimationStart, true); try { dailyReview.click(); + const loading = await waitForStorySelector(canvasElement, '[data-daily-review-loading]'); + // A cold read has one stable loading state until the real service seam + // resolves. It must survive an actual paint, not a microtask-only mock. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(loading).toBeVisible(); + expect(canvasElement.querySelector('.maka-daily-review-content')).toBeNull(); + expect(skeletons).toBe(1); + await finishDailyReviewRead(); const content = await waitForStorySelector( canvasElement, '.maka-daily-review-content', ); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(canvasElement.querySelector('[data-daily-review-loading]')).toBeNull(); + expect(getComputedStyle(content).animationName).toBe('none'); + + const scheduledTasks = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.includes('定时任务') === true); + scheduledTasks.click(); + await waitForStorySelector(canvasElement, '[data-module="scheduled-tasks"]'); + const revisit = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.includes('每日回顾') === true); + revisit.click(); + await waitForStorySelector(canvasElement, '[data-module="daily-review"]'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + expect(skeletons).toBe(1); + const cached = await waitForStorySelector(canvasElement, '.maka-daily-review-content'); + expect(cached).toBeVisible(); + expect(cached).toHaveAttribute('data-refreshing', 'true'); + expect(canvasElement.querySelector('[data-daily-review-loading]')).toBeNull(); + expect(skeletons).toBe(1); + await finishDailyReviewRead(true); + await waitFor(() => expect(cached).toHaveAttribute('aria-busy', 'false')); + expect(cached).toBeVisible(); + const retry = await waitForStoryButton(canvasElement, + (candidate) => candidate.textContent?.trim() === '重试'); + retry.click(); + await finishDailyReviewRead(); + await waitFor(() => expect(cached).toHaveAttribute('aria-busy', 'false')); + expect(skeletons).toBe(1); expect(lazyFallbacks).toBe(0); expect(dailyReviewEntryAnimations).toBe(0); - expect(getComputedStyle(content).animationName).toBe('none'); + expect(getComputedStyle(cached).animationName).toBe('none'); } finally { mutationObserver.disconnect(); canvasElement.removeEventListener('animationstart', onAnimationStart, true); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 1f6108455c..83ac2e16ff 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -3432,6 +3432,11 @@ export const PermissionCenterDiagnosticsExpanded: Story = { } }, }; +/** + * Starts on About so play can observe navigation into the cached Permissions + * and Health pages. Play leaves Health visible for the visual catalog; + * disabling autoplay intentionally shows the initial About page. + */ // Real path: 设置 → 健康 (also reachable from the topbar health action), with probes // reporting. export const HealthCenter: Story = { @@ -3496,6 +3501,7 @@ export const HealthCenterRefreshFailed: Story = { expect(canvas.getByText('Z.AI Live')).toBeVisible(); healthRefreshRead.reject(); await canvas.findByText(copy.readFailed); + expect(canvas.getByRole('alert')).toHaveTextContent(copy.readFailed); expect(canvas.getByText('Z.AI Live')).toBeVisible(); expect(refresh).not.toHaveAttribute('aria-busy', 'true'); @@ -3516,6 +3522,7 @@ export const HealthCenterRefreshFailed: Story = { await waitFor(() => expect(refresh).toHaveAttribute('aria-busy', 'true')); healthRefreshRead.reject(); await canvas.findByText(copy.readFailed); + expect(canvas.getByRole('alert')).toHaveTextContent(copy.readFailed); expect(canvas.getByText('Z.AI Updated')).toBeVisible(); expect(refresh).not.toHaveAttribute('aria-busy', 'true'); } finally { diff --git a/docs/images/pr/daily-review-transition/after.png b/docs/images/pr/daily-review-transition/after.png deleted file mode 100644 index d497e95380b826cead16c31b52caf4fdd33cfd3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46790 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%0Ac0(8LN?M2+J*E)3HlGv)&lF3o(tS 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 createDailyReviewActivityState( + scope, + props.bridge.readCachedDay?.(scope.offsetDays, scope.range), + ), ); const [reloadToken, setReloadToken] = useState(0); const [archiveState, setArchiveState] = useState({ status: 'loading' }); @@ -124,9 +127,10 @@ export function DailyReviewPanel(props: { useEffect(() => { let cancelled = false; + const request = new AbortController(); const requestedScope = { range, offsetDays }; dispatchActivity({ type: 'selected', scope: requestedScope }); - bridgeRef.current.fetchDay(offsetDays, range).then((next) => { + bridgeRef.current.fetchDay(offsetDays, range, request.signal).then((next) => { if (cancelled) return; dispatchActivity({ type: 'resolved', scope: requestedScope, summary: next }); }).catch((nextError: unknown) => { @@ -139,6 +143,7 @@ export function DailyReviewPanel(props: { }); return () => { cancelled = true; + request.abort(); }; }, [locale, offsetDays, range, reloadToken]); @@ -335,7 +340,7 @@ export function DailyReviewPanel(props: { ) : null} {!displayedSummary && loading ? ( - + diff --git a/packages/ui/src/daily-review-view-state.ts b/packages/ui/src/daily-review-view-state.ts index 4ce41ecbd7..83359ffde9 100644 --- a/packages/ui/src/daily-review-view-state.ts +++ b/packages/ui/src/daily-review-view-state.ts @@ -43,10 +43,13 @@ export type DailyReviewActivityAction = | { readonly type: 'resolved'; readonly scope: DailyReviewScope; readonly summary: DailyReviewSummary } | { readonly type: 'rejected'; readonly scope: DailyReviewScope; readonly error: string }; -export function createDailyReviewActivityState(scope: DailyReviewScope): DailyReviewActivityState { +export function createDailyReviewActivityState( + scope: DailyReviewScope, + summary?: DailyReviewSummary, +): DailyReviewActivityState { return { selection: scope, - resolvedView: null, + resolvedView: summary ? { scope, scopeKey: scopeKey(scope), summary } : null, pendingScopeKey: scopeKey(scope), error: null, }; diff --git a/packages/ui/src/module-panel-types.ts b/packages/ui/src/module-panel-types.ts index 5fed6a89e3..c498b14648 100644 --- a/packages/ui/src/module-panel-types.ts +++ b/packages/ui/src/module-panel-types.ts @@ -166,7 +166,9 @@ export type ScheduledTaskDeliveryMethod = ScheduledTaskDelivery['channel'] | 'ag * (e.g. a desktop notification renderer). */ export interface DailyReviewBridge { - fetchDay(offsetDays: number, daySpan?: number): Promise; + /** A controller-owned last-good snapshot; reads still revalidate on mount. */ + readCachedDay?(offsetDays: number, daySpan?: number): DailyReviewSummary | undefined; + fetchDay(offsetDays: number, daySpan?: number, signal?: AbortSignal): Promise; /** * PR-DAILY-REVIEW-FULL-0 — optional pipeline methods. Renderer checks * for presence before exposing the matching UI. When undefined, the