From f992f5cc6eca4a132bf4b58e04146c8404d6f2c6 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:56:41 +0000 Subject: [PATCH 1/3] feat(web): add opt-in thread notifications and sounds --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/assets/notification-completion.mp3 | Bin 0 -> 11947 bytes apps/web/src/assets/notification-input.mp3 | Bin 0 -> 11633 bytes .../ThreadNotificationCoordinator.tsx | 110 ++++++++++++++++++ .../settings/NotificationSettings.tsx | 83 +++++++++++++ .../components/settings/SettingsPanels.tsx | 7 ++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/threadNotifications.ts | 55 +++++++++ packages/contracts/src/settings.test.ts | 26 +++++ packages/contracts/src/settings.ts | 12 ++ third-party-licenses.config.json | 28 +++++ 12 files changed, 330 insertions(+) create mode 100644 apps/web/src/assets/notification-completion.mp3 create mode 100644 apps/web/src/assets/notification-input.mp3 create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.tsx create mode 100644 apps/web/src/components/settings/NotificationSettings.tsx create mode 100644 apps/web/src/threadNotifications.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea2a80010124..f71312be2502 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { ...DEFAULT_CLIENT_SETTINGS, + notificationMode: "notifications-and-sound", appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, diff --git a/apps/web/src/assets/notification-completion.mp3 b/apps/web/src/assets/notification-completion.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1955f5eb05f16bf90d2290dc14f46eaa210f4958 GIT binary patch literal 11947 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/assets/notification-input.mp3 b/apps/web/src/assets/notification-input.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..07e10da3bd574a1d8f2f913644bfae69c6ba5c55 GIT binary patch literal 11633 zcmeHtXHZj7yY5ct5FkK+P?P`x0U;!zBPI0Sdo}cqGz%6?=wRs5i%65IG!YRMkWP>e z(i9L8RIt$mB?rHm^Uaxi=gj?P?%eOko%77zv)6v#HTzj>&9mNFYweeEobCzyA7Jkn z>~mU$_4Gai08*g zW>!z}{{8z8r`X)w{PgM5ms5QG`t|!Mc6WF8PjPs7czlXKfBsM?lv6GxV+|7>859PM z26y~zAxaqlWV?_w7A64D^|u|?OZ7^B9sgg0a!kubS+cdJOj-I2NEkILHC9kv;gwO-&V9~b%WZ=X)KA(-Gn51o z<4nd z#&Hs)yrMy$1Fz>wWg=@KiQID(r+2rg*nc@scz=?m>`*gwE~-<=HwguGZL8eUFQZE$zn8Y>kP4FN?d5#C}p zAzS8hrCq*ycL+GFqBqqdfL}eLNxkWp-a~IIM9kxNH-|u_ZWoA6#xPxbhtxIJcwc_p z{I+NUmehl|NSQr&I|$Dm-jtH#z5NqGR48;WH5Y?1(twFL925YkF!dSbiW5ygK~Po? znxfJv@H!-hq0y7$gR7y8zP=CTc*T>8z@QyCv~#-@*bi(^AM*v^O2XVO2Ly%%h|swg zT|gEpqMeFfTae1SkxLjb#>lY1owT5_N>4AQ z)^c3-V4HeUcEC|pDo)NTB=Tm>#?$k5=6lU{O zBDa7b@8$0IION@l!=;O-=95=W90})dmL7=jMI+NGC*S{krTn#1C|`S(DcbHB`ry>_ zsQB_G#;l_`%E`eo<*d$`AIG}xV9Z${&AfI1iQoJ9{T=?BVt?yb-Ck7A2N$pG zqZe1}PTC5K(oagwdBNET2>{TP>{!qR62Th)z_vjP74T*X5vZbmb1`0Uo=$g!SdUx!#jNf3F${pDXz4OAXAS$+)*r}nxIXB8n zWkrBD1tx}91PS3002t2>SmL!nllWpPUN;uneUB+oL-(pUdHfc567LqRg7+r6<8g#& zyf_erX9G}@fNwQmae1Mh4X8p@V5NAJCqE3lTXtP^I>>2Q_LOC%;n#goBMNaesI?7} zEI%2xnsQV>z7?=L0|3&0hacNmn$Ctk1B#&{Kw(s?xHPPUOAA+HCwi8tTzGO0%R{Bb zwX%TDLbdR+6=A7CS<<03m~Y4!nu#q8j&lm0u7bG^dBtjhK_l5;F_u0@{{#uMBI1=g zr>Fc&MgCi5FB2Cb^VVnH2E^0G_b9ge(0ELGHwSy_Yl(|m znxBFD2#Z?Ba$ztTnk_ApRJxf4DW1c&7kII5XME?;MI71T3_TU1)znLTTLc&7K~AVnhDCNN$dg7ng__>UCQs@;1rK5Vlk^N!g0-C>#M9VzzD+_9@$stbNj zzF~0jJkQ+l-gA%JOtl%AkEGgcg_^v4kD`tM2u&+@2pz+Y5p5?u$XJ~oa(FeB2qsh0 z8*8YHDrxCyYO#Y`I5cGgq=kfJTQ>=|DzeXzs3lj#`%xvBNif$6hJH9&z1~#!NHLx7 zWeBYb)NOqeC^dfOd+;a5#!NHLV(3>8N)N5||m_&Tt zsSL%5(;hz(?#pkJ&TE11j?&U8$aa%!2u&D(u;T9urIvtpemFeK?*@L*h_2-3PZPqGTf3ueQ zRTbiu7E`oNR+Xy%spM@|As`r5npY8&II3aM-f*y=+1bYXRHW>QRJK#iZSg{GpCChD zsgFiAgpv%9xlDUD)Fo);Pb^9`JMUSk;T$qU*<+GMdm!KZyM$_2prX+gOQPNM(J6DxODP1WCwiw7WH4-HI1#E$4JdsTtpjH}C%rvVL+Udh0B!-5 zA~PLJX_~ns?WG3TR3*iQV6UBQ3~iV!la4O;8IK5ce3TfD)LuwX*K)QVu9|^>I*1~= zmfEq6P|x`&%?yz~eLvZvCj9ePTPuAT`vSobd$Z<)kbpbh9dV`A)rE=^rL_Q{YQRpN zB{3${3>y-ReZ`j77qi>9WZrWw3O=j^zt(R@wb{L5yRv@bHT|lZC2!*(TmeHlF7@P1 zoN5>7vwFFTh%Qgm4F8Q}HrHcA-L8l>%EA->WIK?&ucGfGdfb1W222eNyc6u+6XvP0 z#fs-vA4FOoQ+|1kz5CfYa+{JSN6B&eW7~@R))Bm8N7=tUahtMjMmbjBVgRW0Qb&#qagVbjHE`_8!d7{`X?qyWvlf57v3Nz;P6yH37(m#f>)ucb?eNR_oN4Xa$_RW zyG=5E3o>SNb;D43UJer_MR*B|B4A)98ZZKL`Gv)`FdsW(7O^s-y#LH>i_DhR&B(5Eo%3{Hck5-g)SIi6gl7T`6#r~4r>h;5k6*v_4h%lD z4@j&Ee;8J%`y?Cy#M_6pv#1GxW)_Sod~JX9ac$`7O4Zf4v=X}2h!RIR)YGdbF0F&A1%~M zYP2!zx{@LrS2|k`R8W@#Vsv4EGzk=9ZcKwOfUsvYlbfX_$rr&Mtqw-hGQvQ?FD7Y`+U4dD7|`H~l(>SgvprBpFA z)zoxyP0nq~vaGD#qi2FRm@Fm<{+&nDgJ9|-K(wyV9=Xi9rmLZf*EqlY4BJ1^&#vwNf#R3`S2=OHz+Z)zq$>h9T)Y(EcMka5nq>A^KMe%QV4yvLsWeOb+6Xm_LjvEy*v<9+Fzzp)3+Fm;hh zv*lEAah~*SH?*uBW)==vcZZYVd@C9+AvHz^ew0kJ88dmwI&<7P2_sHGvYMKjP>W|r z>I!Cc`P2%~n)b-!=LI$#8tn1wqTUBhcf;_bG}KjA;c-2_`w4F3-Qk_~lf_!4_Pp53 zPEP3kx#Q9G7oV;Q-LNA%%+ASB1SvaN3sS!#jZ^M@&08_JGA{J?<=tF5>o3k@$uu=8 zkC#(uz8oDCEuB3ii~2;9X#uz~9h#rSKoB=(L2Hw^OH+-}m@ZN?t3;{_yBBGd%@Lw{ z0!y(7P$QqiG?1M8$T49MXMM7?G?D5lkvLATBE*(Jggdh5PhEPzX^@GYDPRp9fsSV> zCFpqEU$`$$w`{o_pyceeMpro*Sa%)W>te}}DaYOCVMt&2z1-@#^~m~-SD0DH%U8C| zL>~{ml@M$3BpHQkoHh3n_KJ{ec(ctP%q@TG?A>A7r*Zcg7i%>iuYA=1qv6T8h_}sA z^D6qlHJuOk<7yR-HINQS;=%*8ST+ACU6XHk&qEjgJLZ zO4V3E)&-tv6{Eohrj`my_cDK53H@%9lV5t)F(voeGf-ze;F=Ys1;yrbe5U*Jl(MFh z*xXs@+<8+CMGfHf}d zvUD!94FhP>iaI zl5MSvhZyY(w9mzE>yvoBA(}xD7~4|I`5}vmR9-r7^iKc01)0NqaD5yj+2?}9T`)Cb z(Vk+2FX&;WxPQt3P>d>-7F~s?e3fzwEusn=3gwRp0G5^$d;ff$vh2sF9mXzrG*cRF*$@J4(5+-_+;vvf$hK=I=q2&p%3| z*PSnoubFK;QZD|gphU<40r<03G>57BIm91h}tub99iPnI`*Dzgk{jvYPLqx`0U8Mj>QbzpxX6UY0LrNvdXWfUi)Fk&?#C9u4p zD|66McMas`?i(xK)cwm|cWTuc*p_U(Y^rM1lRjhW+J~okJ~%XpT<4kxs9B=(37NEc zH>eEa=@sG5xvRB#LB@_kRss(%ZMveL-L29`N~K z$?CVOyvFo4=J3WNCi_SGFWuet)2_c6wjdWg%HZ^&K;N~STn(3A`Sa2!eC6f47nCEj z4T0km8#FUcAI(6xgl6cfMl*xB(Qr*(v_jlxwBscc*@*b>*l6n8*!+s5ztV=WCMZTe zr}|@97DUv?Tz!M%-`?0|{VtL8I2ON}x{|Km#gU1{P|YjHEy=C&$y79B%>yvTC>Dqg zP75H&awV8Jm78MNhpWrZs2TsF`*NNuCm1&IM`)@tL+MJMUSj|gpGgaSwwCXVn@~pS zkCA~FzG7CF*9@mREJSVEuY9U_I|7#Gfj87WRcV!+u#x{~Lqzj+e3} z98Ql5s_3j_U`Nb`9=2S9S1PIyE)1cO7bnU`~Vfc zW3cuag#EROi!LuYYfyfA;uZsRQe}Y>>8QZ15E<%rE@4J~xt}uKBjNfG1rC}{u9&mv zuO43*jHY-)_&nAhne;l!!pXy6DxNs9A{QFL18iec;v9_3*PcoEZfR;xpn^(VuGU8w zMXDA|athnBkUmtH_76As3PSV}jj_dP`~{rm1}NhKWNIIBj?^hfi#R7{C#7`Pi+$p$ zP>}OW`=&JxTk-`zMg&Pk!$PrhOl`DBaP7OAXS7){$MmFR9R%udvgADk-Zp4ArH&75 zDywSa%#weOC@^)nOJnKm{yt&Ab78_@>HVD?*}i@{o9mw+QmesQCvyFajbC7En&o=w zv4iRu{{+R=cnnTQzWwQW$gBshk-pm65!K&11+K-?$2BoOawof(z z+C&Nzr#nn01Qq1yRd!XwgFBPi*Bui-Chx1nq${5f&7QLm!uM~MWrKnYYu&-#gdmpd zt@vq1Q2}>fzZ5-FHf37F-%Hm#)HhV!JKbbq8;ponyfv*iHRA7zkvB1J zsGJ2Y*8-`ZUEhk9X_KR0BrkY?ZLql?Ax14ogcFHpS-`Q0H1|uHKwUKDi=KC<*cRWU zEM+?=)>fEm*>1oPlls+Ls06`s&jU`8fi?-HqR{3_ai1{0Fotm_@xIXuWxaM8<@|*O zS3)01Fnga7){&gp8ALnqKUr!zgVMbl8LC@bCck%%Gf?}vtNFp0WY8Mxe&iLbu7r5i z=!AAbbN~H)ajF%sF*cu}Y?TuGkMQAgI>qiL+^ko*O7k@AzLaw$4G4? z-tby5(R2mtOByfN|7LI)Pc?vjwjao;@-5&lXdoQQ}859!I7r)9&iwY{MShLRdo_oXsT zuA|QOkNH=0I?$%P=?!p*@t@V#nyM}3J*-u>uWWVU4eiik4HkKt z^!qwj4ful9yg%wUM5G>2)su1}@II#p|p}`W}M!Ka9N7@91Axdyh)GoAWi# z<6cm1irc=5Zli`{l1W;lL1`jCvg!$y=3R{HYZMd>pvAzn0GdV=L=T~*G8e7lVBoDn zj|+=JpaL75UE|*QjcU5=c>0gF*8ZARw)89_ytJ-q6*3Z2W=1Slo;})&0%|_*3*ID*~0qH}d3d=uG(=vCKtSo;dI$|;wVI)31Xx0Fi`Of&<7 zAzZ>3L346QIMTomdj=ZLiSEQeJzrIc9H3X2Vt{Wtw7^O=RT+579lGv1a2K)jJvV7W zb=om(V?hzi{p5%goR%_B(h=@@Yq@CgvO-IZw&_DlQmo1gh3Upv;Ipj+z4t(vYDRxAx|=o z?(4>fa~sG!uCu&%sYc{--|Vj0R#f$$q5v1yA*>3xyQ=`dMip<}^U2Q^Br3l8#(n>l zp04+rdpsl|KdDa46BI)f57KD^GBo`pU6(-fNnmB~>erjr7JY{3om)Nimd^L){W<+n7=e<7w#@^*Ete-EsVA{J z`@Ia0&!&Y{eyZl@T)*?doCQIJ7IT2Bqlg)shHxApp2Wab^gcH_O!*kTh9&ZaJhHn8%Qa6RefOGF#Gkbjbo^qt-|s{agb!@J$CkHaluq0+^nJlZ)!#?CeVJ(hgeyb*Qr^4NQ8cFlpkw<4Gp-l-N3RW0pG z&hWp;8vLl&RLj@klXcFOwx&Bd zD)(LViRMBVUVFs1kg2IiG+f5lJ4V4A5Cb}ro)1Lxy73umP}e5~?4&n-ncbA@S*C%3 zsp_{*gxh8)$g8O}+b}7k(Qu)^TUO6p?Qyzog@I5ti2PmKl+!m^D?B+1jQikHtz-K2PnU zCX@&3_DBD48CK0hVm-I(G|EI2iytY)VP((XA@C$>B2z+AHIxQFUdk6O7w1>BNz!S6 zPK(six<>d z`I4hX#!|bq-H#P6#Obnr_!>D%45LQG%5Lf>{^`qo*>5#Cd3?$K&|7;15)Jk=Kc@?i zjcLYPU1^;rP*|lcHyo}?IYqKOKWKjLy5aYBUCZ#JgU=@~{^W#p$%k_cGJZiKTDz=4 zz8gU!@U3+H9Ny$^mZ8yUhOoEa_l(=J6hz)aquvdWM5^u_tOmPa*|lJ<{&!-fn;-GW zEyEWrM+XB>1E5`{&GHy^aiR?a+?VH7QHGsUX(+Ya-FgHs^#Z>JcdmYRD(?WcJn&bz zU)e{8sZ2jwA?b+{?+%};8qhY(iCo77*EV2Nr(xYP%`z=-=;w$fBjQD`!ZGCIu+{)_t zXr`5+v8GjRcB0nTg!!-_u*Ai-{srYA@J^O_)ww4Ot2%c{ezSo`ns3MEIomxtPWK$x zH#<>8ivwjpKELy(a+dKwQ9Q3ZzAMwi%pIO2Luyq%hLeAFC}}^$aavPb@`wkE5#WKe z0VX5PR!|;DM`d<_A_o(ud&5mAk$>?^vj9hllYQj<2}6j8T=A6^T&Nz;L*4f!F$6e4BK@Gm~|DEe-O!2p3HB3MK)W@pom)Ku`VI z_hn0sjJy47ID$?HX)Cg^&w`v9vS(UZ=TA&mibX0hgKB$!6kM5N$uc;{-Oo+an$7ai zRX;0wR*RjSD3sF9HF87cGb4XlW1->GQH|kSVEsr>>!Kf<4-aoNhE#d)D34%bW1Pxp#WbMPlm&Qa80b?eiB3&(8%GaSD+3x4g~eelGe zcceUHLi$$I%rL3yXhz0Krnzp}@L1*1{H+QLVH7I59Y1Rkmo}KnaDQ{w zPvqHS-#i_C^Bm-$AnI|8NZ6wBvw4H70<00)>d4>bbtbiMA0bzct7!7Y_Bq|3nUo&# zi$a>EYg?=X1@*?G$(k$9lU~<5$$xfMrq9T=49{;5YiJ8gFmOck&=x<~vuRp?E%}=+ zi>>+7Gb12?G^%K^H5a(0<|5?GEvyevBLObH=xu6yPeJUerbz>*v|Y2e{Fo=oF@`Rq zP&>;|!aU;50xak`IN~w0!qNAKqg; z*r^BE^p{q~-?uz*Rp7JTQv*{W1!TL+cjv(1Sh~goe!hgyuleH`)ZX$vjdDwVJDIrJ zqLbj8WSlR9WJHQm74)t_U_kt*LQ(Sm)sNEKR zy87l&K9$rB@qZL~E#g%5arb*j7L5|;g@9EzvmYrE>+2tj3`$BTA@RbE8q z`(IEfNE;BgdA{KzYBDhTDm6R7-HzSXiE<8GKbg|+`kJ)>sAS2zc@!{^@}uu;bPrB9 z2DGqIFAs|96Fd zkw+!%8vyv7e)u2xZU6u}z5ZYDxc-}X2>&LYvVRj#@4ty>`Tv&ZztiD=vH#We|M2{m S0r?k;|1)m?;z0c`)&2uuH^6WJ literal 0 HcmV?d00001 diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx new file mode 100644 index 000000000000..84c3618b8d06 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -0,0 +1,110 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useRef } from "react"; + +import { getClientSettings, useClientSettings } from "../hooks/useSettings"; +import { useEnvironments } from "../state/environments"; +import { environmentShell } from "../state/shell"; +import { + hasDesktopNotifications, + hasNotificationSound, + playNotificationSound, + unlockNotificationAudio, +} from "../threadNotifications"; +import { resolveSidebarThreadStatus } from "./Sidebar.logic"; + +export function ThreadNotificationCoordinator() { + const { environments } = useEnvironments(); + const mode = useClientSettings((settings) => settings.notificationMode); + + useEffect(() => { + if (!hasNotificationSound(mode)) return; + document.addEventListener("pointerdown", unlockNotificationAudio); + document.addEventListener("keydown", unlockNotificationAudio); + return () => { + document.removeEventListener("pointerdown", unlockNotificationAudio); + document.removeEventListener("keydown", unlockNotificationAudio); + }; + }, [mode]); + + if (mode === "off") return null; + + return environments.map((environment) => ( + + )); +} + +function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { + const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); + const mode = useClientSettings((settings) => settings.notificationMode); + const navigate = useNavigate(); + const previous = useRef(new Map()); + + useEffect(() => { + if (shell.status !== "live" || Option.isNone(shell.snapshot)) { + previous.current.clear(); + return; + } + const next = new Map(); + for (const thread of shell.snapshot.value.threads) { + const status = resolveSidebarThreadStatus(thread); + const prior = previous.current.get(thread.id); + const input = + status === "input" || status === "approval" + ? `${thread.latestTurn?.turnId ?? ""}:${status}` + : null; + const completion = + status === "ready" && thread.latestTurn?.state === "completed" + ? thread.latestTurn.completedAt + : (prior?.completion ?? null); + next.set(thread.id, { input, completion }); + if (!prior || mode === "off" || thread.archivedAt !== null) continue; + const kind = + input && input !== prior.input + ? "input" + : completion && completion !== prior.completion + ? "completion" + : null; + if (!kind) continue; + if (hasNotificationSound(mode)) { + void playNotificationSound(kind, () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + if ( + !hasDesktopNotifications(mode) || + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) + continue; + try { + const notification = new Notification( + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : "Input needed", + { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, + ); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + } + previous.current = next; + }, [environmentId, mode, navigate, shell]); + + return null; +} diff --git a/apps/web/src/components/settings/NotificationSettings.tsx b/apps/web/src/components/settings/NotificationSettings.tsx new file mode 100644 index 000000000000..22b4d2912302 --- /dev/null +++ b/apps/web/src/components/settings/NotificationSettings.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; + +import { + hasDesktopNotifications, + hasNotificationSound, + NOTIFICATION_MODE_LABELS, + unlockNotificationAudio, +} from "../../threadNotifications"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; + +export function NotificationSettings() { + const mode = useScopedSettings((settings) => settings.notificationMode); + const updateSettings = useUpdateScopedSettings(); + const [permissionMessage, setPermissionMessage] = useState(null); + const [requesting, setRequesting] = useState(false); + + return ( + { + if ( + value !== "off" && + value !== "notifications" && + value !== "sound" && + value !== "notifications-and-sound" + ) + return; + setPermissionMessage(null); + if (hasNotificationSound(value)) unlockNotificationAudio(); + if (hasDesktopNotifications(value)) { + if (typeof Notification === "undefined" || !window.isSecureContext) { + setPermissionMessage( + "Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.", + ); + return; + } + setRequesting(true); + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setPermissionMessage( + "Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.", + ); + return; + } + } catch { + setPermissionMessage( + "Notifications are unavailable in this browser. Sound only is still available.", + ); + return; + } finally { + setRequesting(false); + } + } + updateSettings({ notificationMode: value }); + }} + > + + {NOTIFICATION_MODE_LABELS[mode]} + + + {Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => ( + + {label} + + ))} + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8a78d4138c6b..762fb5ccbaa4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,5 @@ import { Spinner } from "~/components/ui/spinner"; +import { NotificationSettings } from "./NotificationSettings"; import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; @@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode + ? ["Thread notifications"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -632,6 +636,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, + settings.notificationMode, settings.wordWrap, followSystem, theme, @@ -705,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, @@ -2238,6 +2244,7 @@ export function GeneralSettingsPanel() { + + diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts new file mode 100644 index 000000000000..8050411382aa --- /dev/null +++ b/apps/web/src/threadNotifications.ts @@ -0,0 +1,55 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; + +import completionUrl from "./assets/notification-completion.mp3"; +import inputUrl from "./assets/notification-input.mp3"; + +type NotificationMode = ClientSettings["notificationMode"]; +export const NOTIFICATION_MODE_LABELS = { + off: "Off", + notifications: "Notifications only", + sound: "Sound only", + "notifications-and-sound": "Notifications with sound", +} satisfies Record; + +export function hasNotificationSound(mode: NotificationMode) { + return mode === "sound" || mode === "notifications-and-sound"; +} + +export function hasDesktopNotifications(mode: NotificationMode) { + return mode === "notifications" || mode === "notifications-and-sound"; +} + +let audioContext: AudioContext | undefined; +const buffers = new Map>(); + +/** Called from a gesture so browsers allow later background playback. */ +export function unlockNotificationAudio() { + audioContext ??= new AudioContext(); + void audioContext.resume().catch(() => undefined); +} + +export async function playNotificationSound( + kind: "completion" | "input", + shouldPlay: () => boolean, +) { + if (!audioContext || audioContext.state !== "running") return; + const context = audioContext; + const url = kind === "completion" ? completionUrl : inputUrl; + try { + let buffer = buffers.get(url); + if (!buffer) { + buffer = fetch(url) + .then((response) => response.arrayBuffer()) + .then((data) => context.decodeAudioData(data)); + buffers.set(url, buffer); + } + const decoded = await buffer; + if (!shouldPlay() || context.state !== "running") return; + const source = context.createBufferSource(); + source.buffer = decoded; + source.connect(context.destination); + source.start(); + } catch { + buffers.delete(url); + } +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2fb6ead09c16..deac47890e1c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings notifications", () => { + it("requires opt-in when existing settings omit notification preferences", () => { + expect(decodeClientSettings({}).notificationMode).toBe("off"); + expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode"); + }); + + it.each(["off", "notifications", "sound", "notifications-and-sound"])( + "round-trips the %s mode", + (notificationMode) => { + const settings = decodeClientSettings({ notificationMode }); + expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode); + expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe( + notificationMode, + ); + }, + ); + + it.each(["always", true, null])( + "rejects unsupported notification mode %s", + (notificationMode) => { + expect(() => decodeClientSettings({ notificationMode })).toThrow(); + expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow(); + }, + ); +}); + describe("ClientSettings diff colors", () => { it("keeps red and green for existing settings without a saved palette", () => { expect(decodeClientSettings({}).diffColorScheme).toBe("red-green"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6b931f4ef3ea..813f0b1f702f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = { kind: "both-shift-keys", }; +export const NotificationMode = Schema.Literals([ + "off", + "notifications", + "sound", + "notifications-and-sound", +]); +export type NotificationMode = typeof NotificationMode.Type; + export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; @@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record( export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ClientSettingsSchema = Schema.Struct({ + notificationMode: NotificationMode.pipe( + Schema.withDecodingDefault(Effect.succeed("off" as const)), + ), diffColorScheme: DiffColorScheme.pipe( Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), @@ -1415,6 +1426,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + notificationMode: Schema.optionalKey(NotificationMode), diffColorScheme: Schema.optionalKey(DiffColorScheme), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 8670eaf64ad5..01955554adf6 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -1,5 +1,33 @@ { "customNotices": [ + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Notification Sound 1 by deadrobotmusic", + "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750607/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled notification-completion.mp3 is derived from Notification Sound 1 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750607/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Notification Sound 3 by deadrobotmusic", + "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750609/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled notification-input.mp3 is derived from Notification Sound 3 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750609/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, { "bundles": ["assets", "desktop", "web"], "license": "CC0-1.0", From 3b3fe7202c41174ef3a96f1ac67558c03443f399 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:11:48 +0000 Subject: [PATCH 2/3] fix(web): ignore historical completion alerts --- apps/web/src/components/ThreadNotificationCoordinator.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 84c3618b8d06..6ca62c948656 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -67,7 +67,8 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen const kind = input && input !== prior.input ? "input" - : completion && completion !== prior.completion + : completion && + (prior.completion === null || Date.parse(completion) > Date.parse(prior.completion)) ? "completion" : null; if (!kind) continue; From eaf5ad528339f6fbc6e4175ba4b3bf427d56917b Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:24:16 +0000 Subject: [PATCH 3/3] fix(web): ignore invalid notification timestamps --- .../components/ThreadNotificationCoordinator.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 6ca62c948656..e89175a77808 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -43,14 +43,14 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); const navigate = useNavigate(); - const previous = useRef(new Map()); + const previous = useRef(new Map()); useEffect(() => { if (shell.status !== "live" || Option.isNone(shell.snapshot)) { previous.current.clear(); return; } - const next = new Map(); + const next = new Map(); for (const thread of shell.snapshot.value.threads) { const status = resolveSidebarThreadStatus(thread); const prior = previous.current.get(thread.id); @@ -58,17 +58,19 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen status === "input" || status === "approval" ? `${thread.latestTurn?.turnId ?? ""}:${status}` : null; + const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); const completion = - status === "ready" && thread.latestTurn?.state === "completed" - ? thread.latestTurn.completedAt + status === "ready" && + thread.latestTurn?.state === "completed" && + Number.isFinite(completedAt) + ? completedAt : (prior?.completion ?? null); next.set(thread.id, { input, completion }); if (!prior || mode === "off" || thread.archivedAt !== null) continue; const kind = input && input !== prior.input ? "input" - : completion && - (prior.completion === null || Date.parse(completion) > Date.parse(prior.completion)) + : completion !== null && (prior.completion === null || completion > prior.completion) ? "completion" : null; if (!kind) continue;