diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2843231..d96018f2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -45,6 +45,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -140,12 +158,32 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -343,6 +381,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -467,6 +548,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -482,6 +569,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -597,6 +693,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -852,6 +954,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -985,6 +1093,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1019,6 +1137,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1508,6 +1632,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1567,12 +1711,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1977,6 +2144,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2144,6 +2321,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2527,13 +2715,38 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -2587,6 +2800,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2852,6 +3076,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2891,6 +3121,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2968,6 +3208,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3023,6 +3272,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3188,6 +3447,30 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify-rust" version = "4.17.0" @@ -3195,6 +3478,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ "futures-lite", + "image", + "lazy_static", "log", "mac-notification-sys", "serde", @@ -3248,6 +3533,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] @@ -3257,6 +3543,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -3732,6 +4029,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.12.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4076,6 +4375,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4408,6 +4719,25 @@ dependencies = [ "yansi", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "prost" version = "0.12.6" @@ -4504,18 +4834,56 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "qrcode" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -4676,12 +5044,97 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.12.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4890,6 +5343,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5422,6 +5881,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -5689,6 +6154,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -6696,6 +7170,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -7298,6 +7786,34 @@ dependencies = [ "url", ] +[[package]] +name = "user-notify" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb77c909b11ade0d691874366685ecb61288d06b458e88a50613e324a942948" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "block2 0.6.2", + "image", + "log", + "notify-rust", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-user-notifications 0.3.2", + "quick-xml 0.37.5", + "send_wrapper", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", + "windows 0.61.3", + "windows-collections 0.2.0", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -7328,6 +7844,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -7657,6 +8184,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "weft" version = "0.1.0" @@ -7700,6 +8233,7 @@ dependencies = [ "toml 0.8.2", "toml_edit 0.22.27", "tower", + "user-notify", ] [[package]] @@ -8505,6 +9039,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -8769,6 +9309,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d9e57763..efbbcb0c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-plugin-opener = "2" tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-notification = "2" +user-notify = "0.4.2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 02374a64..1531cda0 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,9 @@ }, "dialog:default", "notification:default", + "core:window:allow-set-badge-count", + "core:window:allow-request-user-attention", + "core:window:allow-is-focused", "updater:default", "mcp-bridge:default", "process:default" diff --git a/src-tauri/src/ask.rs b/src-tauri/src/ask.rs index d4f9c52f..cbcef3ca 100644 --- a/src-tauri/src/ask.rs +++ b/src-tauri/src/ask.rs @@ -1302,6 +1302,9 @@ pub struct Ask { pub thread_title: String, #[serde(default)] pub dir_name: String, + /// Owning workspace id, filled when listed (pending_asks). + #[serde(default)] + pub workspace_id: Option, /// The canonical, EXACT action identity (full command / full path set / /// full args) set by the engine's ask-creation call — distinct from /// `summary`, which may truncate for display. Used ONLY for Always-grant @@ -1688,6 +1691,7 @@ impl AskRegistry { ts: now(), thread_title: String::new(), dir_name: String::new(), + workspace_id: None, action_key: action_key.to_string(), }; g.open.push(ask.clone()); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f8e28305..d089f02d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1971,6 +1971,7 @@ pub async fn pending_asks( for a in &mut open { if let Ok(Some(t)) = repo::get_thread(&db, a.thread).await { a.thread_title = t.title; + a.workspace_id = Some(t.workspace_id); } if let Ok(id) = a.dir.parse::() { if let Ok(Some(d)) = repo::get_direction(&db, id).await { @@ -2369,19 +2370,26 @@ pub async fn workspace_needs_counts( let open_asks = asks.open(); let mut out = Vec::new(); for w in repo::list_workspaces(&db).await.map_err(e)? { - let threads: Vec<_> = repo::list_threads(&db, w.id) + let all_threads = repo::list_threads(&db, w.id) .await - .map_err(e)? + .map_err(e)?; + let workspace_tids: HashSet = all_threads.iter().map(|t| t.id).collect(); + let threads: Vec<_> = all_threads .into_iter() .filter(|t| t.kind != "curator") // hidden curator chat isn't a board issue .collect(); - let tids: HashSet = threads.iter().map(|t| t.id).collect(); let mut count: u32 = 0; for t in &threads { - // Excludes self-clearing NOTICEs (e.g. the stall hint) — they carry - // no action, so they must not inflate the "needs you" count that - // flags other workspaces (issue #105). + // Questions still come from open_answerable_ask_count (issue #105). + // Action-required notices are not answerable, but they leave a durable + // Retry control in Needs-you, so the dock badge and workspace switcher + // should count them. Self-clearing notices stay out. count += bus.open_answerable_ask_count(t.id) as u32; + count += bus + .open_asks(t.id) + .iter() + .filter(|a| a.kind == crate::bus::AskKind::NoticeActionRequired) + .count() as u32; count += crate::planner::pending_writes(&db, t.id) .await .map_err(e)? @@ -2389,7 +2397,10 @@ pub async fn workspace_needs_counts( } count += open_asks .iter() - .filter(|a| tids.contains(&a.thread)) + // Permission asks are global and the hidden curator thread is not + // part of `threads` above, but its asks still belong to this + // workspace and appear in Needs-you. + .filter(|a| workspace_tids.contains(&a.thread)) .count() as u32; out.push((w.id, count)); } diff --git a/src-tauri/src/im/outbound.rs b/src-tauri/src/im/outbound.rs index 4ab6edc9..58a4c5e2 100644 --- a/src-tauri/src/im/outbound.rs +++ b/src-tauri/src/im/outbound.rs @@ -221,6 +221,7 @@ mod tests { ts: 0, thread_title: "登录超时修复".into(), dir_name: "backend".into(), + workspace_id: None, action_key: "claude:npm test".into(), } } diff --git a/src-tauri/src/lead_chat/commands.rs b/src-tauri/src/lead_chat/commands.rs index 88a03bdc..79f748cf 100644 --- a/src-tauri/src/lead_chat/commands.rs +++ b/src-tauri/src/lead_chat/commands.rs @@ -52,6 +52,7 @@ fn push_model_arg(extra: &mut Vec, model: Option<&str>) { #[derive(serde::Serialize, Clone)] pub struct SessionInfo { pub session_id: i32, + pub thread_id: i32, pub repo: String, pub worktree: String, pub branch: String, @@ -1086,6 +1087,9 @@ pub struct LiveWorkerSlot { /// The worker's OWN thread (from EngineInner.thread_id) — adoption must not /// assume the active thread. pub thread_id: i32, + /// Owning workspace of the worker's thread, so the frontend can deep-link + /// adopted/revived workers even before that workspace is visited. + pub workspace_id: Option, pub busy: bool, pub queue: Vec, } @@ -1119,9 +1123,15 @@ async fn build_worker_slots(db: &Db, snaps: Vec) -> Vec) -> Vec> = OnceLock::new(); +/// Last native permission settlement we observed (`granted` / `denied`). +static PERM_SETTLED: OnceLock>> = OnceLock::new(); +/// Click that arrived before the frontend listener was mounted. +static PENDING_OPEN: OnceLock>> = OnceLock::new(); + +fn manager() -> Result<&'static Arc, String> { + MANAGER + .get() + .ok_or_else(|| "os_notify not initialized".to_string()) +} + +fn perm_settled() -> &'static Mutex> { + PERM_SETTLED.get_or_init(|| Mutex::new(None)) +} + +fn pending_open() -> &'static Mutex> { + PENDING_OPEN.get_or_init(|| Mutex::new(None)) +} + +/// Payload the frontend stores in `user_info` and gets back on click. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NotifyOpenPayload { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ask_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_needs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_curator: Option, +} + +/// Frontend → backend send request. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotifySendRequest { + pub title: String, + pub body: String, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub direction_id: Option, + #[serde(default)] + pub repo_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub ask_id: Option, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub open_needs: Option, + #[serde(default)] + pub open_curator: Option, +} + +fn parse_bool(raw: &str) -> Option { + match raw { + "1" | "true" | "True" | "TRUE" => Some(true), + "0" | "false" | "False" | "FALSE" => Some(false), + _ => None, + } +} + +fn payload_from_user_info(info: &HashMap) -> NotifyOpenPayload { + NotifyOpenPayload { + kind: info.get("kind").cloned().unwrap_or_default(), + thread_id: info.get("threadId").and_then(|s| s.parse().ok()), + direction_id: info.get("directionId").and_then(|s| s.parse().ok()), + repo_id: info.get("repoId").and_then(|s| s.parse().ok()), + session_id: info.get("sessionId").and_then(|s| s.parse().ok()), + ask_id: info.get("askId").and_then(|s| s.parse().ok()), + workspace_id: info.get("workspaceId").and_then(|s| s.parse().ok()), + open_needs: info.get("openNeeds").and_then(|s| parse_bool(s)), + open_curator: info.get("openCurator").and_then(|s| parse_bool(s)), + } +} + +fn user_info_from_req(req: &NotifySendRequest) -> HashMap { + let mut info = HashMap::new(); + if !req.kind.is_empty() { + info.insert("kind".to_string(), req.kind.clone()); + } + if let Some(v) = req.thread_id { + info.insert("threadId".to_string(), v.to_string()); + } + if let Some(v) = req.direction_id { + info.insert("directionId".to_string(), v.to_string()); + } + if let Some(v) = req.repo_id { + info.insert("repoId".to_string(), v.to_string()); + } + if let Some(v) = req.session_id { + info.insert("sessionId".to_string(), v.to_string()); + } + if let Some(v) = req.ask_id { + info.insert("askId".to_string(), v.to_string()); + } + if let Some(v) = req.workspace_id { + info.insert("workspaceId".to_string(), v.to_string()); + } + if let Some(v) = req.open_needs { + info.insert( + "openNeeds".to_string(), + if v { "1".to_string() } else { "0".to_string() }, + ); + } + if let Some(v) = req.open_curator { + info.insert( + "openCurator".to_string(), + if v { "1".to_string() } else { "0".to_string() }, + ); + } + info +} + +fn focus_main_window(app: &AppHandle) -> Result<(), String> { + let win = app + .get_webview_window("main") + .ok_or_else(|| "os_notify: main window missing".to_string())?; + win.unminimize() + .map_err(|e| format!("os_notify unminimize: {e}"))?; + win.show() + .map_err(|e| format!("os_notify show: {e}"))?; + win.set_focus() + .map_err(|e| format!("os_notify set_focus: {e}"))?; + Ok(()) +} + +fn remember_settled_permission(value: &str) -> Result<(), String> { + let mut guard = perm_settled() + .lock() + .map_err(|e| format!("os_notify permission lock poisoned: {e}"))?; + *guard = Some(value.to_string()); + Ok(()) +} + +fn handle_response(app: AppHandle, response: NotificationResponse) { + // Only deep-link on an actual click (Default) or a custom action button. + // Dismiss must stay silent. + let open = match &response.action { + NotificationResponseAction::Default => true, + NotificationResponseAction::Other(_) => true, + NotificationResponseAction::Dismiss => false, + }; + if !open { + return; + } + if let Err(err) = focus_main_window(&app) { + // Keep navigating the webview even if focus fails, but never swallow + // the failure silently — otherwise a click can look like a no-op. + eprintln!("[weft] {err}"); + } + let payload = payload_from_user_info(&response.user_info); + // Retain as pending first so a cold-start click survives if no frontend + // listener is mounted yet. Live handlers must ack/clear after handling. + match pending_open().lock() { + Ok(mut guard) => { + *guard = Some(payload.clone()); + } + Err(err) => { + eprintln!("[weft] os_notify pending lock poisoned: {err}"); + } + } + if let Err(err) = app.emit(OPEN_EVENT, payload.clone()) { + eprintln!("[weft] os_notify emit {OPEN_EVENT}: {err}"); + } +} + +/// Register the platform notification manager once during Tauri setup. +/// Safe to call only from the main thread (macOS permission APIs require it). +pub fn init(app: &AppHandle) -> Result<(), String> { + if MANAGER.get().is_some() { + return Ok(()); + } + let mgr = get_notification_manager(APP_ID.to_string(), None); + let app_handle = app.clone(); + mgr.register( + Box::new(move |response| { + handle_response(app_handle.clone(), response); + }), + Vec::new(), + ) + .map_err(|e| format!("os_notify register: {e}"))?; + + if MANAGER.set(mgr).is_err() { + // Another setup path won the race — fine, keep the first. + return Ok(()); + } + Ok(()) +} + +/// `"granted" | "denied" | "prompt"` — mirrors the frontend `NotifyPermission`. +#[tauri::command] +pub async fn os_notify_permission() -> Result { + let mgr = manager()?.clone(); + // Always re-query the OS so Settings recovery / revocation is visible without + // restarting Weft. Cached settlement only disambiguates native `false` + // (NotDetermined vs previously denied). + match mgr.get_notification_permission_state().await { + Ok(true) => { + remember_settled_permission("granted")?; + Ok("granted".to_string()) + } + Ok(false) => { + // Native false is ambiguous: never-prompted vs previously denied vs + // revoked after grant. Prefer the strongest known negative state. + let guard = perm_settled() + .lock() + .map_err(|e| format!("os_notify permission lock poisoned: {e}"))?; + if guard.as_deref() == Some("denied") { + return Ok("denied".to_string()); + } + if guard.as_deref() == Some("granted") { + // OS reports unauthorized after we previously saw granted — + // treat as revocation so Settings can offer recovery. + drop(guard); + remember_settled_permission("denied")?; + return Ok("denied".to_string()); + } + Ok("prompt".to_string()) + } + Err(e) => Err(format!("os_notify permission: {e}")), + } +} + +#[tauri::command] +pub async fn os_notify_request_permission() -> Result { + let mgr = manager()?.clone(); + match mgr.first_time_ask_for_notification_permission().await { + Ok(true) => { + remember_settled_permission("granted")?; + Ok("granted".to_string()) + } + Ok(false) => { + remember_settled_permission("denied")?; + Ok("denied".to_string()) + } + Err(e) => Err(format!("os_notify request permission: {e}")), + } +} + +#[tauri::command] +pub async fn os_notify_send(req: NotifySendRequest) -> Result<(), String> { + let mgr = manager()?.clone(); + let info = user_info_from_req(&req); + let mut builder = NotificationBuilder::new() + .title(&req.title) + .body(&req.body); + if !info.is_empty() { + builder = builder.set_user_info(info); + } + // Group by kind so the OS can collapse related pings. + if !req.kind.is_empty() { + builder = builder.set_thread_id(&format!("weft.{}", req.kind)); + } + mgr.send_notification(builder) + .await + .map_err(|e| format!("os_notify send: {e}"))?; + Ok(()) +} + +/// Drain a click that arrived before the frontend listener was ready. +#[tauri::command] +pub fn os_notify_take_pending_open() -> Result, String> { + let mut guard = pending_open() + .lock() + .map_err(|e| format!("os_notify pending lock poisoned: {e}"))?; + Ok(guard.take()) +} + +/// Clear a retained pending open after the frontend has handled a live event. +#[tauri::command] +pub fn os_notify_ack_open(payload: NotifyOpenPayload) -> Result<(), String> { + let mut guard = pending_open() + .lock() + .map_err(|e| format!("os_notify pending lock poisoned: {e}"))?; + ack_pending_if_matches(&mut guard, &payload); + Ok(()) +} + +fn ack_pending_if_matches( + pending: &mut Option, + handled: &NotifyOpenPayload, +) { + if pending.as_ref() == Some(handled) { + *pending = None; + } +} + +fn restore_pending_if_empty( + pending: &mut Option, + payload: NotifyOpenPayload, +) { + if pending.is_none() { + *pending = Some(payload); + } +} + +/// Put a previously taken pending open back — used when StrictMode cancels the +/// first cold-start drain before the frontend can apply it. +#[tauri::command] +pub fn os_notify_restore_pending_open(payload: NotifyOpenPayload) -> Result<(), String> { + let mut guard = pending_open() + .lock() + .map_err(|e| format!("os_notify pending lock poisoned: {e}"))?; + // A newer click may have arrived during listener teardown. Preserve it + // instead of allowing the older taken payload to overwrite it. + restore_pending_if_empty(&mut guard, payload); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_info_roundtrip_preserves_route_fields() { + let req = NotifySendRequest { + title: "t".into(), + body: "b".into(), + kind: "needs".into(), + thread_id: Some(7), + direction_id: Some(12), + repo_id: Some(3), + session_id: Some(44), + ask_id: Some(99), + workspace_id: Some(3), + open_needs: Some(true), + open_curator: Some(true), + }; + let info = user_info_from_req(&req); + let payload = payload_from_user_info(&info); + assert_eq!(payload.kind, "needs"); + assert_eq!(payload.thread_id, Some(7)); + assert_eq!(payload.direction_id, Some(12)); + assert_eq!(payload.repo_id, Some(3)); + assert_eq!(payload.session_id, Some(44)); + assert_eq!(payload.ask_id, Some(99)); + assert_eq!(payload.workspace_id, Some(3)); + assert_eq!(payload.open_needs, Some(true)); + assert_eq!(payload.open_curator, Some(true)); + } + + #[test] + fn empty_request_yields_empty_info() { + let req = NotifySendRequest { + title: "t".into(), + body: "b".into(), + kind: String::new(), + thread_id: None, + direction_id: None, + repo_id: None, + session_id: None, + ask_id: None, + workspace_id: None, + open_needs: None, + open_curator: None, + }; + assert!(user_info_from_req(&req).is_empty()); + } + + #[test] + fn restoring_taken_payload_preserves_newer_pending_click() { + let newer = NotifyOpenPayload { + kind: "newer".into(), + thread_id: None, + direction_id: None, + repo_id: None, + session_id: None, + ask_id: None, + workspace_id: None, + open_needs: None, + open_curator: None, + }; + let older = NotifyOpenPayload { + kind: "older".into(), + ..newer.clone() + }; + let mut pending = Some(newer); + restore_pending_if_empty(&mut pending, older); + assert_eq!(pending.as_ref().map(|p| p.kind.as_str()), Some("newer")); + } + + #[test] + fn acknowledging_only_clears_the_matching_pending_click() { + let pending_payload = NotifyOpenPayload { + kind: "pending".into(), + thread_id: Some(7), + direction_id: None, + repo_id: None, + session_id: None, + ask_id: None, + workspace_id: Some(3), + open_needs: None, + open_curator: None, + }; + let other_payload = NotifyOpenPayload { + kind: "other".into(), + ..pending_payload.clone() + }; + let mut pending = Some(pending_payload.clone()); + + ack_pending_if_matches(&mut pending, &other_payload); + assert_eq!(pending, Some(pending_payload.clone())); + + ack_pending_if_matches(&mut pending, &pending_payload); + assert!(pending.is_none()); + } +} diff --git a/src/components/NeedsDock.tsx b/src/components/NeedsDock.tsx index c981ca96..bc242826 100644 --- a/src/components/NeedsDock.tsx +++ b/src/components/NeedsDock.tsx @@ -1,15 +1,15 @@ -import { GitBranch, HelpCircle, Layers, ShieldQuestion } from "lucide-react"; +import { AlertTriangle, GitBranch, HelpCircle, Layers, ShieldQuestion } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { PermissionAsk, WriteTrigger, NeedItem } from "../lib/types"; import { cn } from "../lib/cn"; -import { isPendingNeed, pendingNeedsCount, useStore } from "../state/store"; +import { isActionableNeed, pendingNeedsCount, useStore } from "../state/store"; import { needsBarMotion } from "../lib/motion"; type DockItem = | { kind: "write"; item: WriteTrigger } | { kind: "permission"; item: PermissionAsk } - | { kind: "question"; item: NeedItem }; + | { kind: "need"; item: NeedItem }; /** * Workspace-wide "Needs you" strip: a quiet indicator + router only. It shows @@ -22,11 +22,10 @@ export function NeedsDock() { const { needs, asks, writeTriggers, openNeeds } = useStore(); const { t } = useTranslation(); const reduce = useReducedMotion(); - // Self-clearing stall notices don't count — this strip promises "N things - // need your action" (issue #105), and a notice has none. It still shows up - // once you open the queue via the persistent "Needs you" nav entry. + // Self-clearing stall notices don't count; persistent action-required notices + // do count because their retry control needs explicit attention. const total = pendingNeedsCount(needs, asks, writeTriggers); - const top = topDockItem(writeTriggers, asks, needs.filter(isPendingNeed)); + const top = topDockItem(writeTriggers, asks, needs.filter(isActionableNeed)); return ( @@ -63,7 +62,7 @@ function topDockItem( ): DockItem | null { if (writeTriggers[0] != null) return { kind: "write", item: writeTriggers[0] }; if (asks[0] != null) return { kind: "permission", item: asks[0] }; - if (needs[0] != null) return { kind: "question", item: needs[0] }; + if (needs[0] != null) return { kind: "need", item: needs[0] }; return null; } @@ -94,6 +93,15 @@ function DockSummary({ top }: { top: DockItem }) { } const item = top.item; + if (item.kind === "notice_action_required") { + return ( + + + {t("needs.actionRequired")} + + + ); + } return ( diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e00c092e..982e89d9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -312,6 +312,12 @@ export const en = { reviewTitle: "Ready for review", reviewBody_one: "{{count}} task reached review", reviewBody_other: "{{count}} tasks reached review", + stalledTitle: "Agent stalled", + stalledBody_one: "{{count}} session went quiet", + stalledBody_other: "{{count}} sessions went quiet", + quotaTitle: "Process capacity full", + quotaBody_one: "New sessions and turns are paused", + quotaBody_other: "New sessions and turns are paused", }, processQuota: { warningTitle: "Process capacity is running low", @@ -748,8 +754,17 @@ export const en = { skillsOverridden: "Shadowed by {{source}}", skillsRemove: "Remove source", notifications: "System notifications", - notificationsHint: "While the window is unfocused, new Needs-you items and review-ready tasks raise OS notifications.", + notificationsHint: "While the window is unfocused, Weft raises OS notifications for the events you enable below. Dock badge tracks Needs-you regardless. Click a notification to jump to it.", notifyDenied: "Notifications are blocked by the OS — open System Settings", + notifyDeniedUnavailable: "Notifications are blocked by the OS.", + notifyCat_needs: "Needs you", + notifyCat_review: "Ready for review", + notifyCat_stalled: "Stalled sessions", + notifyCat_quota: "Process capacity full", + notifyQuietHours: "Quiet hours", + notifyQuietHoursHint: "No OS pings during this local window. Dock badge still updates.", + notifyQuietStart: "Quiet hours start", + notifyQuietEnd: "Quiet hours end", agentLanguage: "Agent output language", agentLanguageHint: "Language for plans, briefs, commits, and PR text; code and identifiers stay English.", diagnostics: "Agent diagnostics", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ec59264d..84cdb4de 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -302,6 +302,10 @@ export const zh = { needsBody_other: "{{count}} 项新增待办", reviewTitle: "可以 review 了", reviewBody_other: "{{count}} 个任务进入 review", + stalledTitle: "Agent 停滞", + stalledBody_other: "{{count}} 个会话长时间无进展", + quotaTitle: "进程额度已满", + quotaBody_other: "已暂停新会话和新回合", }, processQuota: { warningTitle: "进程额度接近上限", @@ -722,8 +726,17 @@ export const zh = { skillsOverridden: "已被 {{source}} 覆盖", skillsRemove: "删除来源", notifications: "系统通知", - notificationsHint: "窗口不在前台时,新的待办与进入 review 的任务会弹系统通知。", + notificationsHint: "窗口不在前台时,按下方勾选的事件弹出系统通知;点击通知可跳转到对应位置。Dock 角标始终反映待你处理数量。", notifyDenied: "系统已拒绝通知 — 打开系统设置开启", + notifyDeniedUnavailable: "系统已阻止通知。", + notifyCat_needs: "待你处理", + notifyCat_review: "可以 review 了", + notifyCat_stalled: "会话停滞", + notifyCat_quota: "进程额度已满", + notifyQuietHours: "免打扰时段", + notifyQuietHoursHint: "此时段内不弹系统通知;Dock 角标仍会更新。", + notifyQuietStart: "免打扰开始", + notifyQuietEnd: "免打扰结束", agentLanguage: "Agent 产出语言", agentLanguageHint: "plan / brief / commit / PR 文案的语言;代码与标识符始终英文。", diagnostics: "Agent 诊断", diff --git a/src/lib/api.ts b/src/lib/api.ts index 7802f904..3b345604 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -361,6 +361,56 @@ export const api = { setGuardrails: (idleSecs: number, wallSecs: number) => invoke("set_guardrails", { idleSecs, wallSecs }), processQuotaStatus: () => invoke("process_quota_status"), + // Desktop OS notifications via user-notify (click deep-link capable). + osNotifyPermission: () => invoke("os_notify_permission"), + osNotifyRequestPermission: () => invoke("os_notify_request_permission"), + osNotifyAckOpen: (payload: { + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + askId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; + }) => invoke("os_notify_ack_open", { payload }), + osNotifyTakePendingOpen: () => + invoke<{ + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + askId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; + } | null>("os_notify_take_pending_open"), + osNotifyRestorePendingOpen: (payload: { + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + askId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; + }) => invoke("os_notify_restore_pending_open", { payload }), + osNotifySend: (req: { + title: string; + body: string; + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + askId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; + }) => invoke("os_notify_send", { req }), // Local-runtime resource dashboard (issue #112): read-only aggregate of // process_quota / proc_registry / session_gate. Polled while the Settings → // Resources page is open; no new sampling happens on the backend for this. diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 9cb3619a..56793407 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,55 +1,96 @@ -import { useEffect, useRef, useState } from "react"; -import { - isPermissionGranted, - requestPermission, - sendNotification, -} from "@tauri-apps/plugin-notification"; +import { useCallback, useEffect, useRef, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { getCurrentWindow, UserAttentionType } from "@tauri-apps/api/window"; +import { listen } from "@tauri-apps/api/event"; import { useTranslation } from "react-i18next"; -import { api } from "./api"; import { useStore } from "../state/store"; -import type { NeedItem, PermissionAsk, ThreadOverview, WriteTrigger } from "./types"; +import { api } from "./api"; +import { + diffForNotifications, + emptyNotifySnapshot, + isAppInForeground, + isInQuietHours, + NOTIFY_CATEGORIES, + notifyCopyKeys, + planNotifyOpen, + snapshotOf, + type NotifyCategory, + type NotifyCategoryFlags, + type NotifyRoute, + type NotifySnapshot, +} from "./notificationsCore"; + +export * from "./notificationsCore"; /** Three-state OS permission. macOS prompts exactly once — after a refusal, * requestPermission returns "denied" without a dialog and the only remedy is * the OS settings pane, so callers must tell "denied" apart from "prompt". */ export type NotifyPermission = "granted" | "denied" | "prompt"; +function asNotifyPermission(raw: string): NotifyPermission { + if (raw === "granted" || raw === "denied" || raw === "prompt") return raw; + return "denied"; +} + export async function notifyPermission(): Promise { try { - if (await isPermissionGranted()) return "granted"; - const perm = (window as { Notification?: { permission?: string } }).Notification + const p = asNotifyPermission(await api.osNotifyPermission()); + // Backend is authoritative for denied/granted (user-notify). Only use the + // Web Notification bit as a secondary signal when backend is still prompt. + if (p === "granted" || p === "denied") return p; + const web = (window as { Notification?: { permission?: string } }).Notification ?.permission; - return perm === "denied" ? "denied" : "prompt"; + if (web === "denied") return "denied"; + if (web === "granted") return "granted"; + return "prompt"; } catch { - return "denied"; // pure-vite dev: plugin unavailable + return "denied"; // pure-vite dev: command unavailable } } -/** Resolve to a settled state, asking the OS only from "prompt" (a dismissed - * dialog stays "prompt" so the user can be asked again later). */ -export async function ensureNotifyPermission(): Promise { - const p = await notifyPermission(); - if (p !== "prompt") return p; - try { - const r = await requestPermission(); - if (r === "granted") return "granted"; - if (r === "denied") return "denied"; - return "prompt"; - } catch { - return "denied"; - } +/** Resolve to a settled state, asking the OS only from "prompt". */ +let notifyPermissionInFlight: Promise | null = null; + +export function ensureNotifyPermission(): Promise { + if (notifyPermissionInFlight) return notifyPermissionInFlight; + + const request = (async () => { + const p = await notifyPermission(); + if (p !== "prompt") return p; + try { + return asNotifyPermission(await api.osNotifyRequestPermission()); + } catch { + return "denied"; + } + })(); + notifyPermissionInFlight = request; + void request.then( + () => { + if (notifyPermissionInFlight === request) notifyPermissionInFlight = null; + }, + () => { + if (notifyPermissionInFlight === request) notifyPermissionInFlight = null; + }, + ); + return request; } /** Jump to the OS notification settings. macOS / Windows have stable URLs; * Linux has no portable one — returns false and the caller's copy stands. */ +export function canOpenSystemNotificationSettings(): boolean { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + return ua.includes("Mac") || ua.includes("Windows"); +} + export async function openSystemNotificationSettings(): Promise { const ua = navigator.userAgent; - const url = ua.includes("Mac") - ? "x-apple.systempreferences:com.apple.preference.notifications" - : ua.includes("Windows") - ? "ms-settings:notifications" - : null; + let url: string | null = null; + if (ua.includes("Mac")) { + url = "x-apple.systempreferences:com.apple.preference.notifications"; + } else if (ua.includes("Windows")) { + url = "ms-settings:notifications"; + } if (!url) return false; try { await openUrl(url); @@ -59,137 +100,980 @@ export async function openSystemNotificationSettings(): Promise { } } -/** - * OS notifications for the two things worth pulling the human back (spec - * 2026-06-11): a new Needs-you item, and a sub-task reaching review. Desktop - * notifications carry no click callback (Tauri v2 platform limit), so the body - * carries the context and the in-app badges take over once focused. - */ +async function setDockBadge(count: number): Promise { + try { + const win = getCurrentWindow(); + await win.setBadgeCount(count > 0 ? count : undefined); + } catch { + /* pure-vite / unsupported platform */ + } +} -/** Notify-relevant state reduced to stable identity keys → context line. */ -export interface NotifySnapshot { - needs: Map; - review: Map; +async function requestAttention(): Promise { + try { + const win = getCurrentWindow(); + await win.requestUserAttention(UserAttentionType.Informational); + } catch { + /* pure-vite / unsupported */ + } } -export function snapshotOf( - needs: NeedItem[], - asks: PermissionAsk[], - triggers: WriteTrigger[], - overview: ThreadOverview[], -): NotifySnapshot { - const n = new Map(); - for (const it of needs) { - n.set(`need:${it.ask_id}`, `${it.thread_title} · ${it.direction_name}`); +async function focusMainWindow(): Promise { + try { + const win = getCurrentWindow(); + await win.unminimize(); + await win.show(); + await win.setFocus(); + } catch { + /* pure-vite */ } - for (const a of asks) { - n.set(`ask:${a.id}`, `${a.thread_title} · ${a.dir_name}`); +} + +export interface OsNotifyOpenEvent { + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + askId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; +} + +function notifyOpenKey(payload: OsNotifyOpenEvent): string { + return JSON.stringify([ + payload.kind, + payload.threadId ?? null, + payload.directionId ?? null, + payload.repoId ?? null, + payload.sessionId ?? null, + payload.askId ?? null, + payload.workspaceId ?? null, + payload.openNeeds ?? null, + payload.openCurator ?? null, + ]); +} + +/** Apply a notification click to the in-app navigation surface. */ +const PENDING_NAV_KEY = "weft-notify-pending-nav"; +const NAV_RUNTIME_GENERATION = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +type PendingNav = OsNotifyOpenEvent & { + /** Absolute workspaceLoadSeq that must be reached before applying intents. */ + expectedLoadSeq?: number; + /** Runtime token used to rebase sequence markers after a WebView reload. */ + loadGeneration?: string; +}; + +function stashPendingNav( + payload: OsNotifyOpenEvent, + opts?: { expectedLoadSeq?: number }, +): void { + try { + const pending: PendingNav = { ...payload }; + pending.loadGeneration = NAV_RUNTIME_GENERATION; + if (opts?.expectedLoadSeq != null) { + pending.expectedLoadSeq = opts.expectedLoadSeq; + } + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); + } catch { + /* ignore quota / private mode */ } - for (const w of triggers) { - n.set(`wt:${w.thread_id}:${w.index}`, `${w.thread_title} · ${w.name}`); +} + +export function takePendingNav(): PendingNav | null { + try { + const raw = sessionStorage.getItem(PENDING_NAV_KEY); + if (!raw) return null; + sessionStorage.removeItem(PENDING_NAV_KEY); + return JSON.parse(raw) as PendingNav; + } catch { + return null; } - const r = new Map(); - for (const o of overview) { - o.statuses.forEach((s, i) => { - if (s === "review") r.set(`rev:${o.direction_ids[i]}`, o.title); - }); +} + +function putPendingNav(pending: PendingNav): void { + try { + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); + } catch { + /* ignore */ } - return { needs: n, review: r }; } -export interface NotifyEvent { - kind: "needs" | "review"; - count: number; - /** Context of the first new item, used as the body when count === 1. */ - sample: string; +/** Restore a deferred route only when a newer click has not replaced it. */ +function putPendingNavIfUnchanged(pending: PendingNav): void { + try { + const raw = sessionStorage.getItem(PENDING_NAV_KEY); + if (raw) { + const current = JSON.parse(raw) as PendingNav; + if (notifyOpenKey(current) !== notifyOpenKey(pending)) return; + } + sessionStorage.setItem(PENDING_NAV_KEY, JSON.stringify(pending)); + } catch { + /* ignore malformed storage / private mode */ + } } -/** New keys in `next` that weren't in `prev` — the things worth a ping. */ -export function diffForNotifications( - prev: NotifySnapshot, - next: NotifySnapshot, -): NotifyEvent[] { - const out: NotifyEvent[] = []; - for (const kind of ["needs", "review"] as const) { - const fresh = [...next[kind]].filter(([k]) => !prev[kind].has(k)); - if (fresh.length > 0) { - out.push({ kind, count: fresh.length, sample: fresh[0][1] }); +export async function applyNotifyIntents( + payload: OsNotifyOpenEvent, + deps: { + goToDirectionRef: ( + thread: number, + dir: string, + opts?: { repoId?: number; sessionId?: number }, + ) => Promise; + openNeeds: () => void; + openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; + openCurator: () => void; + }, +): Promise { + const intents = planNotifyOpen(payload).filter((i) => i.type !== "workspace"); + for (const intent of intents) { + if (intent.type === "resources") { + deps.openSettings("resources"); + continue; + } + if (intent.type === "curator") { + deps.openCurator(); + continue; + } + if (intent.type === "direction") { + await deps.goToDirectionRef(intent.threadId, intent.direction, { + repoId: intent.repoId, + sessionId: intent.sessionId, + }); + continue; + } + if (intent.type === "needs") { + deps.openNeeds(); } } - return out; } -const OVERVIEW_POLL_MS = 10_000; +/** Apply a notification click to the in-app navigation surface. */ +export async function handleNotifyOpen( + payload: OsNotifyOpenEvent, + deps: { + selectWorkspace: (id: number) => Promise | void; + goToDirectionRef: ( + thread: number, + dir: string, + opts?: { repoId?: number; sessionId?: number }, + ) => Promise; + openNeeds: () => void; + openSettings: (page?: "resources" | "general" | "appearance" | "automation" | "skills" | "im" | "backup") => void; + openCurator: () => void; + activeWorkspaceId: number | null; + workspaceLoadSeq: number; + workspaceLoading?: boolean; + workspaceRestoring?: boolean; + workspaceLoadReady?: boolean; + needsHydrated?: boolean; + liveWorkersHydrated?: boolean; + }, +): Promise { + await focusMainWindow(); + const intents = planNotifyOpen(payload); + const workspaceIntent = intents.find((i) => i.type === "workspace"); + if (workspaceIntent && workspaceIntent.type === "workspace") { + const sameWorkspace = + workspaceIntent.workspaceId === deps.activeWorkspaceId; + if (!sameWorkspace) { + // Defer the rest until the store finishes the workspace switch effect. + // A second selectWorkspace(activeWorkspaceId) otherwise races and clears + // the deep-link destination. The store coalesces the direct call with the + // activeWorkspaceId effect, so one successful load advances the marker. + stashPendingNav(payload, { + expectedLoadSeq: deps.workspaceLoadSeq + 1, + }); + await deps.selectWorkspace(workspaceIntent.workspaceId); + return false; + } + // Same workspace id, but a load may still be in flight (cold-start restore + // or manual switch). Defer until that load finishes so its reset cannot + // wipe the deep-link destination. + if (deps.workspaceLoading) { + stashPendingNav(payload, { + expectedLoadSeq: deps.workspaceLoadSeq + 1, + }); + return false; + } + } + if ( + deps.activeWorkspaceId != null && + deps.workspaceLoadReady === false + ) { + stashPendingNav(payload, { + expectedLoadSeq: deps.workspaceLoadSeq + 1, + }); + try { + await deps.selectWorkspace(deps.activeWorkspaceId); + } catch { + /* Keep the deferred route; a later successful load can apply it. */ + } + return false; + } + if (deps.workspaceRestoring) { + stashPendingNav(payload); + return false; + } + const needsIntent = intents.some((intent) => intent.type === "needs"); + if (needsIntent && deps.needsHydrated === false) { + stashPendingNav(payload); + return false; + } + const workerIntent = intents.some( + (intent) => intent.type === "direction" && intent.sessionId != null, + ); + if (workerIntent && deps.liveWorkersHydrated === false) { + stashPendingNav(payload); + return false; + } + // Global routes (quota and ownership-unknown Needs-you entries) also mutate + // the current surface. A workspace reset that is already in flight would + // otherwise erase Resources/Needs immediately after this handler opens it. + if (deps.workspaceLoading) { + stashPendingNav(payload); + return false; + } + await applyNotifyIntents(payload, deps); + return true; +} + +async function sendOsNotification( + title: string, + body: string, + route: NotifyRoute, +): Promise { + await api.osNotifySend({ + title, + body, + kind: route.kind, + threadId: route.threadId ?? null, + directionId: route.directionId ?? null, + repoId: route.repoId ?? null, + sessionId: route.sessionId ?? null, + askId: route.askId ?? null, + workspaceId: route.workspaceId ?? null, + openNeeds: route.openNeeds ?? null, + openCurator: route.openCurator ?? null, + }); +} /** - * Mounted once in App. Reuses the store's Needs-you aggregation (4s poll + - * push); polls workspace_overview itself for review transitions (the board - * only fetches it on mount, and the per-issue direction poll covers only the - * open issue). First load and workspace switches rebuild the baseline silently. + * Mounted once in App. Reuses the store's Needs-you aggregation, overview, + * live session/lead turn state, and process-quota snapshot. First load and + * workspace switches rebuild the baseline silently. + * + * Delivery uses the community `user-notify` bridge (not the official Tauri + * notification plugin) so desktop clicks can deep-link via `notify://open`. */ export function useSystemNotifications() { - const { needs, asks, writeTriggers, notifyEnabled, activeWorkspaceId } = useStore(); + const { + needs, + asks, + writeTriggers, + notificationOverview, + sessions, + leadTurn, + processQuota, + notificationHydration, + threads, + notifyEnabled, + notifyCategories, + quietHours, + activeWorkspaceId, + needsByWorkspace, + threadWorkspaceById, + threadKindById, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + } = useStore(); + const needsHydrated = + notificationHydration.workspaceId === activeWorkspaceId && + notificationHydration.needs; + const liveWorkersHydrated = notificationHydration.liveWorkers; const { t } = useTranslation(); - const [overview, setOverview] = useState([]); const prev = useRef(null); const baselineWs = useRef(null); - const granted = useRef(null); + const baselineReady = useRef>(new Set()); + const sourceReadyPrevious = useRef>({ + needs: false, + review: false, + stalled: false, + quota: false, + }); + const liveWorkersReadyPrevious = useRef(false); + const asksReadyPrevious = useRef(false); + const workspaceNeedsReadyPrevious = useRef(false); + const globalBaselineReady = useRef({ + asks: false, + review: false, + stalled: false, + quota: false, + }); + const reviewNotificationsEnabledPrevious = useRef( + notifyEnabled && notifyCategories.review, + ); + const [permissionState, setPermissionState] = useState("prompt"); + const [windowFocused, setWindowFocused] = useState(null); + const lastBadge = useRef(null); - // OS permission, settled once per enable. Only a never-asked "prompt" state - // raises the system dialog; a past refusal stays silent (Settings shows the - // denied hint with the System-Settings jump). + const threadsById = useRef< + Record + >({}); + useEffect(() => { + const m: Record< + number, + { title: string; workspaceId?: number; kind?: string } + > = { + ...threadsById.current, + }; + for (const [idStr, ws] of Object.entries(threadWorkspaceById)) { + const id = Number(idStr); + const prevMeta = m[id]; + m[id] = { + title: prevMeta?.title ?? `#${id}`, + workspaceId: ws, + kind: threadKindById[id] ?? prevMeta?.kind, + }; + } + for (const th of threads) { + m[th.id] = { + title: th.title, + workspaceId: th.workspace_id, + kind: th.kind, + }; + } + threadsById.current = m; + }, [threads, threadWorkspaceById, threadKindById]); + + // OS permission, settled once per enable. useEffect(() => { if (!notifyEnabled) return; void ensureNotifyPermission().then((p) => { - granted.current = p === "granted"; + setPermissionState(p); }); }, [notifyEnabled]); - // Review-transition source: our own modest overview poll. + // Track OS-level window focus. useEffect(() => { - if (!notifyEnabled || activeWorkspaceId == null) { - setOverview([]); - return; - } - let alive = true; - const tick = async () => { + let unFocus: (() => void) | undefined; + let cancelled = false; + void (async () => { try { - const o = await api.workspaceOverview(activeWorkspaceId); - if (alive) setOverview(o); + const win = getCurrentWindow(); + const focused = await win.isFocused(); + if (!cancelled) setWindowFocused(focused); + unFocus = await win.onFocusChanged(({ payload }) => { + if (cancelled) return; + setWindowFocused(payload); + if (payload && notifyEnabled) { + void notifyPermission().then((p) => { + if (!cancelled) setPermissionState(p); + }); + } + }); + if (cancelled) { + unFocus(); + unFocus = undefined; + } } catch { - /* backend unavailable */ + if (!cancelled) setWindowFocused(null); } - }; - void tick(); - const h = setInterval(tick, OVERVIEW_POLL_MS); + })(); return () => { - alive = false; - clearInterval(h); + cancelled = true; + unFocus?.(); }; - }, [notifyEnabled, activeWorkspaceId]); + }, [notifyEnabled]); + // Live click / action deep-link from the native bridge. Keep this effect + // dependency-light so we do not re-drain pending opens on every navigation. + const navDepsRef = useRef({ + selectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + activeWorkspaceId, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, + needsHydrated, + liveWorkersHydrated, + }); + const navigationTailRef = useRef>(Promise.resolve()); + const navigationSelectWorkspace = useCallback( + async (id: number) => { + // React's passive effects can lag a notification event by one tick. Keep + // the navigation snapshot synchronously honest while the store starts its + // async workspace load so a following click cannot open over the reset. + navDepsRef.current.activeWorkspaceId = id; + navDepsRef.current.workspaceLoading = true; + navDepsRef.current.workspaceRestoring = false; + navDepsRef.current.workspaceLoadReady = false; + await selectWorkspace(id); + }, + [selectWorkspace], + ); useEffect(() => { - const next = snapshotOf(needs, asks, writeTriggers, overview); - const base = baselineWs.current === activeWorkspaceId ? prev.current : null; - prev.current = next; - baselineWs.current = activeWorkspaceId; - if (!base) return; // first load / workspace switch: baseline only - if (!notifyEnabled || granted.current !== true) return; - if (document.hasFocus()) return; // already looking at the app - for (const ev of diffForNotifications(base, next)) { + navDepsRef.current = { + selectWorkspace: navigationSelectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + activeWorkspaceId, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, + needsHydrated, + liveWorkersHydrated, + }; + }, [ + navigationSelectWorkspace, + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + activeWorkspaceId, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, + needsHydrated, + liveWorkersHydrated, + ]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + let takenPending: OsNotifyOpenEvent | null = null; + const openInFlight = new Map>(); + + const handleOpen = (payload: OsNotifyOpenEvent): Promise => { + const key = notifyOpenKey(payload); + const existing = openInFlight.get(key); + if (existing) return existing; + const task = navigationTailRef.current.then(async () => { + const appliedImmediately = await handleNotifyOpen(payload, navDepsRef.current); + if (!appliedImmediately) return false; + // Native delivery retains the payload until the frontend confirms it was + // handled. This also prevents a live event from being drained again by + // the ordered cold-start check below. + try { + await api.osNotifyAckOpen(payload); + } catch { + /* pure-vite */ + } + return true; + }); + navigationTailRef.current = task.then( + () => undefined, + () => undefined, + ); + openInFlight.set(key, task); + void task.then( + () => { + if (openInFlight.get(key) === task) openInFlight.delete(key); + }, + () => { + if (openInFlight.get(key) === task) openInFlight.delete(key); + }, + ); + return task; + }; + + void (async () => { try { - sendNotification({ - title: ev.kind === "needs" ? t("notify.needsTitle") : t("notify.reviewTitle"), - body: - ev.count === 1 - ? ev.sample - : t(ev.kind === "needs" ? "notify.needsBody" : "notify.reviewBody", { - count: ev.count, - }), + unlisten = await listen("notify://open", (event) => { + void handleOpen(event.payload).catch(() => undefined); }); + if (cancelled) { + unlisten?.(); + return; + } + // Register the listener before taking the retained payload. A click + // arriving during async listen() setup is then either delivered live + // or consumed by this drain, never lost between the two effects. + const pending = await api.osNotifyTakePendingOpen(); + if (!pending) return; + takenPending = pending; + if (cancelled) { + await api.osNotifyRestorePendingOpen(pending); + takenPending = null; + return; + } + const appliedImmediately = await handleOpen(pending); + if (appliedImmediately) { + takenPending = null; + } else { + // `take` removes the native slot. Keep the payload there as well as in + // sessionStorage while deferred navigation waits for workspace state. + await api.osNotifyRestorePendingOpen(pending); + takenPending = null; + } } catch { - /* never let a failed ping disturb the app */ + if (takenPending) { + try { + await api.osNotifyRestorePendingOpen(takenPending); + takenPending = null; + } catch { + /* pure-vite / older backend */ + } + } + /* pure-vite */ + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + // Finish a deep link that had to switch workspaces first. Wait until + // selectWorkspace has finished loading (workspaceLoadSeq) so a concurrent + // selection reset cannot clear the destination right after we navigate. + const pendingAppliedKey = useRef(null); + const pendingApplyingKey = useRef(null); + const pendingNavRetryTimer = useRef | null>(null); + const [pendingNavRetry, setPendingNavRetry] = useState(0); + useEffect(() => { + return () => { + if (pendingNavRetryTimer.current != null) { + clearTimeout(pendingNavRetryTimer.current); + pendingNavRetryTimer.current = null; } + }; + }, []); + useEffect(() => { + void pendingNavRetry; + const pending = takePendingNav(); + if (!pending) return; + if ( + pending.workspaceId != null && + pending.workspaceId !== activeWorkspaceId + ) { + // Not settled yet — put it back. + putPendingNav(pending); + return; + } + if (pending.loadGeneration !== NAV_RUNTIME_GENERATION) { + // sessionStorage survives a WebView reload, but workspaceLoadSeq does + // not. The original absolute marker belongs to the old runtime; after a + // reload only the current workspace load needs to settle. + pending.loadGeneration = NAV_RUNTIME_GENERATION; + pending.expectedLoadSeq = workspaceLoadSeq + (workspaceLoading ? 1 : 0); + } + if (workspaceLoading) { + // Active workspace id may already match, but selectWorkspace is still + // mid-reset/fetch. Wait for it to finish. + putPendingNav(pending); + return; + } + if (workspaceRestoring) { + putPendingNav(pending); + return; + } + if (activeWorkspaceId != null && !workspaceLoadReady) { + // A failed workspace load must never be treated as a route prerequisite; + // leave the click pending until a later selection commits successfully. + putPendingNav(pending); + return; + } + if ( + pending.expectedLoadSeq != null && + workspaceLoadSeq < pending.expectedLoadSeq + ) { + putPendingNav(pending); + return; + } + const applicationKey = `${workspaceLoadSeq}:${notifyOpenKey(pending)}`; + if ( + pendingAppliedKey.current === applicationKey || + pendingApplyingKey.current === applicationKey + ) { + // Already applied for this load. + return; + } + // If a workspace switch just started, wait for its load seq bump. + if (pending.workspaceId != null && workspaceLoadSeq === 0) { + putPendingNav(pending); + return; } - }, [needs, asks, writeTriggers, overview, notifyEnabled, activeWorkspaceId, t]); + const needsIntent = planNotifyOpen(pending).some( + (intent) => intent.type === "needs", + ); + if (needsIntent && !needsHydrated) { + // selectWorkspace clears the old workspace's Needs rows; wait until the + // target workspace has completed its authoritative Needs refresh. + putPendingNav(pending); + return; + } + const workerIntent = planNotifyOpen(pending).some( + (intent) => intent.type === "direction" && intent.sessionId != null, + ); + if (workerIntent && !liveWorkersHydrated) { + // A retained stalled-worker click must wait for adopted sessions before + // resolving the exact session route; otherwise it falls back to lead. + putPendingNav(pending); + return; + } + pendingApplyingKey.current = applicationKey; + const application = navigationTailRef.current.then(() => + applyNotifyIntents(pending, { + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + }), + ); + navigationTailRef.current = application.catch(() => undefined); + void application.then( + () => { + pendingApplyingKey.current = null; + pendingAppliedKey.current = applicationKey; + void api.osNotifyAckOpen(pending).catch(() => { + /* pure-vite / older backend */ + }); + }, + () => { + pendingApplyingKey.current = null; + putPendingNavIfUnchanged(pending); + if (pendingNavRetryTimer.current == null) { + pendingNavRetryTimer.current = setTimeout(() => { + pendingNavRetryTimer.current = null; + setPendingNavRetry((n) => n + 1); + }, 1_000); + } + } + ); + }, [ + activeWorkspaceId, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, + needsHydrated, + liveWorkersHydrated, + goToDirectionRef, + openNeeds, + openSettings, + openCurator, + pendingNavRetry, + ]); + + // Dock / taskbar badge tracks actionable Needs-you across all workspaces. + // needsByWorkspace already includes questions + asks + writeTriggers + + // action-required notices per workspace (self-clearing notices stay out). + useEffect(() => { + const count = Object.values(needsByWorkspace).reduce((a, b) => a + b, 0); + if (lastBadge.current === count) return; + lastBadge.current = count; + void setDockBadge(count); + }, [needsByWorkspace]); + + // Notification sources hydrate asynchronously after mount / workspace switch. + // The store marks each source only after its authoritative request completes; + // do not arm diffs from a fixed timeout while those requests are still empty. + // Each category keeps its own baseline so a ready source can accumulate real + // events while an unrelated source is still retrying. + useEffect(() => { + const sourceWorkspaceMatches = + notificationHydration.workspaceId === activeWorkspaceId; + const asksReady = sourceWorkspaceMatches && notificationHydration.asks; + const workspaceNeedsReady = + sourceWorkspaceMatches && notificationHydration.workspaceNeeds; + const liveWorkersReady = + sourceWorkspaceMatches && notificationHydration.liveWorkers; + const sessionRefs: Record< + number, + { + info: { session_id: number }; + status: (typeof sessions)[number]["status"]; + directionId: number; + repoId: number; + threadId: number; + eventDriven?: boolean; + workspaceId?: number; + } + > = {}; + for (const [sid, s] of Object.entries(sessions)) { + if (!liveWorkersReady && !s.eventDriven) continue; + sessionRefs[Number(sid)] = { + info: { session_id: s.info.session_id }, + status: s.status, + directionId: s.directionId, + repoId: s.repoId, + threadId: s.threadId, + eventDriven: s.eventDriven, + workspaceId: s.workspaceId, + }; + } + const next = snapshotOf( + needs, + asks, + writeTriggers, + notificationOverview, + sessionRefs, + leadTurn, + processQuota, + threadsById.current, + activeWorkspaceId, + ); + const reviewNotificationsEnabled = notifyEnabled && notifyCategories.review; + const reviewMuteBegan = + reviewNotificationsEnabledPrevious.current && !reviewNotificationsEnabled; + reviewNotificationsEnabledPrevious.current = reviewNotificationsEnabled; + if (reviewMuteBegan) { + // A muted review source must be rebaselined when it returns; transitions + // that happened while muted are not notification events. + globalBaselineReady.current.review = false; + baselineReady.current.delete("review"); + } + // Do not advance any baseline while the current workspace selection is + // resetting. Global asks/stalls/quota/review transitions must survive until + // the settled snapshot can be compared. + if (workspaceLoading) return; + const sourceReady: Record = { + needs: asksReady || workspaceNeedsReady, + review: + reviewNotificationsEnabled && + sourceWorkspaceMatches && + notificationHydration.overview, + // Lead-turn pushes and locally observed sessions are authoritative even + // while the adopted-worker snapshot is retrying. + stalled: sourceWorkspaceMatches, + quota: notificationHydration.quota, + }; + const eventStalledKeys = new Set(); + for (const s of Object.values(sessionRefs)) { + if (s.eventDriven && s.status === "stalled") { + eventStalledKeys.add(`stall:worker:${s.info.session_id}`); + } + } + for (const [tidStr, turn] of Object.entries(leadTurn)) { + if (turn.state !== "stalled") continue; + const tid = Number(tidStr); + const kind = threadsById.current[tid]?.kind; + eventStalledKeys.add( + kind === "curator" ? `stall:curator:${tid}` : `stall:lead:${tid}`, + ); + } + const sameWorkspace = + baselineWs.current === activeWorkspaceId && prev.current != null; + const needsSourceBecameUnready = + notifyCategories.needs && + ((asksReadyPrevious.current && !asksReady) || + (workspaceNeedsReadyPrevious.current && !workspaceNeedsReady)); + const liveWorkersBecameReady = + liveWorkersReady && !liveWorkersReadyPrevious.current; + const sourceBecameUnready = + needsSourceBecameUnready || + NOTIFY_CATEGORIES.some( + (kind) => + notifyCategories[kind] && + sourceReadyPrevious.current[kind] && + !sourceReady[kind], + ); + if (!sameWorkspace || sourceBecameUnready) { + const previous = prev.current; + const preserveGlobal = { ...globalBaselineReady.current }; + baselineWs.current = activeWorkspaceId; + prev.current = emptyNotifySnapshot(); + if (previous) { + if (preserveGlobal.asks) { + prev.current.needs = new Map( + [...previous.needs].filter(([key]) => key.startsWith("ask:")), + ); + } + if (preserveGlobal.stalled) { + prev.current.stalled = new Map(previous.stalled); + } + if (preserveGlobal.review) { + prev.current.review = new Map(previous.review); + } + if (preserveGlobal.quota) { + prev.current.quota = new Map(previous.quota); + } + } + baselineReady.current.clear(); + globalBaselineReady.current = preserveGlobal; + liveWorkersReadyPrevious.current = false; + asksReadyPrevious.current = false; + workspaceNeedsReadyPrevious.current = false; + } + sourceReadyPrevious.current = sourceReady; + liveWorkersReadyPrevious.current = liveWorkersReady; + const asksBecameReady = asksReady && !asksReadyPrevious.current; + const workspaceNeedsBecameReady = + workspaceNeedsReady && !workspaceNeedsReadyPrevious.current; + asksReadyPrevious.current = asksReady; + workspaceNeedsReadyPrevious.current = workspaceNeedsReady; + const base = prev.current; + if (!base) return; + const isNeedsKeyReady = (key: string): boolean => { + if (key.startsWith("ask:")) return asksReady; + return workspaceNeedsReady; + }; + for (const kind of NOTIFY_CATEGORIES) { + if (sourceReady[kind] && !baselineReady.current.has(kind)) { + if (kind === "quota" && notificationHydration.quotaPushPending) { + // A push arrived before the initial quota snapshot. Keep the empty + // (or preserved global) baseline so the first degraded transition is + // diffed instead of being mistaken for initial state. + globalBaselineReady.current.quota = true; + baselineReady.current.add(kind); + continue; + } + if (kind === "needs") { + const initialNeeds = [...next.needs].filter(([key]) => { + if (key.startsWith("ask:")) { + return asksReady && !globalBaselineReady.current.asks; + } + return workspaceNeedsReady; + }); + base.needs = new Map([ + ...base.needs, + ...initialNeeds, + ]); + if (asksReady) { + globalBaselineReady.current.asks = true; + } + } else if (kind === "review" && globalBaselineReady.current.review) { + // Keep the all-workspace review baseline across a workspace reset. + } else if ( + kind === "stalled" && globalBaselineReady.current.stalled + ) { + // Keep the global stalled baseline across a workspace reset. + } else if (kind === "quota" && globalBaselineReady.current.quota) { + // Keep the global quota baseline across a workspace reset. + } else { + base[kind] = new Map(next[kind]); + if (kind === "stalled") { + globalBaselineReady.current.stalled = true; + } + if (kind === "review") { + globalBaselineReady.current.review = true; + } + if (kind === "quota") { + globalBaselineReady.current.quota = true; + } + } + baselineReady.current.add(kind); + } + } + if (asksBecameReady && !globalBaselineReady.current.asks) { + for (const [key, entry] of next.needs) { + if (key.startsWith("ask:")) base.needs.set(key, entry); + } + globalBaselineReady.current.asks = true; + } + if (workspaceNeedsBecameReady) { + for (const [key, entry] of next.needs) { + if (!key.startsWith("ask:")) base.needs.set(key, entry); + } + } + if (liveWorkersBecameReady) { + // Initial adopted-worker rows are not transitions. Keep lead/local event + // keys in the diff baseline so stalls observed before hydration survive. + for (const [key, entry] of next.stalled) { + if (!eventStalledKeys.has(key)) base.stalled.set(key, entry); + } + } + const nextForDiff = { + ...next, + needs: new Map( + [...next.needs].filter(([key]) => isNeedsKeyReady(key)), + ), + }; + // Diff each authoritative source independently. A slow or failed overview + // request must not suppress Needs, stalled, or quota notifications whose + // sources are already ready. Unready categories keep their previous + // snapshot until their own source becomes authoritative. + const diffCategories: NotifyCategoryFlags = { + needs: notifyCategories.needs && sourceReady.needs, + review: notifyCategories.review && sourceReady.review, + stalled: notifyCategories.stalled && sourceReady.stalled, + quota: notifyCategories.quota && sourceReady.quota, + }; + const events = diffForNotifications(base, nextForDiff, diffCategories); + // Advance ready categories before the foreground/quiet-hours gate, + // preserving the existing behavior that suppressed events are not replayed + // later while leaving unready categories untouched. + const baselineNeeds = new Map( + [...base.needs].filter(([key]) => !isNeedsKeyReady(key)), + ); + for (const [key, entry] of next.needs) { + if (isNeedsKeyReady(key)) baselineNeeds.set(key, entry); + } + const advanced: NotifySnapshot = { ...base }; + for (const kind of NOTIFY_CATEGORIES) { + if (!sourceReady[kind]) continue; + advanced[kind] = kind === "needs" + ? baselineNeeds + : new Map(next[kind]); + } + prev.current = advanced; + if (!notifyEnabled || permissionState !== "granted") return; + if ( + isAppInForeground({ + windowFocused, + documentFocused: document.hasFocus(), + }) + ) { + return; + } + if (isInQuietHours(quietHours)) return; + + if (events.length === 0) return; + + void (async () => { + let sent = false; + for (const ev of events) { + const keys = notifyCopyKeys(ev.kind); + const title = t(keys.title); + const body = + ev.count === 1 ? ev.sample : t(keys.body, { count: ev.count }); + try { + await sendOsNotification(title, body, ev.route); + sent = true; + } catch { + /* never let a failed ping disturb the app */ + } + } + if (sent) void requestAttention(); + })(); + }, [ + needs, + asks, + writeTriggers, + notificationOverview, + sessions, + leadTurn, + processQuota, + notificationHydration, + notifyEnabled, + notifyCategories, + quietHours, + activeWorkspaceId, + workspaceLoading, + windowFocused, + t, + permissionState, + threads, + threadWorkspaceById, + threadKindById, + ]); } diff --git a/src/lib/notificationsCore.ts b/src/lib/notificationsCore.ts new file mode 100644 index 00000000..4d99e956 --- /dev/null +++ b/src/lib/notificationsCore.ts @@ -0,0 +1,510 @@ +import type { + NeedItem, + PermissionAsk, + ProcessQuotaStatus, + SessionStatus, + ThreadOverview, + TurnState, + WriteTrigger, +} from "./types"; + +/** Notification preference helpers (localStorage-backed). Kept separate from + * the React hook so the store can import without a cycle through notifications. */ + +export type NotifyCategory = "needs" | "review" | "stalled" | "quota"; + +export const NOTIFY_CATEGORIES: readonly NotifyCategory[] = [ + "needs", + "review", + "stalled", + "quota", +] as const; + +export type NotifyCategoryFlags = Record; + +/** Overview row enriched by the all-workspace notification poll. */ +export type NotificationOverview = ThreadOverview & { workspace_id?: number }; + +export const DEFAULT_NOTIFY_CATEGORIES: NotifyCategoryFlags = { + needs: true, + review: true, + stalled: true, + quota: true, +}; + +/** Quiet hours window in local wall-clock minutes. End may wrap past midnight. */ +export interface QuietHours { + enabled: boolean; + /** Inclusive start, minutes since local midnight (0–1439). */ + startMin: number; + /** Exclusive end, minutes since local midnight (0–1439). */ + endMin: number; +} + +export const DEFAULT_QUIET_HOURS: QuietHours = { + enabled: false, + startMin: 22 * 60, + endMin: 8 * 60, +}; + +function clampMinute(n: number): number { + if (!Number.isFinite(n)) return 0; + const m = Math.trunc(n) % (24 * 60); + return m < 0 ? m + 24 * 60 : m; +} + +/** Parse category flags stored as JSON; unknown / partial shapes fall back to + * defaults so a future category lands ON without a migration. */ +export function parseNotifyCategories(raw: string | null): NotifyCategoryFlags { + const out: NotifyCategoryFlags = { ...DEFAULT_NOTIFY_CATEGORIES }; + if (!raw) return out; + try { + const parsed = JSON.parse(raw) as Partial>; + for (const key of NOTIFY_CATEGORIES) { + if (typeof parsed[key] === "boolean") out[key] = parsed[key]; + } + } catch { + /* keep defaults */ + } + return out; +} + +export function serializeNotifyCategories(flags: NotifyCategoryFlags): string { + return JSON.stringify(flags); +} + +export function parseQuietHours(raw: string | null): QuietHours { + if (!raw) return { ...DEFAULT_QUIET_HOURS }; + try { + const parsed = JSON.parse(raw) as Partial; + const startMin = + typeof parsed.startMin === "number" && Number.isFinite(parsed.startMin) + ? clampMinute(parsed.startMin) + : DEFAULT_QUIET_HOURS.startMin; + const endMin = + typeof parsed.endMin === "number" && Number.isFinite(parsed.endMin) + ? clampMinute(parsed.endMin) + : DEFAULT_QUIET_HOURS.endMin; + return { + enabled: parsed.enabled === true, + startMin, + endMin, + }; + } catch { + return { ...DEFAULT_QUIET_HOURS }; + } +} + +export function serializeQuietHours(qh: QuietHours): string { + return JSON.stringify({ + enabled: qh.enabled, + startMin: clampMinute(qh.startMin), + endMin: clampMinute(qh.endMin), + }); +} + +/** `HH:MM` for Settings inputs; always two-digit. */ +export function formatQuietTime(min: number): string { + const m = clampMinute(min); + const hh = String(Math.floor(m / 60)).padStart(2, "0"); + const mm = String(m % 60).padStart(2, "0"); + return `${hh}:${mm}`; +} + +/** Parse an `` value; invalid → null. */ +export function parseQuietTime(value: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!m) return null; + const hh = Number(m[1]); + const mm = Number(m[2]); + if (!Number.isInteger(hh) || !Number.isInteger(mm)) return null; + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null; + return hh * 60 + mm; +} + +/** + * Quiet-hours membership. When start === end the window is empty (never quiet). + * When start < end it is a same-day range; otherwise it wraps past midnight. + */ +export function isInQuietHours( + qh: QuietHours, + now: Date = new Date(), +): boolean { + if (!qh.enabled) return false; + if (qh.startMin === qh.endMin) return false; + const cur = now.getHours() * 60 + now.getMinutes(); + if (qh.startMin < qh.endMin) { + return cur >= qh.startMin && cur < qh.endMin; + } + return cur >= qh.startMin || cur < qh.endMin; +} + + +/** Minimal session shape for stalled detection — avoids importing store. */ +export interface NotifySessionRef { + info: { session_id: number }; + status: SessionStatus; + directionId: number; + repoId: number; + threadId: number; + /** True when the frontend observed this session from a local/event-driven start. */ + eventDriven?: boolean; + /** Owning workspace when known (adopted live sessions may predate a visit). */ + workspaceId?: number; +} + +/** Deep-link fields carried through the OS notification `user_info`. */ +export interface NotifyRoute { + kind: NotifyCategory; + threadId?: number; + directionId?: number; + /** Prefer opening this exact worker session when set. */ + repoId?: number; + sessionId?: number; + askId?: number; + workspaceId?: number; + /** Prefer the Needs-you surface (write triggers, action-required notices). */ + openNeeds?: boolean; + /** Prefer the Repo Map curator surface instead of board lead chat. */ + openCurator?: boolean; +} + +/** One snapshot entry: human sample line + optional deep-link route. */ +export interface NotifyEntry { + sample: string; + route: NotifyRoute; +} + +/** Notify-relevant state reduced to stable identity keys → entry. */ +export interface NotifySnapshot { + needs: Map; + review: Map; + stalled: Map; + quota: Map; +} + +export function emptyNotifySnapshot(): NotifySnapshot { + return { + needs: new Map(), + review: new Map(), + stalled: new Map(), + quota: new Map(), + }; +} + +export function snapshotOf( + needs: NeedItem[], + asks: PermissionAsk[], + triggers: WriteTrigger[], + overview: NotificationOverview[], + sessions: Record, + leadTurn: Record, + processQuota: ProcessQuotaStatus | null, + threadsById: Record< + number, + { title: string; workspaceId?: number; kind?: string } | undefined + > = {}, + workspaceId: number | null = null, +): NotifySnapshot { + const n = new Map(); + for (const it of needs) { + // Self-clearing notices stay silent; questions and action-required notices ping. + if (it.kind === "notice") continue; + const openNeeds = it.kind === "notice_action_required"; + const ws = + threadsById[it.thread_id]?.workspaceId ?? workspaceId ?? undefined; + n.set(`need:${it.ask_id}`, { + sample: `${it.thread_title} · ${it.direction_name}`, + route: { + kind: "needs", + threadId: it.thread_id, + directionId: it.direction_id, + askId: it.ask_id, + workspaceId: ws, + openNeeds, + }, + }); + } + for (const a of asks) { + const directionId = Number(a.dir); + // Global permission asks must not inherit the currently selected workspace. + // Prefer thread cache, then backend-provided ownership; omit when unknown. + const ws = + threadsById[a.thread]?.workspaceId ?? a.workspace_id ?? undefined; + n.set(`ask:${a.id}`, { + sample: `${a.thread_title} · ${a.dir_name}`, + route: { + kind: "needs", + threadId: a.thread, + directionId: Number.isFinite(directionId) ? directionId : undefined, + askId: a.id, + workspaceId: ws == null ? undefined : ws, + openNeeds: true, + }, + }); + } + for (const w of triggers) { + const ws = threadsById[w.thread_id]?.workspaceId ?? workspaceId ?? undefined; + n.set(`wt:${w.thread_id}:${w.index}`, { + sample: `${w.thread_title} · ${w.name}`, + route: { + kind: "needs", + threadId: w.thread_id, + workspaceId: ws, + openNeeds: true, + }, + }); + } + + const r = new Map(); + for (const o of overview) { + o.statuses.forEach((s, i) => { + if (s !== "review") return; + const directionId = o.direction_ids[i]; + const ws = + o.workspace_id ?? + threadsById[o.thread_id]?.workspaceId ?? + workspaceId ?? + undefined; + r.set(`rev:${directionId}`, { + sample: o.title, + route: { + kind: "review", + threadId: o.thread_id, + directionId, + workspaceId: ws, + }, + }); + }); + } + + const stalled = new Map(); + for (const s of Object.values(sessions)) { + if (s.status !== "stalled") continue; + const meta = threadsById[s.threadId]; + // Global live sessions may predate a visit to their workspace; prefer the + // session's own workspace, then thread metadata. Never fall back to the + // currently selected workspace for global stalled workers. + const ws = s.workspaceId ?? meta?.workspaceId; + if (ws == null) continue; + const title = meta?.title ?? `#${s.threadId}`; + stalled.set(`stall:worker:${s.info.session_id}`, { + sample: `${title} · #${s.directionId}`, + route: { + kind: "stalled", + threadId: s.threadId, + directionId: s.directionId, + repoId: s.repoId, + sessionId: s.info.session_id, + workspaceId: ws, + }, + }); + } + for (const [tidStr, turn] of Object.entries(leadTurn)) { + if (turn.state !== "stalled") continue; + const tid = Number(tidStr); + const meta = threadsById[tid]; + // Hidden curator leads belong in the Repo Map panel, not board lead chat. + if (meta?.kind === "curator") { + const ws = meta.workspaceId; + if (ws == null) continue; + stalled.set(`stall:curator:${tid}`, { + sample: meta.title ?? `#${tid}`, + route: { + kind: "stalled", + threadId: tid, + workspaceId: ws, + openCurator: true, + }, + }); + continue; + } + const ws = meta?.workspaceId; + if (ws == null) continue; + const title = meta?.title ?? `#${tid}`; + stalled.set(`stall:lead:${tid}`, { + sample: title, + route: { + kind: "stalled", + threadId: tid, + workspaceId: ws, + }, + }); + } + + const quota = new Map(); + if (processQuota?.status === "degraded") { + // Identity is the transition sequence so a later re-degrade re-notifies. + const key = `quota:degraded:${processQuota.transitionSeq}`; + const limit = processQuota.processLimit ?? 0; + const sample = + limit > 0 + ? `${processQuota.processCount} / ${limit}` + : `${processQuota.processCount}`; + quota.set(key, { + sample, + route: { + kind: "quota", + // Quota / Resources is global — do not pin a workspace that may be stale by click time. + }, + }); + } + + return { needs: n, review: r, stalled, quota }; +} + +export interface NotifyEvent { + kind: NotifyCategory; + count: number; + /** Context of the first new item, used as the body when count === 1. */ + sample: string; + /** Deep-link of the first new item (best-effort; multi-item pings open the first). */ + route: NotifyRoute; +} + +/** New keys in `next` that weren't in `prev` — the things worth a ping. */ +export function diffForNotifications( + prev: NotifySnapshot, + next: NotifySnapshot, + enabled: NotifyCategoryFlags = DEFAULT_NOTIFY_CATEGORIES, +): NotifyEvent[] { + const out: NotifyEvent[] = []; + for (const kind of NOTIFY_CATEGORIES) { + if (!enabled[kind]) continue; + const fresh = [...next[kind]].filter(([k]) => !prev[kind].has(k)); + if (fresh.length > 0) { + const first = fresh[0][1]; + out.push({ + kind, + count: fresh.length, + sample: first.sample, + route: first.route, + }); + } + } + return out; +} + +/** Title / body i18n keys for each category. Plural forms use i18next `_one/_other`. */ +export function notifyCopyKeys(kind: NotifyCategory): { + title: string; + body: string; +} { + switch (kind) { + case "needs": + return { title: "notify.needsTitle", body: "notify.needsBody" }; + case "review": + return { title: "notify.reviewTitle", body: "notify.reviewBody" }; + case "stalled": + return { title: "notify.stalledTitle", body: "notify.stalledBody" }; + case "quota": + return { title: "notify.quotaTitle", body: "notify.quotaBody" }; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +/** Pending Needs-you badge count (actionable only). Exported for Dock badge. */ +export function badgeCountFrom( + needs: NeedItem[], + asks: PermissionAsk[], + writeTriggers: WriteTrigger[], +): number { + return ( + needs.filter((n) => n.kind === "question" || n.kind === "notice_action_required") + .length + + asks.length + + writeTriggers.length + ); +} + +/** + * Focus / visibility gate. Prefer Tauri window focus when available; fall back + * to document focus so a visible-but-unfocused window still receives pings. + */ +export function isAppInForeground( + opts: { + windowFocused?: boolean | null; + documentFocused?: boolean; + } = {}, +): boolean { + if (opts.windowFocused === true) return true; + if (opts.windowFocused === false) return false; + if (typeof opts.documentFocused === "boolean") return opts.documentFocused; + if (typeof document !== "undefined") return document.hasFocus(); + return false; +} + + +/** Pure navigation intent for a notification click. UI layer applies it. */ +export type NotifyOpenIntent = + | { type: "workspace"; workspaceId: number } + | { + type: "direction"; + threadId: number; + direction: string; + repoId?: number; + sessionId?: number; + } + | { type: "needs" } + | { type: "resources" } + | { type: "curator" }; + +export function planNotifyOpen(payload: { + kind: string; + threadId?: number | null; + directionId?: number | null; + repoId?: number | null; + sessionId?: number | null; + workspaceId?: number | null; + openNeeds?: boolean | null; + openCurator?: boolean | null; +}): NotifyOpenIntent[] { + const out: NotifyOpenIntent[] = []; + // Quota is process-global (Resources). Never switch workspaces for it, even if + // an older payload still carries a workspaceId. + if (payload.kind === "quota") { + out.push({ type: "resources" }); + return out; + } + if (payload.workspaceId != null) { + out.push({ type: "workspace", workspaceId: payload.workspaceId }); + } + // Hidden curator stalls open the Repo Map curator panel, not board lead chat. + if (payload.openCurator) { + out.push({ type: "curator" }); + return out; + } + // Write triggers / action-required notices: open the Needs-you surface so the + // human can act on the control the notification advertised. + if (payload.openNeeds) { + out.push({ type: "needs" }); + return out; + } + if (payload.threadId != null) { + const direction = + payload.directionId != null ? String(payload.directionId) : "lead"; + if (payload.repoId != null || payload.sessionId != null) { + out.push({ + type: "direction", + threadId: payload.threadId, + direction, + repoId: payload.repoId ?? undefined, + sessionId: payload.sessionId ?? undefined, + }); + } else { + out.push({ + type: "direction", + threadId: payload.threadId, + direction, + }); + } + return out; + } + if (payload.kind === "needs") { + out.push({ type: "needs" }); + } + return out; +} diff --git a/src/lib/notifyPrefs.ts b/src/lib/notifyPrefs.ts new file mode 100644 index 00000000..480a4dde --- /dev/null +++ b/src/lib/notifyPrefs.ts @@ -0,0 +1,16 @@ +/** @deprecated Import from `./notificationsCore` (or `./notifications`) instead. */ +export { + DEFAULT_NOTIFY_CATEGORIES, + DEFAULT_QUIET_HOURS, + formatQuietTime, + isInQuietHours, + NOTIFY_CATEGORIES, + parseNotifyCategories, + parseQuietHours, + parseQuietTime, + serializeNotifyCategories, + serializeQuietHours, + type NotifyCategory, + type NotifyCategoryFlags, + type QuietHours, +} from "./notificationsCore"; diff --git a/src/lib/storageKeys.ts b/src/lib/storageKeys.ts index 6b1435d8..cda73e46 100644 --- a/src/lib/storageKeys.ts +++ b/src/lib/storageKeys.ts @@ -12,6 +12,8 @@ export const STORAGE_KEYS = { reviewSkill: "weft-review-skill", autoReview: "weft-auto-review", notify: "weft-notify", + notifyCategories: "weft-notify-categories", + notifyQuietHours: "weft-notify-quiet-hours", keepAwake: "weft-keep-awake", dangerousMode: "weft-dangerous", idleCapMins: "weft-idle-cap-mins", diff --git a/src/lib/types.ts b/src/lib/types.ts index b8fafefb..8904c61d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -189,6 +189,7 @@ export interface Worktree { export interface SessionInfo { session_id: number; + thread_id: number; repo: string; worktree: string; branch: string; @@ -207,6 +208,8 @@ export interface LiveWorkerSlot { direction_id: number; repo_id: number; thread_id: number; + /** Owning workspace when known (boot-revived workers may predate a visit). */ + workspace_id?: number | null; busy: boolean; queue: QueuedItem[]; } @@ -766,6 +769,8 @@ export interface PermissionAsk { /** owning thread title + asking task name, for context on the card. */ thread_title: string; dir_name: string; + /** Owning workspace when backend could resolve the thread. */ + workspace_id?: number | null; } /** A persisted "full access" grant: every ask from this (thread, dir) auto-allows. */ diff --git a/src/nav/SettingsDialog.tsx b/src/nav/SettingsDialog.tsx index 252a5e2c..9f5a8329 100644 --- a/src/nav/SettingsDialog.tsx +++ b/src/nav/SettingsDialog.tsx @@ -37,9 +37,14 @@ import { api } from "../lib/api"; import { cn } from "../lib/cn"; import { spawnableToolsOf } from "../lib/toolStatus.ts"; import { + canOpenSystemNotificationSettings, ensureNotifyPermission, + formatQuietTime, + NOTIFY_CATEGORIES, notifyPermission, openSystemNotificationSettings, + parseQuietTime, + type NotifyCategory, type NotifyPermission, } from "../lib/notifications"; import { useStore, type SettingsPage } from "../state/store"; @@ -79,17 +84,27 @@ const NAV_GROUPS: { labelKey: string; items: NavItem[] }[] = [ export function SettingsScreen() { const { t } = useTranslation(); - const { closeSettings, settingsInitialPage, clearSettingsInitialPage } = useStore(); + const { + closeSettings, + settingsInitialPage, + clearSettingsInitialPage, + settingsRequestedPage, + } = useStore(); // Seeded once from the store's one-shot request (e.g. the quota banner's "View // details" landing on Resources instead of General) — captured at mount, not // subscribed, so navigating within an already-open Settings never gets yanked // back by a stale request. Consumed immediately after so the NEXT openSettings() // (e.g. the nav rail's plain gear icon) isn't stuck reusing this destination. + // Live deep links while Settings is already open go through settingsRequestedPage. const [active, setActive] = useState(() => settingsInitialPage ?? "general"); useEffect(() => { clearSettingsInitialPage(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (settingsRequestedPage == null) return; + setActive(settingsRequestedPage.page); + }, [settingsRequestedPage]); const [query, setQuery] = useState(""); const groups = useMemo(() => { @@ -215,10 +230,28 @@ function GeneralSettings() { refreshDefaultTool, notifyEnabled, setNotifyEnabled, + notifyCategories, + setNotifyCategory, + quietHours, + setQuietHours, } = useStore(); const [lang, setLangState] = useState(currentLang()); const spawnable = spawnableToolsOf(installedTools); + const canOpenNotificationSettings = canOpenSystemNotificationSettings(); + const deniedNotificationHint = canOpenNotificationSettings ? ( + + ) : ( + + {t("settings.notifyDeniedUnavailable")} + + ); // Per-tool command overrides ("aliases", e.g. claude → cc-claude). `draft` // holds in-progress edits; `saved` is what the backend persisted, so a Save @@ -379,20 +412,69 @@ function GeneralSettings() { -
+
- {notifyEnabled && notifyPerm === "denied" && ( - + {notifyEnabled && notifyPerm === "denied" && deniedNotificationHint} + {notifyEnabled && ( +
+ {NOTIFY_CATEGORIES.map((kind) => ( + + ))} +
+ + {quietHours.enabled && ( +
+ { + const v = parseQuietTime(e.currentTarget.value); + if (v == null) return; + setQuietHours({ ...quietHours, startMin: v }); + }} + className="rounded-[var(--radius-sm)] border border-border bg-bg px-1.5 py-0.5 text-[12px] text-ink" + aria-label={t("settings.notifyQuietStart")} + /> + + { + const v = parseQuietTime(e.currentTarget.value); + if (v == null) return; + setQuietHours({ ...quietHours, endMin: v }); + }} + className="rounded-[var(--radius-sm)] border border-border bg-bg px-1.5 py-0.5 text-[12px] text-ink" + aria-label={t("settings.notifyQuietEnd")} + /> +
+ )} +

+ {t("settings.notifyQuietHoursHint")} +

+
+
)}
diff --git a/src/state/store.tsx b/src/state/store.tsx index b336a998..e9407e80 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -26,6 +26,16 @@ import { } from "../lib/processQuota"; import { routeReasonKey } from "../lib/engineRoutingDisplay"; import { STORAGE_KEYS } from "../lib/storageKeys"; +import { + parseNotifyCategories, + parseQuietHours, + serializeNotifyCategories, + serializeQuietHours, + type NotifyCategory, + type NotifyCategoryFlags, + type NotificationOverview, + type QuietHours, +} from "../lib/notificationsCore"; import { fillMetaHoles, mergeSnapshot, metaFromInit, metaFromSnapshot, metaFromUsage } from "../session/sessionMeta"; import type { BusMsg, @@ -64,6 +74,22 @@ import type { export type HomeTab = "board" | "repos" | "settings"; export type ThreadTab = "lead" | "board"; +export interface NotificationHydration { + workspaceId: number | null; + /** Global permission asks have completed one authoritative pull. */ + asks: boolean; + /** Active workspace Needs-you rows have completed one authoritative pull. */ + workspaceNeeds: boolean; + /** Both Needs-you sources are ready for opening the combined surface. */ + needs: boolean; + overview: boolean; + quota: boolean; + /** A quota push arrived before the initial status snapshot was ready. */ + quotaPushPending: boolean; + liveWorkers: boolean; +} + +const NOTIFICATION_SOURCE_RETRY_MS = 10_000; /** Settings nav destinations (mirrors `nav/SettingsDialog.tsx`'s own list — * that file imports this type rather than redeclaring it, so the two can't * drift). */ @@ -85,6 +111,10 @@ export interface OpenSession { /** the thread this session belongs to (the worker's parent). */ threadId: number; nativeId: string | null; + /** True when the frontend observed this session from a local/event-driven start. */ + eventDriven?: boolean; + /** Owning workspace when known (adopted/revived workers may predate a visit). */ + workspaceId?: number; } const SESSION_STATUS: Record = { @@ -101,31 +131,29 @@ export function isInFlight(state: TurnState): boolean { return state === "busy" || state === "stalled"; } -/** A NeedItem the human must actually act on — excludes EITHER display-only - * NOTICE kind (`item.kind !== "question"`: the self-clearing stall hint, the - * stopped-worker hint, or the non-self-clearing PR/MR give-up notice). Every - * notice surfaces in the Needs-you queue as an FYI row but has no answer to - * give. Single source of truth for every "needs you" badge/count/urgent-flag - * so a notice can't inflate a number that promises "N things need your - * action" (issue #105) — even the one notice kind that, unlike the others, - * won't clear itself; see `NeedsRows.tsx`'s `AskRow` for how that one is - * instead made visually unmissable. */ +/** A NeedItem that accepts a human reply in a worker conversation. Persistent + * action-required notices have a retry control instead, so they remain + * excluded from the inline answer form and urgency checks. */ export function isPendingNeed(item: NeedItem): boolean { return item.kind === "question"; } -/** Workspace-wide "needs you" count: real agent questions (excludes - * self-clearing NOTICEs via `isPendingNeed`) + tool-permission asks + pending - * write triggers — everything actually waiting on the human. Single source for - * every numeric badge (dock strip, nav item) so they can't drift apart; the - * Needs-you queue itself still RENDERS a notice row (so a stall hint stays - * reachable) — it just doesn't count toward this number. */ +/** A NeedItem that contributes to numeric "needs you" badges. Ordinary notices + * are FYI-only; action-required notices stay counted because their retry + * control needs explicit human attention. */ +export function isActionableNeed(item: NeedItem): boolean { + return item.kind === "question" || item.kind === "notice_action_required"; +} + +/** Workspace-wide "needs you" count: actionable Needs-you rows + tool- + * permission asks + pending write triggers. The queue still renders ordinary + * self-clearing notices as FYI rows without counting them. */ export function pendingNeedsCount( needs: NeedItem[], asks: PermissionAsk[], writeTriggers: WriteTrigger[], ): number { - return needs.filter(isPendingNeed).length + asks.length + writeTriggers.length; + return needs.filter(isActionableNeed).length + asks.length + writeTriggers.length; } const PROCESS_QUOTA_NOTICE_VIEW: Record< @@ -198,6 +226,18 @@ interface Store { activeWorkspaceId: number | null; repos: RepoRef[]; threads: Thread[]; + /** Cumulative thread_id → workspace_id map across visited workspaces. */ + threadWorkspaceById: Record; + /** Cumulative thread_id → kind map, including background workspace indexes. */ + threadKindById: Record; + /** Bumps after each selectWorkspace finishes loading repos/threads. */ + workspaceLoadSeq: number; + /** True while selectWorkspace is mid-fetch/reset for the active workspace. */ + workspaceLoading: boolean; + /** True while the workspace list is being restored during startup/refresh. */ + workspaceRestoring: boolean; + /** True only after the latest workspace selection committed repos and threads. */ + workspaceLoadReady: boolean; directionsByThread: Record; worktreesByDirection: Record; @@ -291,6 +331,8 @@ interface Store { /** App-wide process quota state; null until the governor's first snapshot. */ processQuota: ProcessQuotaStatus | null; refreshProcessQuota: () => Promise; + /** Initial notification sources have completed at least one authoritative load. */ + notificationHydration: NotificationHydration; /** Whether the board canvas is showing the proposal's scope-confirm. */ reviewingProposal: boolean; setReviewingProposal: (v: boolean) => void; @@ -352,7 +394,11 @@ interface Store { * name is shown. `dir` is the direction id as a string (backend convention, * see WorkerConversation's dir-parsing note) or a non-numeric sentinel * ("lead") for a thread-level ask with no specific direction. */ - goToDirectionRef: (thread: number, dir: string) => Promise; + goToDirectionRef: ( + thread: number, + dir: string, + opts?: { repoId?: number; sessionId?: number }, + ) => Promise; answerPermission: ( askId: number, answer: "allow" | "deny" | "always" | "full", @@ -384,6 +430,11 @@ interface Store { * `openSettings()` call (e.g. from the nav rail's gear icon). */ settingsInitialPage: SettingsPage | null; clearSettingsInitialPage: () => void; + /** Live requested settings page while Settings is already open. Unlike + * `settingsInitialPage`, this is not cleared on mount and is intended for + * in-session deep links (e.g. quota notification → Resources). `seq` + * changes even when the page repeats so SettingsScreen can re-apply. */ + settingsRequestedPage: { page: SettingsPage; seq: number } | null; /** Jump to the workspace home's Repos tab. */ openRepoMap: () => void; refreshRepoMap: () => Promise; @@ -434,7 +485,9 @@ interface Store { /** Workspace board: per-thread roll-ups for the portfolio view. */ overview: ThreadOverview[]; - refreshOverview: () => Promise; + /** All-workspace roll-ups used by background review notifications. */ + notificationOverview: NotificationOverview[]; + refreshOverview: (force?: boolean) => Promise; selectWorkspace: (id: number) => Promise; refreshWorkspaces: () => Promise; @@ -526,9 +579,15 @@ interface Store { /** Auto-run the review skill when a task flows into the review column. */ autoReview: boolean; setAutoReview: (on: boolean) => void; - /** OS notifications for new Needs-you items / review-ready sub-tasks. */ + /** OS notifications master switch (categories below only apply when on). */ notifyEnabled: boolean; setNotifyEnabled: (on: boolean) => void; + /** Per-category mute flags under the master switch. */ + notifyCategories: NotifyCategoryFlags; + setNotifyCategory: (kind: NotifyCategory, on: boolean) => void; + /** Local quiet-hours window; suppresses OS pings while active. */ + quietHours: QuietHours; + setQuietHours: (qh: QuietHours) => void; /** Prevent system idle sleep while any session is running. */ keepAwake: boolean; setKeepAwake: (on: boolean) => void; @@ -573,16 +632,67 @@ export function StoreProvider({ children }: { children: ReactNode }) { // workspace instead of the stale one captured when they started. const activeWorkspaceIdRef = useRef(activeWorkspaceId); activeWorkspaceIdRef.current = activeWorkspaceId; + const workspacesRef = useRef(workspaces); + workspacesRef.current = workspaces; const [repos, setRepos] = useState([]); const [threads, setThreads] = useState([]); + const [threadWorkspaceById, setThreadWorkspaceById] = useState>({}); + const [threadKindById, setThreadKindById] = useState>({}); + const [workspaceLoadSeq, setWorkspaceLoadSeq] = useState(0); + const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [workspaceRestoring, setWorkspaceRestoring] = useState(true); + const [workspaceLoadReady, setWorkspaceLoadReady] = useState(false); + const workspaceSelectionGenerationRef = useRef(0); + const workspaceSelectionInFlightRef = useRef< + Map }> + >(new Map()); + const rememberThreads = useCallback((list: Thread[]) => { + setThreads(list); + setThreadWorkspaceById((prev) => { + let changed = false; + const next = { ...prev }; + for (const th of list) { + if (next[th.id] !== th.workspace_id) { + next[th.id] = th.workspace_id; + changed = true; + } + } + return changed ? next : prev; + }); + setThreadKindById((prev) => { + let changed = false; + const next = { ...prev }; + for (const th of list) { + if (next[th.id] !== th.kind) { + next[th.id] = th.kind; + changed = true; + } + } + return changed ? next : prev; + }); + }, []); const [directionsByThread, setDirections] = useState>({}); const [worktreesByDirection, setWorktrees] = useState>({}); const [activeThreadId, setActiveThreadId] = useState(null); + // Invalidates proposal loads started by an older selection. Notification + // navigation can await getProposal while a user manually selects another + // thread; the late response must not overwrite the new thread's proposal. + const threadSelectionGenerationRef = useRef(0); // Live mirror so async tasks can check the CURRENT active thread instead of // the stale one captured when they started (mirrors activeWorkspaceIdRef). const activeThreadIdRef = useRef(activeThreadId); activeThreadIdRef.current = activeThreadId; const [sessions, setSessions] = useState>({}); + const [notificationHydration, setNotificationHydration] = useState({ + workspaceId: null, + asks: false, + workspaceNeeds: false, + needs: false, + overview: false, + quota: false, + quotaPushPending: false, + liveWorkers: false, + }); const [checksByDirection, setChecksByDirection] = useState>({}); const [checkingDirections, setCheckingDirections] = useState>({}); // Directions with an auto-(re)dispatch in flight, so the poll-driven effect @@ -608,12 +718,21 @@ export function StoreProvider({ children }: { children: ReactNode }) { }); const [writeTriggers, setWriteTriggers] = useState([]); const [needsByWorkspace, setNeedsByWorkspace] = useState>({}); + const needsRefreshSeqRef = useRef(0); + const needsRefreshInFlightRef = useRef | null>(null); + const needsRefreshPendingRef = useRef(false); + const needsRefreshTrailingTimerRef = useRef | null>(null); + const refreshNeedsRef = useRef<() => Promise>(() => Promise.resolve()); const [showNeeds, setShowNeeds] = useState(false); const [repoProfiles, setRepoProfiles] = useState([]); const [repoEdges, setRepoEdges] = useState([]); const [repoAnalysisActive, setRepoAnalysisActive] = useState(false); const [homeTab, setHomeTab] = useState("board"); const [settingsInitialPage, setSettingsInitialPage] = useState(null); + const [settingsRequestedPage, setSettingsRequestedPage] = useState< + { page: SettingsPage; seq: number } | null + >(null); + const settingsRequestSeqRef = useRef(0); const [curatorThreadId, setCuratorThreadId] = useState(null); const [repoDrawerOpen, setRepoDrawerOpen] = useState(false); const [repoDrawerTab, setRepoDrawerTabState] = useState<"detail" | "curator">("detail"); @@ -636,11 +755,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { } | null>(null); const [proposal, setProposal] = useState(null); const [overview, setOverview] = useState([]); + const [notificationOverview, setNotificationOverview] = useState([]); // Monotonic request token so an older, slower-resolving workspace_overview // fetch (e.g. the WorkspaceKanban mount fetch racing a just-issued // post-create refresh) can never clobber a fresher one that already landed — // last REQUEST issued wins, not last response to arrive. const overviewReqRef = useRef(0); + const overviewInFlightRef = useRef | null>(null); + const overviewPendingRef = useRef(false); + const refreshOverviewRef = useRef<(force?: boolean) => Promise>( + () => Promise.resolve(), + ); // Thread-bus drawer + proposal-review state. const [showBus, setShowBus] = useState(false); const [reviewingProposal, setReviewingProposal] = useState(false); @@ -706,6 +831,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [processQuota, setProcessQuota] = useState(null); const processQuotaRef = useRef(null); + const processQuotaObservedRef = useRef(null); + const processQuotaFirstPushRef = useRef(null); + const processQuotaHydratedRef = useRef(false); const applyProcessQuota = useCallback((next: ProcessQuotaStatus) => { const previous = processQuotaRef.current; if (!shouldApplyProcessQuotaStatus(previous, next)) return; @@ -720,7 +848,25 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []); const refreshProcessQuota = useCallback(async () => { try { - applyProcessQuota(await api.processQuotaStatus()); + const next = await api.processQuotaStatus(); + const firstPush = processQuotaFirstPushRef.current; + const firstPushAdvancedBeyondRead = + firstPush !== null && + (firstPush.transitionSeq > next.transitionSeq || + (firstPush.transitionSeq === next.transitionSeq && + firstPush.status !== next.status)); + applyProcessQuota(next); + const observed = processQuotaObservedRef.current; + if (observed === null || shouldApplyProcessQuotaStatus(observed, next)) { + processQuotaObservedRef.current = next; + } + processQuotaFirstPushRef.current = null; + processQuotaHydratedRef.current = true; + setNotificationHydration((current) => ({ + ...current, + quota: true, + quotaPushPending: current.quotaPushPending || firstPushAdvancedBeyondRead, + })); } catch (error) { // Pure Vite dev and an older backend do not expose the governor command. if (import.meta.env.DEV) console.error(error); @@ -728,11 +874,32 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, [applyProcessQuota]); useEffect(() => { const unlisten = listen("process-quota://changed", (event) => { + const previous = processQuotaObservedRef.current; + const isNewTransition = + previous !== null && event.payload.transitionSeq > previous.transitionSeq; + if (previous === null) { + processQuotaFirstPushRef.current ??= event.payload; + } + if ( + previous === null || + shouldApplyProcessQuotaStatus(previous, event.payload) + ) { + processQuotaObservedRef.current = event.payload; + } applyProcessQuota(event.payload); + setNotificationHydration((current) => ({ + ...current, + quota: processQuotaHydratedRef.current, + quotaPushPending: current.quotaPushPending || isNewTransition, + })); }); void refreshProcessQuota(); + const retryId = setInterval(() => { + if (!processQuotaHydratedRef.current) void refreshProcessQuota(); + }, NOTIFICATION_SOURCE_RETRY_MS); return () => { void unlisten.then((stop) => stop()); + clearInterval(retryId); }; }, [applyProcessQuota, refreshProcessQuota]); @@ -758,14 +925,37 @@ export function StoreProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_KEYS.autoReview, on ? "1" : "0"); setAutoReviewState(on); }, []); - // System notifications: new Needs-you items / review-ready sub-tasks raise an - // OS notification while the window is unfocused. Default ON. + // System notifications: master switch + per-category mutes + quiet hours. + // Default master ON; categories all ON; quiet hours OFF. const [notifyEnabled, setNotifyEnabledState] = useState( () => localStorage.getItem(STORAGE_KEYS.notify) !== "0", ); const setNotifyEnabled = useCallback((on: boolean) => { localStorage.setItem(STORAGE_KEYS.notify, on ? "1" : "0"); setNotifyEnabledState(on); + setNotificationHydration((current) => ({ ...current, overview: false })); + setNotificationOverview([]); + }, []); + const [notifyCategories, setNotifyCategoriesState] = useState( + () => parseNotifyCategories(localStorage.getItem(STORAGE_KEYS.notifyCategories)), + ); + const setNotifyCategory = useCallback((kind: NotifyCategory, on: boolean) => { + setNotifyCategoriesState((prev) => { + const next = { ...prev, [kind]: on }; + localStorage.setItem(STORAGE_KEYS.notifyCategories, serializeNotifyCategories(next)); + return next; + }); + if (kind === "review") { + setNotificationHydration((current) => ({ ...current, overview: false })); + setNotificationOverview([]); + } + }, []); + const [quietHours, setQuietHoursState] = useState( + () => parseQuietHours(localStorage.getItem(STORAGE_KEYS.notifyQuietHours)), + ); + const setQuietHours = useCallback((qh: QuietHours) => { + localStorage.setItem(STORAGE_KEYS.notifyQuietHours, serializeQuietHours(qh)); + setQuietHoursState(qh); }, []); // Keep-awake: hold a "prevent idle sleep" OS assertion while any session is // busy (the display may still turn off). Default ON; synced to the backend @@ -876,49 +1066,151 @@ export function StoreProvider({ children }: { children: ReactNode }) { }, []); const refreshWorkspaces = useCallback(async () => { - const ws = await api.listWorkspaces(); - setWorkspaces(ws); - setActiveWorkspaceId((cur) => { - // Keep the live selection as long as it still exists. - if (cur != null && ws.some((w) => w.id === cur)) return cur; - // Cold start / webview reload drops the in-memory selection back to null; - // restore the last-used workspace instead of snapping to the first one. - // Only fall back to the first when the saved id is gone (deleted) or there - // is none yet. - const saved = Number(localStorage.getItem(STORAGE_KEYS.activeWorkspace)); - if (saved && ws.some((w) => w.id === saved)) return saved; - return ws[0]?.id ?? null; - }); + setWorkspaceRestoring(true); + try { + const ws = await api.listWorkspaces(); + setWorkspaces(ws); + // Lead turns are global and can belong to a workspace the user has not + // visited since this WebView launched. Index all thread owners before the + // notification baseline arms, so a background lead stall is not dropped. + const threadLists = await Promise.all( + ws.map(async (workspace) => { + try { + return await api.listThreads(workspace.id); + } catch (e) { + console.error(e); + return []; + } + }), + ); + setThreadWorkspaceById((prev) => { + let changed = false; + const next = { ...prev }; + for (const list of threadLists) { + for (const thread of list) { + if (next[thread.id] === thread.workspace_id) continue; + next[thread.id] = thread.workspace_id; + changed = true; + } + } + return changed ? next : prev; + }); + setThreadKindById((prev) => { + let changed = false; + const next = { ...prev }; + for (const list of threadLists) { + for (const thread of list) { + if (next[thread.id] !== thread.kind) { + next[thread.id] = thread.kind; + changed = true; + } + } + } + return changed ? next : prev; + }); + setActiveWorkspaceId((cur) => { + // Keep the live selection as long as it still exists. + if (cur != null && ws.some((w) => w.id === cur)) return cur; + // Cold start / webview reload drops the in-memory selection back to null; + // restore the last-used workspace instead of snapping to the first one. + // Only fall back to the first when the saved id is gone (deleted) or there + // is none yet. + const saved = Number(localStorage.getItem(STORAGE_KEYS.activeWorkspace)); + if (saved && ws.some((w) => w.id === saved)) return saved; + return ws[0]?.id ?? null; + }); + } finally { + setWorkspaceRestoring(false); + } }, []); - const selectWorkspace = useCallback(async (id: number) => { - setActiveWorkspaceId(id); - // Clear the old workspace's repo map first so the curator panel (gated on - // repoProfiles.length >= 2) can't mount from stale, other-workspace profiles - // during the switch and ensure a thread for the wrong workspace. - setRepoProfiles([]); - setRepoEdges([]); - // Remember the choice so a relaunch/reload lands here, not on the first one. - localStorage.setItem(STORAGE_KEYS.activeWorkspace, String(id)); - // Drop the previous workspace's curator thread id so it is re-ensured lazily. - setCuratorThreadId(null); - // Repos side panel: open state resets each visit (canvas starts full-width); - // per-surface width is remembered in the panel's own localStorage, not here. - setRepoDrawerOpen(false); - setRepoDrawerTabState("detail"); - setSelectedRepoId(null); - const [r, t] = await Promise.all([api.listRepos(id), api.listThreads(id)]); - setRepos(r); - setThreads(t); - setDirections({}); - setWorktrees({}); - setActiveThreadId(null); - setViewing(null); - setShowNeeds(false); - setHomeTab("board"); - setProposal(null); - setOverview([]); - }, []); + const selectWorkspace = useCallback((id: number): Promise => { + const inFlight = workspaceSelectionInFlightRef.current.get(id); + if (inFlight && inFlight.generation === workspaceSelectionGenerationRef.current) { + return inFlight.promise; + } + const load = (async () => { + const selectionGeneration = ++workspaceSelectionGenerationRef.current; + setActiveWorkspaceId(id); + setWorkspaceRestoring(false); + setWorkspaceLoading(true); + setWorkspaceLoadReady(false); + // Do not render the previous workspace's actionable rows under the new + // workspace while its authoritative Needs refresh is still pending. + setNeeds([]); + setWriteTriggers([]); + setNotificationHydration((current) => ({ + ...current, + workspaceId: id, + workspaceNeeds: false, + needs: false, + overview: false, + })); + try { + // Clear the old workspace's repo map first so the curator panel (gated on + // repoProfiles.length >= 2) can't mount from stale, other-workspace profiles + // during the switch and ensure a thread for the wrong workspace. + setRepoProfiles([]); + setRepoEdges([]); + // Remember the choice so a relaunch/reload lands here, not on the first one. + localStorage.setItem(STORAGE_KEYS.activeWorkspace, String(id)); + // Drop the previous workspace's curator thread id so it is re-ensured lazily. + setCuratorThreadId(null); + // Repos side panel: open state resets each visit (canvas starts full-width); + // per-surface width is remembered in the panel's own localStorage, not here. + setRepoDrawerOpen(false); + setRepoDrawerTabState("detail"); + setSelectedRepoId(null); + const [r, t] = await Promise.all([api.listRepos(id), api.listThreads(id)]); + if (selectionGeneration !== workspaceSelectionGenerationRef.current) return; + setRepos(r); + rememberThreads(t); + setDirections({}); + setWorktrees({}); + setActiveThreadId(null); + setViewing(null); + setShowNeeds(false); + setHomeTab("board"); + setProposal(null); + // A concurrent overview poll may have marked the source ready before this + // load resets its data. Invalidate that response and re-arm hydration for + // the empty snapshot that is now authoritative until the next poll. + overviewReqRef.current += 1; + setOverview([]); + setNotificationHydration((current) => { + if (current.workspaceId !== id) return current; + return { ...current, overview: false }; + }); + setWorkspaceLoadReady(true); + } finally { + // Deep-link markers count every settled load. Route application separately + // checks workspaceLoadReady so a rejected load can never satisfy a route + // prerequisite with stale repos/threads. + setWorkspaceLoadSeq((n) => n + 1); + if (selectionGeneration === workspaceSelectionGenerationRef.current) { + setWorkspaceLoading(false); + } + } + })(); + const tracked = load.then( + () => { + if (workspaceSelectionInFlightRef.current.get(id)?.promise === tracked) { + workspaceSelectionInFlightRef.current.delete(id); + } + }, + (error) => { + if (workspaceSelectionInFlightRef.current.get(id)?.promise === tracked) { + workspaceSelectionInFlightRef.current.delete(id); + } + throw error; + }, + ); + workspaceSelectionInFlightRef.current.set(id, { + generation: workspaceSelectionGenerationRef.current, + promise: tracked, + }); + return tracked; + }, [rememberThreads]); const loadThreadChildren = useCallback(async (threadId: number) => { const dirs = await api.listDirections(threadId); @@ -935,6 +1227,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const selectThread = useCallback( async (threadId: number) => { + const selectionGeneration = ++threadSelectionGenerationRef.current; setActiveThreadId(threadId); setViewing(null); setShowNeeds(false); @@ -942,34 +1235,109 @@ export function StoreProvider({ children }: { children: ReactNode }) { setThreadTab("lead"); setShowBus(false); setReviewingProposal(false); + setProposal(null); try { - setProposal(await api.getProposal(threadId)); + const nextProposal = await api.getProposal(threadId); + if ( + selectionGeneration !== threadSelectionGenerationRef.current || + (nextProposal != null && nextProposal.thread_id !== threadId) + ) { + return; + } + setProposal(nextProposal); } catch (e) { - setProposal(null); + if (selectionGeneration === threadSelectionGenerationRef.current) { + setProposal(null); + } } await loadThreadChildren(threadId); }, [loadThreadChildren], ); - const refreshOverview = useCallback(async () => { - if (activeWorkspaceId == null) { + const refreshOverviewOnce = useCallback(async () => { + const activeId = activeWorkspaceIdRef.current; + if (activeId == null) { overviewReqRef.current += 1; setOverview([]); + setNotificationOverview([]); return; } + const workspaceIds = new Set(workspacesRef.current.map((workspace) => workspace.id)); + workspaceIds.add(activeId); const reqId = ++overviewReqRef.current; try { - const data = await api.workspaceOverview(activeWorkspaceId); - // A newer refreshOverview() was issued while this one was in flight — + const entries = await Promise.all( + [...workspaceIds].map(async (workspaceId) => { + try { + return { + workspaceId, + data: await api.workspaceOverview(workspaceId), + }; + } catch (error) { + console.error(error); + return { workspaceId, data: null }; + } + }), + ); + // A newer refreshOverviewOnce() was issued while this one was in flight — // drop this stale response instead of overwriting the fresher state. - if (reqId !== overviewReqRef.current) return; - setOverview(data); + if ( + reqId !== overviewReqRef.current || + activeWorkspaceIdRef.current !== activeId + ) { + return; + } + const activeOverview = entries.find((entry) => entry.workspaceId === activeId); + if (activeOverview?.data != null) setOverview(activeOverview.data); + const allOverview = entries.flatMap((entry) => + entry.data?.map((row) => ({ ...row, workspace_id: entry.workspaceId })) ?? [], + ); + setNotificationOverview(allOverview); + const allWorkspacesSucceeded = entries.every((entry) => entry.data != null); + setNotificationHydration((current) => { + if (current.workspaceId !== activeId) return current; + return { ...current, overview: allWorkspacesSucceeded }; + }); } catch (e) { /* ignore */ console.error(e); } - }, [activeWorkspaceId]); + }, []); + + const refreshOverview = useCallback(async (force = false) => { + const inFlight = overviewInFlightRef.current; + if (inFlight) { + if (!force) { + await inFlight; + return; + } + // A mutation caller must observe a pass issued AFTER the in-flight poll, + // not merely the stale pass that started before its backend mutation. + overviewPendingRef.current = true; + await inFlight; + const trailing = overviewInFlightRef.current; + if (trailing && trailing !== inFlight) { + await trailing; + return; + } + if (overviewPendingRef.current) { + overviewPendingRef.current = false; + await refreshOverviewRef.current(true); + } + return; + } + overviewPendingRef.current = false; + const current = refreshOverviewOnce(); + overviewInFlightRef.current = current; + try { + await current; + } finally { + if (overviewInFlightRef.current !== current) return; + overviewInFlightRef.current = null; + } + }, [refreshOverviewOnce]); + refreshOverviewRef.current = refreshOverview; const backToWorkspace = useCallback(() => { setActiveThreadId(null); @@ -981,6 +1349,21 @@ export function StoreProvider({ children }: { children: ReactNode }) { const openSettings = useCallback( (page?: SettingsPage) => { + const requestPage = (target: SettingsPage) => { + settingsRequestSeqRef.current += 1; + setSettingsRequestedPage({ + page: target, + seq: settingsRequestSeqRef.current, + }); + }; + // Already on Settings: only navigate pages. Re-snapshotting would capture + // homeTab="settings" and make Back restore Settings instead of the app. + if (homeTab === "settings") { + if (page != null) { + requestPage(page); + } + return; + } // Snapshot first — once we flip homeTab + clear thread/viewing the // info is gone and the back arrow can't restore it. prevHomeRef.current = { @@ -993,6 +1376,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { setViewing(null); setShowNeeds(false); setSettingsInitialPage(page ?? null); + if (page != null) { + requestPage(page); + } else { + setSettingsRequestedPage(null); + } setHomeTab("settings"); }, [homeTab, activeThreadId, viewing, showNeeds], @@ -1005,6 +1393,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const closeSettings = useCallback(() => { const prev = prevHomeRef.current; prevHomeRef.current = null; + setSettingsRequestedPage(null); if (!prev) { // First-launch / direct deep link into Settings — nothing to restore. setHomeTab("board"); @@ -1052,6 +1441,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } setActiveWorkspaceId(null); + setWorkspaceLoadReady(false); setRepos([]); setThreads([]); setDirections({}); @@ -1070,6 +1460,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setSelectedRepoId(null); setProposal(null); setOverview([]); + setNotificationOverview([]); }, [selectWorkspace], ); @@ -1224,9 +1615,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { // views are already consistent. See issue #106. const [threadList] = await Promise.all([ api.listThreads(activeWorkspaceId), - refreshOverview(), + refreshOverview(true), ]); - setThreads(threadList); + rememberThreads(threadList); return t; }, [activeWorkspaceId, refreshOverview], @@ -1236,7 +1627,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { async (threadId: number) => { await api.deleteThread(threadId); if (activeWorkspaceId != null) - setThreads(await api.listThreads(activeWorkspaceId)); + rememberThreads(await api.listThreads(activeWorkspaceId)); setDirections((m) => { const n = { ...m }; delete n[threadId]; @@ -1301,13 +1692,14 @@ export function StoreProvider({ children }: { children: ReactNode }) { status: "running", directionId, repoId, - threadId: activeThreadId ?? -1, + threadId: info.thread_id, nativeId: info.native_id, + eventDriven: true, }, })); if (focus) openWorker(directionId, repoId); }, - [activeThreadId, openWorker], + [openWorker], ); const viewDirection = useCallback( @@ -1354,15 +1746,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { status: "running", directionId, repoId, - threadId: activeThreadId ?? -1, + // The backend resolves the direction's owning thread. Do not use + // activeThreadId here: background auto-review can start a worker + // for a different cached thread while another issue is open. + threadId: info.thread_id, nativeId: info.native_id, + eventDriven: true, }, }; }); if (focus) openWorker(directionId, repoId); return info.session_id; }, - [activeThreadId, openWorker], + [openWorker], ); // Adopt a backend-initiated worker (boot revive, or one still alive after a @@ -1374,41 +1770,55 @@ export function StoreProvider({ children }: { children: ReactNode }) { // authoritatively by the backend (see the idle-turn handler), so the busy seed // below is UI-only (typing indicator / Stop button / nav running count) and arms // no verify latch. - const adoptWorker = useCallback((slot: LiveWorkerSlot) => { - const sid = slot.info.session_id; - if (slot.busy) { - // Seed the worker's busy turn state so the chat surface shows the typing - // indicator + Stop button and WorkspaceNav counts it as running — a revived - // worker emits no turn push until its turn completes. Done BEFORE the - // already-mapped early return so a session that driveDirection (the - // active-thread redispatch) inserted without a workerTurn entry still gets - // seeded. Guard on absence so a raced idle/stopped value the listener already - // recorded wins. (Verify is backend-driven, so this seeds UI state only.) - setWorkerTurn((t) => - t[sid] ? t : { ...t, [sid]: { state: "busy", queue: slot.queue ?? [] } }, - ); - } - if (sessionsRef.current[sid]) return; - // Reconcile status with any turn state the lead-chat listener already recorded: - // if the worker's idle push raced in before this adoption, the live slot still - // says busy, but the dot/live-counts must show idle (not stuck "running"). - const status = adoptionStatus(workerTurnRef.current[sid]?.state, slot.busy); - setSessions((m) => - m[sid] - ? m - : { - ...m, - [sid]: { - info: slot.info, - status, - directionId: slot.direction_id, - repoId: slot.repo_id, - threadId: slot.thread_id, - nativeId: slot.info.native_id, + const adoptWorker = useCallback( + (slot: LiveWorkerSlot, eventDriven = false) => { + const sid = slot.info.session_id; + if (slot.busy) { + // Seed the worker's busy turn state so the chat surface shows the typing + // indicator + Stop button and WorkspaceNav counts it as running — a revived + // worker emits no turn push until its turn completes. Done BEFORE the + // already-mapped early return so a session that driveDirection (the + // active-thread redispatch) inserted without a workerTurn entry still gets + // seeded. Guard on absence so a raced idle/stopped value the listener already + // recorded wins. (Verify is backend-driven, so this seeds UI state only.) + setWorkerTurn((t) => + t[sid] ? t : { ...t, [sid]: { state: "busy", queue: slot.queue ?? [] } }, + ); + } + if (sessionsRef.current[sid]) { + if (eventDriven && !sessionsRef.current[sid].eventDriven) { + setSessions((m) => + m[sid] && !m[sid].eventDriven + ? { ...m, [sid]: { ...m[sid], eventDriven: true } } + : m, + ); + } + return; + } + // Reconcile status with any turn state the lead-chat listener already recorded: + // if the worker's idle push raced in before this adoption, the live slot still + // says busy, but the dot/live-counts must show idle (not stuck "running"). + const status = adoptionStatus(workerTurnRef.current[sid]?.state, slot.busy); + setSessions((m) => + m[sid] + ? m + : { + ...m, + [sid]: { + info: slot.info, + status, + directionId: slot.direction_id, + repoId: slot.repo_id, + threadId: slot.thread_id, + nativeId: slot.info.native_id, + eventDriven, + workspaceId: slot.workspace_id ?? undefined, + }, }, - }, - ); - }, []); + ); + }, + [], + ); // Pull the backend's live worker engines and adopt any the frontend doesn't // know about. Called on mount (backstop for workers live before the listener @@ -1418,43 +1828,81 @@ export function StoreProvider({ children }: { children: ReactNode }) { // (e.g. the revive event firing while the mount pull is still in flight). const hydratingRef = useRef(false); const hydratePendingRef = useRef(false); - const hydrateLiveWorkers = useCallback(async () => { + const hydratePendingEventRef = useRef(false); + const hydrateTrailingTimerRef = useRef | null>(null); + const hydrateLiveWorkersRef = useRef< + (eventDriven?: boolean) => Promise + >(() => Promise.resolve()); + const liveWorkersHydratedRef = useRef(false); + const scheduleTrailingHydrate = useCallback(() => { + if (hydrateTrailingTimerRef.current != null) return; + hydrateTrailingTimerRef.current = setTimeout(() => { + hydrateTrailingTimerRef.current = null; + if (hydratingRef.current) return; + void hydrateLiveWorkersRef.current().catch((error) => console.error(error)); + }, 0); + }, []); + const hydrateLiveWorkers = useCallback(async (eventDriven = false) => { if (hydratingRef.current) { hydratePendingRef.current = true; + hydratePendingEventRef.current ||= eventDriven; return; } hydratingRef.current = true; + const passEventDriven = + eventDriven || + hydratePendingEventRef.current || + liveWorkersHydratedRef.current; + hydratePendingEventRef.current = false; try { - do { - hydratePendingRef.current = false; - const slots = await api.listLiveWorkerSlots(); - // Load each adopted worker's thread directions so WorkspaceNav can match the - // session to its direction and count it as running — a revived worker can - // live in a thread the user never opened this session, whose - // directionsByThread entry would otherwise be empty. (Best-effort; verify - // does not depend on this — the backend reads the phase itself.) - const threadIds = [...new Set(slots.map((s) => s.thread_id))]; - await Promise.all( - threadIds.map(async (tid) => { - try { - const dirs = await api.listDirections(tid); - setDirections((m) => ({ ...m, [tid]: dirs })); - } catch (e) { - /* best-effort: a thread whose directions fail to load just won't - show its running count until opened */ - console.error(e); - } - }), - ); - for (const slot of slots) adoptWorker(slot); - } while (hydratePendingRef.current); + const slots = await api.listLiveWorkerSlots(); + // Load each adopted worker's thread directions so WorkspaceNav can match the + // session to its direction and count it as running — a revived worker can + // live in a thread the user never opened this session, whose + // directionsByThread entry would otherwise be empty. (Best-effort; verify + // does not depend on this — the backend reads the phase itself.) + const threadIds = [...new Set(slots.map((s) => s.thread_id))]; + await Promise.all( + threadIds.map(async (tid) => { + try { + const dirs = await api.listDirections(tid); + setDirections((m) => ({ ...m, [tid]: dirs })); + } catch (e) { + /* best-effort: a thread whose directions fail to load just won't + show its running count until opened */ + console.error(e); + } + }), + ); + for (const slot of slots) adoptWorker(slot, passEventDriven); + // Index adopted workers' thread ownership so notifications can route + // even before the user visits those workspaces. + setThreadWorkspaceById((prev) => { + let changed = false; + const next = { ...prev }; + for (const slot of slots) { + if (slot.workspace_id == null) continue; + if (next[slot.thread_id] !== slot.workspace_id) { + next[slot.thread_id] = slot.workspace_id; + changed = true; + } + } + return changed ? next : prev; + }); + liveWorkersHydratedRef.current = true; + setNotificationHydration((current) => ({ ...current, liveWorkers: true })); } catch (e) { /* best-effort hydration */ console.error(e); } finally { hydratingRef.current = false; + if (hydratePendingRef.current) { + hydratePendingRef.current = false; + scheduleTrailingHydrate(); + } } - }, [adoptWorker]); + }, [adoptWorker, scheduleTrailingHydrate]); + hydrateLiveWorkersRef.current = hydrateLiveWorkers; // Adopt backend-headless workers the frontend never drove (boot revive, or // alive after a reload/HMR). Register the `worker-revived` listener BEFORE the @@ -1467,8 +1915,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { let un: (() => void) | undefined; let cancelled = false; + const retryId = setInterval(() => { + if (!liveWorkersHydratedRef.current) void hydrateLiveWorkers(); + }, NOTIFICATION_SOURCE_RETRY_MS); async function attach() { - un = await listen("worker-revived", () => void hydrateLiveWorkers()); + un = await listen("worker-revived", () => void hydrateLiveWorkers(true)); if (cancelled) { un(); un = undefined; @@ -1480,6 +1931,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; un?.(); + clearInterval(retryId); + if (hydrateTrailingTimerRef.current != null) { + clearTimeout(hydrateTrailingTimerRef.current); + hydrateTrailingTimerRef.current = null; + } }; }, [hydrateLiveWorkers]); @@ -1864,6 +2320,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { [sid]: { ...m[sid], status: SESSION_STATUS[p.state], + eventDriven: true, }, } : m, @@ -2214,10 +2671,21 @@ export function StoreProvider({ children }: { children: ReactNode }) { [activeThreadId], ); - const refreshNeeds = useCallback(async () => { + const refreshNeedsOnce = useCallback(async () => { + const workspaceId = activeWorkspaceIdRef.current; + const requestId = ++needsRefreshSeqRef.current; + const isLatestRequest = () => requestId === needsRefreshSeqRef.current; // Permission Asks are global (not workspace-scoped); always refresh them. try { - setAsks(await api.pendingAsks()); + const nextAsks = await api.pendingAsks(); + if (isLatestRequest()) { + setAsks(nextAsks); + setNotificationHydration((current) => ({ + ...current, + asks: true, + needs: current.workspaceNeeds, + })); + } } catch (e) { /* server may not be ready */ console.error(e); @@ -2225,7 +2693,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { // Standing grants are global too; refresh so "inherited access" markers stay // in sync (e.g. seeded from disk at boot, or granted in another view). try { - setAuthGrants(await api.listAuthGrants()); + const nextGrants = await api.listAuthGrants(); + if (isLatestRequest()) setAuthGrants(nextGrants); } catch (e) { console.error(e); } @@ -2234,30 +2703,88 @@ export function StoreProvider({ children }: { children: ReactNode }) { // releaseSessionReadOnly/revokeReadOnlyGrant below) is the only way the // frontend's copy stays honest; the backend's own gate never depends on it. try { - setReadOnlyGrants(await api.readOnlyGrants()); + const nextGrants = await api.readOnlyGrants(); + if (isLatestRequest()) setReadOnlyGrants(nextGrants); } catch (e) { console.error(e); } - if (activeWorkspaceId == null) { - setNeeds([]); - setWriteTriggers([]); + if (workspaceId == null) { + if (isLatestRequest() && activeWorkspaceIdRef.current == null) { + setNeeds([]); + setWriteTriggers([]); + setNotificationHydration((current) => ({ + ...current, + workspaceNeeds: true, + needs: current.asks, + })); + } return; } try { - setNeeds(await api.needsYou(activeWorkspaceId)); - setWriteTriggers(await api.writeTriggers(activeWorkspaceId)); + const [nextNeeds, nextWriteTriggers] = await Promise.all([ + api.needsYou(workspaceId), + api.writeTriggers(workspaceId), + ]); + if (isLatestRequest() && activeWorkspaceIdRef.current === workspaceId) { + setNeeds(nextNeeds); + setWriteTriggers(nextWriteTriggers); + setNotificationHydration((current) => { + if (current.workspaceId !== workspaceId) return current; + return { + ...current, + workspaceNeeds: true, + needs: current.asks, + }; + }); + } } catch (e) { /* bus may not be ready */ console.error(e); } // per-workspace counts so the switcher can flag OTHER workspaces. try { - setNeedsByWorkspace(Object.fromEntries(await api.workspaceNeedsCounts())); + const counts = await api.workspaceNeedsCounts(); + if (isLatestRequest()) setNeedsByWorkspace(Object.fromEntries(counts)); } catch (e) { /* ignore */ console.error(e); } - }, [activeWorkspaceId]); + }, []); + + const scheduleTrailingNeedsRefresh = useCallback(() => { + if (needsRefreshTrailingTimerRef.current != null) return; + needsRefreshTrailingTimerRef.current = setTimeout(() => { + needsRefreshTrailingTimerRef.current = null; + // A caller may have started the trailing pass before this timer fired; + // that pass already represents the pending refresh. + if (needsRefreshInFlightRef.current != null) return; + void refreshNeedsRef.current().catch((error) => console.error(error)); + }, 0); + }, []); + + const refreshNeeds = useCallback(async () => { + const inFlight = needsRefreshInFlightRef.current; + if (inFlight) { + // Coalesce callers onto the current pass, but let each waiter resolve + // when that bounded pass ends. A trailing pass is scheduled separately. + needsRefreshPendingRef.current = true; + await inFlight; + return; + } + const current = refreshNeedsOnce(); + needsRefreshInFlightRef.current = current; + try { + await current; + } finally { + if (needsRefreshInFlightRef.current !== current) return; + needsRefreshInFlightRef.current = null; + if (needsRefreshPendingRef.current) { + needsRefreshPendingRef.current = false; + scheduleTrailingNeedsRefresh(); + } + } + }, [refreshNeedsOnce, scheduleTrailingNeedsRefresh]); + refreshNeedsRef.current = refreshNeeds; const openNeeds = useCallback(() => { setViewing(null); @@ -2321,7 +2848,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { // across threads — refresh the board overview and the open thread's children // so stale task cards (now pointing at deleted rows) don't linger and open // blank worker views or failed diff/session fetches. - await refreshOverview(); + await refreshOverview(true); if (activeThreadId != null) await loadThreadChildren(activeThreadId); }, [activeWorkspaceId, refreshReposAndMap, refreshOverview, activeThreadId, loadThreadChildren], @@ -2339,7 +2866,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const id = await api.openCuratorChat(ws); // get-or-create; returns the id const list = await api.listThreads(ws); if (activeWorkspaceIdRef.current === ws) { - setThreads(list); + rememberThreads(list); setCuratorThreadId(id); } return id; @@ -2367,9 +2894,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { setRepoDrawerOpen(true); }, []); const openCurator = useCallback(() => { + // Curator lives inside RepoMapView's side panel. Always land on the Repo Map + // first and clear issue/worker overlays; otherwise openCurator only flips + // drawer flags that no mounted surface can show. + setActiveThreadId(null); + setShowNeeds(false); + setViewing(null); + setHomeTab("repos"); setRepoDrawerTabState("curator"); setRepoDrawerOpen(true); - }, []); + void refreshRepoMap(); + }, [refreshRepoMap]); // Every Analyze entry — the graph's button, the map's regenerate, or a typed // request — funnels to the ONE reanalyze tool: post a real user message to the @@ -2820,14 +3355,37 @@ export function StoreProvider({ children }: { children: ReactNode }) { // never spawned a worker). `dir` non-numeric (e.g. the "lead" sentinel) just // fails the live lookup and falls through to the thread — never throws. const goToDirectionRef = useCallback( - async (thread: number, dir: string) => { + async ( + thread: number, + dir: string, + opts?: { repoId?: number; sessionId?: number }, + ) => { setShowNeeds(false); setViewing(null); const directionId = Number(dir); - const live = Number.isFinite(directionId) - ? Object.values(sessions).find((s) => s.directionId === directionId) + const all = Object.values(sessions); + let live = + opts?.sessionId != null + ? all.find( + (s) => s.info.session_id === opts.sessionId && s.status !== "exited", + ) : undefined; + if (!live && opts?.repoId != null && Number.isFinite(directionId)) { + live = all.find( + (s) => + s.directionId === directionId && + s.repoId === opts.repoId && + s.status !== "exited", + ); + } + if (!live && opts?.repoId == null && Number.isFinite(directionId)) { + live = all.find( + (s) => s.directionId === directionId && s.status !== "exited", + ); + } if (live) { + threadSelectionGenerationRef.current += 1; + setProposal(null); setActiveThreadId(thread); openWorker(live.directionId, live.repoId); return; @@ -2846,9 +3404,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { void refreshWorkspaces(); }, [refreshWorkspaces]); useEffect(() => { - if (activeWorkspaceId != null) void selectWorkspace(activeWorkspaceId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeWorkspaceId]); + if (activeWorkspaceId != null && !workspaceLoadReady) { + void selectWorkspace(activeWorkspaceId); + } + }, [activeWorkspaceId, workspaceLoadReady, selectWorkspace]); // Reset a thread's sub-view (lead tab, in-flight proposal review) only when the // active thread actually CHANGES. This lives in the store — not in ThreadBoard — @@ -2887,6 +3446,34 @@ export function StoreProvider({ children }: { children: ReactNode }) { }; }, [activeWorkspaceId, refreshNeeds]); + useEffect(() => { + return () => { + if (needsRefreshTrailingTimerRef.current != null) { + clearTimeout(needsRefreshTrailingTimerRef.current); + needsRefreshTrailingTimerRef.current = null; + } + }; + }, []); + + // Board overview poll: review transitions (and any status drift) stay fresh + // even when the kanban is unmounted. 10s matches the previous notify hook. + useEffect(() => { + if (activeWorkspaceId == null || !notifyEnabled || !notifyCategories.review) { + return; + } + let alive = true; + const tick = () => { + if (alive) void refreshOverview(); + }; + tick(); + const h = setInterval(tick, 10_000); + return () => { + alive = false; + clearInterval(h); + }; + }, [activeWorkspaceId, notifyEnabled, notifyCategories.review, refreshOverview]); + + // Live-refresh the repo map when the curator calibrates an edge (or the auto // pass finishes) for the active workspace. useEffect(() => { @@ -3028,6 +3615,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { activeWorkspaceId, repos, threads, + threadWorkspaceById, + threadKindById, + workspaceLoadSeq, + workspaceLoading, + workspaceRestoring, + workspaceLoadReady, directionsByThread, worktreesByDirection, activeThreadId, @@ -3085,6 +3678,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setGuardrails, processQuota, refreshProcessQuota, + notificationHydration, needs, asks, authGrants, @@ -3113,6 +3707,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { closeSettings, settingsInitialPage, clearSettingsInitialPage, + settingsRequestedPage, openRepoMap, refreshRepoMap, refreshReposAndMap, @@ -3139,6 +3734,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { setProposalDirectionBase, approvePlanCard, overview, + notificationOverview, refreshOverview, selectWorkspace, refreshWorkspaces, @@ -3176,6 +3772,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { setAutoReview, notifyEnabled, setNotifyEnabled, + notifyCategories, + setNotifyCategory, + quietHours, + setQuietHours, keepAwake, setKeepAwake, focusSession, diff --git a/tests/frontend/notifications.test.ts b/tests/frontend/notifications.test.ts new file mode 100644 index 00000000..d923e91c --- /dev/null +++ b/tests/frontend/notifications.test.ts @@ -0,0 +1,440 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + badgeCountFrom, + DEFAULT_NOTIFY_CATEGORIES, + DEFAULT_QUIET_HOURS, + diffForNotifications, + emptyNotifySnapshot, + formatQuietTime, + isAppInForeground, + isInQuietHours, + notifyCopyKeys, + planNotifyOpen, + parseNotifyCategories, + parseQuietHours, + parseQuietTime, + serializeNotifyCategories, + serializeQuietHours, + snapshotOf, + type NotifySnapshot, +} from "../../src/lib/notificationsCore.ts"; +import type { + NeedItem, + PermissionAsk, + ProcessQuotaStatus, + ThreadOverview, + WriteTrigger, +} from "../../src/lib/types.ts"; + +function need(partial: Partial & Pick): NeedItem { + return { + ask_id: partial.ask_id, + thread_id: partial.thread_id ?? 1, + direction_id: partial.direction_id ?? 10, + thread_title: partial.thread_title ?? "Issue A", + direction_name: partial.direction_name ?? "task-1", + text: partial.text ?? "?", + ts: partial.ts ?? 0, + kind: partial.kind ?? "question", + }; +} + +function ask(id: number): PermissionAsk { + return { + id, + thread: 1, + dir: "10", + tool: "Bash", + summary: "rm", + detail: "rm -rf", + risk: "write", + ts: 0, + thread_title: "Issue A", + dir_name: "task-1", + }; +} + +function wt(index: number): WriteTrigger { + return { + thread_id: 1, + index, + name: "lane", + repo_name: "weft", + reason: "x", + thread_title: "Issue A", + base_branch: "", + }; +} + +function overview(statuses: string[], ids: number[] = statuses.map((_, i) => i + 1)): ThreadOverview { + return { + thread_id: 1, + title: "Issue A", + kind: "feature", + direction_ids: ids, + statuses, + write_repos: [], + }; +} + +function quota(status: ProcessQuotaStatus["status"], seq: number): ProcessQuotaStatus { + return { + status, + processCount: 900, + processLimit: 1000, + usagePercent: 90, + warningPercent: 80, + degradedPercent: 90, + recoveryPercent: 70, + transitionSeq: seq, + }; +} + +test("snapshotOf keys cover needs / asks / writeTriggers / review", () => { + const snap = snapshotOf( + [need({ ask_id: 7 })], + [ask(3)], + [wt(0)], + [overview(["working", "review"], [11, 12])], + {}, + {}, + null, + ); + assert.equal(snap.needs.get("need:7")?.sample, "Issue A · task-1"); + assert.equal(snap.needs.get("need:7")?.route.threadId, 1); + assert.equal(snap.needs.get("need:7")?.route.askId, 7); + assert.equal(snap.needs.get("ask:3")?.sample, "Issue A · task-1"); + assert.equal(snap.needs.get("wt:1:0")?.sample, "Issue A · lane"); + assert.equal(snap.review.get("rev:12")?.sample, "Issue A"); + assert.equal(snap.review.get("rev:12")?.route.directionId, 12); + assert.equal(snap.review.has("rev:11"), false); +}); + +test("snapshotOf skips self-clearing notices but keeps action-required notices", () => { + const silent = snapshotOf( + [need({ ask_id: 1, kind: "notice" })], + [], + [], + [], + {}, + {}, + null, + ); + assert.equal(silent.needs.size, 0); + const action = snapshotOf( + [need({ ask_id: 2, kind: "notice_action_required" })], + [], + [], + [], + {}, + {}, + null, + ); + assert.equal(action.needs.get("need:2")?.route.openNeeds, true); +}); + +test("snapshotOf captures stalled workers and leads", () => { + const snap = snapshotOf( + [], + [], + [], + [], + { + 99: { + info: { session_id: 99 }, + status: "stalled", + directionId: 5, + repoId: 8, + threadId: 2, + workspaceId: 4, + }, + }, + { 2: { state: "stalled", queue: [] }, 3: { state: "busy", queue: [] } }, + null, + { 2: { title: "Issue B", workspaceId: 4 } }, + ); + assert.equal(snap.stalled.get("stall:worker:99")?.sample, "Issue B · #5"); + assert.equal(snap.stalled.get("stall:worker:99")?.route.directionId, 5); + assert.equal(snap.stalled.get("stall:worker:99")?.route.repoId, 8); + assert.equal(snap.stalled.get("stall:worker:99")?.route.sessionId, 99); + assert.equal(snap.stalled.get("stall:worker:99")?.route.workspaceId, 4); + assert.equal(snap.stalled.get("stall:lead:2")?.sample, "Issue B"); + assert.equal(snap.stalled.get("stall:lead:2")?.route.workspaceId, 4); + assert.equal(snap.stalled.has("stall:lead:3"), false); +}); + +test("snapshotOf skips stalled items without owning workspace and routes curator leads", () => { + const missing = snapshotOf( + [], + [], + [], + [], + { + 1: { + info: { session_id: 1 }, + status: "stalled", + directionId: 5, + repoId: 8, + threadId: 9, + }, + }, + { 10: { state: "stalled", queue: [] } }, + null, + {}, + 1, + ); + assert.equal(missing.stalled.size, 0); + + const curator = snapshotOf( + [], + [], + [], + [], + {}, + { 22: { state: "stalled", queue: [] } }, + null, + { 22: { title: "Curator", workspaceId: 3, kind: "curator" } }, + ); + assert.equal(curator.stalled.get("stall:curator:22")?.route.openCurator, true); + assert.equal(curator.stalled.get("stall:curator:22")?.route.workspaceId, 3); + assert.equal(curator.stalled.has("stall:lead:22"), false); +}); + +test("snapshotOf only surfaces degraded quota keyed by transitionSeq", () => { + const snap = snapshotOf([], [], [], [], {}, {}, quota("degraded", 4), {}, 9); + assert.equal(snap.quota.get("quota:degraded:4")?.sample, "900 / 1000"); + assert.equal(snap.quota.get("quota:degraded:4")?.route.kind, "quota"); + assert.equal(snap.quota.get("quota:degraded:4")?.route.workspaceId, undefined); + const warning = snapshotOf([], [], [], [], {}, {}, quota("warning", 5)); + assert.equal(warning.quota.size, 0); +}); + +test("diffForNotifications emits only new keys and merges per category", () => { + const entry = (sample: string, kind: "needs" | "review" | "stalled" | "quota" = "needs") => ({ + sample, + route: { kind }, + }); + const prev: NotifySnapshot = { + needs: new Map([["need:1", entry("a")]]), + review: new Map(), + stalled: new Map(), + quota: new Map(), + }; + const next: NotifySnapshot = { + needs: new Map([ + ["need:1", entry("a")], + ["need:2", entry("b")], + ["ask:9", entry("c")], + ]), + review: new Map([["rev:1", entry("Issue", "review")]]), + stalled: new Map(), + quota: new Map([["quota:degraded:1", entry("1 / 1", "quota")]]), + }; + const events = diffForNotifications(prev, next); + assert.deepEqual( + events.map((e) => e.kind), + ["needs", "review", "quota"], + ); + const needs = events.find((e) => e.kind === "needs")!; + assert.equal(needs.count, 2); + assert.equal(needs.sample, "b"); +}); + +test("diffForNotifications respects category mutes", () => { + const prev = emptyNotifySnapshot(); + const entry = (sample: string, kind: "needs" | "review" | "stalled" | "quota") => ({ + sample, + route: { kind }, + }); + const next: NotifySnapshot = { + needs: new Map([["need:1", entry("a", "needs")]]), + review: new Map([["rev:1", entry("b", "review")]]), + stalled: new Map([["stall:lead:1", entry("c", "stalled")]]), + quota: new Map([["quota:degraded:1", entry("d", "quota")]]), + }; + const events = diffForNotifications(prev, next, { + needs: true, + review: false, + stalled: true, + quota: false, + }); + assert.deepEqual( + events.map((e) => e.kind), + ["needs", "stalled"], + ); +}); + +test("quiet hours same-day and wrap-past-midnight", () => { + const sameDay = { enabled: true, startMin: 9 * 60, endMin: 17 * 60 }; + assert.equal(isInQuietHours(sameDay, new Date(2026, 0, 1, 10, 0)), true); + assert.equal(isInQuietHours(sameDay, new Date(2026, 0, 1, 8, 0)), false); + assert.equal(isInQuietHours(sameDay, new Date(2026, 0, 1, 17, 0)), false); + + const wrap = { enabled: true, startMin: 22 * 60, endMin: 8 * 60 }; + assert.equal(isInQuietHours(wrap, new Date(2026, 0, 1, 23, 0)), true); + assert.equal(isInQuietHours(wrap, new Date(2026, 0, 1, 7, 0)), true); + assert.equal(isInQuietHours(wrap, new Date(2026, 0, 1, 12, 0)), false); + + assert.equal(isInQuietHours({ ...wrap, enabled: false }, new Date(2026, 0, 1, 23, 0)), false); + assert.equal(isInQuietHours({ enabled: true, startMin: 10, endMin: 10 }, new Date()), false); +}); + +test("quiet hours parse/serialize and time helpers", () => { + const raw = serializeQuietHours({ enabled: true, startMin: 22 * 60 + 30, endMin: 7 * 60 }); + const parsed = parseQuietHours(raw); + assert.deepEqual(parsed, { enabled: true, startMin: 22 * 60 + 30, endMin: 7 * 60 }); + assert.equal(formatQuietTime(8 * 60 + 5), "08:05"); + assert.equal(parseQuietTime("23:59"), 23 * 60 + 59); + assert.equal(parseQuietTime("24:00"), null); + assert.deepEqual(parseQuietHours(null), DEFAULT_QUIET_HOURS); +}); + +test("category flags parse with defaults for missing keys", () => { + assert.deepEqual(parseNotifyCategories(null), DEFAULT_NOTIFY_CATEGORIES); + const partial = parseNotifyCategories(JSON.stringify({ needs: false })); + assert.equal(partial.needs, false); + assert.equal(partial.review, true); + assert.equal(partial.stalled, true); + assert.equal(partial.quota, true); + assert.equal(JSON.parse(serializeNotifyCategories(partial)).needs, false); +}); + +test("foreground gate prefers explicit window focus", () => { + assert.equal(isAppInForeground({ windowFocused: true, documentFocused: false }), true); + assert.equal(isAppInForeground({ windowFocused: false, documentFocused: true }), false); + assert.equal(isAppInForeground({ windowFocused: null, documentFocused: true }), true); + assert.equal(isAppInForeground({ windowFocused: null, documentFocused: false }), false); +}); + +test("badgeCountFrom counts only actionable needs + asks + writeTriggers", () => { + const n = badgeCountFrom( + [ + need({ ask_id: 1, kind: "question" }), + need({ ask_id: 2, kind: "notice" }), + need({ ask_id: 3, kind: "notice_action_required" }), + ], + [ask(1)], + [wt(0), wt(1)], + ); + assert.equal(n, 1 + 1 + 1 + 2); +}); + +test("notifyCopyKeys covers every category", () => { + assert.equal(notifyCopyKeys("needs").title, "notify.needsTitle"); + assert.equal(notifyCopyKeys("review").title, "notify.reviewTitle"); + assert.equal(notifyCopyKeys("stalled").title, "notify.stalledTitle"); + assert.equal(notifyCopyKeys("quota").title, "notify.quotaTitle"); +}); + + +test("planNotifyOpen routes needs/review to direction and switches workspace", () => { + assert.deepEqual( + planNotifyOpen({ + kind: "needs", + workspaceId: 2, + threadId: 9, + directionId: 11, + }), + [ + { type: "workspace", workspaceId: 2 }, + { type: "direction", threadId: 9, direction: "11" }, + ], + ); +}); + +test("planNotifyOpen routes quota to resources and lead-only stalled to lead", () => { + // Quota is process-global: ignore any workspaceId so the current workspace stays. + assert.deepEqual(planNotifyOpen({ kind: "quota" }), [ + { type: "resources" }, + ]); + assert.deepEqual(planNotifyOpen({ kind: "quota", workspaceId: 1 }), [ + { type: "resources" }, + ]); + assert.deepEqual(planNotifyOpen({ kind: "stalled", threadId: 4 }), [ + { type: "direction", threadId: 4, direction: "lead" }, + ]); +}); + +test("planNotifyOpen falls back to needs list when only kind is present", () => { + assert.deepEqual(planNotifyOpen({ kind: "needs" }), [{ type: "needs" }]); +}); + +test("planNotifyOpen routes write-trigger style openNeeds to Needs-you", () => { + assert.deepEqual( + planNotifyOpen({ + kind: "needs", + threadId: 9, + openNeeds: true, + workspaceId: 2, + }), + [ + { type: "workspace", workspaceId: 2 }, + { type: "needs" }, + ], + ); +}); + +test("planNotifyOpen carries repo/session for stalled workers", () => { + assert.deepEqual( + planNotifyOpen({ + kind: "stalled", + threadId: 4, + directionId: 5, + repoId: 8, + sessionId: 99, + }), + [ + { + type: "direction", + threadId: 4, + direction: "5", + repoId: 8, + sessionId: 99, + }, + ], + ); +}); + +test("planNotifyOpen routes curator stalls to curator surface", () => { + assert.deepEqual( + planNotifyOpen({ + kind: "stalled", + workspaceId: 3, + threadId: 22, + openCurator: true, + }), + [ + { type: "workspace", workspaceId: 3 }, + { type: "curator" }, + ], + ); +}); + +test("snapshotOf does not stamp active workspace onto global asks without ownership", () => { + const snap = snapshotOf( + [], + [ask(9)], + [], + [], + {}, + {}, + null, + {}, + 1, + ); + assert.equal(snap.needs.get("ask:9")?.route.workspaceId, undefined); + + const owned = snapshotOf( + [], + [{ ...ask(10), workspace_id: 7 }], + [], + [], + {}, + {}, + null, + {}, + 1, + ); + assert.equal(owned.needs.get("ask:10")?.route.workspaceId, 7); +});